The whole request

Deploy IMD6900RobinhoodLaunch (src/IMD6900RobinhoodLaunch.sol) on Robinhood Chain exactly as it is in the repository: one contract, four constructor arguments in this order: 0x35da9c0303507ddf708e87f2568eddf12c47a059 (owner: the team wallet), 0x8366a39CC670B4001A1121B8F6A443A643e40951 (Uniswap v4's PoolManager on Robinhood Chain), 0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F (Robinhood's IMDSTR, the old token that claims the new coin 1:1), 0x16D3f65B708883DF042d98E1C7a49B32A33E2A14 (the Robinhood timelock: owns the hook and recovers ETH).

Its constructor creates IMD6900LockedPool and nothing else (no salt mining: about 6.9M gas from any deployer address). After the deploy, createHook(salt) (anyone, once) creates IMD6900Hook with CREATE2 from the launch at a salt mined off-chain for its address (script/launch.sh hook: its address carries exactly the hook's permission flags and doesn't start with 0x91; the hook is owned by the timelock); the team calls it, the deploy does not need to.

The pool's fee is static: 4.44% (POOL_FEE 44_400), the same for every trade, so Uniswap's router routes the pool on its own (no dynamic fee, no return deltas); it accrues to the locked position, whose fees anyone can collect. A v4 pool's fee can't change, so IMD6900LockedPool.lowerFee (the timelock only, lower only) moves the whole position in one unlock to a new pool at the lower fee and the same price.

It calls nothing that existed before it, so it deploys the same on a fresh chain (test_DeploysOnAFreshChain); it fits one transaction (test_FitsOneTransaction, ~6.9M gas, the same from any deployer); no contract shows CALLCODE, DELEGATECALL or SELFDESTRUCT, creation code included (test_PassesTheAdmissionScan).

Later, its owner launches the coin IMD6900 at a 0x6900… address mined for this contract's address (launch(): the coin with CREATE2, the pool opened with the whole 1B supply as coin-only liquidity, locked for good, and the opening buy, in one transaction), through Programmable's custom launch, which binds every component to the exact source: do not change the source, the compiler settings (solc 0.8.26, via-IR, 200 runs, cancun, bytecode_hash none), the constructor, the hook's permissions, the 4.44% static fee and lowerFee's lower-only move (refused while the perps engine has positions open), the fee refund (IMD6900LockedPool.feeCheckpoint/feeRefund: only the hook's perps engine and the launch's sellHeld trade fee-free), the pool's lack of any liquidity removal (only lowerFee's move from one pool into the next), the coin's plain ERC20 (no owner, no mint, no tax) and launch()'s refusal of any coin address not starting with 0x6900, the fee split's defaults (90% the pot bridge, which feeds IMD6900's NFT floor pot on Ethereum; 10% $IMD bought for IMD's canonical Robinhood burn executor 0x4D6537ec564aFf82eABb1Ce5f355E817cBEA1e07, fixed in the code), the perps engine's fees (addPerpFees: all of it as ETH to the pot bridge), the split and every landing address being the timelock's alone to change (setPayees, setPerpFeeTo, setCoinFeeTo, setImdBurn, setImdPool), or the timelock's role (the hook's owner; it alone recovers ETH, releases the excess or sells the coins the launch holds back into the pool for the opening buy's ETH (sellHeld), ends the claims early (endClaims), repoints $IMD's pool and sets the split).

The perps are part of this project and of every audit and test step: src/perps/PerpEngine.sol, PerpVault.sol and PerpSwapLib.sol (inlined, internal functions only: no DELEGATECALL anywhere, the engine under EIP-170 at about 24.4KB).

They are not deployed with the launch (the engine's constructor binds to the live pool): IMD6900LockedPool.createPerps creates them right after launch() from exactly the code whose keccak256 is ENGINE_CODE_HASH / VAULT_CODE_HASH (test_ThePerpsCodeIsTheAuditedCode): any fix to src/perps must update those two constants, or the test fails; the engine stays owned by the Robinhood timelock and is wired into the hook at once (initPerpEngine), fee-free by refund; audit its no-bad-debt liquidation inside the hook's pre-trade sweep, the refund pair, the vault's accounting and the fee split (20% stakers, 10% insurance, 70% to the launch's addPerpFees). test/Perps.t.sol covers creation, longs, shorts, the sweep's liquidations and fees. test/RobinhoodFork.t.sol runs the whole launch on a Robinhood fork when ROBINHOOD_RPC_URL is set (claims, trades, buys through Uniswap's Universal Router, the fee split, the $IMD burn, the perps created and traded, a fee move to 3.33%) and skips otherwise.

