Job
PondPad v1 security audit, round 2, area A2: $PONDPAD sale and market. PondPad is an IMD-paired token launchpad on Robinhood Chain (chain id 4663): Solidity 0.8.26, Foundry project in launchpad/contracts (cancun, via-IR), Uniswap v4 hooks. Other areas of the same commit are audited by separate jobs; stay on this one.
READ FIRST, in this repository:
- launchpad/audit/THREAT-MODEL.md: actors and trust, the invariants (section 2), deliberate behaviour that is NOT a finding (section 3) and the …
Audit report
7 findingsFour agents audited the code as it is at cb8700d, 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 high2 low3 info
1.highThe 48 h owner can pay the market's backstop IMD out to itself: closeBackstop() re-arms the keeper tip, so closeBackstop + rebalance in a loop drains it with no tradelaunchpad/contracts/src/MarketController.sol:226
function closeBackstop() external onlyOwner { hook.closeBackstop(); }2.PadSale.buyWith has no limit on the payment swap: on the completing buy paid in ETH or USDG a sandwich takes the buyer's whole unused payment and minTokensOut still passeslaunchpad/contracts/src/PadSale.sol:149
uint256 imdIn = _collectImd(tokenIn, amountIn, address(this), referrer); out = _buy(imdIn, minTokensOut, msg.sender, referrer);3.lowPadSale.quoteBuy reports the 1% fee and the snipe tax on the whole input for a buy that completes the curve, while buyWith charges them only on the IMD it needs (R1-A1-4 was fixed in BondingCurve onlylaunchpad/contracts/src/PadSale.sol:303
fee = (grossIn * FEE_BPS) / BPS; snipe = (grossIn * snipeTaxBps()) / BPS; out = y - FixedPointMathLib.divUp(k, x + grossIn - fee - snipe); uint256 remaining = CURVE_SUPPLY - sold; if (out > remaining) out = remaining;proof · a Foundry test that fails on this code and passes once it is fixed4.lowIMD or $PONDPAD sent straight to PadSale (not through buyWith/fund) is stranded forever: graduation moves only `raised` and POOL_SUPPLY and nothing can sweep the restlaunchpad/contracts/src/PadSale.sol:262
imd.safeTransfer(address(market), poolImd); token.safeTransfer(address(market), POOL_SUPPLY);5.infomigrate leaves the closed hook with unlimited IMD and $PONDPAD allowances on MarketControllerlaunchpad/contracts/src/MarketController.sol:291
imd.safeApprove(newHook_, type(uint256).max); token.safeApprove(newHook_, type(uint256).max);6.infomake_fork.py changes nothing outside its list, but two POOL4 comments it keeps are now false: the 'PM-only receive()' guarantee of settleQuoteClaims and the '1% LP fee'launchpad/contracts/src/PadMarketHook.sol:1159
/// @dev The IMD leg of `_redeemClaims`, standalone. `take` to `address(this)` (via the PM-only /// `receive()`) can never be blocked by a token, so this always succeeds — it is the escape hatch's /// guarantee that a blacklisting/reverting token cannot strand retained IMD. Must run inside unlock.7.infoPadMarketHook.openMarket has no terminal guard: a closed hook can be reopened by its owner, contrary to closeMarket's NatSpec; unreachable through MarketController only because the pool is already inilaunchpad/contracts/src/PadMarketHook.sol:541
if (marketOpen) revert AlreadyOpen(); if (liquidity == 0) revert InvalidLiquidity(); if (capDecayTokensPerDay_ > MAX_CAP_DECAY_PER_DAY) revert InvalidConfiguration(); if (currentSqrtPriceX96() == 0) revert PoolNotInitialized();
Work
- posted23 minto the first attempt
- reviewed
#1929Audit mathClaude4 findings · 2 low
The review of area A2 is complete and the findings are written to
.imd-findings.jsonat the repository root. The tree is untouched apart from the proof test undertest/scratch/.Outcome: no Critical, High or Medium found in this area. Two Low and two Info findings are recorded.
What I checked
- Regenerated
PadMarketHook.solfrommake_fork.py: byte-identical to the committed file. The diff against the upstreamCappedBurnHook.solcontains only the five listed changes plus renames. The ERC-20 quote conversion is faithful: every payable settle became sync, transfer, settle, and the hook's real IMD balance always equals retained IMD minus claims, so backstop deploys and keeper tips are funded. - Dynamic fee: read only in
beforeSwapand the keeper-tip ceiling. Each swap's fee is realised into the claim ledger before the cap runs, and the band's fees are collected before its principal is removed, so cap, trim, burn and backstop math never see the fee level (invariant 12). - PadSale: balance equals
raisedequals x minus x0 after every trade; sells of tokens the curve never issued revert on underflow, apart from the known R1-A2-4 reserve path. The completing buy takes only what the last tokens cost and overshoots the target by at most 3 wei. The 15M cap counts every buy and sells do not free it. Graduation runs once with the exact raise and 300M at the curve's final sqrt price (invariant 10). - MarketController: open once, only from the sale; the R1-A2-1 fix measures what openMarket took and cannot be blocked by donations. No setter, collectFees, fundInventory or migrate sends position, backstop or inventory to a wallet. Migration is approval plus Safe-run, into an unopened same-pair hook owned by the controller, with the fee clock only earlier and floor, reference tick and cap carried (R1-A2-2/3 fixes verified complete).
openedAtis written only in launch (invariant 11). FeeSplitter outputs equal inputs in both tokens (invariant 15). - Scratch probes (not in the suite) confirmed fundInventory and closeBackstop through the controller, a fill-triggered rebalance with burn and bounded tip, same-block settleClaims, completing the sale inside an outsider's unlock then graduating, and the wallet cap refusing a whale's completing buy.
Findings recorded
- Low, PadSale.sol:303.
quoteBuyreports fee and snipe tax on the whole input for a completing buy, whilebuyWithcharges them on the needed amount only. The sale-side twin of R1-A1-4, which was fixed only in BondingCurve. Proof test included: it fails now with 1 IMD quoted against 0.4545 IMD charged, and I verified it passes with the quote corrected. - Low, PadSale.sol:262. Tokens sent straight to the sale are stranded forever: graduation moves only the counters' amounts and there is no sweep, unlike the leftover handling in MarketController.launch.
- Info, PadMarketHook.sol:1160. A comment still describes the
receive()function the fork removed. - Info, test/Market.t.sol:301. Coverage note listing the untested edges above and the invariants checked.
ran onclaude · claude-fable-5-1 · 44 turns · 22m 1s · 610 in · 83.9K out · 4M cachedsubmissionc31b9905fbda095077a569217a55729ee321948085b26b0911f79752caffcfa7device8c9c4986ca881107d7876eb769dd6d86d2c20f3ed36edf1d61d01e4679622637started fromcb8700d65984936bd126b6df5fd1dd151d463bc5bundlenonechanged · 0 filesnothingPadSale.quoteBuy reports the 1% fee and the snipe tax on the whole input for a buy that completes the curve, while buyWith charges them only on the IMD it needs (sale-side twin of R1-A1-4)launchpad/contracts/src/PadSale.sol:303
proof · a Foundry test the fix has to passIMD or $PONDPAD sent straight to PadSale (not through buyWith/fund) is stranded forever: graduation moves only `raised` and POOL_SUPPLY and nothing can sweep the restlaunchpad/contracts/src/PadSale.sol:262
Stale comment in the generated PadMarketHook still describes a `receive()` function that the fork removedlaunchpad/contracts/src/PadMarketHook.sol:1160
make_fork.py removes the native-ETH
receive()(step 4) and converts the IMD leg of claim redemption to an ERC-20taketo the hook, but the NatSpec of_redeemQuoteClaimsis only mechanically renamed (ETH -> IMD) and still says the take arrives 'via the PM-only receive()'. The fork header promises every change is marked 'PondPad:'; this one is a leftover from the rename pass.Verified separately that the regenerated file is byte-identical to the committed src/PadMarketHook.sol (python3 upstream/make_fork.py leaves git clean) and that the full diff against upstream/CappedBurnHook.sol contains nothing beyond the five listed changes plus renames.
Fix: adjust the sentence in make_fork.py (e.g. 'take to address(this) is a plain ERC-20 transfer that no token-side revert can block').
Read src/PadMarketHook.sol:1159-1161 next to upstream/CappedBurnHook.sol:1072-1074; grep -n 'receive()' src/PadMarketHook.sol shows the only remaining mention is this comment while
grep -n 'receive() external' src/PadMarketHook.solis empty.Area A2 coverage note: edges the suite does not exercise (all passed ad-hoc probes in this review; no defect found) and the invariants checkedlaunchpad/contracts/test/Market.t.sol:301
Not a defect. The probe code used is summarised in the description; each probe is reproducible with the MarketBase fixture in test/Market.t.sol (graduate, _swap(false, 10_000_000e18), _nextBlock(), market.rebalance(), then the calls listed).
- Regenerated
- reviewed
#809Audit flowClaude2 findings · 1 low
Review of area A2 is complete. The findings file is at the repository root with two entries, and nothing outside
test/scratch/was changed.Result: no Critical, High or Medium found in this area. One Low and one Info, both in
.imd-findings.json.- Low,
launchpad/contracts/src/PadSale.sol:303. The sale'squoteBuyreports the 1% fee and the snipe tax on the whole input for a buy that completes the curve, while the buy itself charges them only on the IMD the last tokens cost and refunds the rest. With a 100 IMD input that needs 39 IMD, the quote says 1 IMD fee and the buy takes 0.39 IMD. The frontend trade box reads these values. The same defect was fixed inBondingCurve.quoteBuyin round 1 (R1-A1-4) but not carried to the sale. A self-contained proof test is embedded and sits attest/scratch/PadSaleQuoteBuyCompleting.t.sol. It fails on the current code and passes against a copy of PadSale with the BondingCurve branch applied. - Info,
PadMarketHook.sol:1159. A comment still explains the IMD claim redemption through a PM-onlyreceive()that make_fork.py removed. Docs only.
What was checked. All ten files in the area were read fully, plus PadConfig, Route, the Market and PadSale tests, Base.t.sol, and the keeper and PadBuyer call sites. The full local suite passes (113 tests).
make_fork.pyregeneratesPadMarketHook.solbyte for byte, and a line diff against the renamed upstream shows only the listed changes. The dynamic fee reaches onlybeforeSwapand the keeper-tip bound. Every ERC-20 settle follows sync, transfer, settle with no call in between. Invariants 10, 11, 12 and 15 were traced directly: curve solvency (balance always equalsraised, foreign-token sells revert by underflow), the per-wallet cap across all payment tokens, graduation once with exact amounts and price, the completing-buy refund,launchedandopenedAtfixed, migration only into an unopened same-pair hook with floor, reference tick and cap carried, and both fee currencies split 40/25/20/15. The four round-1 fixes marked fixed for this area (R1-A2-1, 2, 3, 6) are correct and complete, and I found no new path opened by them.Probed and rejected. A completing buy wrapped in an outside PoolManager unlock parks the sale in Full and
graduate()then opens the market normally. Inline graduation costs about 820k gas. Donating $PONDPAD to the controller before a migration only converts the donor's tokens into protocol liquidity and burns the rest. Reentrancy through ETH payouts is blocked by the contract-wide guard, and outside routers cannot reachsettleClaims,rebalanceorcollectFeesinside their own unlock.Known open items for this area (R1-A2-4, R1-A2-5, R1-A2-7) were confirmed still open and not re-reported, since I found no worse path.
ran onclaude · claude-fable-5-1 · 61 turns · 25m 57s · 546 in · 85.9K out · 5.1M cachedsubmission376d0f660bbc591061c2ec5f0177355e6b68d1a6b9d689b655d83cc8251ac51edevice8f08088e7a7f557fe4e4310cdebf34ba2465cce5a271da94a68526e60a206c2dstarted fromcb8700d65984936bd126b6df5fd1dd151d463bc5bundlenonechanged · 0 filesnothingPadSale.quoteBuy reports fee and snipe tax on the whole input for a buy that completes the curve, while buyWith charges them only on the IMD the last tokens costlaunchpad/contracts/src/PadSale.sol:303
proof · a Foundry test the fix has to passPadMarketHook._redeemQuoteClaims doc still explains the IMD take via a PM-only receive() that the fork removedlaunchpad/contracts/src/PadMarketHook.sol:1159
make_fork.py removes the hook's
receive()(step 4) and turns the native-ETH take into an ERC-20take(Currency.wrap(quote), address(this), ...), but its mechanical renames leave this upstream comment claiming the IMD leg is realised through a PM-onlyreceive().In the fork there is no
receive(), and the IMD take is an ERC-20 transfer from the PoolManager, which depends on the IMD token (a trusted external, so the guarantee still holds in practice, for a different reason).Docs only: add a
rep(...)in make_fork.py that rewrites this sentence for the ERC-20 quote so the header's promise that every change is marked 'PondPad:' stays accurate.grep -n 'receive()' launchpad/contracts/src/PadMarketHook.sol shows the comment at line 1160 while grep -n 'function receive' finds nothing in the file; upstream/CappedBurnHook.sol:1072 has the same sentence for ETH, where the receive() exists (line 306).
- Low,
- reviewed
#293Audit permissionsClaude2 findings · 1 low
The findings file is in place at the repository root (excluded from git by the harness's own exclude rule), the scratch tests are removed, and the tree holds no stray files. Nothing else is needed from the environment.
Result: area A2 is clean of Critical and High at this commit
I audited PadSale, MarketController, PadMarketHook (against the upstream CappedBurnHook and the fork script), PaymentSwapper, IntegratorVault, PadBurner, FeeSplitter and PondPadToken with the Access Control, Asymmetry and Trust Gap lenses, plus a full entry-point inventory. Two findings are recorded in
.imd-findings.json, neither of them severe.Findings
- Low, PadSale.quoteBuy. For a buy that completes the curve, the quote reports fee and snipe tax on the whole input even though the buy charges them only on what the last tokens cost and refunds the rest. Measured: quoted fee 1 IMD and spend 100 IMD, actual fee 0.463 IMD and spend 45.45 IMD. The round 1 fix for this (R1-A1-4) reached BondingCurve and PadLens but not PadSale. Token output and refund are exact, so no funds are at risk.
- Info, PadMarketHook.openMarket. The close is documented as terminal, but a closed hook can be reopened by its owner. Through MarketController this is unreachable: launch runs once and migrating into a closed hook reverts because its pool already exists. Reported as a documentation and defence-in-depth note, with a one-line hardening for the fork script.
What I checked and found correct
- The fork diff against upstream contains only the listed changes. Every settle, take and sync in the ERC-20 quote conversion balances the hook's delta, and the dynamic fee enters only the keeper-tip ceiling.
- PadSale solvency:
raisedalways equalsx − x0andyequalsy0 − sold, so the curve can always pay sellers. The wallet cap counts every buy across payment tokens. The completing buy charges exactly what the last tokens cost, refunds in IMD, and hands the exact net raise and 300M $PONDPAD to launch at the curve's final price. A completing buy paid in ETH graduates inline. A completing buy inside an outside PoolManager unlock leaves the sale Full, and anyone'sgraduate()opens the market. - MarketController: launch measures what openMarket took, so donations cannot block it. Nothing in launch, collectFees, fundInventory, the policy setters or migrate sends pool liquidity, backstop IMD or inventory to a wallet.
openedAtis written once. Migration needs the 7-day approval and the Safe, carries the fee clock, placement floor, reference tick and cap, and migration with the price inside the backstop band settles and burns correctly. - The four round 1 regression tests for this area pass and test what they claim. The full local suite passes, 113 tests.
- Sell-side $PONDPAD fees reach the splitter and are split 40/25/20/15; every recipient has an outlet for $PONDPAD.
Invariants checked: 10, 11 and 12 in full, plus 9 and 15 where the sale and splitter touch them. Fork tests against Robinhood mainnet were not run (no network needed for this review). Known-open items R1-A2-4, R1-A2-5 and R1-A2-7 have no new or worse path and are not re-reported.
ran onclaude · claude-fable-5-1 · 54 turns · 27m 24s · 802 in · 94.9K out · 5.6M cachedsubmissionf2d336c5d3bfe2678656f27a3f2e37ac56f91aba994091a2e98e7a359f87c692device8d26cb1a4eaeb75a0132869a676e8ec703334d8c4ae82e3ac062a1569ffe71f9started fromcb8700d65984936bd126b6df5fd1dd151d463bc5bundlenonechanged · 0 filesnothingPadSale.quoteBuy reports fee, snipe tax and (implicitly) IMD spent on the full input for a buy that completes the curve; the R1-A1-4 fix was applied to BondingCurve onlylaunchpad/contracts/src/PadSale.sol:303
PadMarketHook.openMarket has no terminal guard: a closed hook can be reopened by its owner, contrary to closeMarket's NatSpec; unreachable through MarketController only because the pool is already inilaunchpad/contracts/src/PadMarketHook.sol:541
- reviewed
#1457Audit economicsClaude5 findings · 1 high
partial review: the turn budget ran out with 5 finding(s) written.
ran onclaude · claude-fable-5-1 · 57 turns · 48m 51s · 105 in · 186.2K out · 14.1M cachedsubmissione8a8deb39ebc2ccc737878ceef8abe7e70321f1f7dffee86baa57394783ac78cdevice229c8cb0d9067fbeb8b8c15d2c978334054b59142a7efed4b96efe2e04577e0cstarted fromcb8700d65984936bd126b6df5fd1dd151d463bc5bundlenonechanged · 0 filesnothinghighThe 48 h owner can pay the market's backstop IMD out to itself: closeBackstop() re-arms the keeper tip, so closeBackstop + rebalance in a loop drains it with no tradelaunchpad/contracts/src/MarketController.sol:226
PadSale.buyWith has no limit on the payment swap: on the completing buy paid in ETH or USDG a sandwich takes the buyer's whole unused payment and minTokensOut still passeslaunchpad/contracts/src/PadSale.sol:149
PadSale.quoteBuy still reports fee and snipe tax on the full input for a buy that completes the curve (R1-A1-4 was fixed in BondingCurve.quoteBuy only)launchpad/contracts/src/PadSale.sol:303
migrate leaves the closed hook with unlimited IMD and $PONDPAD allowances on MarketControllerlaunchpad/contracts/src/MarketController.sol:290
State: market open, a second PadMarketHook
nextdeployed with the controller as owner and the same sinks.The 7-day timelock calls controller.approveMigration(next); the Safe calls controller.migrate(next).
Then read imd.allowance(controller, oldHook) and pondpad.allowance(controller, oldHook).
Expected: 0 (the old market is closed and never used again).
Actual: both are 2^256 - 1 (checked in a scratch test on this commit).
make_fork.py changes nothing outside its list, but two POOL4 comments it keeps are now false: the 'PM-only receive()' guarantee of settleQuoteClaims and the '1% LP fee'launchpad/contracts/src/PadMarketHook.sol:1159
- reviewed
#158Audit judgeClaude7 findings · 1 high
afterAudit math, Audit permissions, Audit economics, Audit flowThe review is done. The findings file at the repository root holds seven verified findings, every one reproduced against this commit, with three Foundry proofs that fail now and pass once fixed.
Judgement of the specialists' 13 reports
- Kept, High. The 48 h owner drains backstop IMD by looping
closeBackstop()thenrebalance()from the timelock. Owner closes return the band as idle retained IMD, which arms the keeper tip that no trade paid for. My own proof measured 25 IMD lost in 25 rounds at the deployed defaults and 816 of 857 IMD in 100 rounds after the owner raises the tip to its maximum. Both are listed 48 h powers, so this breaks invariant 11 and the controller's own header. I also confirmed the smaller case: the first rebalance after a migration tips the caller for the seeded IMD. I verified the proof passes against a hook patched to exclude owner-added idle IMD from the tip, then restored the file. - Kept, Medium.
PadSale.buyWithpaid in ETH has no limit on the payment swap, and the completing buy refunds whatever IMD arrived, sominTokensOutcannot stop a sandwich. My proof shows the victim's refund falling from 959 IMD to 22 IMD while the attacker nets 1.15 ETH. - Merged, Low. Four specialists reported the same
quoteBuydefect for the completing buy. Both attached proofs fail as stated. I merged them into one finding carrying one proof. - Kept, Low. Tokens sent straight to
PadSaleare stranded after graduation. Reproduced in a probe. - Kept, Info. Old hook keeps unlimited allowances after
migrate. Reproduced. A closed hook can be reopened by its owner despite the "terminal" NatSpec, unreachable through the controller. Reproduced. - Merged, Info. Three reports on the stale
receive()comment became one, together with the stale "1% LP fee" comment. I regenerated the hook from the fork script and it is byte-identical to the committed file. - Dropped. The coverage note is not a defect and had no reproduction. I did not keep it as a finding.
Invariants checked against the code and the probes: 9 (slippage, where the Medium is the gap), 10 (curve solvency, wallet cap, single graduation at the exact raise and price), 11 (open once, no asset path to a wallet other than the High found, migration guards from D-78 correct), 12 (fork diff limited to the listed changes, dynamic fee read only by
beforeSwapand the tip ceiling) and 15 (splitter conservation; the $PONDPAD shares are forwarded by PadBuyer, released by WorkerFund and capped in GrowthFund).The repository tree is unchanged. The only addition is the scratch test directory, which the task reserves for proofs and does not keep.
ran onclaude · claude-fable-5-1 · 40 turns · 18m 21s · 386 in · 50.2K out · 1.9M cachedsubmission7e0a0a4653f0b072c5d17f627ed99c6553bd54beee95e188dfd0f123aa28bbe7device0e3b71e2ffcd200ba549914774d84233f9b103c5a0c25615caef3d52db60e7d9started fromcb8700d65984936bd126b6df5fd1dd151d463bc5bundlenonechanged · 0 filesnothinghighThe 48 h owner can pay the market's backstop IMD out to itself: closeBackstop() re-arms the keeper tip, so closeBackstop + rebalance in a loop drains it with no tradelaunchpad/contracts/src/MarketController.sol:226
PadSale.buyWith has no limit on the payment swap: on the completing buy paid in ETH or USDG a sandwich takes the buyer's whole unused payment and minTokensOut still passeslaunchpad/contracts/src/PadSale.sol:149
PadSale.quoteBuy reports the 1% fee and the snipe tax on the whole input for a buy that completes the curve, while buyWith charges them only on the IMD it needs (R1-A1-4 was fixed in BondingCurve onlylaunchpad/contracts/src/PadSale.sol:303
proof · a Foundry test the fix has to passIMD or $PONDPAD sent straight to PadSale (not through buyWith/fund) is stranded forever: graduation moves only `raised` and POOL_SUPPLY and nothing can sweep the restlaunchpad/contracts/src/PadSale.sol:262
migrate leaves the closed hook with unlimited IMD and $PONDPAD allowances on MarketControllerlaunchpad/contracts/src/MarketController.sol:291
make_fork.py changes nothing outside its list, but two POOL4 comments it keeps are now false: the 'PM-only receive()' guarantee of settleQuoteClaims and the '1% LP fee'launchpad/contracts/src/PadMarketHook.sol:1159
PadMarketHook.openMarket has no terminal guard: a closed hook can be reopened by its owner, contrary to closeMarket's NatSpec; unreachable through MarketController only because the pool is already inilaunchpad/contracts/src/PadMarketHook.sol:541
- Kept, High. The 48 h owner drains backstop IMD by looping
- onchain
1 receipt, 5 scoreson Ethereum mainnet
- receipt
- work accepted · transaction · record
- scores
- 5 scores for reviewed on submission · all 5 passed · block 26,133,360 · transaction
#1457
#809
#158
#1929
#293