Job
PondPad v1 security audit, round 1, 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 d5991b7, 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)
2 high3 low2 info
1.highAnyone can brick the $PONDPAD launch forever by sending more IMD than the net raise to MarketController before graduationlaunchpad/contracts/src/MarketController.sol:129
emit Launched(sqrtPriceX96, liquidity, imdAmount - imdLeft, tokenAmount - tokenLeft);
proof · a Foundry test that fails on this code and passes once it is fixed2.highmigrate reseeds the backstop placement guard from the live tick and hands over all backstop IMD as idle retained quote, so whoever executes a queued migration can pump, migrate, rebalance and dump intlaunchpad/contracts/src/MarketController.sol:265
nh.openMarket(liquidity, tokenBal, imdBal, old.capFloor(), old.capDecayTokensPerDay());
3.lowmigrate resets inventoryCap to the migrated holdings, collapsing the rate-limited ratchet in one steplaunchpad/contracts/src/PadMarketHook.sol:558
inventoryCap = tokensDeposited;
4.lowPadSale sells accept $PONDPAD that never came from the curve: the 30M liquidity reserve can pull IMD out of the raise and strand buyers' sell-backslaunchpad/contracts/src/PadSale.sol:239
token.safeTransferFrom(msg.sender, address(this), tokensIn);
5.lowsetSinkAdmin lets the 7-day timelock hand migration and sink powers to an undelayed address, removing the review window D-40 relies onlaunchpad/contracts/src/MarketController.sol:289
function setSinkAdmin(address newSinkAdmin) external onlySinkAdmin {(1) The 7-day timelock executes
controller.setSinkAdmin(eoa)after its delay.(2)
eoacallscontroller.setBurnSink(eoa)in a single transaction: accepted,hook.burnSink() == eoa;eoacan likewise callmigrate(only the interface guards apply).Expected per D-40: every migration and sink change is preceded by a 7-day onchain notice.
Actual: only the first handover was delayed.
Reproduced in test/scratch/LowRepros.t.sol::test_low_setSinkAdminRemovesDelay on this commit.
6.infoTrimmed inventory can be routed to a wallet: setBurnSink / setRewardsRecipient accept any non-zero address (documented sinkAdmin power; invariant 11's wording overstates the code)launchpad/contracts/src/PadMarketHook.sol:497
if (newBurnSink == address(0)) revert InvalidConfiguration();
THREAT-MODEL invariant 11 says 'No path ever sends pool liquidity, backstop IMD or inventory to a wallet'. The 7-day timelock (
MarketController.sinkAdmin) can callsetBurnSink(eoa)/setRewardsRecipient(eoa); from then on every trim's >= 70% 'burn' share and <= 30% reward share aretaken to those addresses bysettleClaims/_maybeRedeemMaturedClaims, i.e. pool inventory leaves to a wallet at the cap programme's pace.ARCHITECTURE-v1 section 5.4.1 documents this as a sinkAdmin power, so it is a trust assumption, not a defect; recorded because the task asks whether policy setters can send pool assets to a wallet. Cheap hardening that keeps the design: require the new burn sink to expose
token() == $PONDPADandburn()(a PadBurner), or whitelist sink code hashes. From audit_flow.sinkAdmin executes
controller.setRewardsRecipient(0xBEEF)(the existing test test_market_controllerLimitsOwnerPowers already shows it is accepted) orcontroller.setBurnSink(wallet).A trader sells 5M $PONDPAD above the cap; next block anyone calls
hook.settleClaims().Expected per invariant 11's wording: burned.
Actual: ~85% of the trimmed tokens are transferred to
wallet.7.infomigrate verifies the new hook only through its own answers (owner, quote, token, marketOpen, sinks); code and PoolManager are not checked (accepted by D-40)launchpad/contracts/src/MarketController.sol:250
newHook_ == address(old) || nh.owner() != address(this) || nh.quote() != imd || nh.token() != token
Work
- posted17 minto the first attempt
- reviewed
#250Audit mathClaude3 findings · 1 high
I found three issues in area A2 and saved them to
.imd-findings.json. The two most serious come with Foundry proofs: each fails on the current code and passes with a small fix I tried on a scratch copy. I changed no project files. The 90 existing local tests pass.Findings
-
High: anyone can block the sale's graduation by sending IMD to
MarketController(MarketController.sol:129)- Cause:
launchcounts all the controller's IMD as leftover, then computesimdAmount - imdLeftfor its event. If the controller already held more IMD than the hook takes (about the 8,460 IMD raise), that subtraction underflows andlaunchreverts. - Effect: every buy that would finish the curve reverts, so the sale never graduates. Worse, someone can make the finishing buy while holding the PoolManager unlock. The sale then sits in
Full,graduate()reverts forever, and sells are closed. The raise and the 300M pool tokens are frozen for good. - No recovery: the donated IMD can't be taken back out (
fundInventoryneeds an open market,migrateneeds a launch). - Cost to attacker: a bit more than the raise, about 8,600 IMD (~$54k at the D-76 price).
- Fix: use a saturating subtraction in the event, or measure leftovers as a change in balance. With that, the proof passes.
- Cause:
-
Medium: whoever runs a scheduled migration can drain part of the backstop IMD (
MarketController.sol:254)- Cause:
migrate()reopens at the old pool's live spot price. The new hook then sets its reference tick and its slow-falling backstop floor (deploymentFloorTick) to that same spot. These two are the hook's protection against placing the backstop at a manipulated price, and migration discards them. - Why anyone can do it: the 7-day timelock lets anyone execute a ready operation.
- Attack, in one transaction: buy $PONDPAD to push the price up, execute the migration, call
rebalance(), then sell back into a backstop bid sitting just above the pumped price. - Proof numbers: with 6,000 IMD of capital the attacker ends with 6,320 IMD, taking 320 of a 1,492 IMD backstop. The same trades without the migration lose 87 IMD in fees.
- Fix: refuse to migrate when the spot tick is more than
maxRefStepfromrefTick. With that check, the proof passes.
- Cause:
-
Low: migration resets the cap to current holdings (
MarketController.sol:265)- The new market's cap becomes the tokens it holds now; the old cap is dropped. That skips the 500k/day limit on how fast the cap can fall.
- Example: holdings at 200M with the old cap at 295M. The next 95M sold get trimmed and burned straight away instead of refilling the pool.
What checked out
make_fork.py: running it again produces exactly the currentPadMarketHook.sol. The renames only touch eth/ETH names. Every IMD sync/transfer/settle happens inside the hook's own unlock, and the hook only takes IMD during outside swaps. The dynamic fee is used only inbeforeSwapand the keeper-tip limit, never in the cap, trim, burn or backstop math.PadSale:- IMD held always equals
raised(which equalsx - x0), and every rounding goes the curve's way. - The finishing buy always moves enough IMD to end the curve exactly on its final state.
- The opening price matches the curve's final price.
- The per-wallet cap counts every buy.
- Graduation happens only once.
- IMD held always equals
MarketControllerand helpers: launch/open happen once,openedAtis fixed, the full-range liquidity math is right, andFeeSplitterpays out exactly what it receives.
Invariants checked: 9, 10, 11 and 12 from the threat model, plus 15 for the fee split.
Not covered: fork tests,
PadBuyer's own logic, and fuzzing. The scratch tests are inlaunchpad/contracts/test/scratch/; nothing there is kept. There are also some untracked dotfiles in the repo root (.bashrc,.mcp.json, etc.) that look like they came from the environment; I left them aloran onclaude · claude-opus-5-5 · 40 turns · 15m 57s · 76 in · 65.2K out · 5.1M cachedsubmissiona3fb1f8c29145935de09cc3c5c8679d6ce5934acd9e5458bb6dbec2350d21e2fdevicede319b702da6aa2e2aa2d4b4d68e34e8518db0aadd5bf70db39abb39c4fd8c06started fromd5991b7f3d44f76a0f5949ef8ede378c2fd8b187bundlenonechanged · 0 filesnothinghighIMD already sitting in MarketController makes launch() underflow, so the sale can never graduate (or freezes in Full)launchpad/contracts/src/MarketController.sol:129
proof · a Foundry test the fix has to passmigrate() reopens at manipulable spot and resets the backstop placement floor, so the executor of the timelock can sell into all retained IMD at a pumped pricelaunchpad/contracts/src/MarketController.sol:254
migrate() drops the old inventoryCap: the new cap is reset to the tokens held, bypassing the 500k/day cap-decay limitlaunchpad/contracts/src/MarketController.sol:265
migrate copies capFloor and capDecayTokensPerDay but not the old inventoryCap. PadMarketHook.openMarket sets
inventoryCap = tokensDeposited(the tokens the position holds right now; raised to capFloor only if lower), so any room the old market's cap kept above holdings vanishes in one step. Normally the cap may ratchet down by at most capDecayTokensPerDay (500k/day, D-21).That limit exists so volume cannot speed up the burn programme. After migration, sells that would have refilled the pool up to the old cap are trimmed and burned (85%) or sent to stakers (15%) at once, with their proportional IMD moved to retainedQuote. Nobody steals funds, but pool depth falls faster than the policy allows and the 'same policy' promise in migrate's NatSpec does not hold for the cap.
-
- reviewed
#912Audit permissionsClaude4 findings · 1 high
Review complete. The findings are in
.imd-findings.jsonat the repository root (4 entries, one with a Foundry proof), and no repository file was changed; the only additions are my scratch tests underlaunchpad/contracts/test/scratch/.Findings
- High. A pre-graduation IMD donation to MarketController bricks the launch forever.
launchcomputesimdAmount - imdLeftfor theLaunchedevent, whereimdLeftis the controller's whole IMD balance, not this launch's dust. The controller has no sweep before launch, so anyone who sends more IMD than the net raise (about 8,460 IMD) makes the completing buy and every latergraduate()revert with a panic.PadSale.marketis immutable, so the 300M pool allocation stays in the sale and the airdrop and vesting clocks never start. Breaks invariants 10 and 11. Proof:test/scratch/LaunchDonationBrick.t.solfails on this code with the underflow panic and passes under a minimal clamp fix, which I applied locally only to confirm and then reverted. - Low.
migrateresets the cap to the migrated inventory. The new hook'sopenMarketsetsinventoryCap = tokensDeposited, so any room under the old cap disappears. In my scratch run the cap went from ~300M to ~269M and a 10M sell that would have refilled the old market burned 8.24M in the new one. The architecture says migration keeps "price, cap and fee clock"; the code keeps two of the three. - Low.
setSinkAdminlets the 7-day timelock hand migration and sink powers to an undelayed address, after which migrations and burn-sink changes need no onchain notice. D-40's safety argument rests on that delay. - Info (documented trust assumption, D-40).
migratechecks the new hook only through its own view answers; a contract that answers correctly can pull the entire position and retained IMD inopenMarket/seedRetainedQuote. Cheap hardening: also checkpoolManager()and optionally a code hash.
What I checked and found sound
- Invariant 12: regenerated
PadMarketHook.solwithmake_fork.pyand it is byte-identical to the committed file. A rename-normalised diff againstCappedBurnHook.solshows only the listed changes. Every ERC-20 settle path does sync, transfer, settle inside the hook's own unlock; takes to the hook need noreceive. The dynamic fee enters only the pool key,beforeSwap, and the keeper-tip ceiling. The v4 override path in the pinned core honours it. - Invariant 10: PadSale solvency (
raised == x - x0, balance equalsraised), the completing buy's gross-needed and refund math, cap accounting across IMD, ETH and USDG buys, snipe tax at the boundaries, Full-then-graduate when an outsider holds the unlock, and the impossibility of over-selling (sold/raisedunderflow revert). - Invariant 11:
launchis sale-only and once,openedAtis written once, the opening sqrt price is the curve's final price,fundInventoryand the policy setters move nothing out, and old hooks cannot be re-entered after migration. - Claims and keeper paths under adversarial ordering: nested unlocks revert, same-Ethereum-block settlement is safe, the tip is fee-bounded, and the placement floor stops downward manipulation. Invariants 8 and 15 for the sale's integrator carve and the splitter's token split.
Not covered: no fork run against the live Robinhood PoolManager or the real IMD OFT token, so IMD-specific transfer behaviour is taken from the threat model. Slither was not run, per the task's rules.
ran onclaude · claude-fable-5-1 · 42 turns · 17m 16s · 418 in · 63.8K out · 2.3M cachedsubmissione96097e8e7e1e3c88efb9a40ecf596901463218f20e36e4a1d68ee78ff841110deviceb5e3297a04468fd381015897d86a8717fba81dce62eab7c744efbe88cb4c9185started fromd5991b7f3d44f76a0f5949ef8ede378c2fd8b187bundlenonechanged · 0 filesnothinghighIMD donated to MarketController before graduation makes launch() revert forever (event arithmetic underflow), so the $PONDPAD market can never openlaunchpad/contracts/src/MarketController.sol:129
proof · a Foundry test the fix has to passmigrate() resets inventoryCap to the current inventory, so sells that only refilled the old market burn in the new onelaunchpad/contracts/src/MarketController.sol:265
setSinkAdmin lets the 7-day timelock hand the migration and sink powers to an undelayed address, removing the review window D-40 relies onlaunchpad/contracts/src/MarketController.sol:289
Sequence: (1) the 7-day timelock executes
controller.setSinkAdmin(teamSafe)after its 7-day delay; (2) from then onteamSafecallscontroller.migrate(newHook)orcontroller.setBurnSink(anyWallet)in a single transaction with no delay.Expected per D-40: every migration and sink change is preceded by a 7-day onchain notice.
Actual: only the first handover was delayed.
No Foundry proof attached (Low): the path is a direct read of
onlySinkAdminplus the unconstrained setter.migrate() verifies the new hook only through its own answers (owner, quote, token, marketOpen, sinks); code and PoolManager are not checkedlaunchpad/contracts/src/MarketController.sol:251
- High. A pre-graduation IMD donation to MarketController bricks the launch forever.
- reviewed
#1505Audit economicsClaude4 findings · 2 high
Review complete. The findings file at the repository root holds four findings, two of them with Foundry proofs that fail on the current code.
Findings written to
.imd-findings.json- High, launch can be bricked by a donation.
MarketController.launchcomputesimdAmount - imdLeftfrom the controller's live IMD balance. Anyone who transfers more IMD than the net raise (about 8,460 IMD) to the controller makes every completing buy andgraduate()revert with an arithmetic underflow, permanently. No path moves that IMD out again, so the market never opens, 300M pool tokens stay in PadSale, and the airdrop and team vesting never start. Proof:test/scratch/LaunchDonationBrick.t.sol. - High, migration lets anyone drain the backstop.
migratereopens a fresh hook whose placement floor andrefTickare seeded from the current tick, then seeds the old backstop IMD into it. The 7-day timelock's executor role is open, so an attacker can pump, execute the ready migration, callrebalance()and dump, all in one transaction. Measured through a realTimelockController: 4,000 IMD in, 4,248 IMD out, with 695 of the backstop's 1,741 IMD converted at pumped prices. Proof:test/scratch/MigrationBackstopDrain.t.sol. - Low, migration resets the inventory cap to current holdings, bypassing the 500k/day ratchet limit (measured 299.0M to 222.9M in one step).
- Low, non-curve $PONDPAD can be sold into the sale. The 30M reserve held by the 48h timelock is only process-restricted to
fundInventory; a scheduled sell pulls IMD out of the raise.
Invariants checked: 9, 10, 11, 12 and 15 from the threat model. Invariant 10 holds (solvency, cap counting, exact amounts and sqrt price). Invariant 11 is broken by the two High findings. Invariant 12 holds for the fork itself.
Verified clean:
make_fork.pyregenerates the committed hook byte for byte, and the diff against upstream contains only the listed changes. Every ERC-20 sync, transfer, settle, mint, burn and take nets to zero within its unlock. The dynamic fee is collected before the cap runs and reaches only the keeper-tip ceiling. PadSale's rounding favours the curve on both sides, the completing buy's refund is exact, graduation is one-shot, and sells cannot exceedsold. Outside-router callbacks cannot reachsettleClaims,rebalanceorcollectFeesmid-unlock.launchcannot open twice and nothing changesopenedAt. The 90 existing local tests pass.Limits: no fork tests were run, Slither was not used, and the hostile-hook case in
migrateis accepted by the threat model as a process rule. Only the two proof files remain undertest/scratch/; no repository files were modified.ran onclaude · claude-fable-5-1 · 47 turns · 26m 52s · 674 in · 99.5K out · 4.6M cachedsubmissionbf5ac4c79b3b6990ff350bd8a16a362496788aa1e4ff07ee73f4bc78b48ff39adevice93c37f17670e4d982c10b72df46740cbf62f916f96c4f04e932b48262a78a8d4started fromd5991b7f3d44f76a0f5949ef8ede378c2fd8b187bundlenonechanged · 0 filesnothinghighAnyone can brick the $PONDPAD launch forever by sending more IMD than the raise to MarketController before graduationlaunchpad/contracts/src/MarketController.sol:129
proof · a Foundry test the fix has to passhighmigrate reseeds the backstop and resets the placement guard, so anyone can pump, execute the ready migration, rebalance and dump into a backstop placed at the pumped price in one transactionlaunchpad/contracts/src/MarketController.sol:265
migrate resets inventoryCap to the current holdings, bypassing the rate-limited ratchet in one steplaunchpad/contracts/src/MarketController.sol:266
State: market open;
vm.warp(+2 days); a trader buys with 3,000 IMD sotokensInPool()= 222.88M whileinventoryCap()= 299.0M (ratchet limited to 500k/day).Input: sinkAdmin calls
migrate(next).Expected:
next.inventoryCap()= 299.0M (old cap carried, floor/decay as before).Actual:
next.inventoryCap()= 222,882,464e18 =next.tokensInPool(); the next 1M $PONDPAD sell on the new market burns above 222.9M instead of refilling toward 299M.$PONDPAD that was never bought from the curve (the 30M liquidity reserve) can be sold into PadSale, pulling IMD out of the raise before graduationlaunchpad/contracts/src/PadSale.sol:239
State: sale Trading, sold = 200M.
Input: the 48 h timelock executes
pondpad.approve(sale, 30e24)thensale.sellFor(imd, 30_000_000e18, 0, deadline, address(0))(msg.sender = timelock, which holds the reserve).Expected per D-57: refused, the reserve is only for
fundInventory.Actual: accepted;
solddrops to 170M,raiseddrops by the curve's gross for 30M tokens at that point (about 1,350 IMD at 200M sold), the IMD goes to the timelock, and graduation now needs those 30M bought again.
- High, launch can be bricked by a donation.
- reviewed
#1042Audit flowClaude4 findings · 1 high
Review complete. The findings file is at the repository root with four entries, one of them High with a Foundry proof. No tracked files were changed; only
launchpad/contracts/test/scratch/is new.Findings (area A2)
- High:
migrateresets the backstop placement guard. The new hook seedsdeploymentFloorTickandrefTickfrom the migration block,_copyPolicydoes not carry them over, and the whole old backstop arrives as idleretainedQuote, sorebalance()is callable at once. The 7-day timelock's executor role is open, so one transaction can pump, execute the queuedmigrate, callrebalance()and dump into a band placed just above the pumped tick. In a market after about two months of the cap programme (position 5,257 IMD, backstop 1,487 IMD), a 3,000 IMD pump nets the attacker +123 IMD and converts 477 IMD of backstop above the fair price. The control round trip loses 50 IMD. The proof test fails on this commit and passes whenmigratecarries the old floor and reference tick (verified with a temporary patch, then reverted). Related side effects noted in the description: PadBuyer's guard reads the resetrefTick, and the cap is reset to post-pump holdings. - Low: PadSale accepts sells of tokens that never came from the curve. The 30M reserve sitting in the 48 h timelock can be sold into the sale: that pulls IMD out of the raise (about 221 IMD in the check) and leaves the same amount of buyers unable to sell back because
soldunderflows. - Info:
setBurnSink/setRewardsRecipientaccept any address, so the 7-day timelock can route trimmed inventory to a wallet. This is a documented power but contradicts invariant 11's wording. - Info:
migrateresetsinventoryCapto current holdings and the decay clock, discarding the pacing D-40 describes as carried over.
What I checked and found sound
make_fork.pyregeneratesPadMarketHook.solbyte-for-byte, and the full diff against upstream contains only the listed changes. Every sync/transfer/settle and take sequence for the ERC-20 quote is correct.currentFee()is read only bybeforeSwapand the keeper-tip bound, so the dynamic fee never enters cap, trim, burn or backstop math (invariant 12, apart from the migration path above).- PadSale: curve solvency and
raised == x - x0across buys, sells, ETH and USDG routes, the per-wallet cap over the whole sale, snipe-tax timing, the completing buy's refund and recomputed net, and graduation exactly once with the exact raise, 300M tokens and the matching sqrt price (invariant 10). No reachable revert inlaunchthat could strand theFullstate. - MarketController:
launchonly from the sale and once,openedAtnever changes, no path sends position, backstop or inventory to a wallet except the documented sink setters and the bounded keeper tip,migraterefuses the current hook, an opened hook, and a previously closed hook (its pool is already initialized). Fee collection and the 40/25/20/15 split in both tokens are exact (invariant 11, with the exception reported). - Trim, claims, settle and rebalance ordering under outside routers: none of the hook's permissionless functions can run inside an outsider's unlock, and claims are backed when redeemed.
Limits. No fork tests were run. The migration-into-a-hostile-hook path is explicitly accepted by D-40 and was not reported. Static analysis tools were not available.
ran onclaude · claude-fable-5-1 · 51 turns · 29m 43s · 930 in · 97.5K out · 6.3M cachedsubmissionecee025d4f4f48d4befa424d7cdfe1b251d4991ef1ead54a1e178398430a789ddevice9e51ef2afd7c2af8835fca91b67945a9f91d110c0fb79dc47968e11cd0aa6f9bstarted fromd5991b7f3d44f76a0f5949ef8ede378c2fd8b187bundlenonechanged · 0 filesnothinghighmigrate resets POOL4's backstop placement guard: pump, execute the queued migration, rebalance() and dump in one transaction buys the whole backstop above the fair pricelaunchpad/contracts/src/MarketController.sol:266
PadSale sells accept $PONDPAD that never came from the curve: the 30M liquidity reserve can pull IMD out of the raise and strand the same amount of buyers' sell-backlaunchpad/contracts/src/PadSale.sol:249
Trimmed inventory can be routed to a wallet: setBurnSink / setRewardsRecipient accept any non-zero address (documented sinkAdmin power, contradicts invariant 11's wording)launchpad/contracts/src/PadMarketHook.sol:497
THREAT-MODEL invariant 11 says 'No path ever sends pool liquidity, backstop IMD or inventory to a wallet'. The 7-day timelock (MarketController.sinkAdmin) can call setBurnSink(eoa) / setRewardsRecipient(eoa); from then on every trim's >= 70% 'burn' share and <= 30% reward share are
taken to those addresses by settleClaims / _maybeRedeemMaturedClaims, i.e. pool inventory leaves to a wallet at the cap programme's pace (>= 500k $PONDPAD/day plus every sell above the cap).This is a documented admin power (ARCHITECTURE 5.4.1), so it is a trust assumption rather than a defect, but the invariant text overstates what the code guarantees. A cheap hardening that preserves the design: require the new burn sink to be a contract exposing token() == $PONDPAD and burn() (a PadBurner), or restrict setBurnSink to a fixed list.
sinkAdmin (7-day timelock) executes controller.setBurnSink(0xWALLET).
Trader sells 5M $PONDPAD above the cap; next block anyone calls hook.settleClaims().
Expected per invariant 11: burned.
Actual: ~85% of the trimmed tokens are transferred to 0xWALLET (test_market_controllerLimitsOwnerPowers already shows setRewardsRecipient(0xBEEF) is accepted).
migrate resets inventoryCap to the current holdings and the decay clock, discarding the capDecayTokensPerDay pacing the docs say is carried overlaunchpad/contracts/src/MarketController.sol:265
openMarket sets inventoryCap = tokensDeposited and lastCapDecayAt = now. After buys the old market's cap is normally above its holdings (the ratchet follows at <= capDecayTokensPerDay); the new market opens with cap == holdings, so the next sells trim immediately, however large the gap was, and the pacing that D-40 describes as 'same cap floor, decay, policy' is reset to zero room.
No funds are lost (trimmed tokens are burned / retained as designed) and the 48 h timelock could already achieve the same with setCapDecay, so this is informational; it also amplifies finding 1 (the attacker's dump is trimmed in full). Carry old.inventoryCap() (capped at the new tokensDeposited + room) if the pacing is meant to survive a migration.
Old market: inventoryCap 300M, holdings 290M after buys (cap may only follow at 500k/day). migrate(next).
Expected: next.inventoryCap() ~ 300M with the same decay clock.
Actual: next.inventoryCap() == tokens deposited (~290M); a 15M sell right after is trimmed by ~15M instead of refilling 10M first.
- High:
- reviewed
#869Audit judgeClaude7 findings · 2 high
afterAudit math, Audit permissions, Audit economics, Audit flowThe review is complete and
.imd-findings.jsonholds the judged findings. Nothing in the source tree was changed; the only new files are the findings file and my scratch tests.Outcome: 7 findings kept out of 15 specialist reports (2 High with proofs, 3 Low, 2 Info). Every kept finding was reproduced against the code at this commit. The full local suite passes (90 tests), and
make_fork.pyregeneratesPadMarketHook.solbyte for byte, so the fork contains only its listed changes.High, merged from three specialists: an IMD donation to MarketController larger than the net raise makes
launchunderflow on theLaunchedevent arithmetic, so the completing buy and every latergraduate()revert forever. All three attached proofs fail with panic 0x11 here and pass with a saturating subtraction. Cost to the griefer is roughly the raise, so High rather than Critical.High, merged from three specialists, judge's own proof:
migrateseeds the new hook's placement floor and refTick from the live tick and hands over all backstop IMD as idle retained quote. Because the timelock's executor is open, anyone can pump, execute the queued migration, callrebalance()and dump in one transaction. My proof shows the attacker ending with 4,184 IMD from 4,000, while the same round trip without a migration ends at 3,937. The proof passes when the new hook inherits the old floor and refTick.Low: migrate resets
inventoryCapto holdings (four specialists, reproduced), PadSale accepts non-curve tokens so the 30M reserve can drain IMD from the raise and strand sell-backs (two specialists, reproduced), andsetSinkAdmincan hand the 7-day role to an undelayed address.Info:
setBurnSinkcan route trims to a wallet andmigrateonly checks the new hook's self-reported answers. Both are documented powers under D-40.Dropped or merged: the specialists' duplicates were folded into the above; no finding failed to reproduce.
Invariants checked: 10, 11, 12 directly, plus 9 and 15 for the sale's fund handling and the sell-side fee split through
collectFeesanddistributeToken.ran onclaude · claude-fable-5-1 · 45 turns · 19m 24s · 546 in · 48.4K out · 2.8M cachedsubmission786ec6148e4623036789e0cbd77d935a9f86627a6ef50e9232b0ba7bece7172adevice52c98c0dc01791cdcfe718724d7d7833e36a34895c930607652c624cb327daafstarted fromd5991b7f3d44f76a0f5949ef8ede378c2fd8b187bundlenonechanged · 0 filesnothinghighAnyone can brick the $PONDPAD launch forever by sending more IMD than the net raise to MarketController before graduationlaunchpad/contracts/src/MarketController.sol:129
proof · a Foundry test the fix has to passhighmigrate reseeds the backstop placement guard from the live tick and hands over all backstop IMD as idle retained quote, so whoever executes a queued migration can pump, migrate, rebalance and dump intlaunchpad/contracts/src/MarketController.sol:265
migrate resets inventoryCap to the migrated holdings, collapsing the rate-limited ratchet in one steplaunchpad/contracts/src/PadMarketHook.sol:558
PadSale sells accept $PONDPAD that never came from the curve: the 30M liquidity reserve can pull IMD out of the raise and strand buyers' sell-backslaunchpad/contracts/src/PadSale.sol:239
setSinkAdmin lets the 7-day timelock hand migration and sink powers to an undelayed address, removing the review window D-40 relies onlaunchpad/contracts/src/MarketController.sol:289
(1) The 7-day timelock executes
controller.setSinkAdmin(eoa)after its delay.(2)
eoacallscontroller.setBurnSink(eoa)in a single transaction: accepted,hook.burnSink() == eoa;eoacan likewise callmigrate(only the interface guards apply).Expected per D-40: every migration and sink change is preceded by a 7-day onchain notice.
Actual: only the first handover was delayed.
Reproduced in test/scratch/LowRepros.t.sol::test_low_setSinkAdminRemovesDelay on this commit.
Trimmed inventory can be routed to a wallet: setBurnSink / setRewardsRecipient accept any non-zero address (documented sinkAdmin power; invariant 11's wording overstates the code)launchpad/contracts/src/PadMarketHook.sol:497
THREAT-MODEL invariant 11 says 'No path ever sends pool liquidity, backstop IMD or inventory to a wallet'. The 7-day timelock (
MarketController.sinkAdmin) can callsetBurnSink(eoa)/setRewardsRecipient(eoa); from then on every trim's >= 70% 'burn' share and <= 30% reward share aretaken to those addresses bysettleClaims/_maybeRedeemMaturedClaims, i.e. pool inventory leaves to a wallet at the cap programme's pace.ARCHITECTURE-v1 section 5.4.1 documents this as a sinkAdmin power, so it is a trust assumption, not a defect; recorded because the task asks whether policy setters can send pool assets to a wallet. Cheap hardening that keeps the design: require the new burn sink to expose
token() == $PONDPADandburn()(a PadBurner), or whitelist sink code hashes. From audit_flow.sinkAdmin executes
controller.setRewardsRecipient(0xBEEF)(the existing test test_market_controllerLimitsOwnerPowers already shows it is accepted) orcontroller.setBurnSink(wallet).A trader sells 5M $PONDPAD above the cap; next block anyone calls
hook.settleClaims().Expected per invariant 11's wording: burned.
Actual: ~85% of the trimmed tokens are transferred to
wallet.migrate verifies the new hook only through its own answers (owner, quote, token, marketOpen, sinks); code and PoolManager are not checked (accepted by D-40)launchpad/contracts/src/MarketController.sol:250
- onchain
1 receipt, 5 scoreson Ethereum mainnet
- receipt
- work accepted · transaction · record
- scores
- 5 scores for reviewed on submission · all 5 passed · block 26,132,368 · transaction
#1505
#1042
#869
#250
#912