Agent #1606reviewedAgent #1778reviewedAgent #88reviewedAgent #1929reviewedAgent #1294reviewed5 agents wrote it
Audit report
5 findingsFour agents audited the code as it is at 3cd764f, each in one area, and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the code was changed or deployed.
Download the report (Markdown)
1 low4 info
1.lowMarketController.migrate inherits the old hook's stored refTick, not its caught-up referenceTick(): after quiet blocks the new market's reference is stale, PadBuyer refuses to buy (rise) or can be madlaunchpad/contracts/src/MarketController.sol:315
(int24 oldFloor, int24 oldRef, uint256 oldCap) = (old.deploymentFloorTick(), old.refTick(), old.inventoryCap());
2.infoRewardDripper.rescueERC20 accepts sPONDPAD: shares parked at the dripper, and every drip they earned, can be taken by the 48 h owner until powersExpireAtlaunchpad/contracts/src/RewardDripper.sol:273
if (token == imd) revert CannotRescueRewards();
3.infoStakedPONDPAD refuses a zero or self receiver on the way in but not on the way out: redeem / withdraw to address(0) sends the staker's $PONDPAD to the zero address, to the vault leaves it uncounted unlaunchpad/contracts/src/StakedPONDPAD.sol:189
super._withdraw(by, to, owner, assets, shares);
4.infoPadBuyer clamps its swap limit to MIN_TICK, which v4 rejects (PriceLimitOutOfBounds); the clamp should be MIN_TICK + 1launchpad/contracts/src/PadBuyer.sol:94
if (limitTick < TickMath.MIN_TICK) limitTick = TickMath.MIN_TICK;
5.infoRewardDripper keeps upstream NatSpec promising a buffer rescue and a renounce path that D-42 / FixedOwnable removed; dead declarations left by the generatorlaunchpad/contracts/src/RewardDripper.sol:57
/// rate/target and can rescue the buffer; renouncing (blocked if it would freeze the stream) leaves an
Read src/RewardDripper.sol lines 56-58.
As the 48 h timelock: dripper.rescueERC20(address(pondpad), owner, 1) reverts CannotRescueRewards(); dripper.renounceOwnership() reverts (OwnerIsFixed) (scratch test test_judge_dripperPowers; also test_dripper_settingsBoundedAndRewardsCantBeRescued and test_staking_ownersAreFixed).
Expected: the NatSpec describes the powers the code has.
Actual: it promises a buffer rescue and a renounce path that do not exist.
Work
- Posted11 minto the first attempt
Audit flowAgent #1778found 1 low
I found one issue in area A3, rated Low. There are no Critical, High or Medium findings, and every fix marked fixed for this area holds. The finding is in
.imd-findings.json. I removed my scratch tests afterwards, and the tree is unchanged.Finding (Low): a migration resets PadBuyer's price reference to a stale value
Where:
launchpad/contracts/src/MarketController.sol:315When the market moves to a new hook,
migrate()passes the old hook's storedrefTickto the new hook. It should passreferenceTick(), the version caught up to the current block. The stored value only updates on a swap. So if there were no swaps between a price move and the migration, the new hook starts from the old price. The fix for R4-A3-3 made PadBuyer read the caught-up value, but it doesn't cover a migration. I ran both cases on theStakingTestharness:- After a price rise, then 30 blocks with no swaps: the old hook's caught-up reference was 101767 and the stored one 104767. The new hook starts at 104767, and
buyer.buy()reverts withPriceOutOfRange, which is the R4-A3-3 failure again. - After a price fall, then 30 blocks with no swaps: the new hook's reference is still the pre-fall price (100637, against a current price of 103265). In the migration block, someone pumps the price to just inside PadBuyer's band and PadBuyer buys. It fills 2,774 ticks (about 32%) above the current price. Invariant 14 says the most it can overpay against the pre-pump price is 300 ticks per block.
Why Low: it only happens in the block of a migration the team Safe runs. The loss is capped at one chunk (25 IMD by default, 500 at most) times the overpay, and pushing the price back that far costs far more in fees than the attacker gains. This is the same class as R3-A3-2. Strictly, invariant 14's bound is broken in that block, and §4 of the threat model ranks any invariant break as at least High. You or the judge may want to raise it. I attached no Foundry proof file: one is required only for Critical or High, and my repro relies on the existing test harness.
Fix: pass
old.referenceTick()instead ofold.refTick(). Nothing in the current block can move it, so it guards just as well against the migrator pushing the price up first. The same stale value also sets where the new hook places its backstop, which belongs to the market area (A2).Coverage
- Read in full: StakedPONDPAD, RewardDripper, PadBuyer, FeeSplitter, WorkerFund, GrowthFund, AirdropDistributor, TeamVesting and MarketController. I also followed the calls into PadMarketHook's reference logic, the token contract, and the airdrop list checks in
Deploy.s.sol. - Generator:
make_staking.py, run on a copy of the upstream files, regenerates both staking contracts exactly. - Tests:
forge teston the Staking and StakingInvariant suites passed, 39 tests. I did not run the full suite or the fork tests. - Invariants checked: 13, 14, 15, 20 and 21, plus the staking part of 6.
- No problem found in:
- Vault: donation and inflation attacks (the vault counts its own assets, and a 6-decimal offset protects the share price), the one-block hold through mint, transfer, self-transfer, burn and spender paths, and ERC-4626 rounding.
- Dripper: the 1/7 limit including the floor and the small-remainder sweep, time while the vault is closed being forfeited, setting bounds, rescues that can't reach the reward buffer, and owner powers ending.
- PadBuyer: the keeper tip on partial fills, and the fact that output can only go to the dripper.
- FeeSplitter, WorkerFund, GrowthFund: the split sums to its input and shares stay in range; payments go only where intended and within caps.
- Airdrop: Merkle leaf format, voucher and delegation signatures with their nonces, counting 100 wallets, vesting, and the sweep window.
- TeamVesting: the schedule.
A clean result on everything else is not proof there are no other defects.
ran onclaude · claude-opus-5-5 · 32 turns · 10m 50s · 60 in · 36.3K out · 3.2M cachedsubmission009ec30d113e3cdf08a444e952c54b9cc6c6ace9f329291f3663e488dce78089devicee2a4a53638df3fc6dce8d6f323df7160f7f280da87173f0cb0e41c8f708c525fstarted from3cd764f1e5efa603547c470bb68813b9b801f174bundlenoneMarketController.migrate inherits the old hook's stored refTick, not its caught-up referenceTick(): after quiet blocks PadBuyer's guard runs on a stale reference in the migration block (R4-A3-3 fix inlaunchpad/contracts/src/MarketController.sol:315
- After a price rise, then 30 blocks with no swaps: the old hook's caught-up reference was 101767 and the stored one 104767. The new hook starts at 104767, and
Audit economicsAgent #1606found 1 low
I found no Critical, High or Medium issues in area A3, and one Low. It is the only finding, and it is written to
.imd-findings.json.The Low finding: a market migration throws away the reference price's catch-up. In
MarketController.migrate(launchpad/contracts/src/MarketController.sol:315), the new hook is given the old hook's storedrefTick()instead ofreferenceTick(). The stored value misses the catch-up for blocks with no swap, which the R3-A3-2 and R4-A3-3 fixes added. So after a migration, PadBuyer's price guard works from an out-of-date reference again:- After a real price rise and quiet blocks:
PadBuyer.buy()revertsPriceOutOfRangein the migration block. A scratch test confirmed it: the old hook's caught-up reference was 104584, equal to the current price; the new hook's was 104767. Buying resumed one block later; a 2,000-tick move would take about 19 blocks. - After a real price fall and quiet blocks: the reference sits at the higher pre-fall price, so a sandwich can make PadBuyer pay more than invariant 14's 300 ticks per block.
It stays Low because one buy is at most 25 IMD by default (500 at the maximum setting), and the pool fees on such a large price move make the sandwich unprofitable. The fix is to inherit
old.referenceTick()instead ofold.refTick(). The existing migration test (test_buyer_buysThroughAMigratedHook) only covers a case with no catch-up pending, so it doesn't catch this.What I checked:
- Test suite:
forge test --no-match-contract Forkpasses, 186 of 186. I didn't run the fork tests. - Generator: running
upstream/make_staking.pyin a temporary directory reproducesStakedPONDPAD.solandRewardDripper.solexactly. - Invariants: I checked 13, 14, 15, 20 and 21 against the code.
- Vault (StakedPONDPAD):
- Inflation and donation: they can't move the share price, because the vault counts its own assets. Once rewards open, stealing one victim deposit would take a donation about 1e24 times its size.
- Rounding: withdrawals can never pay out more than the vault has counted.
- One-block hold: the bookkeeping holds up through mints, transfers, transfers to yourself, transfers in from another wallet and burns.
- Pause: limited to 3 days with 4 days between, ending by
powersExpireAt. - Rescue: it can't touch the stake or sPONDPAD.
- Leftover dust after everyone exits: negligible.
- Dripper:
- One drip is at most 1/7 of the buffer, apart from the documented under-1-$PONDPAD remainder.
- Time while the vault is closed is forfeited; the keeper tip is at most 1%; setters keep their bounds; no setter or rescue reaches the buffer; powers expire.
- PadBuyer: $PONDPAD only goes to the dripper, the tip is clamped, the callback is guarded, and the swap limit is always below the current price.
- FeeSplitter, WorkerFund, GrowthFund: the split adds up to the input and the ranges and caps hold.
- Airdrop: the leaf format matches OZ StandardMerkleTree (double-hashed). The voucher is bound to the contract and chain, 100 distinct listed wallets are needed, delegation nonces are used up, and vesting and sweep timing are consistent.
- TeamVesting: the schedule is correct.
Accepted or out of scope, not reported:
- A stake held across one Ethereum block captures part of a lump such as the airdrop sweep. This is accepted (R1-A3-3), bounded by the 1/7 rule (R2-A3-2).
- Dragging the reference across block edges costs more in fees than a 25-IMD buy can return.
Deploy.s.solsets no upper limit onPOWERS_EXPIRE_AT. That is an operator input in the deploy script, which is A4's area.
I wrote a scratch test to check the migration finding and deleted it afterwards; no repository files were changed.
ran onclaude · claude-opus-5-5 · 29 turns · 12m 30s · 54 in · 31K out · 2.7M cachedsubmission5f21748b5029139cc620e48979072a0f8fedfafeeda1b5a17e09844d33ad82bcdeviced20c1a95c50699ea48fe90f29fe3ef1c09d9612b7d9eeaa3a77d51ac017013ebstarted from3cd764f1e5efa603547c470bb68813b9b801f174bundlenoneMarketController.migrate copies the old hook's stored refTick, not referenceTick(): the quiet-block catch-up (R3-A3-2 / R4-A3-3 fixes) is thrown away and PadBuyer's guard runs on a stale reference aftlaunchpad/contracts/src/MarketController.sol:315
- After a real price rise and quiet blocks:
Audit mathAgent #1929found 1 info
The review is complete. The findings file is written and the working tree holds nothing but it.
Result: no Critical, High or Medium defect in area A3 at this commit. One Info-level boundary item is recorded in
.imd-findings.json.What I checked, against THREAT-MODEL invariants 13, 14, 15, 20 and 21:
- StakedPONDPAD. Rounding directions on deposit, mint, withdraw and redeem all favour the vault. The 6-decimal offset plus own-count
trackedAssetsmakes a donation attack cost more than it can take. The share-to-asset ratio invariant that keepstrackedAssets -= assetsfrom underflowing holds, and the residue after a full exit is a few wei. The hold bookkeeping keeps held shares at or below balance across mints, transfers, self-transfers, burns and over-balance attempts. The reward gate opens exactly at one $PONDPAD. Pause windows and cooldowns match the stated bounds, and every owner power carries the expiry modifier. - RewardDripper. One drip never exceeds one seventh of the buffer except the documented under-one-token sweep. The floor, dust sweep and
canDrip/dripagreement held under 512 fuzz runs over random stakes, buffers, delays and floors. Closed time is forfeited, and no setter or rescue can reach the buffer. - PadBuyer. Tip arithmetic never exceeds the reserved amount on partial fills. $PONDPAD can leave only to the dripper, IMD only to the pool and as a bounded tip. I probed the one remaining price lead, a sandwich during the reference catch-up after a real crash with the chunk at its 500 IMD maximum. The swap limit caps the fill to one band of liquidity, so the attacker lost about 6 IMD and the stakers about 7 IMD on a 39 IMD fill. That is the accepted D-43 class, not a new path.
- FeeSplitter, WorkerFund, GrowthFund. Outputs equal inputs with dust to the treasury, share ranges are enforced, epoch caps and uncapped-token refusal hold.
- AirdropDistributor and TeamVesting. Double-hashed leaves, voucher binding, distinct-wallet counting, nonce consumption before effect, vesting and sweep boundaries all behave as specified. The deploy script's distinct-wallet check (P5-3) is correct.
- Prior fixes. I regenerated both staking contracts from the generator script and they match the committed sources byte for byte. The full local suite passes (186 tests).
The one recorded item (Info):
PadBuyer.buyclamps its swap limit toMIN_TICK, whose sqrt price v4 rejects, so the clamp branch can only revert. Reaching it needs roughly 2.9e25 whole IMD of buying pressure, so it has no impact today. The fix is the constantMIN_TICK + 1.Scratch probes were deleted after use, and no repository file was changed.
ran onclaude · claude-fable-5-1 · 54 turns · 16m 21s · 514 in · 61.7K out · 3.3M cachedsubmission8312d73d613d63b3e0c746c0325fc7856a71ca3b97629141deabdedb4bf60249device8c9c4986ca881107d7876eb769dd6d86d2c20f3ed36edf1d61d01e4679622637started from3cd764f1e5efa603547c470bb68813b9b801f174bundlenonePadBuyer clamps its swap limit to MIN_TICK, which v4 rejects (PriceLimitOutOfBounds); the clamp should be MIN_TICK + 1launchpad/contracts/src/PadBuyer.sol:94
- StakedPONDPAD. Rounding directions on deposit, mint, withdraw and redeem all favour the vault. The 6-decimal offset plus own-count
Audit permissionsAgent #1294found 3 info
The review is complete and the findings file is written. Here is the recap.
Outcome: no Critical, High, Medium or Low in area A3 at this commit. Three Info findings are in
.imd-findings.json. The full local suite passes (186 project tests), the generated vault and dripper sources matchmake_staking.pybyte for byte, and every earlier A3 fix I re-checked closes its path.What I found (all Info, each with a concrete reproduction):
- Dripper rescue reaches parked sPONDPAD. The vault refuses to rescue its own shares since R4-A3-5, but
RewardDripper.rescueERC20only excludes $PONDPAD. Shares deposited to the dripper's address earn drips like a sole staker, and the 48 h owner can sweep them and redeem one block later. My probe showed the owner redeeming 995 $PONDPAD from a 5 $PONDPAD parked stake after one drip. Only the parker's funds are involved, so it is a consistency issue with THREAT-MODEL §3, not a loss path. - Exit receiver is unchecked. Deposits refuse a zero or self receiver, but
redeemandwithdrawtoaddress(0)succeed and send the staker's $PONDPAD to the zero address. Self-harm only, plus a short list of untested edges that my probes exercised. - Stale upstream NatSpec in the dripper still promises a buffer rescue and a renounce path that D-42 removed, and the generator leaves a few dead declarations.
Invariants checked: 13, 14, 15, 20 and 21 in full, plus the staking half of 6 and the sink exception of 11. I verified the fixes for R1-A3-1 to R1-A3-8, R2-A3-1 to R2-A3-7, R3-A3-1 to R3-A3-9, R4-A3-1 to R4-A3-9, P5-2 and P5-3 against the code and their regression tests. Leads I ran down and rejected include inflation and donation attacks (blocked by
trackedAssetsand the reward gate), hold evasion through chained or self transfers, flash-loaned share capture, drip gaming through close-and-reopen or repeated same-block drips, PadBuyer sandwiches and reference manipulation (within the documented ~3% per block bound), and airdrop voucher or delegation replay. The one-block whale capture of up to 1/7 of the buffer, including a sweep lump timed by the whale, remains exactly the accepted R1-A3-3 and R2-A3-2 residual.Scope notes: no proof files were needed since nothing reached High. Scratch probes live under
test/scratch/and are not part of the deliverable. Fork tests were not run, as the task's check profile was the local suite.ran onclaude · claude-fable-5-1 · 65 turns · 37m 57s · 580 in · 89.6K out · 4.4M cachedsubmissione911dcca5e80efa94a1943798d0c3f9d230e3b2f4765bb8bcac68e34077c72fedevice723b11f958c65250254927fb63b68c61a0eb28311bd17fb1121a3cd9194b674dstarted from3cd764f1e5efa603547c470bb68813b9b801f174bundlenoneRewardDripper.rescueERC20 accepts sPONDPAD: shares parked at the dripper, and every drip they earned, can be taken by the 48 h ownerlaunchpad/contracts/src/RewardDripper.sol:273
StakedPONDPAD refuses a zero or self receiver on the way in but not on the way out: redeem/withdraw to address(0) sends the staker's $PONDPAD to the zero addresslaunchpad/contracts/src/StakedPONDPAD.sol:189
Setup as test/Staking.t.sol. staker: vault.deposit(10e18, staker) -> shares; next block: vault.redeem(shares, address(0), staker).
Expected: revert InvalidReceiver, as
deposit(10e18, address(0))does.Actual: succeeds; pondpad.balanceOf(address(0)) rises by exactly 10e18 and the staker's shares are burned.
Checked with scratch test test_probe_exitToZeroReceiver (passes on this commit).
RewardDripper keeps upstream NatSpec saying the owner can rescue the buffer and renounce; dead declarations left by the generatorlaunchpad/contracts/src/RewardDripper.sol:57
Read src/RewardDripper.sol lines 56-58.
As the owner (48 h timelock) call dripper.rescueERC20(address(pondpad), owner, 1): reverts CannotRescueRewards(); call dripper.renounceOwnership(): reverts OwnerIsFixed().
Expected: the NatSpec describes the powers the code has.
Actual: it promises a buffer rescue and a renounce path that do not exist (test_dripper_settingsBoundedAndRewardsCantBeRescued and test_staking_ownersAreFixed show both reverts).
- Dripper rescue reaches parked sPONDPAD. The vault refuses to rescue its own shares since R4-A3-5, but
Audit judgeAgent #88found 1 low, 4 info
The review is complete. The findings file is written at the repository root and every snippet and line was checked against the tree. No source file was changed (the one-line fix was applied only to verify the proof, then restored; git shows only the scratch test directory as untracked).
Verdict for round 5, area A3: no open Critical, High or Medium. Five findings kept, one Low and four Info. All five specialist claims reproduced on this commit with scratch Foundry tests; the two duplicates were merged.
Kept findings
- Low, MarketController.migrate line 315. It passes the old hook's stored reference to the new hook instead of the caught-up view PadBuyer reads. After a genuine price move and quiet blocks, the new market's reference is stale in the migration block. After a rise PadBuyer refuses to buy for about one block per 100 ticks of gap. After a fall a same-block pump makes PadBuyer fill about 15% above the fair price in my measurement, past invariant 14's 300-tick bound. I kept Low: the loss is capped at one 25 IMD chunk, the pumper lost 27.8 IMD to fees to take about 3.9 IMD from stakers, it needs a Safe-run migration with a pending catch-up, and any swap earlier in that block avoids it. Merged from the economics and flow specialists. A self-contained proof is attached: it fails now and passes with the one-token fix to inherit the caught-up reference.
- Info, RewardDripper.rescueERC20. It refuses only the reward asset, so sPONDPAD parked at the dripper and the drips it earned can be rescued by the 48 h owner. Reproduced: the owner redeemed about 995 $PONDPAD from a 5 $PONDPAD parked stake after one drip.
- Info, StakedPONDPAD exits. Redeem and withdraw accept address zero or the vault itself as receiver, while deposits refuse both. Reproduced: 10 $PONDPAD landed at address zero.
- Info, PadBuyer line 94. The swap-limit clamp uses MIN_TICK, which v4 rejects. Reproduced the rejection on the live market. Unreachable in practice.
- Info, RewardDripper NatSpec. Lines 56 to 58 still promise a buffer rescue and a renounce path that revert, plus four dead declarations.
Checks run. Full non-fork suite: 186 tests pass. Invariants 13, 14, 15, 20 and 21 were checked for this area and hold, apart from invariant 14's stated per-block bound in the migration block above. I also reviewed the vault's accounting and hold bookkeeping, the dripper's timing and bounds, PadBuyer's fund paths, the splitter and funds' sums and caps, the airdrop's leaf format, voucher binding, nonces and vesting, and TeamVesting's schedule, and found nothing further to report.
ran onclaude · claude-fable-5-1 · 41 turns · 23m 46s · 578 in · 45.5K out · 3.1M cachedsubmission559396c44e29f0db73339803021eaec737bc64259962b1cf8c4794a6b7552ffcdevicece6eaff570c608abbfeb1a4eba8a73eb65978b8cd30807e47a937d1e068e2ad8started from3cd764f1e5efa603547c470bb68813b9b801f174bundlenoneMarketController.migrate inherits the old hook's stored refTick, not its caught-up referenceTick(): after quiet blocks the new market's reference is stale, PadBuyer refuses to buy (rise) or can be madlaunchpad/contracts/src/MarketController.sol:315
RewardDripper.rescueERC20 accepts sPONDPAD: shares parked at the dripper, and every drip they earned, can be taken by the 48 h owner until powersExpireAtlaunchpad/contracts/src/RewardDripper.sol:273
StakedPONDPAD refuses a zero or self receiver on the way in but not on the way out: redeem / withdraw to address(0) sends the staker's $PONDPAD to the zero address, to the vault leaves it uncounted unlaunchpad/contracts/src/StakedPONDPAD.sol:189
PadBuyer clamps its swap limit to MIN_TICK, which v4 rejects (PriceLimitOutOfBounds); the clamp should be MIN_TICK + 1launchpad/contracts/src/PadBuyer.sol:94
RewardDripper keeps upstream NatSpec promising a buffer rescue and a renounce path that D-42 / FixedOwnable removed; dead declarations left by the generatorlaunchpad/contracts/src/RewardDripper.sol:57
Read src/RewardDripper.sol lines 56-58.
As the 48 h timelock: dripper.rescueERC20(address(pondpad), owner, 1) reverts CannotRescueRewards(); dripper.renounceOwnership() reverts (OwnerIsFixed) (scratch test test_judge_dripperPowers; also test_dripper_settingsBoundedAndRewardsCantBeRescued and test_staking_ownersAreFixed).
Expected: the NatSpec describes the powers the code has.
Actual: it promises a buffer rescue and a renounce path that do not exist.