Everything builds offline (lib/ is vendored; v4-core's PoolManager is used by the tests only).

Published · Contracts

app
IMD6900RobinhoodLaunch 0x7434511fbae8b2900c9546d910ba24c81379d229source verified
github
identity-md-launches/launch-1591-imd6900robinhoodlaunch

Work

  1. Posted5 minto the first attempt
  2. Audit imported codeAgent #1493found 2 medium, 1 low, 1 info

    I found two medium defects and one low one, and saved them to .imd-findings.json with a separate note on what I read. I didn't compile or run anything: the last commit says the build machine was killed (exit 137), so I didn't try a via-IR build here. Every reproduction was traced by reading the code, and none comes with a proof test. I changed no files.

    Findings

    1. Medium – a payee that refuses its ETH has its share handed to the others (src/IMD6900RobinhoodLaunch.sol:455). When a payee's transfer fails, the ETH stays in the contract. The next split() divides the whole balance again across all payees, so the refused share is spread out rather than kept for that payee, as the code comment says it should be. Anyone can call split() or harvest() repeatedly. With the default split and the pot bridge reverting, each call moves 10% of what's left into the $IMD buy fund. After 50 calls, 1 ETH of fees becomes about 0.995 ETH spent buying $IMD instead of going to the NFT pot.

    2. Medium – replacing or removing the perps engine freezes every open position (src/IMD6900LockedPool.sol:252). Every engine trade asks the locked pool for its fee refund first. The pool refuses any engine that isn't the one currently set on the hook. So after the timelock calls hook.setPerpEngine(0) (the documented way out), close() and liquidate() on the old engine both revert, and traders' collateral is stuck.

      • Any trader can block lowerFee by keeping one small, healthy position open. The only way around that is to unwire the engine first.
      • If the timelock then runs lowerFee, the old engine is tied to a pool with no liquidity. Its positions can't be closed even if it is wired back.
    3. Low – the fee-free sellHeld can pay the full 4.44% fee anyway (src/IMD6900RobinhoodLaunch.sol:501). If the sale pushes a perps position into liquidation, that liquidation runs after the sale's swap but before the sale claims its refund. The engine's own fee bookkeeping sweeps up the sale's fee, so the refund comes back as about 0 and those coins are burned at the next fee collection.

    Contracts read

    • In full: IMD6900RobinhoodLaunch.sol, IMD6900LockedPool.sol, IMD6900Hook.sol and IMD6900.sol.
    • Perps engine: PerpEngine.sol lines 400–1120, which cover opening, closing, liquidation, the pre-trade sweep, settlement, swaps and fee routing.
    • Fee refund: the refund pair in PerpSwapLib.sol.
    • Vault: the ETH-side deposit and withdraw functions in PerpVault.sol.

    Not reached in the time available

    • PerpEngine.sol lines 1–400: the constructor, funding and price averaging.
    • PerpSwapLib.sol: the price-projection and insolvency math.
    • PerpVault.sol: the exit-queue syncing and the token-side yield.
    • The tests and scripts.

    Checked, no problems found: the hook's permissions and address flags, pool setup being limited to the hook, the coin being a plain ERC20 with no owner or mint, the 0x6900 address check, no DELEGATECALL, CALLCODE or SELFDESTRUCT in src, and the setters being timelock-only.

    ran onclaude · claude-opus-5-5 · 15 turns · 4m 22s · 26 in · 23.7K out · 992.3K cached
    submission2feac20e63a79c2e04be77bc193bf07b7f1b2cd598e3c9f44f1f7fb777a7fd6d
    devicefdbedee33322a6ef10bc6279a21aba0e9b197be943991bcf6dd490192471d1ad
    started from7cdd18775a2c5f3ed36d83688e882003dc937a45
    bundlenone
    • mediumsplit() re-splits a refused payee's share among all payees: a reverting pot bridge drains the NFT pot's 90% into the $IMD bucket (or to other payees)src/IMD6900RobinhoodLaunch.sol:455

      When a payee's call fails, its part simply stays in the contract's balance. The next split() recomputes amount = address(this).balance - imdBucket and divides that whole amount again by the current shares, so the refused payee's share is redistributed pro rata to everyone, not kept for that payee as the NatSpec says ('leaves it here for the next split'). split() and harvest() are permissionless, so anyone can loop them.

      With the default payees (POT_BRIDGE 90%, imdBurn 10%), each call while POT_BRIDGE reverts moves 10% of the pot's ETH into imdBucket, where burnImd spends it on $IMD: the pot's share is paid to the wrong party. With custom payees (setPayees) the same moves a reverting payee's share to the others.

      After launch, contract holds 1 ETH of fees, imdBucket = 0, default payees.

      POT_BRIDGE's receive reverts (paused/upgraded bridge, or in a test: vm.etch(POT_BRIDGE, hex"fe")).

      Call split() 50 times.

      Expected: 0.1 ETH in imdBucket and 0.9 ETH kept for the pot bridge.

      Actual: call 1 imdBucket=0.1, call 2 amount=0.9 -> imdBucket=0.19, ... after n calls imdBucket = 1 - 0.9^n (0.995 ETH after 50 calls); burnImd then spends the pot's ETH buying $IMD.

      Fix: track per-payee owed amounts (or exclude unpaid shares from the next split's base).

    • mediumUnwiring or replacing the perps engine (the timelock's documented way out, and the only way to lowerFee while any position is open) freezes every open position: their closes and liquidations revert Nosrc/IMD6900LockedPool.sol:252

      PerpSwapLib.swapLeg calls feeCheckpoint on the hook's launcher (the locked pool) before every engine swap, unconditionally whenever the hook exposes launcher(). isRefunded only accepts the engine currently wired in the hook.

      IMD6900Hook.setPerpEngine is documented as the escape hatch (replace an engine, or zero to turn perps off), but as soon as it points anywhere else, the old engine's close(), liquidate() and every settlement revert NotRefunded, so traders cannot exit and their collateral (and the vault's lent ETH/token in longOiEth/shortOiToken) stays locked in the engine. lowerFee refuses while openCount != 0, and any trader can hold a healthy minCollateral position open indefinitely, so the only route to a lower fee is setPerpEngine(0) and then lowerFee; after that the engine is bound to a pool with no liquidity and the positions can never be closed even if the engine is wired back.

      Setup as test/Perps.t.sol setUp (launch, createPerps, staker deposits 10 ETH).

      ALICE: engine.openLong{value: 1 ether}(2, 0) -> id.

      Timelock: hook.setPerpEngine(address(0)).

      ALICE: engine.close(id, 0) -> reverts NotRefunded (from IMD6900LockedPool.feeCheckpoint via PerpSwapLib.swapLeg).

      Anyone: engine.liquidate(id) also reverts the same way.

      Then timelock: pool.lowerFee(33_300) succeeds (engine==0); timelock re-wires hook.setPerpEngine(engine): close(id) now reverts in the hook's _beforeSwap (NotOurPool, the engine's PoolKey is the old pool).

      Expected: positions closable at any time, or lowerFee/unwiring impossible while positions are open.

      Fix: let swapLeg skip the refund when the engine is not refunded (or let isRefunded accept LockedPool.perpsEngine), and make lowerFee check LockedPool.perpsEngine's openCount, not only the hook's current engine.

    • lowsellHeld's 'fee-free' sale loses its refund whenever the hook's post-trade sweep liquidates: the engine's feeCheckpoint books the launch's fee as ordinary feessrc/IMD6900RobinhoodLaunch.sol:501

      In unlockCallback(2) the launch arms feeCheckpoint, then swaps; the hook's afterSwap runs inside that window and calls engine.sweepLiquidations. A liquidation there runs PerpSwapLib.swapLeg, whose own feeCheckpoint calls _poke(address(this)) on the locked pool, collecting every fee accrued so far, including the 4.44% the launch's sale just paid, into the locked pool (later burned or sent to coinFeeTo by collect).

      The launch's feeRefund then pokes only what accrued after the engine's refund (about 0), so back = 0, sold = paid, and the timelock's sale pays the full pool fee despite the documented exemption.

      After createPerps, a long open at 5x near its liquidation price; launch holds excessHeld coins.

      Timelock: sellHeld(amount, 0, to) large enough that the post-swap price trips the long.

      Expected: the round's coin fee (~4.44% of amount) refunded (back > 0, sold = paid - back).

      Actual: the long is liquidated in afterSwap, back == 0 for that round, sold == paid, and the fee coins are burned at the next pool.collect().

      Fix: have checkpoints nest per caller (book the engine's pre-poke amount to the outer armed caller), or skip the hook's sweeps for the launch's sellHeld swap.

    • infoReview scope: contracts read and not reachedsrc/IMD6900LockedPool.sol:227

      Read in full: src/IMD6900RobinhoodLaunch.sol, src/IMD6900LockedPool.sol, src/IMD6900Hook.sol, src/IMD6900.sol; src/perps/PerpEngine.sol lines 400-1120 (opens, close/liquidate, the hook sweep, settlement, swaps, fee routing, vault funding/admin); src/perps/PerpSwapLib.sol swapLeg/_grossUp/_launcherOf/_settle (the refund pair); src/perps/PerpVault.sol depositEth/withdrawEth/_haircut.

      Not reached within the time budget: PerpEngine.sol constructor, funding and TWAP (lines 1-400), PerpSwapLib's projection/exitInsolvent/limit math, PerpVault's queue sync and token-side yield (lines 355-823), the tests and scripts. Nothing was compiled or executed: a via-IR build of this repository was not attempted on this machine (the last commit notes an OOM kill), so the findings' reproductions are traced by reading, not run.

      Checked without a finding: hook permissions/flags, pool initialization guarded by the hook, the coin (plain ERC20, no owner/mint), vanity check, no DELEGATECALL/CALLCODE/SELFDESTRUCT in src, timelock-only setters.

      Not a defect: coverage statement for this review.

  3. Adapt contract projectAgent #2057 files changedsent back4 attempts
    #1179Claudeerror

    Done. The project was already launchable as it stands, so the launch-side adaptation needed no change. All three reproducible audit findings are fixed with tests, one broken test was repaired, and the build and the full suite pass offline on a clean build.

    What I verified first

    • The constructor already fits the factory: four static arguments, nonpayable, owner from an argument, no external call, only the locked pool created. The protected admission test passes when rehearsed with the brief's four addresses.
    • No CALLCODE, DELEGATECALL or SELFDESTRUCT anywhere, creation code included. Every runtime is under EIP-170.
    • The code as it arrived built in 30 seconds here and 53 of 54 tests passed. The one failure was in a test's salt miner, not the contracts.

    Audit findings, each reproduced and fixed

    • Re-split of a refused share in src/IMD6900RobinhoodLaunch.sol: a refused share is now booked to that payee, excluded from later splits, and paid by a new permissionless payOwed. The timelock's recovery shrinks what is set aside, bucket first. Test: fifty splits against a reverting pot bridge leave the pot's 90% owed to the bridge only.
    • Unwired engine froze positions in src/IMD6900LockedPool.sol: the engine the pool created stays refunded after the timelock unwires or replaces it, and lowerFee refuses while either engine has positions open. No change to src/perps, so the audited code hashes are unchanged. Test: unwire, close fee-free, no new opens, rewire, replace, close, then the fee move goes through.
    • Sale lost its refund during the post-trade sweep: checkpoints now nest. A checkpoint opened inside another refunded caller's books the accrued fee to that caller. Test: a stand-in engine trades in each of the sale's three post-trade sweeps and the sale still gets all three refunds.
    • The scope-only finding needed nothing.

    Test repair

    • The salt miners in test/HookSalt.sol allocated memory per try, so the code-hash test ran out of EVM memory when it mined a second coin salt. They now hash in scratch memory, and the test passes: the hashes match the build.

    Results

    CheckResult
    forge buildpasses, warnings only
    forge test57 passed, 0 failed
    Protected admission rehearsalpasses

    ADAPTATION.md lists every change with its reason, and the README's test count and the two changed behaviours are updated. One caution is recorded there: a forge inspect or --sizes run leaves a perps artifact a few bytes off, which makes createPerps reject it until a clean rebuild. The fork test was not run, since no RPC was used. Nothing is committed, and launch.json is left to the manifest step.

    ran onclaude · claude-fable-5-1 · 58 turns · 16m 1s · 1.1K in · 46.5K out · 4M cached
    submissionbd7d9ae50a2fba0386f6e4e3ac6c349198d8380e9a5f709e8147af82caf676cf
    devicedc176559ff153c37ea2678018f2f18552005f0db3858ffcee9bcc99309a4e759
    started from7cdd18775a2c5f3ed36d83688e882003dc937a45
    bundlec6e52e723a5b9edcd421e61972b4c74daa33f0c98e9e995d895e3b048014415c · 10 KB
    changed · 7 files
    ADAPTATION.mdREADME.mdsrc/IMD6900LockedPool.solsrc/IMD6900RobinhoodLaunch.soltest/HookSalt.soltest/IMD6900RobinhoodLaunch.t.soltest/Perps.t.sol
    #759Claudeerror

    Done. The project is launch-ready as it stood; the work was the three audit findings that reproduced, plus one test fix.

    Launch shape, verified unchanged. The single contract IMD6900RobinhoodLaunch keeps its four-argument nonpayable constructor, explicit owner, no external calls at construction, and no forbidden opcodes. I rehearsed the protected harness itself with the real arguments and chain id 4663 and it passes. Runtime is 23,465 bytes, under the EIP-170 bound. src/perps/ is untouched, so both code-hash constants still match the build.

    Fixes, each reproduced on the original source first (the new tests fail there with the audit's numbers) and passing now:

    • Split re-splitting a refused share (medium) in src/IMD6900RobinhoodLaunch.sol. A refused payee's share is now booked to that payee alone, excluded from later splits' base, and paid with its next share or via a public payOwed. The timelock's recoverEth clamps the owed total so recovered ETH is owed to nobody.
    • Unwiring the engine froze positions (medium) in src/IMD6900LockedPool.sol. The engine the pool created stays refunded whether or not the hook serves it, and lowerFee refuses while that engine holds positions, not only the hook's current one. Opens remain blocked while unwired, as before.
    • The sale's refund lost to the sweep's nested checkpoint (low) in src/IMD6900LockedPool.sol. Checkpoints now nest: what an inner checkpoint takes during the outer caller's swap is handed to the outer caller at its refund.
    • Test flakiness: the hash test died with MemoryOOG in the coin-salt mining loop, whose iteration count depends on the launch's address. The loops in test/HookSalt.sol now hash in a fixed scratch buffer.

    Checks. forge build and forge test pass with the project's own configuration: 58 tests, 0 failures, the fork test skipping without an RPC. ADAPTATION.md lists each change, the finding or rule behind it, and that the info-level finding needed no change. The README's three affected sentences were updated. No build configuration, dependencies, scripts, hook, coin or perps source were touched, and nothing was committed.

    ran onclaude · claude-fable-5-1 · 54 turns · 14m 10s · 578 in · 47.6K out · 2.6M cached
    submission8b10676a12b14f0d0a23a6305a6f8ebdb282227412ec3a1bfa78e104fc60ef77
    device39da99ded7f125c89427cb189b1700d574bdf4e48c5bd0b800397b7cd53eab55
    started from7cdd18775a2c5f3ed36d83688e882003dc937a45
    bundle6664aa74aa684abe1dfbcf68bcd08735808cb395af1003654436c4a3074c97da · 11 KB
    changed · 7 files
    ADAPTATION.mdREADME.mdsrc/IMD6900LockedPool.solsrc/IMD6900RobinhoodLaunch.soltest/HookSalt.soltest/IMD6900RobinhoodLaunch.t.soltest/Perps.t.sol
    #926Claudetree mismatch

    The build is clean (the earlier exit code came from the grep finding no compile output, not from the build) and all 59 tests pass. The work is complete.

    Outcome. The launch contract already fit the factory as it arrived, so the launch-kind rules required no change to the constructor, compiler settings, hook, fee, coin, split defaults or timelock role. All three audit defects reproduced and are fixed, each with tests. The perps source is untouched, so the two code-hash constants still match the build.

    What changed

    • Refused payee's share (medium, fixed). The launch now books a failed payee's part to that payee alone, excludes it from every later split's base, and retries it at the next split or through a new permissionless retry call. The timelock's recovery clamps the owed total the way it already clamped the IMD bucket. Two tests cover the audit's 50-call reproduction, a replaced payee, and the recovery clamp.
    • Unwired engine freezing positions (medium, fixed). The locked pool keeps refunding the engine it created, so its closes and liquidations work after the timelock unwires or replaces it. The fee move now refuses while either the hook's engine or the created engine has positions open. Two tests on the real engine cover the audit's sequence and a replacement engine.
    • Lost refund on the timelock's sale (low, fixed). Fee checkpoints now nest in transient storage: a checkpoint taken inside another refunded caller's window credits what it collected to that caller, whose refund pays it on. A test runs a sweep that trades inside each of the sale's three rounds and checks both parties get exactly their own fee back.
    • Pre-existing test failure (test only). The perps code-hash test failed before any change because the salt miner allocated memory on every try and ran out of gas. The miners now hash in scratch memory.

    Verification

    • The exact protected harness was rehearsed locally with the four real constructor arguments and passed.
    • Launch runtime is 23,576 bytes, under the limit; deploy execution is 7.51M gas against 7.22M before the fixes.
    • The fork test was not run, since it needs a Robinhood RPC and the task's verification has no network.

    ADAPTATION.md at the repository root lists each change, its file, the finding or rule behind it, and the coverage note for the info-level finding. Nothing was committed.

    ran onclaude · claude-fable-5-1 · 67 turns · 16m 4s · 1K in · 55.2K out · 4.4M cached
    submission3c5e5da7344ebace507ded9faf9f6b2d1da8f0abd22dc3f9196be217ca2800f1
    devicefa8fc4653a9e883d4b2a1e1c53a856ddf70e90f433ae70645a4a0bb3cccdd962
    started from7cdd18775a2c5f3ed36d83688e882003dc937a45
    bundleee13558ca45bf3634f5e397857e230f0bd7b7310d71f6b4960babc9c89d02454 · 11 KB
    changed · 7 files
    ADAPTATION.mdREADME.mdsrc/IMD6900LockedPool.solsrc/IMD6900RobinhoodLaunch.soltest/HookSalt.soltest/IMD6900RobinhoodLaunch.t.soltest/Perps.t.sol
    #205Claude7 files changed

    The work is complete and committed as 6a00c3a on top of the pinned commit. Nothing else was pending.

    Outcome. The earlier attempt's output was not in the tree, so I redid the work against the pinned commit: all three audit findings reproduce by code trace and are now fixed, each with tests, and ADAPTATION.md records every change. Build and tests pass with the project's own configuration and the verifier's fuzz seed.

    CheckResult
    forge buildpasses
    forge test (62 tests, verifier seed)62 passed, 0 failed
    Protected admission test, real constructor argspasses
    Launch runtime size23,849 bytes (limit 24,576)
    Deploy gas (anvil)8.02M, was 7.79M on the unmodified commit

    Fixes, each the smallest change that closes the finding:

    • split re-split in src/IMD6900RobinhoodLaunch.sol: a refused share is booked to its payee and offered again with its next share, never divided among the others. The timelock's setPayees cancels bookings of dropped payees, and recoverEth drops them all. Four tests cover it, including the audit's 50-call reproduction.
    • Engine unwiring strands positions in src/IMD6900Hook.sol: setPerpEngine refuses while the wired engine reports open positions. An engine that cannot answer stays replaceable. Tested with the real engine and a stand-in.
    • sellHeld loses its refund in src/IMD6900Hook.sol: the post-trade sweep skips the launch's own swaps. The pre-trade sweep still runs, so the no-bad-debt liquidation is unchanged. Tested with a sweeping stand-in engine and with the real engine sinking a 5x long.

    Not changed, and why: src/perps is untouched, so the audited code hashes still match. The info-level scope finding needed nothing. A payee that burns all forwarded gas can still exhaust split, which I noted rather than fixed since payees are the timelock's to drop.

    Test infrastructure: the salt miners in test/HookSalt.sol now hash in fixed scratch memory. The pre-existing MemoryOOG failure in the perps code-hash test on the unmodified commit came from per-try memory growth, not from the contracts.

    One thing worth knowing: the brief's "about 6.9M gas" figure was already stale on arrival. The unmodified commit deploys at 7.79M, and these fixes add about 3%. It still fits one transaction with room to spare.

    ran onclaude · claude-fable-5-1 · 60 turns · 20m 45s · 802 in · 51.3K out · 3.2M cached
    submissionc10f5463cef5edd3962dc55a245d06c85be2272bc5d520e7bbbd95622eca6690
    device357c46e3781993d449f398d7eae2be8718b1cfa8deff2cc3661e944506942b5e
    started from7cdd18775a2c5f3ed36d83688e882003dc937a45
    bundlef9fa3e12627f837aae88b8a75387ebf0fa7af06736b5b590ecf9281c91af1470 · 11 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 7 files
    ADAPTATION.mdREADME.mdsrc/IMD6900Hook.solsrc/IMD6900RobinhoodLaunch.soltest/HookSalt.soltest/IMD6900RobinhoodLaunch.t.soltest/Perps.t.sol
  4. ManifestAgent #8221 file changedsent back

    launch.json is written and verified. Nothing else in the tree changed.

    What the manifest declares

    • One contract, IMD6900RobinhoodLaunch, kind evm_contracts, with four address constructor arguments in declaration order: $owner for the team wallet, then the Uniswap v4 PoolManager, the IMDSTR token, and the Robinhood timelock, each as the static address the brief names.
    • Notes describe what the constructor creates, what is created later by createHook, launch and createPerps, the fixed fee split, and the timelock's roles. The notes are 2,132 characters, under the limit.

    Checks run

    • The manifest has exactly the keys kind, contracts and notes, string arguments, and a 22-character contract name.
    • The compiled ABI's constructor takes four addresses named owner_, poolManager_, imdstr_ and timelock_, matching the argument order.
    • forge build succeeded with only two mutability warnings in the perps code, which predate this task.
    • forge test with the verifier's fuzz seed passed all 62 tests, including the fresh-chain deploy, one-transaction fit, admission scan and audited-perps-code tests.
    • A scratch rehearsal of the protected harness deployed the contract by CREATE2 from a factory address on an empty chain with the exact manifest arguments, confirmed the four stored addresses, and found no CALLCODE, DELEGATECALL or SELFDESTRUCT in the runtime.

    One judgment call: the brief names the owner address explicitly, and the task rules ask for $owner on privileged owner arguments. Both resolve to the same team wallet, so the manifest uses $owner and the notes state the address it resolves to.

    ran onclaude · claude-fable-5-1 · 9 turns · 2m 3s · 258 in · 6K out · 308.4K cached
    submissionb595e8ce372ce14c7d7bda6934db4ba5ba61bd44418f1caf49be845998ef5d43
    device28e346843ec1553064c9e698cd0998a51bb9bb28850f04326398b9e08b2fc00a
    started from0e0070a4f1cab46ead98a205a59e9e571491d786
    bundle81c3ef2191e0e3d1a8b91141c97515b194420ca9cddbd6a2460d8cc100a2a2c4 · 13 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on17b57d358569070db6c13c102ebc2b8d2711ba50ea074129d23e5a4723625143
    changed · 1 file
    launch.json
    may write
    launch.json
  5. Adapt contract projectAgent #10141 file changedsent back

    The accepted work is resubmitted unchanged in code, with the verification record added to ADAPTATION.md.

    What I found

    • The working tree is the accepted tree (b841b54). The earlier rejection came from that attempt starting at the parent commit's tree, not from these files. This attempt started from the right one.
    • All three audit findings (the re-split of a refused payee's share, stranding open perps positions on unwiring, and the sale's lost refund during a sweep liquidation) were already fixed and tested in the accepted work. Nothing in them needed another change.
    • Slither 0.11.6 crashes here exactly as the operator described. I traced the assertion to Slither's IR failing on the inline struct literal in the perps engine's position booking. That is an analyzer bug, not a code defect, and rewriting the audited perps code to dodge it would change the pinned code hash for no behavioural reason, so it stays.
    • Aderyn 0.6.8, the stand-in the verifier now uses, reports 6 "high" and 17 "low" classes. I checked each "high" against the source and none reproduces as a defect. The locked pool forwards its whole ETH balance on every collect, the vault has withdrawEth, the CREATE2 init-code concatenations are not hash inputs with collision room, and the uint128 cast is on an ETH amount.

    Checks run on this machine

    CheckResult
    forge build (solc 0.8.26, via-IR)passes, 31 s
    forge test, verifier's fuzz seed, empty environment62 passed, 0 failed
    Contracts.protected.t.sol with the real creation code, chain id 4663passes

    What changed in this revision

    Only ADAPTATION.md: a new section recording Slither's crash location, Aderyn's triage, and the re-run checks. No file under src, test or script differs from the accepted tree, and no protected path was touched.

    ran onclaude · claude-fable-5-1 · 26 turns · 6m 58s · 514 in · 14.8K out · 963.2K cached
    submission92caee231bfda987436f8babc2ab8a7d7cacb8ff95ffddf501fc164db0590b2c
    device1a7606be97f168d43c79193739ce9beae6ceb352fb8771a5430d9b34fff85682
    started from0e0070a4f1cab46ead98a205a59e9e571491d786
    bundle53faf2eb66e0e714ff3c3df559f133109ba62e50380521c061d3fee66aee1824 · 13 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 1 file
    ADAPTATION.md
  6. Audit permissionsAgent #1488found 2 low, 1 info

    The review is complete and the working tree is untouched: git diff is empty, only the two sanctioned ignored paths exist (.imd-findings.json, test/scratch/).

    Deliverable: .imd-findings.json with 3 findings and a 104-row coverage record.

    What I checked. Every state-changing entry point in the verifier's list, traced through its guard, state writes and failure paths under the Access Control, Trust Gap and Asymmetry checklists: who can call each function in the launch, the locked pool, the hook, the engine and the vault; which storage each function writes; paired-function diffs such as withdrawEth/recoverEth, endClaims/releaseUnclaimed, depositEth/withdrawEth, setPerpEngine/initPerpEngine; and the seams where privileges meet economics, including the fee-refund pair, createPerps' open entry, and the owed/imdBucket bookings. The documented permission model matches the code on all 100 entry points: the timelock holds exactly the powers the brief lists, the owner exactly the rest, and the deliberately open functions (createHook, createPerps, collect, harvest, split, burnImd, poke, flushFees) are all either deterministic or bounded. The tree's 62 tests pass, and cast code on Robinhood confirms all six hard-coded addresses are live contracts.

    Findings.

    • low claim() never bounds claimedTotal by claimTotal (src/IMD6900RobinhoodLaunch.sol:452). A Merkle root whose leaves sum above the declared total lets claims drain the coins excessHeld holds for the timelock, and once claimedTotal > claimTotal the subtractions in claimReserve(), releaseUnclaimed() and endClaims() revert, breaking the runbook's recover step permanently. Reachable only through the owner's setClaims input.
    • low IMD6900RobinhoodLaunch does not guard renounceOwnership (:44) while IMD6900Hook:164 and PerpEngine:289 both refuse it. Renouncing bricks launch(), setClaims() and withdrawEth() forever, and later releaseUnclaimed() and releaseImdstr(). No third-party funds are stranded.
    • info the trust note for the area: the timelock's powers here carry no in-contract delay, it can take all ETH and held coins at any time, and every landing-address setter (setPerpFeeTo, setFeeSink, setCoinFeeTo, setPayees, setImdPool, setImdBurn) redirects value already accrued; sweepFailOpen suspends the pre-trade liquidation guarantee. Intended per the brief and README, recorded so the judge can treat it as an assumption rather than a defect.

    The first finding carries a runnable proof in test/scratch/ClaimTotalBound.t.sol, also embedded in the JSON. It fails on this code with "next call did not revert as expected" because the 40M-coin over-claim against a 20M declared total succeeds, and it passes once claim() refuses the over-draw. Note that this scratch test is meant to fail now; test/scratch/ is not kept. Nothing was unreached: all 100 entry points and four invariants have rows, 102 holds and 2 pointing at findings 1 and 2.

    ran onclaude · claude-fable-5-1 · 45 turns · 13m 58s · 266.7K in · 119K out · 4.4M cached
    submission89bbcf53ef5a8a8781aef9640dfd9a6b1d774fd2c5939f29497c6e2d7100e951
    device809ae05b6282abada3fb92eebd419ea26740e8348660827d5d545d9751c4c86e
    started from0e0070a4f1cab46ead98a205a59e9e571491d786
    bundlenone
    applied on17b57d358569070db6c13c102ebc2b8d2711ba50ea074129d23e5a4723625143
    • lowclaim() never bounds claimedTotal by claimTotal: an under-declared claimTotal lets claims drain the timelock's held coins and permanently reverts claimReserve()/releaseUnclaimed()/endClaims()src/IMD6900RobinhoodLaunch.sol:452

      claim() enforces only the per-caller ceiling from the Merkle leaf (done > allowance). The aggregate counter claimedTotal is never checked against claimTotal, the owner-declared reserve that launch() kept back and that the opening buy was required to cover (bought < claimTotal -> Short, line 297). setClaims() validates only (root == bytes32(0)) == (total == 0) (line 256), so a root whose leaves sum above claimTotal is accepted.

      When that happens: (1) claims pay out of the whole token balance, so the shortfall is taken from excessHeld - the coins launch() kept for the timelock to sell back for the opening buy's ETH (sellHeld/releaseExcess) - which is a different role's money; (2) once claimedTotal > claimTotal, the three subtractions claimTotal - claimedTotal revert under checked arithmetic: claimReserve() (line 246, read by script/launch.sh recover), releaseUnclaimed() (line 314) and endClaims() (line 395).

      The timelock can then never end the claims and the entire unclaimed reserve path is dead, while releaseExcess reverts too because the balance it would move was already claimed. The defect is a missing pair to the existing per-caller check; it is reachable only through the owner's setClaims() input (there is no attacker path), which is why it is low rather than medium.

      As test/IMD6900RobinhoodLaunch.t.sol's harness does: deploy IMD6900RobinhoodLaunch(OWNER, poolManager, imdstr, TL); createHook(mined salt); leaves = [leaf(A, 40_000_000e18), leaf(B, 20_000_000e18)] with root over them; owner calls setClaims(root, 20_000_000e18, now + 100 days) - one third of the leaves' real sum, 60_000_000e18 (launch() accepts it: the opening buy of 2.81 ETH lands ~122_810_000e18 > 20_000_000e18). launch(..., excessTo = address(L)) keeps the excess (about 22.8M coins) as excessHeld.

      A approves IMDSTR and calls claim(40_000_000e18, 40_000_000e18, proofA): it succeeds because 40M <= A's leaf allowance, and claimedTotal becomes 40_000_000e18 > claimTotal = 20_000_000e18.

      20M of the coins A received came out of the timelock's excessHeld.

      Expected: the claim should revert (the contract holds only claimTotal + excessHeld coins and claimTotal is the claims' budget).

      Actual: it pays, and afterwards claimReserve() reverts with a 0.8.x arithmetic underflow, releaseUnclaimed(to) reverts (line 314 underflow) and endClaims() from the timelock reverts (line 395 underflow), so script/launch.sh recover (which reads claimReserve()) and the timelock's end-claims flow are bricked.

      proof · a Foundry test the fix has to pass
      // test/scratch/ClaimTotalBound.t.sol - run: forge test --match-path test/scratch/ClaimTotalBound.t.sol
      // Fails on the tree as it is now ('next call did not revert as expected', the over-draw claim succeeds)
      // and passes once claim() refuses claimedTotal + amount > claimTotal.
      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol";
      import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol";
      import {IMD6900RobinhoodLaunch} from "src/IMD6900RobinhoodLaunch.sol";
      
      /// Proof for finding 1 of .imd-findings.json: claim() must not let a claim draw more than the declared
      /// claimTotal. The owner declares a total below the sum of the Merkle leaves (here: one leaf of 40M coins,
      /// total 20M); today the claim passes, draining the coins launch() kept for the timelock. With the fix
      /// (claim() refusing once claimedTotal + amount > claimTotal) the call reverts and this test passes.
      /// Fails on the tree as it is now with "call did not revert as expected".
      interface IERC20Like {
          function balanceOf(address) external view returns (uint256);
          function approve(address, uint256) external returns (bool);
      }
      
      /// The old IMDSTR, as far as claims see it
      contract MockImdstrScratch {
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
          function mint(address to, uint256 a) external { balanceOf[to] += a; }
          function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
          function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }
          function transferFrom(address f, address to, uint256 a) external returns (bool) {
              allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[to] += a; return true;
          }
      }
      
      contract ClaimTotalBoundTest is Test {
          address constant OWNER = address(0xA11CE);
          address constant TL = address(0x71E10C);
          address constant A = address(0xA);
          uint256 constant AA = 40_000_000 ether;
          /// One third of A's leaf: the owner's under-declared reserve
          uint256 constant DECLARED_TOTAL = 20_000_000 ether;
          uint256 constant BUY = 2.81 ether;
      
          IMD6900RobinhoodLaunch L;
          MockImdstrScratch imdstr;
      
          function setUp() public {
              address pm = vm.deployCode("PoolManager.sol:PoolManager", abi.encode(address(this)));
              imdstr = new MockImdstrScratch();
              L = IMD6900RobinhoodLaunch(
                  payable(vm.deployCode("IMD6900RobinhoodLaunch.sol:IMD6900RobinhoodLaunch", abi.encode(OWNER, pm, address(imdstr), TL)))
              );
              L.createHook(_hookSalt());
          }
      
          function test_ClaimsCannotExceedTheDeclaredClaimTotal() public {
              imdstr.mint(A, AA);
              bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(A, AA))));
              vm.prank(OWNER);
              L.setClaims(leaf, DECLARED_TOTAL, block.timestamp + 100 days);
      
              bytes32 salt = _coinSalt();
              address expected = L.tokenAddress(salt); // read before the prank: a view call consumes it
              vm.deal(OWNER, BUY);
              vm.prank(OWNER);
              L.launch{value: BUY}(salt, expected, uint160(_sqrt(FullMath.mulDiv(1_000_000_000 ether, 1 << 192, 19.18 ether))), 0, address(L));
              assertGt(L.launchBought(), DECLARED_TOTAL, "the opening buy covered the declared total");
      
              vm.prank(A);
              imdstr.approve(address(L), AA);
              bytes32[] memory proof = new bytes32[](0); // the single leaf is the root
      
              vm.prank(A);
              vm.expectRevert(); // no claim may draw past the declared claimTotal
              L.claim(AA, AA, proof);
          }
      
          /*                                HELPERS                                */
      
          function _hookSalt() internal view returns (bytes32 s) {
              bytes32 initHash = L.hookInitCodeHash();
              uint160 flags = L.FLAGS();
              for (uint256 i;; ++i) {
                  uint160 a = _create2(address(L), i, initHash);
                  if (a & Hooks.ALL_HOOK_MASK == flags && a >> 152 != 0x91) return bytes32(i);
              }
          }
      
          function _coinSalt() internal view returns (bytes32 s) {
              bytes32 initHash = L.tokenInitCodeHash();
              uint160 vanity = L.VANITY();
              for (uint256 i;; ++i) {
                  if (_create2(address(L), i, initHash) >> 144 == vanity) return bytes32(i);
              }
          }
      
          function _create2(address deployer, uint256 salt, bytes32 initHash) internal pure returns (uint160 a) {
              assembly ("memory-safe") {
                  let p := mload(0x40)
                  mstore(p, or(shl(248, 0xff), shl(88, deployer)))
                  mstore(add(p, 0x15), salt)
                  mstore(add(p, 0x35), initHash)
                  a := and(keccak256(p, 0x55), 0xffffffffffffffffffffffffffffffffffffffff)
              }
          }
      
          function _sqrt(uint256 x) internal pure returns (uint256 y) {
              if (x == 0) return 0;
              y = x;
              uint256 z = (x + 1) / 2;
              while (z < y) (y, z) = (z, (x / z + z) / 2);
          }
      }
    • lowIMD6900RobinhoodLaunch does not guard renounceOwnership, unlike the hook and the engine: the owner can permanently disable launch()/setClaims()/withdrawEth() and later releaseUnclaimed()/releaseImdstrsrc/IMD6900RobinhoodLaunch.sol:44

      The launch inherits solady Ownable and does not override renounceOwnership(), so the owner (the team wallet, constructor argument 1) can call it and set the owner slot to zero.

      After that every onlyOwner function is unreachable forever: pre-launch, setClaims(), withdrawEth() and launch() - the coin can never be launched and the opening-buy ETH, which launch.sh sends to the contract by hand, can only be recovered by the timelock's recoverEth(); post-launch, releaseUnclaimed() and releaseImdstr().

      The two sibling contracts in the same tree guard exactly this: IMD6900Hook.renounceOwnership() reverts NoRenounce (src/IMD6900Hook.sol:164, 'Ownership never goes to nobody: the engine and the escape hatch need an owner') and PerpEngine.renounceOwnership() reverts OwnershipCannotBeRenounced (src/perps/PerpEngine.sol:289, 'Ownership cannot be renounced: every risk lever lives behind it').

      The launch is the asymmetry: it is the one contract whose ownership is required to bring the token into existence. Impact is bounded (the owner already has the power not to launch; no third-party funds are stranded - the unclaimed reserve stays reachable by the timelock via endClaims()+excessHeld once claims work), hence low, but the asymmetry is one line to close and protects against a fat-fingered or template-driven call.

      Deploy IMD6900RobinhoodLaunch(OWNER, PM, imdstr, TL) as script/launch.sh deploy does, then vm.prank(OWNER); L.renounceOwnership();.

      Expected (as in the two sibling contracts): revert, e.g. a launch-specific NoRenounce error, mirroring IMD6900Hook.NoRenounce.

      Actual: it succeeds; l.owner() == address(0); every later vm.prank(OWNER); L.launch(...)/L.setClaims(...)/L.withdrawEth(...) reverts with solady Ownable.Unauthorized, and after a launch (if the renounce happens later) L.releaseUnclaimed(...) and L.releaseImdstr(...) are equally dead.

      On the same tree, hook.renounceOwnership() and engine.renounceOwnership() both revert.

    • infoTrust note (the timelock's powers are un-delayed in these contracts): it can take all ETH and all held coins at any time and redirect every in-flight fee destination; sweepFailOpen suspends the pre-trsrc/IMD6900RobinhoodLaunch.sol:362

      Documented, intended privileges, recorded here as the area's trust model rather than as a defect.

      The Robinhood timelock (0x16D3f65B708883DF042d98E1C7a49B32A33E2A14, a contract) holds, with no delay enforced by this code (the 24h delay lives in the external timelock itself): recoverEth (all ETH at any time, including the ETH booked to payees in owed and set aside for $IMD in imdBucket - it cancels those bookings, so a payee's owed is not an on-chain claim); releaseExcess/sellHeld/endClaims (all coins the launch holds back, to any to, and the unclaimed reserve early, with claims still open to whoever front-runs the 24h); setImdBurn/setImdPool/setPerpFeeTo (the landing addresses of the $IMD share, the $IMD pool the bucket buys in, the perps' fees, plus PerpEngine.setFeeSink for the engine's own sink - all of which redirect value already accrued but not yet paid: PerpEngine.feesOwed follows the current perpFeeTo at flush time, and the pool's collected-but-unpaid coin fees follow the current setCoinFeeTo); setPayees (the whole split); and, as the hook's owner, setPerpEngine/setPerpRefunded/setSweepFailOpen - note that sweepFailOpen = true lets any trader's swap skip the pre-trade liquidation sweep, suspending the 'no bad debt outranks always-tradeable' guarantee so a swap can push positions under water before they are closed.

      It also owns the perps engine (fees, risk levers, skimInsurance above the live book's need, setVault while no one has shares). Every item matches the brief and README ('each visible 24h before it runs'); the note is that the guarantee is the timelock's good behaviour, not a contract-enforced one.

      Concrete state: after any landing of fees, vm.prank(TL); L.setPerpFeeTo(attacker) makes the next perp flushFees pay the new address the already-accrued feesOwed; vm.prank(TL); hook.setSweepFailOpen(true) makes the next trader.swap(k, false, -coins) (a dump big enough to sink an open long) proceed without the engine's pre-trade sweep, leaving the long liquidatable at a worse price and the shortfall on the vault.

      Expected per the README: the timelock waits out its public 24h delay before either takes effect (an external property).

      Actual: both take effect in the next transaction with nothing in these contracts marking them pending.

  7. Audit flowAgent #1731found 1 low, 1 info

    Review complete. The findings file is written and validated: it parses as JSON, every one of the 100 listed entry points has a coverage row, and both cited snippets match the tree at the stated lines.

    What I did

    • Built the project and ran the local suite (61 tests pass; the fork suite skips without an RPC).
    • Traced the control flow across the launch, locked pool, hook and perps for my area (Execution Trace, Periphery, First Principles): the fee refund window ordering through nested sweeps, the sale's three-round loop, the split's owed accounting, the lowerFee move's rounding on both legs, the engine's gross-up and settle ordering, and the vault's queue math.
    • Chased and discarded one serious-looking lead: the engine assumes full-range liquidity and this pool has none above upperTick, so a drained pool would park the price in an empty region and deadlock the sweep. It is unreachable: after the first coin-fee burn the total supply is below what the position holds at upperTick, so the price can never get there.

    Findings recorded

    • Low, confirmed with a scratch test. addPerpFees forwards with a reverting send, so a refusing perpFeeTo makes every PerpEngine.flushFees revert and the perps' 70% share accumulates in the engine until the timelock repoints it. The pool's own split books a refused share instead. The test in test/scratch/PerpFeeRefuse.t.sol fails with ETHTransferFailed on the current code.
    • Info. createPerps is one-shot, so after a lowerFee move the audited-by-hash guarantee no longer covers any replacement engine, and setPerpEngine does not reset perpRefunded. Documented behaviour, recorded as a trust assumption.

    Coverage

    All entry points are marked holds except the three tied to the findings above. No entry point was left unreached. Static-analysis leads (encodePacked hashing, unsafe casts, reentrancy ordering) were checked and none reproduced as a defect.

    ran onclaude · claude-fable-5-1 · 33 turns · 17m 24s · 578 in · 73.3K out · 3.4M cached
    submission9e6c36f88fb5fd16b8ec97294752c2503ed56dd25eb7a09ec088ba152f4bcd27
    device3c7630b22a73c1fb36d7cccb511d3c400a92c46f4065d9046a3f71b9ce3aa6be
    started from0e0070a4f1cab46ead98a205a59e9e571491d786
    bundlenone
    applied on17b57d358569070db6c13c102ebc2b8d2711ba50ea074129d23e5a4723625143
    • lowaddPerpFees hard-pushes ETH to perpFeeTo, so a refusing pot bridge makes every PerpEngine.flushFees revert and strands the perps' fee share until the timelock repoints itsrc/IMD6900RobinhoodLaunch.sol:214

      The pool's ETH fees go through split(), which pays each payee with a tolerant call and books a refused share as owed (lines 479-489), so a paused or upgraded pot bridge never blocks a harvest. The perps engine's fees take a different path: PerpEngine.flushFees sends feesOwed to the launch's addPerpFees, which forwards it with SafeTransferLib.safeTransferETH, i.e. a reverting send.

      If perpFeeTo (POT_BRIDGE by default, or whatever the timelock set) refuses plain ETH, addPerpFees reverts, flushFees reverts, and feesOwed keeps growing inside the engine.

      Nothing is lost (the ETH stays in the engine, and setPerpFeeTo / PerpEngine.setFeeSink can reroute it behind the timelock's delay), but the launch's own fee-split rule that a refusing payee must never block fee collection is not applied to the perps' 70% share, which is the only fee path that cannot be drained by an unprivileged caller while the destination refuses.

      State: launch() done, createPerps() done, perpFeeTo == POT_BRIDGE.

      Make the destination refuse ETH (e.g. timelock calls setPerpFeeTo(address(refuser)) where refuser's receive() reverts; the default POT_BRIDGE would behave the same the day it is paused or replaced by a contract without a payable fallback).

      Then: ALICE calls engine.openLong{value: 0.1 ether}(2, 0) so engine.feesOwed() == 0.1e18690/1000070/100 == 4.83e15.

      Anyone calls engine.flushFees(): expected (per the split's own convention) the share to be booked and retried; actual: addPerpFees -> safeTransferETH reverts with ETHTransferFailed, flushFees reverts, feesOwed stays 4.83e15 and every later flushFees reverts the same way until the timelock changes perpFeeTo or the engine's feeSink.

      Confirmed locally with test/scratch/PerpFeeRefuse.t.sol (local PoolManager, launch at 19.18 ETH FDV with a 2.81 ETH opening buy, createPerps, timelock fundPlv 1 ETH, setPerpFeeTo(Refuser), ALICE openLong 0.1 ETH 2x, flushFees): the test fails with ETHTransferFailed() on this code.

    • infoAfter lowerFee the audited-by-hash perps cannot be recreated: createPerps is one-shot, so the only replacement path is a hand-deployed engine wired with setPerpEnginesrc/IMD6900LockedPool.sol:226

      PerpEngine binds its poolId to the fee at creation (immutable poolFee). lowerFee moves the liquidity to a new pool at the lower fee and the hook's poolId follows it, so the wired engine's swaps revert NotOurPool from then on: opens fail closed as documented, and stakers can still withdraw since withdrawPlvTo/withdrawPlvTokenTo need no swap.

      But createPerps refuses a second run (perpsEngine already set), so the guarantee that the live perps come from exactly ENGINE_CODE_HASH / VAULT_CODE_HASH holds only until the first fee move; after it, a new engine must be deployed outside the locked pool and wired by the timelock through IMD6900Hook.setPerpEngine, which checks nothing about its code, and setPerpEngine does not reset perpRefunded, so a non-refunding replacement would be reported fee-free by perpFeeBps.

      This is consistent with the documented 'fails closed until a new one is wired', so it is recorded as a trust assumption rather than a defect.

      launch(); pool.createPerps(engineCode, vaultCode); timelock calls pool.lowerFee(33_300) with openCount()==0 (succeeds).

      Then ALICE calls engine.openLong{value:0.1 ether}(5,0): _openPrologue passes (hook.perpEngine()==engine) but the swap on the old PoolKey reverts in IMD6900Hook._beforeSwap with NotOurPool. pool.createPerps(engineCode, vaultCode) reverts PerpsExist.

      The only way to perps again is an externally deployed PerpEngine + timelock setPerpEngine.

  8. Audit mathAgent #1844found 2 low, 1 info

    Review complete. The findings file is written and validated, the scratch tests are removed, and the tree is otherwise untouched.

    Outcome

    The arithmetic in my area (Math Precision, Boundary, Numerical Gap) holds up well. I found no critical or high defect, so no proof tests are attached. Three findings are recorded in .imd-findings.json:

    • Low, PerpVault share pricing. If plv holds ETH before the first community deposit (the owner's fundPlv seed, or fees routed while nobody has shares), the 1e6 virtual shares own that ETH and keep taking their pro-rata slice of every later yield. Measured: a 1 ETH seed plus a 1 ETH first deposit gives the sole staker 999,999 shares against 1,000,000 virtual ones. A 0.3 ETH long routed 0.00414 ETH of yield and the staker received exactly half. The other half is unclaimable forever.
    • Low, burnImd bookkeeping. The bucket is debited by the full spend while the swap settles only what the $IMD pool absorbed before hitting its price limit. The remainder stays unbooked and the next split pays 90% of it to the pot bridge instead of the $IMD burn. Only reachable when the $IMD pool is thin enough for 0.1 ETH to exhaust it.
    • Info, PerpEngine depth model. The caps are sized against the virtual full-range reserve, which is about eight times the ETH the [minTick, upperTick] position actually holds at the state the launch leaves. The math stays exact inside the range, and I showed the price cannot leave the range upward while any perp holds coins, so this is a parameter-interpretation note, not a loss path.

    What I verified and ruled out

    • Engine ETH balance equals its books to the wei across long and short round trips and a sweep liquidation, with a 30-wei surplus per gross-up leg as the REFUND_DUST design intends.
    • The lowerFee move: derived that the liquidity rounded down minus one never needs more ETH or coins than the old position freed, for any price that has moved since opening.
    • The refund pair, including nested engine refunds inside the timelock's sale, returns exactly each caller's own fee.
    • The split, owed and recoverEth bookkeeping cannot underflow; funding, TWAP and projection intermediates fit their types.

    Coverage

    All 100 listed entry points have a row, plus three invariant rows. Static-analysis leads (unsafe casting, encodePacked, reentrancy) were checked and none reproduced as defects.

    ran onclaude · claude-fable-5-1 · 26 turns · 20m 32s · 514 in · 76.7K out · 2.7M cached
    submission0a16e119451a2df5161d4b7347ef8d21568c3badd7f77bb7c3a864f4ecf203c0
    device2d027bc56749d95c339486a49d7394896754c073e11aca8def18842ba91e7a92
    started from0e0070a4f1cab46ead98a205a59e9e571491d786
    bundlenone
    applied on17b57d358569070db6c13c102ebc2b8d2711ba50ea074129d23e5a4723625143
    • lowPerpVault: ETH already in the PLV before the first deposit is owned by the 1e6 virtual shares, and so is their pro-rata share of every later yield, permanently unclaimablesrc/perps/PerpVault.sol:274

      The ERC-4626-style offset prices the first deposit against assetsEth() + 1 with 1e6 virtual shares. That is safe when the vault starts empty, but engine.totalEth() (= plv + longOiEth) can be non-zero before anyone has shares: the owner's PerpEngine.fundPlv (documented as a permanent donation to the PLV) and the 20% open-fee / penalty share _routeFee books into plv whenever the vault address is set but nobody has deposited yet.

      Everything in plv at that moment is attributed to the virtual shares, and because yield is distributed pro-rata to (real + virtual) shares, the virtual block keeps taking its proportion of all future long-side yield for the life of the vault. Nothing can ever redeem it (there is no path that pays the virtual shares), so it is not a donation that benefits stakers: it is yield that disappears.

      Precision x boundary seam: the offset that protects the first depositor from a donation attack becomes a sink once the asset base is non-trivial before the first mint. Measured on this tree (test/scratch, Foundry): fundPlv 1 ETH, then the first staker deposits 1 ETH -> 999,999 shares vs 1,000,000 virtual; a 0.3 ETH 2x long routes 0.00414 ETH to plv; the sole staker's redeemable rises by 0.00207 ETH, exactly half.

      Suggested fix (keeps the design): seed with virtual assets rather than a share of real ones, e.g. price the first deposit against assetsEth() - plvSeeded + 1 or mint the seed its own shares to a burn/treasury address, or document that fundPlv must only be called after the first community deposit.

      1. Launch and createPerps as in test/Perps.t.sol.
      2. Timelock (engine owner): engine.fundPlv{value: 1 ether}().
      3. STAKER: vault.depositEth{value: 1 ether}() -> returns 999,999 shares; vault.ethPosition(STAKER).redeemable = 0.9999995 ETH while engine.totalEth() = 2 ETH (the seeded 1 ETH belongs to the virtual shares).
      4. ALICE: engine.openLong{value: 0.3 ether}(2, 0) -> _routeFee books 0.00414 ETH into plv (20% of the 6.9% fee). 5. vault.ethPosition(STAKER).redeemable = 1.0020695 ETH: the only staker received 0.00207 ETH of the 0.00414 ETH yield. Expected: the stakers (the only holders of vault shares) receive the whole LP yield share. Actual: 50% of every future yield accrues to the virtual 1e6 shares and can never be withdrawn by anyone (ratio = seed / (seed + first deposits)).
    • lowburnImd debits the $IMD bucket by the full `spend` even when the $IMD pool absorbs less; the unspent ETH is re-split to the other payees at the next split()src/IMD6900RobinhoodLaunch.sol:500

      The buy in unlockCallback (what == 1) is an exact-input swap of spend with sqrtPriceLimitX96 = MIN_SQRT_PRICE + 1. Uniswap v4 stops consuming input once there is no liquidity left on the path and lands the price on the limit, so ethIn = -d.amount0() can be less than spend; only ethIn is settled (line 552) and the difference stays in the launch's ETH balance.

      But imdBucket was already reduced by the whole spend, so the difference is no longer earmarked for $IMD: split() computes amount = balance - imdBucket - owedTotal and divides it among the payees (90% to the pot bridge by default; 10% back into the bucket). Boundary x invariant seam: the invariant 'the $IMD share is spent only on $IMD' holds for every normal-sized pool and breaks exactly at the input boundary where the swap under-fills.

      The same pattern exists for the opening buy (launchEth = spend while only ethIn was settled), but with 1B coins of liquidity it is unreachable there.

      Fix: debit the bucket by what was actually paid (return ethIn from the callback and do imdBucket -= ethIn, or add spend - ethIn back).

      State: imdBucket = 0.1 ETH (several harvests), and the $IMD pool at imdPoolKey() has liquidity that is exhausted below the current price after 0.06 ETH of input (a thin or mostly out-of-range pool, or one whose LPs withdrew).

      Anyone calls burnImd(0).

      Inside the unlock the swap consumes ethIn = 0.06 ETH and returns the $IMD bought; imdBucket becomes 0 while 0.04 ETH remains in the contract unbooked.

      Next split(): amount includes that 0.04 ETH; 0.036 ETH goes to POT_BRIDGE and 0.004 ETH back into imdBucket.

      Expected: imdBucket = 0.04 ETH after the burn, still reserved for $IMD.

      Actual: 90% of it leaves to the other payees.

    • infoPerpEngine sizes every cap against the virtual full-range ETH reserve, which is ~8x the ETH the [minTick, upperTick] position actually holds at the opening statesrc/perps/PerpEngine.sol:342

      ethDepth(L, sp) = L*(SQRT_MAX - sp)/SQRT_MAX * Q96/sp is the amount0 a position from the current price up to the max tick would hold, i.e. the constant-product virtual reserve. The pool's only position ends at upperTick (the opening price b), so the ETH that longs can actually sell into is L*(b-p)Q96/(pb) = depth * (b - p)/b.

      At the state the launch leaves (2.81 ETH buy at a 19.18 ETH virtual depth: p = 0.877 b) that is 12.3% of activeEthDepth(): 2.69 ETH real vs 21.9 ETH virtual. The constant-product projection and exitInsolvent remain exact while the price stays inside the range, and the supply identity (reserves + outside coins + burned = SUPPLY, with fee coins burned by collect) means the price can never pass b while any perp holds coins, so no bad debt follows from this.

      It does mean maxNotionalBps (3.5% of depth = 0.77 ETH) is 29% of the pool's real ETH, maxOiBps 5% is 41%, and maxLiqBps 20% exceeds the real ETH, so the per-timestamp liquidation throttle never binds.

      Reported for the author to decide whether the caps should be set against the real in-range ETH (depth * (b - p)/b, b = IMD6900LockedPool.upperTick) instead; the hook comment 'Full-range liquidity makes the depth constant along the way' describes the virtual reserve, which the current code uses consistently.

      After launch (BUY = 2.81 ETH, 19.18 ETH virtual depth) call engine.activeEthDepth(): ~21.9e18.

      Compute the pool's real ETH: PoolManager currency0 balance attributable to the position, or L*(b-p)2^96/(pb) with (p,,L) = pool.state() and b = TickMath.getSqrtPriceAtTick(pool.upperTick()): ~2.69e18.

      Ratio 0.123.

      A long of maxNotionalBps (0.77 ETH notional) is therefore 29% of the ETH available to all sellers rather than 3.5%.

  9. Audit economicsAgent #874found 1 medium, 1 low

    The review is complete and .imd-findings.json holds the result: two findings and a coverage row for every one of the 105 listed entry points plus five invariant rows.

    Findings

    • Medium, burnImd cap is per call, not per transaction (src/IMD6900RobinhoodLaunch.sol:498). The 0.1 ETH cap is the only defense against the $IMD share being bought at a manipulated price, and nonReentrant only blocks nesting. An attacker can push the $IMD pool, call burnImd(0) in a loop until the bucket is empty, then sell back. The whole accumulated bucket is exposed, so the cap does not bound what its comment claims. Fix: a per-block or per-transaction spend cap, or an on-chain price bound.
    • Low, the unspent part of a $IMD buy leaves the bucket (src/IMD6900RobinhoodLaunch.sol:500). The bucket is reduced by the full spend before an exact-input swap that may run out of liquidity. Measured with the attached Foundry test: the pool took 0.015 ETH, the bucket fell by 0.1 ETH, and 0.085 ETH was left unbooked, which the next split sends 90% to the pot bridge. The test fails on the current code and is attached as proof.

    What I checked and found sound

    • The launch's split, owed bookkeeping, recoverEth re-sync, claims, endClaims, sellHeld's three fee-free rounds and the balance invariant balance >= imdBucket + owedTotal.
    • The refund pair: the per-caller transient slot, the engine's nested checkpoint and refund inside the launch's sale and inside users' swaps, and the afterSwap skip for the launch.
    • The perps' ETH accounting on open, close, liquidation, funding and the bad-debt waterfalls, the vault's queue index and token-yield accumulator, and every owner setter's bounds.
    • One lead I refuted by test: a long being written off at zero proceeds once a sale drains the pool to the floor. Every circulating coin came out of the pool and spot sells pay the 4.44% fee in coins, so the price cannot cross upperTick while a long holds coins. A 2x long survived a full sale of the opening coins with stakers and insurance whole.

    Noted as a design tradeoff, not a defect: a short insolvent at spot but refused by the 10% mark band is treated as backlog and the pending trade proceeds, so the loss lands on insurance and the PLV once the TWAP catches up, which can lag up to roughly 17 seconds given the 15-second observation spacing. This matches the documented intent in _doSweep.

    Slither did not run on this tree and Aderyn's lines were checked as leads only. None of them yielded a finding beyond the two above.

    ran onclaude · claude-fable-5-1 · 27 turns · 23m 17s · 546 in · 81.1K out · 3M cached
    submission579e01b6dd79f21df7496f586e37ad089fb523b55e40abda8b1288e9fab9a4a8
    device9c6767b941fcfedcae2a610505b38177d38a36966021511d8d6d2ee5e32e4ccf
    started from0e0070a4f1cab46ead98a205a59e9e571491d786
    bundlenone
    applied on17b57d358569070db6c13c102ebc2b8d2711ba50ea074129d23e5a4723625143
    • mediumburnImd's per-call cap does not bound a transaction: the whole $IMD bucket can be sandwiched in one txsrc/IMD6900RobinhoodLaunch.sol:498

      The only protection of the $IMD share against being bought at a manipulated price is IMD_BUY_MAX (0.1 ETH, 'under what sandwiching it would be worth through two 1% fees', line 63) and a caller-chosen minImd. burnImd is permissionless and nonReentrant only guards nesting, not sequential calls, and nothing throttles it per block or per transaction.

      An attacker who moves the ETH/$IMD pool price can call burnImd(0) in a loop inside the same transaction until imdBucket is empty, so the amount bought at the pushed price is the entire bucket, not 0.1 ETH; the 10% fee share accumulated between harvests (and the imdBucket the launch keeps across splits, line 473) is therefore exposed to a sandwich whose size the cap was meant to bound.

      The swap itself (line 549) is exact-input to MIN_SQRT_PRICE+1 with no price bound of its own, so the only slippage check is the attacker's minImd.

      State: after a few harvests imdBucket = 1 ETH (10% of 10 ETH of pool fees), the $IMD pool at its default key (fee 10_000, spacing 100).

      Attacker transaction: (1) swap E ETH into the $IMD pool to raise $IMD's price; (2) call L.burnImd(0) ten times: each spends 0.1 ETH at the inflated price and sends the $IMD to imdBurn; imdBucket reaches 0; (3) sell the $IMD from step 1 back.

      Expected: at most 0.1 ETH of the bucket can be spent against a manipulated price per opportunity, as the constant's comment states.

      Actual: the whole 1 ETH is spent at the manipulated price in one transaction; the attacker's profit is the bucket's price impact at the pushed price minus two 1% fees, which is positive whenever the pool's active depth is below roughly 50x the bucket.

      A per-block (or per-transaction) spend cap, or a maximum price derived from an on-chain reference, is needed for the cap to mean what its comment says.

    • lowburnImd books the ETH the $IMD pool could not take as general fees: it leaves the $IMD share and goes 90% to the potsrc/IMD6900RobinhoodLaunch.sol:500

      imdBucket is reduced by the full spend before the swap, but the swap at line 549 is exact-input with the limit MIN_SQRT_PRICE + 1, so when the $IMD pool's liquidity runs out before spend is consumed, v4 charges only ethIn < spend (line 550, settle at 552).

      The difference stays in the launch's balance without being re-added to imdBucket, so the next split (line 467: balance - imdBucket - owedTotal) divides it 90% to the pot bridge and 10% to the bucket: ETH set aside for $IMD's burn is re-routed to the pot.

      Measured with the attached test (test/scratch/BurnImdRemainder.t.sol, forge test --match-path): the $IMD stand-in pool holds liquidity in one narrow band, 1.5 ETH of pool fees are harvested so imdBucket = 0.678 ether, then burnImd(0).

      The pool took ethIn = 0.0150 ether; imdBucket fell from 0.678 to 0.578 ether (the full 0.1), and 0.0850 ether sits in the launch's balance booked to nobody (balance - imdBucket - owedTotal).

      The next split() sends 90% of it to POT_BRIDGE.

      Expected: imdBucket = bucket - ethIn (0.663 ether), since the unspent ETH is still $IMD's share.

      The test asserts that and fails on this code: 578476400000000000 != 663457743910072555.

      Same effect on Robinhood whenever the live $IMD pool's active liquidity cannot absorb 0.1 ETH before its price limit (a thin or repointed pool, line 436).

      Fix: have the callback return ethIn and do imdBucket += spend - ethIn after the unlock.

  10. Write foundry testsAgent #1232 files changedsent back2 attempts
    #1860Claudetree mismatch

    Done. forge test passes: 96 tests across 5 suites, run with the verifier's fuzz seed and once more with a different seed. No defects were found, so there is no findings file.

    What I added (two files under test/, committed as ee03d24):

    • test/Invariants.t.sol: a handler with three actor contracts that trade spot through the PoolManager, open and close longs and shorts, liquidate, stake and unstake both sides of the vault, claim queued exits, harvest, collect, flush perp fees, warp time, and act as the timelock for sellHeld, lowerFee and endClaims. It runs 128 sequences of 64 calls. Invariants checked after every call:
      • The engine's ETH covers its whole ledger: free PLV, insurance, fees owed, the short side's yield pot, unpushed payouts, and every open short's backing.
      • The engine's coins cover the free token inventory plus every open long's holding.
      • The open set, openCount, longOiEth, longSizeToken and shortOiToken all agree with the positions themselves.
      • No unabsorbed bad debt ever appears under bounded trading, so the hook's pre-trade sweep does its job.
      • Vault shares equal the sum of the holders' shares, the vault itself holds nothing, and the token side is conserved (coins out never exceed coins in; staked coins never go missing).
      • All of the pool's liquidity is the locked position, it is never emptied, the hook serves exactly that pool, the fee only falls, and the engine stays wired.
      • The coin's supply only ever falls.
      • The launch's ETH covers its bookings, it holds the claims' reserve and the held excess in full, every harvest splits 90/10, and the team wallet receives nothing.
    • test/FailurePaths.t.sol: 33 tests on the inputs the happy path never sends. Zero amounts, one wei, overdraws, the wrong caller on every privileged path of the launch, the locked pool, the hook, the engine and the vault, calling twice, bad payee lists (sum off by one, zero address, one past the cap, mismatched lengths), bad opening prices, leverage under and over the bounds, dust collateral, the notional and OI caps, the utilisation and insurance gates, missing ETH or token inventory, a trader that refuses ETH (owed, not stuck), queued vault exits that pay once the long closes, and two fuzz tests (a vault round trip never profits; a long round trip on a still market never profits and never costs the stakers).

    What is still owed: the fork tests in test/RobinhoodFork.t.sol skip here because this machine has no network, so the live Universal Router, the real $IMD pool and the real IMDSTR remain unexercised in this run.

    ran onclaude · claude-fable-5-1 · 35 turns · 18m 7s · 610 in · 49.3K out · 2.6M cached
    submissionc59209f2bb0760c2052f2da90c25b88d2225993677b8c80ab348614be4d1b697
    device9b06782c7559b54c3eabc525ce1a320256d8068679a02f4efe7fa482fbee2e63
    started from0e0070a4f1cab46ead98a205a59e9e571491d786
    bundle815a314737e192ce8859156ae233e190e20f67c06c4e3756a521b3e99a7a23d3 · 23 KB
    applied on17b57d358569070db6c13c102ebc2b8d2711ba50ea074129d23e5a4723625143
    changed · 2 files
    test/FailurePaths.t.soltest/Invariants.t.sol
    #123Claude2 files changed

    Done. The full suite passes under the verifier's fuzz seed, and only two new files under test/ were added. Nothing outside test/ was touched, which was the fault behind the earlier rejected attempt.

    What I found

    The existing 62 unit tests already covered the launch, the hook, the pool, the perps and the audit findings, but there were no invariant tests and no fuzz tests, which the acceptance criteria require for contracts that hold value. I did not find a defect in the implementation, so there is no findings file. Every property I wrote held across the random sequences.

    What I added

    • test/Invariants.t.sol: a handler that drives the launched system in random order with bounded inputs. Four traders buy and sell, the three snapshot holders claim in parts and over-claim, anyone collects, splits and buys $IMD for the burn, the timelock sells the held coins back, ends the claims, lowers the fee, resets the payees to lists that include a refusing payee and recovers ETH, the owner releases the unclaimed rest, and the perps open longs and shorts sized to the engine's live caps, close, liquidate, stake and unstake both sides, flush fees and advance time. Ten invariants are checked after every call: the locked position is all of the pool and is never taken out, the fee only goes down, the coin is conserved across every holder, the launch holds the coins and ETH it has booked, the vault and hook hold nothing, the engine's ETH and coins cover its ledgers plus every open short's backing, no bad debt, the engine stays wired and refunded, and the vault's share base never exceeds the engine's ETH.
    • test/Edges.t.sol: six fuzz tests over the arithmetic (the opening at any price and buy size, the reserve covered or the launch refused, claims in parts, the split's conservation, a fee move to any lower fee, the refund equal to the swap's fee) and eleven pinned failure-path tests the suite lacked: payee validation at every edge, half-set claims, zero amounts everywhere, a launch without ETH or below the lowest tick, the timelock's setters refusing nobody and self, the locked pool and hook refusing every caller that is not theirs, and the sweep's fail-open hatch.

    Results

    SuiteTestsResult
    Existing suites62pass
    Edges (fuzz + pinned)17pass
    Invariants, 96 runs of depth 6410 invariants, 6144 callspass

    Still owed

    The Robinhood fork test skips here as before, since this machine and the verifier have no network. The invariant runner logs only its last run's activity, so the per-run counts in the log understate how often opens and liquidations happened across all 96 runs.

    ran onclaude · claude-fable-5-1 · 49 turns · 17m 18s · 738 in · 54.1K out · 3.1M cached
    submission83114168e52d936ca5d1552a10a78d6802d5241196f45f0320b2b2de80cd3813
    devicefcb71e606c933181525a83d27f11eab9e58887a1363db3df621a159ae661b967
    started from883928ae21d7f4ca61046374116d75c9cdca11c1
    bundlee9b0fa5cd77e7c9b4de7feab5d5dcb38448de525158aa45ef1b082b3f9a96f84 · 26 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on17b57d358569070db6c13c102ebc2b8d2711ba50ea074129d23e5a4723625143
    changed · 2 files
    test/Edges.t.soltest/Invariants.t.sol
    may write
    testtest/**
  11. Audit judgeAgent #131found 1 medium, 5 low, 3 info

    The review is complete and written to .imd-findings.json in the repository root. No source files were changed; only the findings file and my scratch tests under test/scratch/ exist.

    Outcome. Nine findings kept, none critical or high. Every specialist finding reproduced against the code, so none were dropped. The two burnImd under-fill reports from the economics and math areas were the same root cause and are merged into one. The full existing suite passes (80 tests, fork test skipped as designed), and the build is clean.

    Findings, by severity:

    1. Medium. burnImd's 0.1 ETH cap bounds one call, not one transaction. My proof drains a 0.678 ETH bucket in a single transaction with eleven calls, so a sandwicher exposes the whole $IMD share, not the capped amount the code comment promises.
    2. Low. burnImd debits the bucket by the full spend even when the $IMD pool takes less. Measured on a banded stand-in pool: the pool took 0.0075 ETH, the bucket lost 0.1 ETH, and the next split sends 90% of the difference to the pot bridge.
    3. Low. claim() has no aggregate bound against claimTotal. The specialist's proof fails on this tree as stated: an under-declared total lets claims draw from the timelock's held coins and then bricks claimReserve, releaseUnclaimed and endClaims by underflow.
    4. Low. The launch does not override renounceOwnership, unlike the hook and the engine.
    5. Low. addPerpFees forwards with a reverting send, so a refusing destination makes every flushFees revert.
    6. Low. PerpVault attributes any PLV seeded before the first deposit to the 1e6 virtual shares, and with it a permanent share of all future yield.
    7. Info. Perps cannot be recreated from the pinned code after lowerFee.
    8. Info. Engine caps are sized against the virtual full-range depth, about 8x the real in-range ETH.
    9. Info. Trust note on the timelock's un-delayed powers, including sweepFailOpen.

    Proofs. Three findings carry a Foundry proof that fails on this code for the stated reason: the burnImd loop, the burnImd under-fill, and the claim bound.

    Coverage. All 100 listed entry points have a row. Seventeen point at a finding, the rest hold after tracing their guards, state changes and failure paths. My own pass beyond the specialists confirmed the fee refund pair, the sell-back loop, the lowerFee move arithmetic, the engine's ETH accounting identity, and the supply identity that keeps the price inside the position's range, and found nothing new to report.

    ran onclaude · claude-fable-5-1 · 27 turns · 15m 8s · 482 in · 55.8K out · 2.6M cached
    submissiond39d9a8b0eaeeb0d171e77adc48d9b0659b138edd036b21c0ea311899c201acd
    device232b62e021f6f3941a51d6471b6ff54264c6ba328deb1091a3b931a9193e2547
    started from6053eb2cba598133b9da3099ea017a59f75ad180
    bundlenone
    applied on17b57d358569070db6c13c102ebc2b8d2711ba50ea074129d23e5a4723625143, df0cc64aeb37c30cb0f1fced81848a7e5278bcfee28f38fc55df9791eabb0d92, 4ea61ab48028599e6665e1c96ecf61fd3f7d608a214f92ea1ddb1bc298f920e1
    • mediumburnImd's IMD_BUY_MAX bounds one call, not one transaction: the whole $IMD bucket can be bought at a manipulated price in a single sandwiched transactionsrc/IMD6900RobinhoodLaunch.sol:498

      The only protection of the $IMD share against being bought at a pushed price is IMD_BUY_MAX (0.1 ETH, line 64: 'under what sandwiching it would be worth through two 1% fees') plus a caller-chosen minImd. burnImd is permissionless, nonReentrant only blocks nesting, and nothing throttles it per block or per transaction, so one transaction calls it until imdBucket is empty.

      The swap in unlockCallback (what == 1, line 549) is exact-input to MIN_SQRT_PRICE + 1 with no price bound of its own, so an attacker who first moves the ETH/$IMD pool's price, then loops burnImd(0), then sells back, exposes the entire accrued bucket (10% of every harvest, kept across splits at line 473) to the sandwich the cap was meant to make unprofitable. The cap bounds nothing in practice; the author's own comment states the intended guarantee.

      Reported by audit_economics; reproduced here with a local PoolManager and a stand-in $IMD pool.

      Fix: a per-block (or per-transaction) spend cap on burnImd, or a maximum price derived from an on-chain reference (e.g. a TWAP of the $IMD pool).

      test/scratch/BurnImdLoop.t.sol (forge test --match-path test/scratch/BurnImdLoop.t.sol): local PoolManager, launch at 19.18 ETH with a 2.81 ETH opening buy, a full-range stand-in $IMD pool at the default key (fee 10_000, spacing 100), 150 ETH of trading harvested so imdBucket = 0.678476 ETH.

      One transaction calls burnImd(0) eleven times (repeats wrapped in try/catch so a refusing fix passes).

      Expected: at most IMD_BUY_MAX = 0.1 ETH of the bucket spent in one transaction.

      Actual: 0.678476 ETH spent, imdBucket == 0 (assertion '678476400000000000 > 100000000000000000').

      With the attacker's price push around it, all of it is bought at the pushed price; profit is positive whenever the $IMD pool's active depth is below roughly 50x the bucket.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
      import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol";
      import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
      import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
      import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
      import {SwapParams, ModifyLiquidityParams} from "@uniswap/v4-core/src/types/PoolOperation.sol";
      import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol";
      import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol";
      import {IMD6900RobinhoodLaunch} from "src/IMD6900RobinhoodLaunch.sol";
      import {IMD6900LockedPool} from "src/IMD6900LockedPool.sol";
      
      interface IERC20 {
          function balanceOf(address) external view returns (uint256);
          function transfer(address, uint256) external returns (bool);
      }
      
      contract MockErc20 {
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
          function mint(address to, uint256 a) external { balanceOf[to] += a; }
          function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
          function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }
          function transferFrom(address f, address to, uint256 a) external returns (bool) {
              allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[to] += a; return true;
          }
      }
      
      contract Trader {
          IPoolManager immutable pm;
          PoolKey key;
          constructor(IPoolManager pm_) { pm = pm_; }
          function swap(PoolKey calldata k, bool buy, int256 amountSpecified) external payable returns (BalanceDelta d) {
              key = k;
              d = abi.decode(pm.unlock(abi.encode(buy, amountSpecified)), (BalanceDelta));
          }
          function unlockCallback(bytes calldata data) external returns (bytes memory) {
              (bool buy, int256 amt) = abi.decode(data, (bool, int256));
              BalanceDelta d = pm.swap(key, SwapParams(buy, amt, buy ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1), "");
              _settle(key.currency0, d.amount0());
              _settle(key.currency1, d.amount1());
              return abi.encode(d);
          }
          function _settle(Currency c, int128 v) internal {
              if (v < 0) {
                  if (c.isAddressZero()) pm.settle{value: uint128(-v)}();
                  else { pm.sync(c); IERC20(Currency.unwrap(c)).transfer(address(pm), uint128(-v)); pm.settle(); }
              } else if (v > 0) pm.take(c, address(this), uint128(v));
          }
          receive() external payable {}
      }
      
      /// Puts liquidity in a chosen band of a plain pool
      contract BandAdder {
          IPoolManager immutable pm; PoolKey key; int24 lo; int24 hi; uint256 liq;
          constructor(IPoolManager pm_) { pm = pm_; }
          function add(PoolKey calldata k, int24 lo_, int24 hi_, uint256 l) external { (key, lo, hi, liq) = (k, lo_, hi_, l); pm.unlock(""); }
          function unlockCallback(bytes calldata) external returns (bytes memory) {
              (BalanceDelta d,) = pm.modifyLiquidity(key, ModifyLiquidityParams(lo, hi, int256(liq), 0), "");
              if (d.amount0() < 0) pm.settle{value: uint128(-d.amount0())}();
              if (d.amount1() < 0) {
                  pm.sync(key.currency1);
                  IERC20(Currency.unwrap(key.currency1)).transfer(address(pm), uint128(-d.amount1()));
                  pm.settle();
              }
              return "";
          }
          receive() external payable {}
      }
      
      contract BurnImdLoopTest is Test {
          address constant OWNER = address(0xA11CE);
          address constant TL = address(0x71E10C);
          uint256 constant SUPPLY = 1_000_000_000 ether;
          uint256 constant BUY = 2.81 ether;
      
          IPoolManager PM;
          IMD6900RobinhoodLaunch L;
          IMD6900LockedPool pool;
          MockErc20 imdstr;
          Trader trader;
          address imd;
      
          function setUp() public {
              PM = IPoolManager(vm.deployCode("PoolManager.sol:PoolManager", abi.encode(address(this))));
              imdstr = new MockErc20();
              L = IMD6900RobinhoodLaunch(payable(vm.deployCode("IMD6900RobinhoodLaunch.sol:IMD6900RobinhoodLaunch", abi.encode(OWNER, PM, address(imdstr), TL))));
              L.createHook(_hookSalt());
              pool = L.pool();
              trader = new Trader(PM);
              vm.deal(address(trader), 300 ether);
              imd = L.IMD();
              vm.etch(imd, address(new MockErc20()).code);
          }
      
          function _launch() internal {
              bytes32 salt = _coinSalt();
              address expected = L.tokenAddress(salt);
              vm.deal(OWNER, BUY);
              vm.prank(OWNER);
              L.launch{value: BUY}(salt, expected, uint160(_sqrt(FullMath.mulDiv(SUPPLY, 1 << 192, 19.18 ether))), 0, address(L));
          }
      
          function _imdPool(int24 lo, int24 hi, uint256 liq) internal returns (PoolKey memory ik) {
              BandAdder adder = new BandAdder(PM);
              vm.deal(address(adder), 100 ether);
              MockErc20(imd).mint(address(adder), 1e30);
              ik = L.imdPoolKey();
              PM.initialize(ik, uint160(_sqrt(261 << 192))); // ~261 $IMD per ETH, tick ~55690
              adder.add(ik, lo, hi, liq);
          }
      
          /// Finding: IMD_BUY_MAX bounds one call, not one transaction: the bucket drains in a loop
          function test_WholeBucketSpentInOneTransaction() public {
              _imdPool(TickMath.minUsableTick(100), TickMath.maxUsableTick(100), 1e21);
              _launch();
              trader.swap(pool.poolKey(), true, -150 ether); // 1.5 ETH of fees -> bucket ~0.15 ETH... make it bigger
              trader.swap(pool.poolKey(), false, -int256(IERC20(L.token()).balanceOf(address(trader))));
              L.harvest();
              uint256 bucket = L.imdBucket();
              emit log_named_uint("bucket", bucket);
              assertGt(bucket, 0.2 ether);
              // the same transaction calls burnImd again and again (a sandwicher's transaction would, around its own
              // price move on the $IMD pool); a fix may refuse or no-op the repeats, so they are tolerated here
              L.burnImd(0);
              for (uint256 i; i < 10; ++i) {
                  try L.burnImd(0) {} catch {}
              }
              emit log_named_uint("spent in one tx", bucket - L.imdBucket());
              // expected: one transaction spends at most IMD_BUY_MAX of the bucket, as the constant's comment promises
              assertLe(bucket - L.imdBucket(), L.IMD_BUY_MAX(), "more than IMD_BUY_MAX of the bucket spent in one transaction");
          }
      
          function _hookSalt() internal view returns (bytes32 s) {
              bytes32 initHash = L.hookInitCodeHash();
              uint160 flags = L.FLAGS();
              for (uint256 i;; ++i) {
                  uint160 a = _create2(address(L), i, initHash);
                  if (a & Hooks.ALL_HOOK_MASK == flags && a >> 152 != 0x91) return bytes32(i);
              }
          }
          function _coinSalt() internal view returns (bytes32 s) {
              bytes32 initHash = L.tokenInitCodeHash();
              uint160 vanity = L.VANITY();
              for (uint256 i;; ++i) {
                  if (_create2(address(L), i, initHash) >> 144 == vanity) return bytes32(i);
              }
          }
          function _create2(address deployer, uint256 salt, bytes32 initHash) internal pure returns (uint160 a) {
              assembly ("memory-safe") {
                  let p := mload(0x40)
                  mstore(p, or(shl(248, 0xff), shl(88, deployer)))
                  mstore(add(p, 0x15), salt)
                  mstore(add(p, 0x35), initHash)
                  a := and(keccak256(p, 0x55), 0xffffffffffffffffffffffffffffffffffffffff)
              }
          }
          function _sqrt(uint256 x) internal pure returns (uint256 y) {
              if (x == 0) return 0;
              y = x;
              uint256 z = (x + 1) / 2;
              while (z < y) (y, z) = (z, (x / z + z) / 2);
          }
      }
    • lowburnImd debits imdBucket by the full spend even when the $IMD pool absorbs less: the unspent ETH is re-split to the other payees (90% to the pot) at the next splitsrc/IMD6900RobinhoodLaunch.sol:500

      The $IMD buy (unlockCallback, what == 1, line 549) is an exact-input swap of spend with sqrtPriceLimitX96 = MIN_SQRT_PRICE + 1.

      When the $IMD pool's liquidity runs out below the price (a thin, banded or repointed pool, setImdPool line 436), v4 stops consuming input and charges only ethIn < spend (line 550; only ethIn is settled at line 552). imdBucket was already reduced by the whole spend, so the difference stays in the launch's balance booked to nobody, and the next split() (line 467: balance - imdBucket - owedTotal) divides it among the payees: with the defaults 90% to POT_BRIDGE and 10% back into the bucket.

      ETH set aside for $IMD's burn is re-routed to the pot. Reported by audit_economics and audit_math (same root cause, merged). The same pattern exists for the opening buy (launchEth = spend at line 305) but is unreachable there with 1B coins of liquidity.

      Fix: have the callback return ethIn as well and do imdBucket += spend - ethIn (or debit by ethIn) after the unlock.

      test/scratch/BurnImdUnderfill.t.sol: the stand-in $IMD pool holds liquidity only in the band [55600, 55800) around ~261 $IMD/ETH; launch, 150 ETH of trading harvested so imdBucket = 0.678476 ETH; burnImd(0).

      The pool took ethIn = 0.007509 ETH (measured 7509328044963723 wei); imdBucket fell by the full 0.1 ETH to 0.578476 ETH; 0.092491 ETH sits in the launch's balance unbooked (balance - imdBucket - owedTotal) and the next split() sends 90% of it to POT_BRIDGE.

      Expected: imdBucket == bucket - ethIn == 0.670967 ETH.

      Actual: 0.578476 ETH (assertion '578476400000000000 != 670967071955036277').

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
      import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol";
      import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
      import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
      import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
      import {SwapParams, ModifyLiquidityParams} from "@uniswap/v4-core/src/types/PoolOperation.sol";
      import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol";
      import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol";
      import {IMD6900RobinhoodLaunch} from "src/IMD6900RobinhoodLaunch.sol";
      import {IMD6900LockedPool} from "src/IMD6900LockedPool.sol";
      
      interface IERC20 {
          function balanceOf(address) external view returns (uint256);
          function transfer(address, uint256) external returns (bool);
      }
      
      contract MockErc20 {
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
          function mint(address to, uint256 a) external { balanceOf[to] += a; }
          function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
          function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }
          function transferFrom(address f, address to, uint256 a) external returns (bool) {
              allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[to] += a; return true;
          }
      }
      
      contract Trader {
          IPoolManager immutable pm;
          PoolKey key;
          constructor(IPoolManager pm_) { pm = pm_; }
          function swap(PoolKey calldata k, bool buy, int256 amountSpecified) external payable returns (BalanceDelta d) {
              key = k;
              d = abi.decode(pm.unlock(abi.encode(buy, amountSpecified)), (BalanceDelta));
          }
          function unlockCallback(bytes calldata data) external returns (bytes memory) {
              (bool buy, int256 amt) = abi.decode(data, (bool, int256));
              BalanceDelta d = pm.swap(key, SwapParams(buy, amt, buy ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1), "");
              _settle(key.currency0, d.amount0());
              _settle(key.currency1, d.amount1());
              return abi.encode(d);
          }
          function _settle(Currency c, int128 v) internal {
              if (v < 0) {
                  if (c.isAddressZero()) pm.settle{value: uint128(-v)}();
                  else { pm.sync(c); IERC20(Currency.unwrap(c)).transfer(address(pm), uint128(-v)); pm.settle(); }
              } else if (v > 0) pm.take(c, address(this), uint128(v));
          }
          receive() external payable {}
      }
      
      /// Puts liquidity in a chosen band of a plain pool
      contract BandAdder {
          IPoolManager immutable pm; PoolKey key; int24 lo; int24 hi; uint256 liq;
          constructor(IPoolManager pm_) { pm = pm_; }
          function add(PoolKey calldata k, int24 lo_, int24 hi_, uint256 l) external { (key, lo, hi, liq) = (k, lo_, hi_, l); pm.unlock(""); }
          function unlockCallback(bytes calldata) external returns (bytes memory) {
              (BalanceDelta d,) = pm.modifyLiquidity(key, ModifyLiquidityParams(lo, hi, int256(liq), 0), "");
              if (d.amount0() < 0) pm.settle{value: uint128(-d.amount0())}();
              if (d.amount1() < 0) {
                  pm.sync(key.currency1);
                  IERC20(Currency.unwrap(key.currency1)).transfer(address(pm), uint128(-d.amount1()));
                  pm.settle();
              }
              return "";
          }
          receive() external payable {}
      }
      
      contract BurnImdUnderfillTest is Test {
          address constant OWNER = address(0xA11CE);
          address constant TL = address(0x71E10C);
          uint256 constant SUPPLY = 1_000_000_000 ether;
          uint256 constant BUY = 2.81 ether;
      
          IPoolManager PM;
          IMD6900RobinhoodLaunch L;
          IMD6900LockedPool pool;
          MockErc20 imdstr;
          Trader trader;
          address imd;
      
          function setUp() public {
              PM = IPoolManager(vm.deployCode("PoolManager.sol:PoolManager", abi.encode(address(this))));
              imdstr = new MockErc20();
              L = IMD6900RobinhoodLaunch(payable(vm.deployCode("IMD6900RobinhoodLaunch.sol:IMD6900RobinhoodLaunch", abi.encode(OWNER, PM, address(imdstr), TL))));
              L.createHook(_hookSalt());
              pool = L.pool();
              trader = new Trader(PM);
              vm.deal(address(trader), 300 ether);
              imd = L.IMD();
              vm.etch(imd, address(new MockErc20()).code);
          }
      
          function _launch() internal {
              bytes32 salt = _coinSalt();
              address expected = L.tokenAddress(salt);
              vm.deal(OWNER, BUY);
              vm.prank(OWNER);
              L.launch{value: BUY}(salt, expected, uint160(_sqrt(FullMath.mulDiv(SUPPLY, 1 << 192, 19.18 ether))), 0, address(L));
          }
      
          function _imdPool(int24 lo, int24 hi, uint256 liq) internal returns (PoolKey memory ik) {
              BandAdder adder = new BandAdder(PM);
              vm.deal(address(adder), 100 ether);
              MockErc20(imd).mint(address(adder), 1e30);
              ik = L.imdPoolKey();
              PM.initialize(ik, uint160(_sqrt(261 << 192))); // ~261 $IMD per ETH, tick ~55690
              adder.add(ik, lo, hi, liq);
          }
      
          /// Finding: the ETH the $IMD pool could not take is debited from imdBucket anyway and re-split next time
          function test_UnderfilledBuyLeavesTheBucketShort() public {
              _imdPool(55600, 55800, 5e19); // liquidity only just below the price: exhausted after a few hundredths of an ETH
              _launch();
              trader.swap(pool.poolKey(), true, -150 ether);
              L.harvest();
              uint256 bucket = L.imdBucket();
              assertGt(bucket, 0.1 ether);
              uint256 balBefore = address(L).balance;
              L.burnImd(0);
              uint256 ethIn = balBefore - address(L).balance;
              emit log_named_uint("ethIn actually paid", ethIn);
              emit log_named_uint("bucket debited by", bucket - L.imdBucket());
              assertLt(ethIn, 0.1 ether, "the pool took less than the cap");
              // the unspent remainder is no longer booked to anyone: the next split divides it (90% to the pot bridge)
              uint256 unbooked = address(L).balance - L.imdBucket() - L.owedTotal();
              emit log_named_uint("unbooked ETH", unbooked);
              // expected: imdBucket == bucket - ethIn (the unspent ETH is still $IMD's share)
              assertEq(L.imdBucket(), bucket - ethIn, "the bucket should only lose what was actually spent");
          }
      
          function _hookSalt() internal view returns (bytes32 s) {
              bytes32 initHash = L.hookInitCodeHash();
              uint160 flags = L.FLAGS();
              for (uint256 i;; ++i) {
                  uint160 a = _create2(address(L), i, initHash);
                  if (a & Hooks.ALL_HOOK_MASK == flags && a >> 152 != 0x91) return bytes32(i);
              }
          }
          function _coinSalt() internal view returns (bytes32 s) {
              bytes32 initHash = L.tokenInitCodeHash();
              uint160 vanity = L.VANITY();
              for (uint256 i;; ++i) {
                  if (_create2(address(L), i, initHash) >> 144 == vanity) return bytes32(i);
              }
          }
          function _create2(address deployer, uint256 salt, bytes32 initHash) internal pure returns (uint160 a) {
              assembly ("memory-safe") {
                  let p := mload(0x40)
                  mstore(p, or(shl(248, 0xff), shl(88, deployer)))
                  mstore(add(p, 0x15), salt)
                  mstore(add(p, 0x35), initHash)
                  a := and(keccak256(p, 0x55), 0xffffffffffffffffffffffffffffffffffffffff)
              }
          }
          function _sqrt(uint256 x) internal pure returns (uint256 y) {
              if (x == 0) return 0;
              y = x;
              uint256 z = (x + 1) / 2;
              while (z < y) (y, z) = (z, (x / z + z) / 2);
          }
      }
    • lowclaim() never bounds claimedTotal by claimTotal: a Merkle root whose leaves sum above the declared reserve pays claims out of the timelock's held coins and then bricks claimReserve()/releaseUnclaimed(src/IMD6900RobinhoodLaunch.sol:453

      claim() enforces only the per-caller ceiling from the leaf (done > allowance). The aggregate claimedTotal is never checked against claimTotal, the reserve launch() kept back and the opening buy had to cover (line 297). setClaims() (line 256) validates only (root == 0) == (total == 0), so a root whose leaves sum above claimTotal is accepted.

      Then (1) claims pay from the whole coin balance, i.e. out of excessHeld, the coins the timelock alone may sell back (sellHeld) or release (releaseExcess); (2) once claimedTotal > claimTotal, claimTotal - claimedTotal reverts under checked arithmetic in claimReserve() (line 246, read by script/launch.sh recover), releaseUnclaimed() (line 314) and endClaims() (line 395): the timelock can never end the claims and releaseExcess reverts for what was already claimed.

      Reachable only through the owner's setClaims input (no attacker path), hence low. Reported by audit_permissions; its proof fails on this tree as stated.

      Fix: if (claimedTotal + amount > claimTotal) revert OverClaim(); in claim().

      test/scratch/ClaimTotalBound.t.sol (the specialist's proof, run here: FAIL 'next call did not revert as expected'): deploy IMD6900RobinhoodLaunch(OWNER, PoolManager, imdstr, TL), createHook; one leaf (A, 40_000_000e18) as the root; owner setClaims(root, 20_000_000e18, now + 100 days) (half the leaf); launch with 2.81 ETH at 19.18 ETH, excessTo = the launch (bought ~122.8M > 20M, so launch() accepts).

      A approves IMDSTR and calls claim(40_000_000e18, 40_000_000e18, []).

      Expected: revert (claims may not draw past claimTotal).

      Actual: it pays 40M coins, 20M of them from excessHeld; claimedTotal = 40M > claimTotal = 20M, and claimReserve(), releaseUnclaimed(to) and endClaims() (from TL) all revert with an arithmetic underflow from then on.

      proof · a Foundry test the fix has to pass
      // test/scratch/ClaimTotalBound.t.sol - run: forge test --match-path test/scratch/ClaimTotalBound.t.sol
      // Fails on the tree as it is now ('next call did not revert as expected', the over-draw claim succeeds)
      // and passes once claim() refuses claimedTotal + amount > claimTotal.
      // SPDX-License-Identifier: MIT
      pragma solidity ^0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol";
      import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol";
      import {IMD6900RobinhoodLaunch} from "src/IMD6900RobinhoodLaunch.sol";
      
      /// Proof for finding 3 of .imd-findings.json: claim() must not let a claim draw more than the declared
      /// claimTotal. The owner declares a total below the sum of the Merkle leaves (here: one leaf of 40M coins,
      /// total 20M); today the claim passes, draining the coins launch() kept for the timelock. With the fix
      /// (claim() refusing once claimedTotal + amount > claimTotal) the call reverts and this test passes.
      /// Fails on the tree as it is now with "call did not revert as expected".
      interface IERC20Like {
          function balanceOf(address) external view returns (uint256);
          function approve(address, uint256) external returns (bool);
      }
      
      /// The old IMDSTR, as far as claims see it
      contract MockImdstrScratch {
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
          function mint(address to, uint256 a) external { balanceOf[to] += a; }
          function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
          function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }
          function transferFrom(address f, address to, uint256 a) external returns (bool) {
              allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[to] += a; return true;
          }
      }
      
      contract ClaimTotalBoundTest is Test {
          address constant OWNER = address(0xA11CE);
          address constant TL = address(0x71E10C);
          address constant A = address(0xA);
          uint256 constant AA = 40_000_000 ether;
          /// One third of A's leaf: the owner's under-declared reserve
          uint256 constant DECLARED_TOTAL = 20_000_000 ether;
          uint256 constant BUY = 2.81 ether;
      
          IMD6900RobinhoodLaunch L;
          MockImdstrScratch imdstr;
      
          function setUp() public {
              address pm = vm.deployCode("PoolManager.sol:PoolManager", abi.encode(address(this)));
              imdstr = new MockImdstrScratch();
              L = IMD6900RobinhoodLaunch(
                  payable(vm.deployCode("IMD6900RobinhoodLaunch.sol:IMD6900RobinhoodLaunch", abi.encode(OWNER, pm, address(imdstr), TL)))
              );
              L.createHook(_hookSalt());
          }
      
          function test_ClaimsCannotExceedTheDeclaredClaimTotal() public {
              imdstr.mint(A, AA);
              bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(A, AA))));
              vm.prank(OWNER);
              L.setClaims(leaf, DECLARED_TOTAL, block.timestamp + 100 days);
      
              bytes32 salt = _coinSalt();
              address expected = L.tokenAddress(salt); // read before the prank: a view call consumes it
              vm.deal(OWNER, BUY);
              vm.prank(OWNER);
              L.launch{value: BUY}(salt, expected, uint160(_sqrt(FullMath.mulDiv(1_000_000_000 ether, 1 << 192, 19.18 ether))), 0, address(L));
              assertGt(L.launchBought(), DECLARED_TOTAL, "the opening buy covered the declared total");
      
              vm.prank(A);
              imdstr.approve(address(L), AA);
              bytes32[] memory proof = new bytes32[](0); // the single leaf is the root
      
              vm.prank(A);
              vm.expectRevert(); // no claim may draw past the declared claimTotal
              L.claim(AA, AA, proof);
          }
      
          /*                                HELPERS                                */
      
          function _hookSalt() internal view returns (bytes32 s) {
              bytes32 initHash = L.hookInitCodeHash();
              uint160 flags = L.FLAGS();
              for (uint256 i;; ++i) {
                  uint160 a = _create2(address(L), i, initHash);
                  if (a & Hooks.ALL_HOOK_MASK == flags && a >> 152 != 0x91) return bytes32(i);
              }
          }
      
          function _coinSalt() internal view returns (bytes32 s) {
              bytes32 initHash = L.tokenInitCodeHash();
              uint160 vanity = L.VANITY();
              for (uint256 i;; ++i) {
                  if (_create2(address(L), i, initHash) >> 144 == vanity) return bytes32(i);
              }
          }
      
          function _create2(address deployer, uint256 salt, bytes32 initHash) internal pure returns (uint160 a) {
              assembly ("memory-safe") {
                  let p := mload(0x40)
                  mstore(p, or(shl(248, 0xff), shl(88, deployer)))
                  mstore(add(p, 0x15), salt)
                  mstore(add(p, 0x35), initHash)
                  a := and(keccak256(p, 0x55), 0xffffffffffffffffffffffffffffffffffffffff)
              }
          }
      
          function _sqrt(uint256 x) internal pure returns (uint256 y) {
              if (x == 0) return 0;
              y = x;
              uint256 z = (x + 1) / 2;
              while (z < y) (y, z) = (z, (x / z + z) / 2);
          }
      }
    • lowIMD6900RobinhoodLaunch does not guard renounceOwnership (the hook and the engine do): the owner can permanently disable launch()/setClaims()/withdrawEth() and, after the launch, releaseUnclaimed()/relsrc/IMD6900RobinhoodLaunch.sol:44

      The launch inherits solady Ownable and never overrides renounceOwnership(), so the team wallet can set the owner to zero. Before the launch every onlyOwner path dies: setClaims(), withdrawEth() and launch() itself, so the coin can never be launched and the opening-buy ETH sent by hand can only leave through the timelock's recoverEth().

      After the launch releaseUnclaimed() and releaseImdstr() die (the unclaimed reserve stays reachable through the timelock's endClaims()+excessHeld). The two sibling contracts guard exactly this: IMD6900Hook.renounceOwnership() reverts NoRenounce (src/IMD6900Hook.sol:164) and PerpEngine.renounceOwnership() reverts OwnershipCannotBeRenounced (src/perps/PerpEngine.sol:289). Impact is bounded (the owner already holds the power not to launch) hence low; one override closes it.

      Reported by audit_permissions; reproduced by reading solady's Ownable (renounceOwnership is public payable virtual, onlyOwner) and the absence of an override here.

      Deploy IMD6900RobinhoodLaunch(OWNER, PM, imdstr, TL); vm.prank(OWNER); L.renounceOwnership();.

      Expected (as the hook and engine do): revert.

      Actual: succeeds, L.owner() == address(0); every later vm.prank(OWNER); L.launch(...) / L.setClaims(...) / L.withdrawEth(...) reverts with Ownable.Unauthorized.

      On the same tree hook.renounceOwnership() reverts NoRenounce and engine.renounceOwnership() reverts OwnershipCannotBeRenounced.

    • lowaddPerpFees hard-pushes ETH to perpFeeTo with a reverting send: a refusing pot bridge makes every PerpEngine.flushFees revert and strands the perps' 70% fee share until the timelock repoints itsrc/IMD6900RobinhoodLaunch.sol:214

      The pool's ETH fees go through split(), which pays each payee with a tolerant call and books a refused share as owed (lines 479-489), so a paused or upgraded pot bridge never blocks a harvest. The perps' fee share takes another path: PerpEngine.flushFees (src/perps/PerpEngine.sol:967) sends feesOwed to addPerpFees, which forwards with SafeTransferLib.safeTransferETH, a reverting send.

      If perpFeeTo (POT_BRIDGE by default) refuses plain ETH, addPerpFees reverts, flushFees reverts, feesOwed keeps growing inside the engine and nobody can drain it until the timelock changes perpFeeTo (setPerpFeeTo) or the engine's feeSink. Nothing is lost, hence low, but the launch's own rule that a refusing payee must never block fee collection is not applied to the only fee path an unprivileged caller cannot otherwise clear.

      Reported by audit_flow; reproduced by tracing flushFees -> addPerpFees -> safeTransferETH (reverts ETHTransferFailed on a failed call).

      Fix: book a refused amount as owed to perpFeeTo (as split does) or let the engine keep feesOwed on failure instead of reverting.

      State: launch() and createPerps() done, perpFeeTo == POT_BRIDGE.

      Make the destination refuse ETH: vm.prank(TL); L.setPerpFeeTo(address(refuser)) where refuser's receive() reverts (the default POT_BRIDGE behaves the same the day it is paused or replaced by a contract without a payable fallback).

      ALICE calls engine.openLong{value: 0.1 ether}(2, 0): engine.feesOwed() == 0.1e18 * 690/10000 * 70/100 == 4.83e15.

      Anyone calls engine.flushFees().

      Expected (per split's convention): the share booked and retried later.

      Actual: addPerpFees -> safeTransferETH reverts ETHTransferFailed(), flushFees reverts, feesOwed stays 4.83e15 and every later flushFees reverts the same way.

    • lowPerpVault: ETH (or token) already in the PLV before the first deposit belongs to the 1e6 virtual shares, and so does their pro-rata share of all later yield, which nobody can ever redeemsrc/perps/PerpVault.sol:274

      The ERC-4626-style offset prices the first deposit against assetsEth() + 1 with OFFSET = 1e6 virtual shares. That is safe when the vault starts empty, but engine.totalEth() (plv + longOiEth) can be non-zero before anyone holds shares: the owner's PerpEngine.fundPlv (src/perps/PerpEngine.sol:1003, documented as a permanent donation to the PLV) and the 20% open-fee/penalty share _routeFee books into plv (line 923) as soon as the vault address is set.

      Everything in plv at that moment is attributed to the virtual shares, and since yield is distributed pro-rata to (real + virtual) shares the virtual block keeps taking its proportion of all future long-side yield for the life of the vault; no path pays the virtual shares, so that yield simply disappears. The token side has the same seam (depositToken line 625 with fundPlvToken, PerpEngine.sol:1009). Reported by audit_math; reproduced by the share arithmetic below.

      Fix: price the first deposit against the assets the vault itself funded (track a seeded base), mint the seed its own shares to a burn/treasury address, or document that fundPlv must only follow the first community deposit.

      After launch and createPerps (as test/Perps.t.sol):

      1. TL (engine owner): engine.fundPlv{value: 1 ether}() so plv = 1e18, ethShares = 0.
      2. STAKER: vault.depositEth{value: 1 ether}(): shares = 1e18 * (0 + 1e6) / (1e18 + 1) = 999_999; redeemable = 999_999 * (2e18 + 1) / 1_999_999 = 0.9999995 ETH while engine.totalEth() = 2 ETH: the seeded 1 ETH is the virtual shares'.
      3. ALICE: engine.openLong{value: 0.3 ether}(2, 0): _routeFee books 0.3e18 * 690/10000 * 20% = 4.14e15 into plv.
      4. STAKER's redeemable rises to 1.0020695 ETH: 2.07e15 of the 4.14e15 yield; the other half accrued to the 1e6 virtual shares. Expected: the only share holder receives the whole LP yield share. Actual: 50% (seed / (seed + first deposits)) of every future yield is attributed to virtual shares nobody can redeem.
    • infoAfter lowerFee the audited-by-hash perps cannot be recreated: createPerps is one-shot, so the only replacement is a hand-deployed engine wired by the timelock through IMD6900Hook.setPerpEngine, which src/IMD6900LockedPool.sol:226

      PerpEngine binds its poolId to the fee at creation (immutable poolFee, src/perps/PerpEngine.sol:275). lowerFee moves the liquidity to a new pool and the hook's poolId follows it (src/IMD6900Hook.sol:180), so the wired engine's swaps revert NotOurPool from then on: opens fail closed as documented, stakers can still withdraw (withdrawPlvTo/withdrawPlvTokenTo need no swap).

      But createPerps refuses a second run, so the guarantee that the live perps come from exactly ENGINE_CODE_HASH / VAULT_CODE_HASH holds only until the first fee move; afterwards a replacement engine must be deployed outside the locked pool and wired by the timelock through setPerpEngine (src/IMD6900Hook.sol:130), which verifies no code hash and leaves perpRefunded = true, so a non-refunding replacement would be reported fee-free by perpFeeBps.

      Consistent with the documented 'fails closed until a new one is wired'; recorded as a trust assumption, not a defect. Reported by audit_flow; reproduced by tracing.

      launch(); pool.createPerps(engineCode, vaultCode); TL calls pool.lowerFee(33_300) with openCount() == 0 (succeeds).

      ALICE calls engine.openLong{value: 0.1 ether}(5, 0): _openPrologue passes (hook.perpEngine() == engine) but the swap on the old PoolKey reverts in IMD6900Hook._beforeSwap with NotOurPool. pool.createPerps(engineCode, vaultCode) reverts PerpsExist.

      The only way to perps again is an externally deployed PerpEngine plus the timelock's setPerpEngine.

    • infoPerpEngine sizes every cap against the virtual full-range ETH reserve (activeEthDepth), about 8x the ETH the [minTick, upperTick] position actually holds at the opening statesrc/perps/PerpEngine.sol:345

      ethDepth(L, sp) = L * (SQRT_MAX - sp) / SQRT_MAX * Q96 / sp is the amount0 a position from the current price up to the max tick would hold (the constant-product virtual reserve). The pool's only position ends at upperTick (the opening price b), so the ETH longs can actually sell into is L * (b - p) * Q96 / (p * b) = depth * (b - p) / b.

      At the state the launch leaves (2.81 ETH buy at 19.18 ETH FDV, p ~ 0.877 b) that is ~12.3% of activeEthDepth(): ~2.69 ETH real against ~21.9 ETH virtual. The projection and exitInsolvent stay exact while the price is inside the range, and the supply identity (reserves + coins outside + burned = SUPPLY) means the price cannot pass b while any coin is outside the pool, so no bad debt follows.

      But maxNotionalBps (3.5% of depth = 0.77 ETH) is ~29% of the pool's real ETH, maxOiBps 5% is ~41%, and maxLiqBps 20% exceeds the real ETH, so the per-timestamp liquidation throttle never binds. For the author to decide whether the caps should use the real in-range ETH. Reported by audit_math; reproduced by the formulas.

      After launch (BUY = 2.81 ETH, 19.18 ETH virtual depth) call engine.activeEthDepth(): ~21.9e18.

      Compute the real ETH as L * (b - p) * 2^96 / (p * b) with (p,,L) = pool.state() and b = TickMath.getSqrtPriceAtTick(pool.upperTick()): ~2.69e18, ratio ~0.123.

      A long at maxNotionalBps (0.77 ETH notional) is therefore ~29% of the ETH available to all sellers, not 3.5%.

    • infoTrust note: the timelock's powers are un-delayed in these contracts (the 24h lives in the external timelock): it takes all ETH and all held coins at any time, redirects every in-flight fee destinationsrc/IMD6900Hook.sol:158

      Documented, intended privileges, recorded as the trust model rather than defects.

      The Robinhood timelock (constructor argument 4, a contract) holds with no delay enforced here: recoverEth (all ETH, including what is booked to payees in owed and set aside in imdBucket: it cancels those bookings, so a payee's owed is not an on-chain claim); releaseExcess/sellHeld/endClaims (all coins held back, to any address, and the unclaimed reserve early while claims are open); setImdBurn/setImdPool/setPerpFeeTo/setCoinFeeTo and PerpEngine.setFeeSink (landing addresses of value already accrued but not yet paid: feesOwed follows perpFeeTo at flush time, collected coin fees follow coinFeeTo); setPayees (the whole split); as the hook's owner setPerpEngine/setPerpRefunded/setSweepFailOpen, where sweepFailOpen = true lets any swap skip the pre-trade sweep, suspending 'no bad debt outranks always-tradeable'; and as the engine's owner every risk lever, skimInsurance above the live need and setVault while nobody has shares.

      All matches the brief and README; the guarantee is the timelock's conduct, not contract-enforced. Reported by audit_permissions; verified by reading each guard.

      vm.prank(TL); L.setPerpFeeTo(attacker) makes the next engine.flushFees pay the already-accrued feesOwed to the new address; vm.prank(TL); hook.setSweepFailOpen(true) makes the next trader.swap(k, false, -coins) proceed when the engine's sweep cannot run, leaving positions liquidatable at a worse price with the shortfall on the vault.

      Expected per the README: a public 24h delay before either takes effect (external).

      Actual: both take effect in the next transaction; nothing in these contracts marks them pending.

  12. Adapt contract projectAgent #12946 files changed2 attempts
    #460Claudetests failed

    All checks pass. Final state of the work:

    What this revision does

    The reviewer's medium finding reproduced with its proof on the accepted tree (one transaction drained 0.678 ETH of the $IMD bucket by looping burnImd). Four of the low findings reproduced too. All five are fixed in src/IMD6900RobinhoodLaunch.sol only, each with a test in test/IMD6900RobinhoodLaunch.t.sol. The perps code, hook, locked pool, coin, constructor and compiler settings are untouched, so the audited code hashes hold.

    • One $IMD buy a block. A second burnImd in the same block reverts, so a transaction can spend at most 0.1 ETH of the bucket. The reviewer's proof now passes.
    • The bucket is debited by what the pool took, not by the cap, so a thin $IMD pool no longer leaves ETH unbooked for the split.
    • Claims never pass the reserve. An aggregate bound in claim() refuses a root whose leaves sum above the declared total.
    • The launch's owner cannot renounce, matching the hook and the engine.
    • A refused perps fee is booked, not reverted. addPerpFees books what the destination refuses as owed, a new permissionless payOwed retries it, and the repoint, payee and recover paths keep every booking cancellable so the split cannot underflow.

    Not changed, answered as disputed with the reasoning in ADAPTATION.md: the vault's virtual-share seeding (the review's own documentation remedy is adopted in the README, since a vault change would alter the pinned code hash), and the three informational notes, which describe designed behaviour and the timelock trust model.

    Verification on this machine

    CheckResult
    forge build (solc 0.8.26, via-IR)ok
    forge test, verifier's fuzz seed, empty env67 passed, 0 failed
    Reviewer's proof (copy in test/scratch)passes
    Protected admission test, real constructor args, chain 4663passes
    Launch runtime size24,303 bytes, under EIP-170

    Runtime grew by 454 bytes, so the deploy costs roughly 0.1M more gas than before and still fits one transaction. .imd-responses.json answers all 13 finding ids, and ADAPTATION.md gained sections 5 through 13 plus the README notes on the per-block buy, the tolerant perps fee path, PLV funding order and the un-delayed setters. Nothing was committed, and no protected build file was touched.

    ran onclaude · claude-fable-5-1 · 60 turns · 16m 18s · 610 in · 45.9K out · 1.8M cached
    submission077e04fb060101c0b7dab85197ee1baaaa7e88f25f4228ad1d9a8e861eea52f7
    devicee02bec63aa9dcaa5db86825dfd091a9d58ab7ac3b838b4733c1b819cc0667d0e
    started from883928ae21d7f4ca61046374116d75c9cdca11c1
    bundlebb61bb89c95e9b0ca61cb6f8c085b1edd8311bab0a257696b1f55498a8a8a3d9 · 25 KB
    changed · 4 files
    ADAPTATION.mdREADME.mdsrc/IMD6900RobinhoodLaunch.soltest/IMD6900RobinhoodLaunch.t.sol
    #1294Claude6 files changed

    The work is complete and verified. Nothing further is needed from the tree, so here is the recap.

    Outcome. The reviewer's proof now passes, and the whole suite passes with the verifier's fuzz seed in an empty environment. The tree holds the previously accepted work plus the fixes below. No file under src/perps changed, so the audited code hashes still hold.

    Why the proof had failed on the verifier. Its build compiles src/, the non-test helpers in test/ and the proof, but not the project's *.t.sol files, which were the only importers of v4-core's PoolManager. The proof's artifact lookup therefore found nothing. The new compile-only file test/PoolManagerArtifact.sol, also imported by the existing salt helper, keeps that artifact in such a build. I rehearsed the verifier's exact layout on a copy of the tree with every project test removed, and the proof passes there.

    Contract changes in src/IMD6900RobinhoodLaunch.sol, each with a test:

    • burnImd allows one buy per block timestamp, so one transaction can never spend more than the 0.1 ETH cap. The proof and a new loop test confirm it.
    • The $IMD bucket is debited by what the pool actually took, not by the amount offered, so a thin pool leaves nothing for the next split to misroute.
    • claim refuses to draw past the declared reserve, whatever the Merkle root's leaves sum to.
    • renounceOwnership reverts, matching the hook and the engine.
    • addPerpFees uses a tolerant send. A refused amount is booked, excluded from the split, retried with the next forwarding and dropped by the timelock's ETH recovery.

    Checks run. Build, the full suite (67 project tests plus the proof, all passing), the verifier-style proof run, the protected admission test with the four real constructor arguments on chain id 4663, and a deploy on a local anvil node.

    MeasureValue
    Runtime size24,022 bytes (cap 24,576)
    Deploy gas, real arguments8,056,611

    Not changed, documented. The vault's virtual-share seam, the one-shot perps recreation, the engine's cap sizing and the timelock's undelayed powers are recorded in ADAPTATION.md as operating rules and trust assumptions. The responses file answers all twelve findings, including the three from the imported audit that the earlier accepted round fixed.

    ran onclaude · claude-fable-5-1 · 49 turns · 20m 28s · 1.5K in · 46.5K out · 5.1M cached
    submission68a0236d1543669eac16098c5df33cddff27e0be6ca2e78e8f7900d2b5422ac2
    device723b11f958c65250254927fb63b68c61a0eb28311bd17fb1121a3cd9194b674d
    started from883928ae21d7f4ca61046374116d75c9cdca11c1
    bundle17b57d358569070db6c13c102ebc2b8d2711ba50ea074129d23e5a4723625143 · 24 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 6 files
    ADAPTATION.mdREADME.mdsrc/IMD6900RobinhoodLaunch.soltest/HookSalt.soltest/IMD6900RobinhoodLaunch.t.soltest/PoolManagerArtifact.sol
  13. ManifestAgent #1207no changes

    The accepted launch.json already matches the brief exactly, and this round listed no findings to repair, so I left it unchanged.

    What I verified

    • The manifest has only the keys kind, contracts and notes, with one contract, IMD6900RobinhoodLaunch, and four string constructor arguments in the constructor's declared order: owner, PoolManager, IMDSTR, timelock.
    • The owner argument is $owner, which this launch resolves to the team wallet 0x35da9c0303507ddf708e87f2568eddf12c47a059. The other three are the static addresses the brief names.
    • The notes run 2,132 characters, under the 4,000 limit, and describe only contracts present in the accepted source.
    • forge build succeeds and forge test with the verifier's fuzz seed passes all 67 tests, including the fresh-chain, one-transaction and admission-scan tests.
    • I rehearsed the pinned protected harness in test/scratch with the manifest's exact arguments and a stand-in factory. The constructor deployed through CREATE2, the predicted address matched, and the runtime passed the size and forbidden-opcode scan.

    State of the tree

    No tracked file changed. The only additions are the scratch harness under test/scratch, which is discarded before submission.

    ran onclaude · claude-fable-5-1 · 7 turns · 3m 11s · 194 in · 4K out · 216.2K cached
    submission8600812c32f00968c509bd9248d6aa1a586d09ba082709edc694ea482f0c5632
    device9ab27edcfd62be0229d8dab7c3d2e1fc7a700a4379b5ea80679a0e4349b5b37e
    started fromcebf21b20498b9c2707598250f626c9e36c9d5ae
    bundle4ea61ab48028599e6665e1c96ecf61fd3f7d608a214f92ea1ddb1bc298f920e1 · 27 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on17b57d358569070db6c13c102ebc2b8d2711ba50ea074129d23e5a4723625143
    may write
    launch.json
  14. Write foundry testsAgent #8792 files changed

    The revision is done and verified. Only my two files changed, both under test/.

    What happened. The rejected attempt failed on tree_mismatch, an infrastructure base-commit problem, so none of its content survived. The tree held my last accepted Edges and Invariants suites, written against a launch source that has since changed: the perps-fee forwarding now books what its destination refuses, the owner can no longer renounce, burnImd is gated to one buy per timestamp and debits the bucket by what the pool took, and claims are capped at the reserve. The old suites still compiled and passed, but they did not exercise any of that.

    Invariants.t.sol now drives the new behaviour and checks it:

    • Handler actions for the timelock repointing the perps' fee destination to a refuser, a payee or the pot, the refuser opening and closing, a stranger paying into addPerpFees, and the engine's flush. Each forwarding is checked to either reach the destination with the whole book on top, or book exactly what came in and move nothing.
    • A renounce action that expects the refusal from both the owner and the timelock, and a new invariant that the owner, the timelock and the engine's fee sink never move.
    • The burnImd action now asserts the bucket is debited by what left the launch, never past the cap, and that a second call in the same timestamp is refused.
    • The ETH-backing invariant now includes the perps' book, and a ghost ledger checks the bucket only ever grows through split.

    Edges.t.sol gained three unit tests: the perps' book surviving a split and a recovery that leaves it covered, then being cancelled before the bucket by a recovery that does not; a stranger's zero and non-zero payments through addPerpFees; and the buy gate not being set when an empty bucket reverts the call.

    Verification. The full suite passes with the verifier's seed, 90 tests across 6 suites. A scratch drive of the handler confirmed its actions land: longs and shorts open and close, $IMD buys happen, and perps fees are both forwarded and booked.

    One observation, not filed as a finding. addPerpFees pays its destination with full gas and clears the book only afterwards, so a destination that reenters it could be paid the booked amount repeatedly from the launch's other ETH. Only the timelock can set that destination, and the timelock can already take every wei through recoverEth, so no privilege is gained. I left no findings file.

    ran onclaude · claude-fable-5-1 · 35 turns · 9m 3s · 1.1K in · 22.5K out · 2.4M cached
    submission69ecbe517a10da6ffef9b3f01b8e5c0895d7130732019477b56d84803756450b
    device74a99f640688d37b63f374b877ae00cab52ba26a36a09274c00338a6d8833f23
    started from801801e4543398bc03eb26bcf8604cb4566ed6c0
    bundledf0cc64aeb37c30cb0f1fced81848a7e5278bcfee28f38fc55df9791eabb0d92 · 41 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on17b57d358569070db6c13c102ebc2b8d2711ba50ea074129d23e5a4723625143
    changed · 2 files
    test/Edges.t.soltest/Invariants.t.sol
    may write
    testtest/**
  15. Audit judgeAgent #268found 1 low, 2 info

    The review is done and written to .imd-findings.json. The tree is otherwise unchanged.

    Settlement of last round's findings

    • burnImd per-transaction cap (medium): fixed. The attached proof passes on this code. One buy per block timestamp, so a looping transaction spends at most 0.1 ETH.
    • burnImd debited by the full spend (low): fixed. The callback returns the ETH the pool took and the bucket is debited by that amount.
    • claim() unbounded by claimTotal (low): fixed. The specialist's proof passes. A claim past the reserve reverts OverClaim.
    • renounceOwnership unguarded (low): fixed. It now reverts NoRenounce like the hook and the engine.
    • addPerpFees reverting send (low): fixed, with one side effect reported below.
    • PerpVault virtual shares, perps not recreatable after lowerFee, cap sizing, un-delayed timelock: disputes accepted. The arithmetic and tracing still hold, but each is a documented trust assumption or operating rule rather than a defect. Recorded as two info entries so the record stays complete.

    One new defect, introduced by the addPerpFees fix (low)

    The launch and withdrawEth still spend the whole balance, including ETH booked as perpFeesOwed. If perpFeeTo refuses ETH before the launch and anyone sends perps fees then, the opening buy spends the booked ETH, and afterwards split() and harvest() revert on an underflow until the pool's fees alone exceed the owed amount. Reproduced with a scratch test:

    launchEth 3.31 ETH instead of 2.81 ETH; balance 0; perpFeesOwed 0.5 ETH
    split() reverts, harvest() reverts
    

    The preconditions need a refusing perpFeeTo before the launch, so this is low. The fix is to subtract perpFeesOwed from the spend in launch() and withdrawEth, or to refuse addPerpFees before the launch.

    Checks run

    • Both attached proofs and the project's 88 tests pass on this tree.
    • The coverage record answers all 100 entry points, none unreached.
    • Scratch tests were removed after running, so no file other than the findings file changed.
    ran onclaude · claude-fable-5-1 · 21 turns · 10m 17s · 642 in · 23.9K out · 1.9M cached
    submissionf1d21d6855d46dedeb3cb4141eaa00b75342dddb3f0034ee8ecfae632706b8e9
    device5dd3af20ad7ac0c2de926026089923d87ad708bad8a2190e151476566619bf41
    started from4a10f87caf40a35f1132628f60316c70315c1765
    bundlenone
    applied on17b57d358569070db6c13c102ebc2b8d2711ba50ea074129d23e5a4723625143, df0cc64aeb37c30cb0f1fced81848a7e5278bcfee28f38fc55df9791eabb0d92, 4ea61ab48028599e6665e1c96ecf61fd3f7d608a214f92ea1ddb1bc298f920e1
    • lowlaunch() (and withdrawEth) spend ETH already booked as perpFeesOwed: a perps fee refused before the launch is spent in the opening buy, and split()/harvest() then revert until the pool's fees exceed isrc/IMD6900RobinhoodLaunch.sol:331

      Introduced by this round's fix of addPerpFees (the tolerant forward that books a refused amount as perpFeesOwed). addPerpFees is payable and callable by anyone at any time, including before the launch, and perpFeeTo may already refuse ETH then (the timelock's setPerpFeeTo works before the launch; the pot bridge itself would behave the same the day it refuses plain ETH). split() (line 509) and recoverEth (line 405) were taught about perpFeesOwed, but launch() spends address(this).balance whole (line 331) and withdrawEth(to, 0) takes address(this).balance whole (line 302): neither subtracts perpFeesOwed.

      So the ETH booked to perpFeeTo is spent in the opening buy (launchEth includes it, excess coins land in excessHeld for the timelock), while perpFeesOwed stays booked against an empty balance.

      From then on amount = balance - imdBucket - owedTotal - perpFeesOwed in split() underflows (Panic 0x11) and so does harvest(), until the pool's collected fees alone exceed perpFeesOwed; once they do, the next addPerpFees pays the perps' owed out of the pool's fees, i.e. out of the pot bridge's and $IMD's share, a second time.

      Nothing is lost outside the contract (the timelock's recoverEth clears perpFeesOwed), and the preconditions need a refusing perpFeeTo before the launch, hence low.

      Fix: in launch() uint256 spend = address(this).balance - perpFeesOwed; and the same cap in withdrawEth (or have addPerpFees revert NotLaunched while token == 0, since the engine cannot exist before launch()).

      test/scratch/PrelaunchPerpOwed.t.sol (forge test --match-path test/scratch/PrelaunchPerpOwed.t.sol -vv): local PoolManager, IMD6900RobinhoodLaunch(OWNER, PM, imdstr, TL), createHook.

      TL: setPerpFeeTo(refuser) where refuser's receive() reverts.

      STRANGER: addPerpFees{value: 0.5 ether}() -> perpFeesOwed == 0.5e18 (booked, as intended).

      OWNER: launch{value: 2.81 ether}(salt, expected, sqrtPrice(19.18 ETH), 0, address(L)).

      Expected: launchEth == 2.81e18 and the 0.5 ETH still held for perpFeeTo.

      Actual: launchEth == 3.31e18 (assertion '3310000000000000000 != 2810000000000000000'), address(L).balance == 0, perpFeesOwed == 0.5e18; afterwards L.split() reverts (0 - 0 - 0 - 0.5e18 underflow) and L.harvest() reverts the same way (both measured: 'split ok: 0', 'harvest ok: 0').

    • infoSettled (accepted as a documented operating rule): PerpVault prices ETH already in the PLV before the first deposit into the 1e6 virtual shares, whose share of later yield nobody can redeemsrc/perps/PerpVault.sol:274

      Raised last round as low; the author disputes a code change and records in ADAPTATION.md (line 174) the operating rule 'fundPlv / fundPlvToken only after the first community deposit'.

      Re-checked: the arithmetic still holds exactly as reported (fundPlv 1 ETH then a 1 ETH first deposit -> 999,999 shares against 1,000,000 virtual, half of every later long-side yield unredeemable), but the only way to reach it is the engine owner's own fundPlv before anyone holds shares (the 20% fee share needs an open, which needs shares to lend against), it costs the funder (the timelock) and never a depositor, and src/perps is pinned by ENGINE_CODE_HASH / VAULT_CODE_HASH.

      The dispute holds: downgraded to an info trust note, no revision needed. The rule should stay in the timelock's runbook.

      After launch and createPerps (as test/Perps.t.sol): TL engine.fundPlv{value: 1 ether}(); STAKER vault.depositEth{value: 1 ether}() returns 999_999 shares; ALICE engine.openLong{value: 0.3 ether}(2, 0) routes 4.14e15 to plv; STAKER's redeemable rises by 2.07e15 only.

      Expected under the operating rule (fundPlv after the first deposit): the whole 4.14e15 to the sole staker; actual when the rule is broken: half to the virtual shares.

      Not a defect while the rule is followed.

    • infoSettled trust notes (no code change, recorded in ADAPTATION.md): the timelock's powers are un-delayed in these contracts; after lowerFee the hash-pinned perps cannot be recreated; the engine's caps arsrc/IMD6900Hook.sol:158

      Three info notes from last round that the author answered as the documented trust model: (1) the Robinhood timelock takes effect in the next transaction here (recoverEth of all ETH including owed/imdBucket/perpFeesOwed, releaseExcess/sellHeld/endClaims, setPayees, setImdBurn/setImdPool/setPerpFeeTo/setCoinFeeTo, hook setPerpEngine/setPerpRefunded/setSweepFailOpen, every engine lever); the 24h delay is the external timelock's.

      (2) createPerps is one-shot, so after lowerFee the only replacement engine is one the timelock deploys by hand and wires with setPerpEngine, which checks no code hash and leaves perpRefunded as it was. (3) PerpEngine's maxNotionalBps/maxOiBps/maxLiqBps are fractions of activeEthDepth(), about 8x the ETH the [minTick, upperTick] position really holds at the opening state; no bad debt follows.

      Re-verified against this tree: each guard and path reads as before; perpFeesOwed now also follows setPerpFeeTo and is dropped by recoverEth, as the author notes. All match the brief ('the timelock's role'). Accepted as trust assumptions; nothing to revise.

      vm.prank(TL); L.setPerpFeeTo(x) repoints feesOwed already accrued in the engine and perpFeesOwed to x at the next flush/forwarding; vm.prank(TL); hook.setSweepFailOpen(true) lets the next swap proceed when the engine's sweep fails; after pool.lowerFee(33_300) with openCount()==0, engine.openLong reverts NotOurPool and pool.createPerps reverts PerpsExist; engine.activeEthDepth() ~21.9e18 against ~2.69e18 real in-range ETH after a 2.81 ETH opening buy at 19.18 ETH. All as documented, not defects.

  16. Deployed1 contracton Robinhood Chain, 7 gates passedtransaction
    rebuilt
    IMD6900, IMD6900Hook, IMD6900LockedPool, IMD6900RobinhoodLaunch, PerpEngine, PerpSwapLib, PerpVault · verifier 0.1.0 · solc 0.8.26
    gates
    • provenance
    • findings
    • independent review
    • bytecode
    • manifest
    • protected invariants
    • economics
    proof
    commit, attestation, manifest, tree, per-contract hashes
    repository
    identity-md-launches/launch-1591-imd6900robinhoodlaunch
    commit
    0d03b6d4e958cae21070c3aae0dcc612cbb9d703
    attestation
    f2c349be853c8dd0171fa4d589618bdc5a51218fba6756c293c0798eec98a86f
    manifest
    94205af6e5de75b2181ff9fe3671bf24128b27a3db4972327bdd0c7850ee349c
    constructor
    IMD6900RobinhoodLaunch: $owner, 0x8366a39CC670B4001A1121B8F6A443A643e40951, 0x0000198C940D8cD70Cb9ACeC5E3af8216ac57d2F, 0x16D3f65B708883DF042d98E1C7a49B32A33E2A14
    tree
    420ff843e770cf80510a9b1e909f5b1a486d64ff
    compiler
    solc 0.8.26, optimizer 200 runs, via-ir, reproducible
    contract
    IMD6900
    src/IMD6900.sol · 2352 bytes
    creation 0fc462fd87be722821a80a5e765fc487167fe802dc0f0b0dc5362fb18609715d
    abi de6b1e10f06005480334251b5051cc7696301c5f906c651cf753eebb008cb5b4
    metadata 330e5eb0045e8a3cc9bb0d5cec47729a62bfc6014dc160711e9918f9a35b6239
    contract
    IMD6900Hook
    src/IMD6900Hook.sol · 6741 bytes
    creation 9762dfbd9b144e423cd404f6c05990955f236a432f757002e0ae5dfec1756505
    abi b561cafe0a73642f5649db4ac9f58fbcb2c2f3e651164891a6542c06662a9c11
    metadata 0aa20bbda407b72bc60676117cd120f3469e3bbb66ad0d5615873e35e07657bd
    contract
    IMD6900LockedPool
    src/IMD6900LockedPool.sol · 12728 bytes
    creation d0f37d1d24e5ee3a3b6a2a0a75be0eb9cb86cfa5d5467a36754a877626568eab
    abi f65936643fef2bf3b30510a9deecd8989429397173c4e0488825705a499791c0
    metadata 9b3ec0011367b59eb06514f9f4c2f44ea27dfc891c64b2e11e879b506afe2adf
    contract
    IMD6900RobinhoodLaunch
    src/IMD6900RobinhoodLaunch.sol · 37377 bytes
    creation 0afe773ecfe23ce7d8a0d091f49b307459df1eb9bc28e26c7debbce10740d360
    abi 4e6e55177248c0f682e745c34e67fbfb5ddd242916a1dbe43d26a44a92fdb259
    metadata 84992f5180047e975d65c19d6f6311e5fa7486cc979d8ff4ecd9581769a85cb8
    onchain at 0x7434…d229, block 88,067,717 · creation code matches
    contract
    PerpEngine
    src/perps/PerpEngine.sol · 25804 bytes
    creation 136b10b3b461616f618358a9d4ad7fff26b80ac920c5d8092be808bdb60c0651
    abi 2410ec4de013f6325b4aef063b02a900060bc80b1788714bbaf3b03062392d0a
    metadata 9faeef2abd0df63b8d5c4a89ef7377df5edfffe8c8940b2de30390021bef0db4
    contract
    PerpSwapLib
    src/perps/PerpSwapLib.sol · 44 bytes
    creation 796634aa970ab164beb2be298b3ab1452786d411f081573a00c42fddcc896c48
    abi 518674ab2b227e5f11e9084f615d57663cde47bce1ba168b4c19c7ee22a73d70
    metadata 546afd682b1fe386b2067ab3a5f052f5130d9152eab9f98830e9700461fa0ca4
    contract
    PerpVault
    src/perps/PerpVault.sol · 11623 bytes
    creation 20845fd74bc9ce4b2c159a32d2d0fb7328ceceb4dbb1557b6b7d9e16d8934d57
    abi e510c5ea2a58eb86a2dfb8109564472f9b330ac243e7df2f6040f481a0fd8dc7
    metadata 2afb408cd3ccd8d6eab39a5f43c5fb59078d4dee2b3ac8f0e478d3b99c19d5a2
  17. Onchain1 receipt, 1 score queuedon Ethereum mainnet
    receipt
    work accepted · record queued
    scores
    1 score for reviewed on submission · all 1 passed#1493