{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"59db1445-68d3-4495-bd02-087f2c6c768a","kind":"audit","nodes":[{"acceptedSubmissionHash":"19e80f174e6c3d1b8afb7a6f7ece0eb2c1036dbfba483e2494f0ef9ca9909094","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"46931bf7e1bda80e4a4c42ca757a274f67becf18f69ea9a293085d35bab93ef9","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"894791a4b2f5d3484fe3c72d34adec039123652da5bca5d1b966c62af671a75a","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"d2b24073b36e5364122f055db206a22afdb80a91ba6f5f63b6c8a23bc6e57dac","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"3ce392f8c4083c156eb2558c6206afe64f73596a1681f997ab48900b70e48e0c","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"CourierPad: in-game token launchpad for Courier World on Robinhood Chain (4663). Players with an open post office (PostOffice 0x26D80804D0FA2F4598369E0062bFC9536d115D82) launch a PadToken (1B fixed supply, OpenZeppelin ERC20 + Permit, no owner) by burning a $STAMP fee (burnFrom, bounded by the launcher’s maxStampFee; permit variant). 5% of the supply goes to the treasury for drops; 95% is single-sided liquidity in a Uniswap v4 pool against IMD, owned by the pad, with no way to remove it. The pad is every pool’s hook: 4% of the IMD side of every swap to the fee recipient (StampHook’s audited fee code, keyed per pool), full fills only, and nobody else can create pools with the hook or add liquidity. StampRouter and StampEthRouter are reused unchanged.\n\nPlease check: fee accounting across many pools and both currency orders, that launch liquidity can never be removed and no pool can be hijacked, the start-price bounds (liquidity overflow), the fee and permit handling at launch, that a launched token can’t be blocked from trading, and the owner’s powers.\n\nSource: contracts/src/CourierPad.sol and contracts/src/PadToken.sol; tests in contracts/test/CourierPad.t.sol and contracts/test/CourierPad.fork.t.sol. Commit 0b01b0c.","parentJobId":null,"planHash":"9a2e35cc91d5d2389bd5113e6750c13616e69152c08452ca0abf733d5fff59ec","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"59db1445-68d3-4495-bd02-087f2c6c768a","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52160","feedbackHash":"7aace4a7dad684424beb5143f44a63361577732d75582861b26e10759ddc3611","nodeKey":"audit_economics","submissionHash":"19e80f174e6c3d1b8afb7a6f7ece0eb2c1036dbfba483e2494f0ef9ca9909094","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52157","feedbackHash":"6cc69cf662f6b6cec1093a3d899e9129bc28024ef2cc6a23d06986f0793a8293","nodeKey":"audit_flow","submissionHash":"46931bf7e1bda80e4a4c42ca757a274f67becf18f69ea9a293085d35bab93ef9","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51316","feedbackHash":"0f128541f16a196243a39909a132b1c44a2fdf36f6b0778dc5d5b2fe299cbbc9","nodeKey":"audit_judge","submissionHash":"894791a4b2f5d3484fe3c72d34adec039123652da5bca5d1b966c62af671a75a","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52152","feedbackHash":"4b10bfa2c00795a4eb667a68016907f5137871f693c21f5b3a9d9e374c7765f7","nodeKey":"audit_math","submissionHash":"d2b24073b36e5364122f055db206a22afdb80a91ba6f5f63b6c8a23bc6e57dac","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52167","feedbackHash":"6e28fac6f7fbed5c20f7f8c10781bcbb9acbae016f2526d39a9c243894ba5c7b","nodeKey":"audit_permissions","submissionHash":"3ce392f8c4083c156eb2558c6206afe64f73596a1681f997ab48900b70e48e0c","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"7653dbcf4cb537355d206005e967998f6bb773ca0bbc6443a794581fe1494fcf","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"cd8076510aad8b8e","findings":[{"citation":"resolved","description":"A launch is protected against an owner fee change by maxStampFee (line 288, FeeChanged), but the other owner parameter the launch consumes, startTick, is read from storage with no launcher-side bound. The allowed range [-300000, 400000] spans a starting market cap for the 1B supply from about 4 gwei of IMD up to about 1e22 IMD. If setStartTick is sequenced before a player's launch (owner key mistake or compromise, a repricing that races a player's transaction, or simply the owner retuning the price while launches are pending), the player burns stampFee $STAMP and gets a token launched at a completely different price. At the top of the range the whole 950M-token position is worth a few gwei, so the first buyer takes most of the supply for dust and the launcher's and the treasury's 5% are worthless; at the bottom the pool starts at ~1e13 IMD per token and nobody can buy a meaningful amount. The fee bound shows the authors wanted launch parameters pinned by the launcher; the start price is the one that determines the launch's economics and is the one left unpinned. Fix (preserves the owner's power to retune future launches): add an expected start tick (or a min/max acceptable tick) parameter to launch/launchWithPermit and revert e.g. StartTickChanged(startTick) when the stored value differs, mirroring FeeChanged.","line":295,"path":"contracts/src/CourierPad.sol","reproduction":"Pad deployed with startTick 142800 (~635 IMD market cap). 1) owner: pad.setStartTick(400000) (accepted, it is in range). 2) alice (open office, 500 STAMP approved): pad.launch(\"T\",\"T\",\"{}\", 500e18) succeeds and burns 500 STAMP. Expected (from alice's point of view): a token at the advertised ~635 IMD cap, or a revert like FeeChanged. Actual: pad.marketCap(token) == 3999999999 wei (4 gwei of IMD for the whole 1B supply). 3) bob: router.buy(token, 1e9 /* 1 gwei IMD */, 1, deadline) returns 182,253,615.58 tokens (18% of the supply, more than 3x the treasury's drop allocation) for 1 gwei of IMD. Reproduced in Foundry (test_StartTickChangeBeforeLaunch in the scratch suite): mcap 3999999999, bob got 182253615583363540886102967 wei of token.","severity":"low","snippet":"        int24 st = startTick;","title":"launch() bounds the $STAMP fee but not the start price: a setStartTick landing first launches the token at a price the launcher never agreed to"},{"citation":"resolved","description":"Ownership deliberately moves in two steps (transferOwnership/acceptOwnership, 'so a typo can't lose the admin role'), but the two value-receiving roles move in one step and only reject address(0). The recipient role is the one that must keep working after the owner is renounced (the IMD blocklist case the design relies on). Once ownership is renounced, the only account that can change feeRecipient is feeRecipient itself, so a wrong address set by the recipient (typo, a contract that cannot move IMD, a wallet whose key is lost) is final: every 4% fee from every pool is sent there by collectProtocolFees forever and nobody can redirect it. The same holds for dropRecipient (line 474): 5% of every future launch is sent to the wrong address with no recovery. Fix: make both roles two-step (pendingFeeRecipient / acceptFeeRecipient, same for drops), so the new address has to prove it can act before it receives value; this keeps the recipient's ability to move itself after the renounce.","line":466,"path":"contracts/src/CourierPad.sol","reproduction":"1) owner: pad.renounceOwnership(). 2) feeRecipient: pad.setFeeRecipient(0x0000000000000000000000000000000000001234) (a mistyped address) succeeds. 3) feeRecipient: pad.setFeeRecipient(feeRecipient) reverts NotOwner; owner is 0 so no one else can call it either. 4) bob: router.buy(token, 1000e18, 1, deadline); anyone: pad.collectProtocolFees(IMD). Expected: a way to recover from the mistake, as the two-step owner transfer provides for the admin role. Actual: IMD.balanceOf(0x1234) == 40e18 and every future fee goes there. Reproduced in Foundry (test_FeeRecipientTypoIsFinal in the scratch suite).","severity":"low","snippet":"        feeRecipient = feeRecipient_;","title":"setFeeRecipient / setDropRecipient are single-step: after renounceOwnership a mistyped or non-receiving address permanently captures every future fee or drop"},{"citation":"resolved","description":"_launch transfers TOTAL_SUPPLY - POOL_SUPPLY to dropRecipient and then _addLaunchLiquidity burns whatever PadToken balance the pad still holds (line 340: token.transferOut(DEAD, PadToken(token).balanceOf(address(this)))). If dropRecipient is the pad, the 5% lands back in the pad and is burned with the 1e9-wei rounding buffer; if it is the PoolManager, the tokens sit in the PoolManager unaccounted (no delta, no claim) and are unrecoverable. Only the owner or the current drop recipient can set it, so this is a misconfiguration guard, not an attack; but it is cheap to close and the launch gives no error. Fix: in setDropRecipient (and the constructor) reject dropRecipient_ == address(this) and == address(poolManager); optionally reject DEAD.","line":473,"path":"contracts/src/CourierPad.sol","reproduction":"1) owner: pad.setDropRecipient(address(pad)) succeeds (only address(0) is rejected). 2) alice: pad.launch(...). Expected: 50,000,000e18 tokens at the treasury for drops. Actual: PadToken.balanceOf(DEAD) == 50000000000000001000000613 (the 5% plus the buffer burned), pad balance 0, treasury 0. Reproduced in Foundry (test_DropRecipientIsPadBurnsTheDrop in the scratch suite).","severity":"info","snippet":"        if (dropRecipient_ == address(0)) revert ZeroAddress();","title":"setDropRecipient accepts the pad itself (or the PoolManager): the 5% drop allocation of every later launch is burned instead of reaching the treasury"},{"citation":"resolved","description":"Documented as the owner's trust surface, not as defects. (1) setStampFee has no cap: stampFee = type(uint256).max halts all launches (every launch reverts FeeChanged since maxStampFee < fee), and 0 makes launches free. (2) setStartTick may be set anywhere in [-300000, 400000] at any time: every start tick in that range opens (verified: liquidity 3.10e33 at -300000 in both orderings against the per-tick cap 3.84e34, 1.96e18 at 400000), so the bound is a liquidity-safety bound, not an economic one; see the separate finding on launches not pinning it. (3) The owner or the current recipient can redirect all future fees (setFeeRecipient) and all future drops (setDropRecipient) in one step, effective immediately. (4) renounceOwnership works at any time and keeps launches working. Verified limits of the owner: no function removes or modifies the launch position (the only modifyLiquidity call is the positive one in _addLaunchLiquidity, reachable only from _launch); no function touches tokenOfPool, launches, launchTick, a PadToken's balances, or the pad's ERC-6909 claims beyond collectProtocolFees sending exactly pendingProtocolFees to feeRecipient; beforeInitialize and beforeAddLiquidity revert for every caller (the pad's own calls skip the hook via v4's noSelfCall), so no second pool on this hook and no outside liquidity.","line":481,"path":"contracts/src/CourierPad.sol","reproduction":"State: pad deployed, alice has 500 STAMP approved and an open office. owner: pad.setStampFee(type(uint256).max). alice: pad.launch(\"T\",\"T\",\"{}\", 500e18) reverts FeeChanged(type(uint256).max); every other launcher is refused the same way until the owner lowers it. Expected under the stated design: an owner power; Actual: as expected. Listed so the requester can decide whether a fee cap or a timelock is wanted.","severity":"info","snippet":"        stampFee = stampFee_;","title":"Owner powers (trust assumptions): unbounded stampFee, start price anywhere in a 1e31x range, recipient redirection, no timelock; none reach launched tokens, pools, liquidity or fee claims"},{"citation":"resolved","description":"CourierPadImdFirstTest places the mock IMD at 0x1000 and CourierPadImdSecondTest at 2^160 - 0x1001, so within one suite every launched token is on the same side of IMD. On Robinhood Chain IMD is 0x5F7B..., so roughly 37% of launched tokens will be currency0 and 63% currency1 in the same pad, sharing pendingProtocolFees[IMD], the single FEE_SLOT transient slot and the pad's ERC-6909 IMD claims. I checked this state with a scratch test (IMD at 2^159, 12 launches: 4 with IMD as currency0 and 8 as currency1, a buy and a sell in each): fees are exact per pool, pendingProtocolFees equals the pad's claim balance, and one collectProtocolFees sends the sum to feeRecipient. No defect found; the suite should include this case so a future change to the per-pool keying is caught. Also untested: setStartTick between a launcher's permit and their launch (see the Low finding), setDropRecipient to the pad, and a feeRecipient change after renounce to an address that cannot be changed back.","line":651,"path":"contracts/test/CourierPad.t.sol","reproduction":"Place the mock IMD at address(uint160(1) << 159) in setUp, launch 12 tokens from one office, buy 1000e18*(i+1) IMD and sell half back in each, then assert pad.pendingProtocolFees(IMD) == sum of per-swap fees == poolManager.balanceOf(pad, uint256(uint160(IMD))) and that collectProtocolFees(IMD) moves exactly that to feeRecipient. Expected and actual on this code: passes (4 pools with IMD as currency0, 8 as currency1). The existing suites never produce a pad with both orders.","severity":"info","snippet":"contract CourierPadImdFirstTest is CourierPadTest {","title":"Test gap: the two suites each run with a single currency order, so a pad holding pools of both orders at once (what mainnet will have) is never exercised"}],"hash":"19e80f174e6c3d1b8afb7a6f7ece0eb2c1036dbfba483e2494f0ef9ca9909094","nodeId":"17251900-74b5-4997-8046-999c6145fba5","outcome":"completed","summary":"Audit complete. Findings are in `.imd-findings.json` at the repository root, with every snippet verified against the tree. Nothing above Low.\n\n**What I verified holds** (CourierPad.sol, PadToken.sol at 0b01b0c, with a real v4 PoolManager in Foundry):\n\n- **Fee accounting across pools and both currency orders.** The 4% hook code is StampHook's, keyed by `tokenOfPool`. I ran a pad with IMD at a mid-range address so 12 launches landed on both sides of it. Per-swap fees were exact in every pool, `pendingProtocolFees[IMD]` equalled the pad's ERC-6909 claim balance, and one collect moved the sum. The single transient fee slot cannot interleave because v4 makes no external call between beforeSwap and afterSwap.\n- **Locked liquidity, no hijack.** The only `modifyLiquidity` call is the positive one reached from `_launch`. `beforeInitialize` and `beforeAddLiquidity` revert for everyone, and the pad's own calls skip the hook via v4's self-call exemption, so every pool on this hook is registered and nobody can add liquidity. `unlockCallback` is PoolManager-only and only reachable with pad-chosen actions.\n- **Start-price bounds.** Liquidity at the low bound is 3.10e33 in both orientations against a per-tick cap of 3.84e34, and 1.96e18 at the high bound. Every tick in range opens.\n- **Fee and permit at launch.** The permit is for `maxStampFee`, a front-run permit falls back to the allowance, and `burnFrom` is bounded by `maxStampFee`. Leftover allowance can only be spent by the launcher's own later launch.\n- **Trading cannot be blocked.** No path makes the hook revert for a legitimate swap beyond the documented full-fill rule and IMD's own issuer powers.\n\n**Findings:**\n\n1. **Low.** `launch` pins the fee with `maxStampFee` but reads `startTick` unpinned. Owner sets tick 400000, alice launches and burns 500 STAMP, the whole supply is worth 4 gwei of IMD, and bob buys 18% of it for 1 gwei. Fix: an expected-tick parameter mirroring `FeeChanged`.\n2. **Low.** `setFeeRecipient` and `setDropRecipient` are single-step while ownership is two-step. After renounce, a recipient typo is permanent and captures every future fee or drop. Fix: pending/accept for both roles.\n3. **Info.** `setDropRecipient(address(pad))` is accepted and the 5% of every later launch is burned by the buffer sweep. Fix: reject the pad and the PoolManager.\n4. **Info.** Owner powers as trust assumptions: uncapped `stampFee` can halt launches, start price settable across a very wide range, immediate recipient redirection, no timelock. None reach launched tokens, pools, the position or fee claims.\n5. **Info.** Test gap: each suite runs one currency order, so a pad holding both orders is never exercised. My scratch test covering it passed.\n\nNo proofs attached since nothing reached High. Scratch tests were removed and the tree is unchanged apart from the findings file.","treeHash":null,"usage":{"cachedInputTokens":1506815,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":53033,"runtime":"claude","turns":27,"wallClockMs":1130768}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1ca477e8d9b58040","findings":[{"citation":"resolved","description":"_launch bounds the fee the launcher agreed to (`if (fee > maxStampFee) revert FeeChanged(fee);`, line 288) but reads the start price straight from storage (`int24 st = startTick;`, line 295) with nothing the launcher passes to pin it. The owner's setStartTick (line 486) is allowed anywhere in [-300000, 400000], i.e. a per-token start price anywhere between about 1e13 IMD per token and 4e-9 IMD for the entire 950M-token pool. The launch burns the launcher's $STAMP first and only then opens the pool at whatever startTick is at that moment, so a launcher who signed up for the configured ~5000 USD market cap can end up burning 500 $STAMP for a pool that any third party sweeps for dust, with no revert path. The two owner-settable launch parameters are treated asymmetrically: one is guarded by the launcher's own bound, the other is not. This is reachable through a legitimate owner reconfiguration racing a pending launch (not only a malicious owner): the owner lowers the start price for the next 'season' while a launch is in the mempool. Minimal fix that keeps the design: add an `int24 expectedStartTick` argument to launch/launchWithPermit (or `int24 minStartTick`) and `if (st != expectedStartTick) revert TickChanged(st);` before the burnFrom, mirroring maxStampFee.","line":295,"path":"contracts/src/CourierPad.sol","reproduction":"State: pad deployed with startTick 0 (1 token = 1 IMD), stampFee 500e18, bob has an open office and 500e18 $STAMP approved. Owner calls setStartTick(400000) (within MIN/MAX, passes BadTick). bob's pending launch(\"T\",\"T\",\"{}\",500e18) now executes: burnFrom(bob, 500e18) succeeds, the pool opens at tick 400000. Expected: bob's launch reverts (or bob had a way to pin the price), as a fee rise would have. Actual: launch succeeds, bob's balance drops by 500e18 $STAMP, and carol's router.buy(token, 1e12 /* 0.000001 IMD */, 1, now) returns 946,014,896e18 tokens (99.6% of the pool's 950M) with marketCap() of the whole token at 2.4e14 wei IMD. Verified in a Foundry test on commit 0b01b0c (test/scratch/PadProbe.t.sol::test_OwnerCanRepriceAPendingLaunch).","severity":"low","snippet":"        int24 st = startTick;","title":"Launcher can cap the $STAMP fee but not the start price: an owner setStartTick landing before a launch reprices it with no way for the launcher to object"},{"citation":"resolved","description":"These are intentional roles, documented here as the trust assumptions a launcher and a trader rely on, not permission bypasses. (1) setStampFee has no upper bound, so the owner can halt all launches: with stampFee = type(uint256).max every launch(..., maxStampFee) reverts with FeeChanged or an $STAMP balance error, which is an owner pause that the NatSpec ('Launching keeps working') does not mention; trading of already-launched tokens is unaffected (there is no pause on swaps, no liquidity removal path, and no token controls, which was verified). (2) setFeeRecipient (line 463) and setDropRecipient (line 471) are callable by the current recipient itself, and after renounceOwnership only by it: a compromised FEE_RECIPIENT/TREASURY key (both 0x7221...D566 in launch.env) takes 100% of future trading fees and 5% of every future launch permanently, with nobody able to reverse it. (3) _collect (line 433) pays `pendingProtocolFees` to whoever is feeRecipient at collection time, so a setFeeRecipient call moves fees that accrued before the change (e.g. 40e18 IMD pending after a 1000 IMD buy goes to the new address). collectProtocolFees is permissionless, so the old recipient can flush first; this only matters if the recipient change is not the recipient's own decision (pre-renounce owner). (4) postOffice is immutable: if the game migrates PostOffice, launches stay gated on the old contract. No fix is required for the design as commissioned; if a bound is wanted, cap stampFee (e.g. <= some MAX_STAMP_FEE) and have setFeeRecipient call _collect-style flush before switching.","line":480,"path":"contracts/src/CourierPad.sol","reproduction":"(1) Owner: setStampFee(type(uint256).max). Bob (open office, 1,000,000e18 $STAMP, max allowance) calls launch(\"T\",\"T\",\"{}\",type(uint256).max): expected per NatSpec 'launching keeps working'; actual: reverts inside burnFrom (ERC20InsufficientBalance), so no launch is possible until the owner lowers the fee (verified, test/scratch/PadProbe.t.sol::test_OwnerCanPauseLaunchesViaFee). (2) After renounceOwnership, prank(feeRecipient) setFeeRecipient(attacker) and prank(treasury) setDropRecipient(attacker) both succeed and cannot be undone (the project's own test_RecipientsCanMoveTheirOwn shows the path). (3) bob buys 1000e18 IMD of a token, pendingProtocolFees[IMD] = 40e18; owner setFeeRecipient(newAddr); anyone collectProtocolFees(IMD): newAddr receives 40e18, the previous recipient 0.","severity":"info","snippet":"    function setStampFee(uint256 stampFee_) external onlyOwner {\n        stampFee = stampFee_;","title":"Owner and recipient powers (trust assumptions): unbounded stampFee is a de facto launch pause, recipients are self-sovereign and unrecoverable after renounce, and a recipient change redirects already-"},{"citation":"resolved","description":"The suite runs twice with IMD pinned at 0x1000 (every token is currency1) or at max-0x1000 (every token is currency0), so one pad never holds pools of both orders at once, although on Robinhood Chain IMD = 0x5F7B... sits mid-range and launches will fall on both sides of it. The shared fee accounting (one pendingProtocolFees[IMD] bucket and one ERC6909 IMD claim balance fed by both afterSwap branches) is therefore never exercised across orders. It was checked here with IMD at 0x8000... and 10 launches (5 of each order): every buy credits exactly 40e18 on 1000e18, every sell at least 4% rounded up, and collectProtocolFees pays the exact sum, so this is a coverage note, not a defect. Separately, the dust bound `assertLt(token.balanceOf(DEAD), 1.1e9)` is only true near the configured tick: sweeping every allowed start tick in steps of 2000 for both orders (702 launches) found burned rounding dust up to 1,102,155,128 wei (1e9 buffer + 1.02e8 rounding), which still leaves pool + DEAD = 95% of supply exactly. Suggested: run one suite with IMD mid-range and relax or parametrise the dust bound (e.g. < 2e9) if that assertion is ever reused for other ticks.","line":210,"path":"contracts/test/CourierPad.t.sol","reproduction":"Deploy the pad with IMD etched at 0x8000000000000000000000000000000000000000, launch 10 tokens from one wallet: launches(t).quoteIsCurrency0 is true for 5 and false for 5 (no existing test produces that state). For the dust bound: owner setStartTick over [-300000, 400000] step 2000, two launches each: expected per the existing assertion < 1.1e9 wei burned; actual at some ticks 1,102,155,128 wei (test/scratch/PadProbe.t.sol::test_SweepStartTicks logs), while balanceOf(pm) + balanceOf(DEAD) == 950_000_000e18 holds for all 702 launches.","severity":"info","snippet":"        assertEq(token.balanceOf(address(pm)) + token.balanceOf(DEAD), SUPPLY - DROP);\n        assertLt(token.balanceOf(DEAD), 1.1e9);","title":"Test gap: the suite never runs pools of both currency orders inside one pad, and its rounding-dust bound only holds at the configured start tick"}],"hash":"3ce392f8c4083c156eb2558c6206afe64f73596a1681f997ab48900b70e48e0c","nodeId":"3aa6757e-fd03-4579-927e-e8b8459944b6","outcome":"completed","summary":"The review is complete. The findings file is in place at the repository root and parses, and no source or configuration file was changed.\n\n**Outcome: no critical, high or medium defect in CourierPad or PadToken at commit 0b01b0c.** The access-control, trust-gap and asymmetry passes found one low finding, one trust-assumption note, and one test-coverage note. All three are in `.imd-findings.json` with concrete inputs.\n\n**What was verified, with scratch Foundry tests in both currency orders**\n\n- Pool hijack: a stranger cannot initialize any pool with the pad as hook (same currencies with other fee or spacing, dynamic-fee flag, unrelated currencies, or re-initializing a launched pool). All revert with HookNotAllowed.\n- Launch liquidity: nobody but the pad can add liquidity, and a stranger's remove on the launch position's exact ticks reverts. The pad has no code path that removes or sweeps it.\n- Start-price bounds: every allowed start tick from -300000 to 400000 in steps of 2000, in both orders (702 launches), opens, keeps 95% of supply in pool plus burn, and trades both ways. Liquidity stays about 12x under the per-tick cap at the low bound, so the comment's bound is correct.\n- Fee accounting across pools: with IMD mid-range and ten launches split five and five across orders, every buy credits exactly 4%, every sell at least 4% rounded up, and one collect pays the exact sum.\n- Blocking trades: after an outside router pushes the price past the launch price, the official router's buy still fills and later sells work. Owner has no pause on swaps and no token control.\n- Permit at launch: a permit for more than the fee burns only the fee and leaves the difference approved to the pad, usable only by the signer.\n\n**Findings written**\n\n1. Low, CourierPad.sol line 295. The launcher can cap the $STAMP fee but not the start price. An owner `setStartTick(400000)` landing before a pending launch lets it go through: the launcher burns 500 $STAMP and a third party then buys 946M of the 950M tokens for 0.000001 IMD. Suggested fix is an expected-start-tick argument mirroring the fee bound.\n2. Info, CourierPad.sol line 480. Owner and recipient powers as trust assumptions: an unbounded `stampFee` is a de facto launch pause, recipients are self-sovereign and unrecoverable after renounce, and a recipient change redirects already-accrued uncollected fees.\n3. Info, CourierPad.t.sol line 210. The suite never runs both currency orders inside one pad, and its rounding-dust bound only holds near the configured tick (dust reached 1,102,155,128 wei at other ticks while the 95% total still held exactly).\n\nNo proof files were attached because nothing reached high severity. The scratch tests live under `contracts/test/scratch/` and are not part of the submission.","treeHash":null,"usage":{"cachedInputTokens":2900448,"inputTokens":642,"model":"claude-fable-5-1","outputTokens":60865,"runtime":"claude","turns":47,"wallClockMs":938258}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"37eed9f56188ea8b","findings":[{"citation":"resolved","description":"`_launch` reads the owner-settable `startTick` at execution time. `maxStampFee` protects the launcher from a fee rise that lands before their transaction (FeeChanged), but nothing protects them from a `setStartTick` that lands first: the pool opens at whatever tick is current, which fixes the token's starting market cap and the top of its locked liquidity for ever (`launchTick[token]`, `sellPriceLimit`). The allowed range spans ~1e22 IMD down to ~4e-9 IMD of starting market cap for the 1B supply, so the two outcomes are not comparable, and the launcher's 500 $STAMP is burned either way. This is a trust assumption on the owner (an intentional power) rather than a bypass, but it is the one owner action the launch flow does not let the launcher bound.","line":295,"path":"contracts/src/CourierPad.sol","reproduction":"State: pad deployed with startTick 142,600 (the ~635 IMD start used in tests), bob has an open post office and 500 $STAMP approved. Bob sends `launch(\"Bob Coin\",\"BOB\",META,500e18)` expecting a ~635 IMD start. Before it is mined the owner calls `setStartTick(400_000)` (any tick in [-300_000, 400_000] on the spacing is accepted). Actual: bob's launch succeeds, `pad.launchTick(token) == 400_000` and `pad.marketCap(token)` is ~4.3e9 wei (4e-9 IMD) instead of ~635e18; the fee is burned and the pool is locked at that price. Expected: the launcher can state the start they signed up for (e.g. an `expectedStartTick` argument checked like `maxStampFee`, or `startTick` changes taking effect only for launches sent after them), or the owner's power is documented as a trust assumption in the launch UX. Verified with the scratch test `test_LiquidityAtBounds` (launch at 400,000 opens with liquidity 1.96e18 and a 1.46e9-wei burned remainder).","severity":"low","snippet":"        int24 st = startTick;","title":"A pending launch is bound to the $STAMP fee it agreed to, but not to the start price it was sent for"},{"citation":"resolved","description":"The owner can set any `stampFee`, including values no wallet can ever hold ($STAMP is hard-capped at 21,000,000e18 and burns are permanent). `renounceOwnership` (line 511) has no guard that the fee is payable, unlike StampHook's renounce which refuses before `openPool`. After the renounce `stampFee` can never change again, so a mistaken or hostile value makes `launch`/`launchWithPermit` revert for everyone for good, while the NatSpec on `renounceOwnership` promises 'Launching keeps working'. Owner power is intentional; the defect is that it is irreversible and unbounded in the one place the contract claims it is harmless.","line":481,"path":"contracts/src/CourierPad.sol","reproduction":"State: pad deployed as in tests (owner = `owner`). Owner calls `setStampFee(22_000_000e18)` then `renounceOwnership()`. Any player with an open office then calls `launch(\"X\",\"X\",\"{}\", 22_000_000e18)` with a max approval: actual revert `ERC20InsufficientBalance` from `burnFrom` (no account can hold 22M $STAMP); with a smaller `maxStampFee` the revert is `FeeChanged(22000000000000000000000000)`. Nobody can lower the fee again (`setStampFee` is `onlyOwner`, owner is 0). Expected: either bound `stampFee_` (e.g. `<= IStampSupply(STAMP).MAX_SUPPLY()` or a protocol-chosen cap) or have `renounceOwnership` refuse while the fee is unpayable, so a renounced pad really does keep launching. Reproduced in scratch test `test_OwnerCanLockLaunches` (passes: the launch reverts).","severity":"low","snippet":"        stampFee = stampFee_;","title":"setStampFee is unbounded: a fee above the $STAMP cap halts every launch, permanently once ownership is renounced"},{"citation":"resolved","description":"The suite runs twice with the mock IMD at a very low or very high address, so within one run every PadToken lands on the same side of IMD. On mainnet token addresses are hash-derived and a single pad will hold pools of both currency orders sharing one `pendingProtocolFees[IMD]` ledger and one `FEE_SLOT`. I checked this case in a scratch test (IMD at 0x8000…0000, six launches: three of each order, buys, router sells, exact-out buys and sells through a generic router, one collect) and the accounting is correct, so this is a coverage note, not a defect. Separately, the `< 1.1e9` bound on the burned remainder is only true near the default start tick: at `MAX_START_TICK` one unit of liquidity is worth ~4.85e8 wei of token, so flooring the liquidity leaves ~1.46e9 wei to burn. The invariant the code actually guarantees (never asks for more than the allocation; everything left is burned) still holds.","line":211,"path":"contracts/test/CourierPad.t.sol","reproduction":"Set `startTick` to 400_000 and launch: `token.balanceOf(DEAD)` is 1,462,478,968 (IMD as currency0) or 1,467,510,800 (IMD as currency1), both above the 1.1e9 asserted at line 211 for the default tick; `balanceOf(pm) + balanceOf(DEAD) == SUPPLY - DROP` still holds. Mixed-order run: with IMD etched at 0x8000000000000000000000000000000000000000, six launches give 3 pools with IMD as currency0 and 3 with IMD as currency1; after 1,000e18 exact-in buys each pool adds exactly 40e18 to `pendingProtocolFees(IMD)`, the ERC-6909 claim balance equals the ledger, and `collectProtocolFees(IMD)` pays the sum to `feeRecipient`. Suggest adding a mixed-order fixture and asserting the buffer bound per tick (or just `balanceOf(pad) == 0` plus the conservation equation).","severity":"info","snippet":"        assertLt(token.balanceOf(DEAD), 1.1e9);","title":"Test suite never runs a pad whose launches fall on both sides of IMD, and its rounding-buffer bound does not hold at the top start tick"},{"citation":"resolved","description":"Powers that exist by design and were verified to be the only ones: (1) `setStampFee` (any value, see the separate low finding), (2) `setStartTick` within [-300_000, 400_000] on the 200 spacing, affecting future launches only, (3) `setFeeRecipient` by the owner or by the current fee recipient itself, forever, (4) `setDropRecipient` by the owner or the current drop recipient, (5) two-step ownership transfer and renounce. There is no pause, no liquidity removal path (`unlockCallback` only adds liquidity from `_launch` and collects claims; no negative `modifyLiquidity` anywhere), no token control (PadToken has no owner, mint, tax or blocklist), and no way for anyone to initialize a pool with this hook or add liquidity to one (`beforeInitialize`/`beforeAddLiquidity` revert for every caller but the pad, which v4 skips via `noSelfCall`). Notable consequences for the deployer: a compromised fee-recipient or drop-recipient key can redirect all future fees or all future 5% allocations and the (renounced) owner cannot undo it; the 5% drop allocation can be sold into a pool as soon as the first buyer has put IMD into it; `tx.origin` is logged as `Trade.trader` for swaps that do not come through the two routers.","line":464,"path":"contracts/src/CourierPad.sol","reproduction":"After `renounceOwnership()`: `vm.prank(feeRecipient); pad.setFeeRecipient(attacker)` succeeds and every later `collectProtocolFees(IMD)` sends to `attacker`; `vm.prank(treasury); pad.setDropRecipient(attacker)` succeeds and the next launch sends 50,000,000e18 tokens to `attacker`. The existing test `test_RecipientsCanMoveTheirOwn` already asserts this behaviour as intended. Nothing to fix unless the deployer wants these keys behind a multisig or a timelock; documenting them as trust assumptions.","severity":"info","snippet":"        if (msg.sender != owner && msg.sender != feeRecipient) revert NotOwner();","title":"Owner and recipient powers (trust assumptions, for the record)"}],"hash":"46931bf7e1bda80e4a4c42ca757a274f67becf18f69ea9a293085d35bab93ef9","nodeId":"189128a0-a8c1-426c-8c36-8612a064dfaf","outcome":"completed","summary":"The audit is complete and the findings file is written at the repository root with four entries.\n\n**Outcome: no critical, high or medium defects found in CourierPad or PadToken.** The hook logic (fee on the IMD side in both currency orders, full-fill enforcement when IMD is the specified side, ERC-6909 claim accounting, collection inside or outside a foreign unlock) matches the audited StampHook and held up under every probe I ran against a real PoolManager. Launch liquidity has no removal path, no outside caller can initialize a pool with the hook or add liquidity to one, and the start-tick range is safe: liquidity at the bounds is 3.1e33 against a per-tick cap of 3.8e34, and a 256-run fuzz over every allowed tick in both currency orders launched and traded.\n\nWhat I checked beyond the shipped suite, in scratch tests that I ran and then removed:\n\n- One pad with six launches split three and three across both currency orders, with router buys and sells plus exact-out buys and sells through a generic v4 router. Fees matched 4% per pool, the claim balance equalled the ledger, and one collect paid the sum.\n- Partial exact-out buys via price limit (fee charged on the actual IMD paid, no revert), dust buys where the fee eats the whole input, sells that run through the launch price followed by router buys, and exact-out sells larger than the pool's IMD (correctly revert PartialFill).\n- Permit path: front-run permits fall back to the allowance, expired permits bubble the reason, fee rises are capped by maxStampFee.\n\nFindings recorded, all with concrete inputs:\n\n1. **Low.** A pending launch is bound to the fee it agreed to but not to the start price. An owner `setStartTick` mined before the launcher's transaction opens the pool at the new tick for good, while the launcher's $STAMP is burned.\n2. **Low.** `setStampFee` is unbounded and `renounceOwnership` has no payability guard, so a fee above the 21M $STAMP cap followed by a renounce halts launches forever, contradicting the \"launching keeps working\" NatSpec.\n3. **Info.** The suite never exercises a pad with pools on both sides of IMD, and its `< 1.1e9` burned-remainder assertion only holds near the default tick (about 1.46e9 at the top tick). I verified both cases behave correctly.\n4. **Info.** Owner and recipient powers documented as trust assumptions, including that the fee and drop recipients can redirect their own streams permanently after the renounce.\n\nNot covered: the deploy script's derivation of the start tick from the IMD/ETH spot price (outside the two assigned files, and the owner can correct it after deploy), live IMD token behaviour on Robinhood Chain (no fork run), and the web front end's handling of launcher-supplied metadata.","treeHash":null,"usage":{"cachedInputTokens":1795447,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":59458,"runtime":"claude","turns":27,"wallClockMs":1039641}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"6b0a28df3d585600","findings":[{"citation":"resolved","description":"Merged from audit_math, audit_flow, audit_permissions and audit_economics (all four reported the same mechanism). _launch bounds one owner-settable launch parameter with a launcher-supplied value (`if (fee > maxStampFee) revert FeeChanged(fee);`, line 288, documented in setStampFee as protecting a pending launch) but reads the other, `startTick`, straight from storage with nothing from the launcher to pin it. The owner may move startTick anywhere in [-300000, 400000] on the 200 spacing, and the launch burns the $STAMP fee before it opens the pool at whatever tick is current, so a launcher who submitted against the configured ~635 IMD (5000 USD) start can burn 500 $STAMP and get a token whose entire 1B supply is worth 4 gwei of IMD (top of the range) or ~1e22 IMD (bottom), with the launch tick, sellPriceLimit and marketCap fixed at that value forever. Robinhood Chain has no public mempool (AUDIT.md), so no third party can exploit this; it is reachable only when the owner's own setStartTick is sequenced ahead of a player's pending launch (an honest retune while launches are in flight, or a compromised owner key). It is an owner power and not a permission bypass; it is reported because the contract already treats the launch as a commitment the launcher bounds (FeeChanged) and leaves the half that decides the launch's economics unbounded. Minimal fix that keeps the owner's power to retune future launches: add an `int24 expectedStartTick` (or min/max) parameter to launch and launchWithPermit and revert e.g. `StartTickChanged(st)` before burnFrom when `startTick` differs, mirroring FeeChanged. If this is accepted as a trust assumption instead, document it in the launch UX.","line":295,"path":"contracts/src/CourierPad.sol","reproduction":"State: pad deployed with startTick 142600 (~635 IMD market cap, as in the tests), stampFee 500e18, bob has an open office and $STAMP approved. 1) owner: pad.setStartTick(400000) (in range, accepted). 2) bob: pad.launch(\"T\",\"T\",\"{}\",500e18). Expected (from bob's side): a revert like FeeChanged, or a token at the advertised ~635 IMD start. Actual (test/scratch/Judge.t.sol::test_StartTickRace, both currency orders): launch succeeds, bob's $STAMP drops by 500000000000000000000, pad.launchTick(token) == 400000, pad.price(token) == 4 wei, pad.marketCap(token) == 3999999999 wei (4 gwei of IMD for the whole supply). 3) carol: router.buy(token, 1e9 /* 1 gwei IMD */, 1, now) returns 182253615583363540886102967 tokens (18% of the supply, more than 3x the treasury's 5% drop allocation) for 1 gwei of IMD. The same launch sequenced one slot earlier would have opened at ~635e18 wei market cap.","severity":"low","snippet":"        int24 st = startTick;","title":"A launch pins the $STAMP fee it agreed to but not the start price: an owner setStartTick sequenced before it reprices the launch with no way for the launcher to object"},{"citation":"resolved","description":"Merged from audit_flow (low) and audit_permissions (trust-assumption item 1). setStampFee accepts any uint256; $STAMP is hard-capped at 21,000,000e18 with permanent burns, so any stampFee above the cap (or simply above the largest balance) makes launch and launchWithPermit revert for everyone, either with FeeChanged (maxStampFee below the fee) or inside burnFrom (ERC20InsufficientBalance). While an owner exists this is a reversible de facto launch pause, which is an owner power. The defect is that renounceOwnership (line 511) has no guard that the fee is payable, unlike StampHook's renounce which refuses before openPool, while its NatSpec on line 510 promises 'Launching keeps working'. After the renounce stampFee can never change, so a mistaken or hostile value set just before (or an owner-key compromise that sets fee then renounces) ends launching permanently. Trading of already-launched tokens is unaffected. Fix: bound stampFee_ in setStampFee (e.g. <= 21_000_000e18, StampToken.MAX_SUPPLY, or a protocol-chosen MAX_STAMP_FEE) and/or have renounceOwnership refuse while stampFee is unpayable, so a renounced pad really does keep launching.","line":481,"path":"contracts/src/CourierPad.sol","reproduction":"State: pad deployed as in the tests. owner: pad.setStampFee(22_000_000e18); owner: pad.renounceOwnership(). bob (open office, max $STAMP approval): pad.launch(\"T\",\"T\",\"{}\",22_000_000e18). Expected per the renounce NatSpec: launching keeps working. Actual (test/scratch/Judge.t.sol::test_StampFeeLocksLaunches, both orders): reverts inside burnFrom (no account can hold 22M $STAMP); with maxStampFee 500e18 it reverts FeeChanged(22000000000000000000000000). pad.owner() == address(0), setStampFee is onlyOwner, so nobody can ever lower it.","severity":"low","snippet":"        stampFee = stampFee_;","title":"setStampFee is unbounded and renounceOwnership has no payability guard, so a fee above what any wallet can hold halts every launch for good, contradicting the renounce NatSpec"},{"citation":"resolved","description":"From audit_math. The `tick` emitted is the local `int24 tick = quoteIs0 ? st : -st;` (line 312), i.e. currency1-per-currency0 for the pool, while startTick, launchTick[token] and the setStartTick/StartTickSet documentation are all in tokens-per-IMD and store `st`. Whether a token is currency0 depends only on its address relative to IMD (0x5F7B... on mainnet, so roughly 37% of launches will have the token as currency0), so for those launches the only event that carries the launch tick carries the opposite sign of what launchTick(token) returns. An indexer or the game's own UI that reads the start price from the event gets a price of 1/p unless it also reads launches(token).quoteIsCurrency0 and flips the sign; the existing test suite never checks the event's tick. Fix: emit `st` (the documented tokens-per-IMD tick), or add a separately named pool-tick field and document the existing one as the pool tick.","line":316,"path":"contracts/src/CourierPad.sol","reproduction":"Deploy the pad with IMD at an address above every PadToken's (test/scratch/Judge.t.sol JudgeImdHigh and JudgeImdMid, IMD at 2^160-0x1001 or 2^159) with startTick 142600 and call launch(\"T\",\"T\",\"{}\",500e18) under vm.recordLogs. Expected: TokenLaunched.tick == launchTick(token) == 142600. Actual (test_EventTickSign): decoded event tick = -142600 while pad.launchTick(token) = 142600 and launches(token).quoteIsCurrency0 = false. With IMD at a low address (JudgeImdLow) the event and the mapping agree (142600).","severity":"low","snippet":"        emit TokenLaunched(token, msg.sender, name, symbol, metadata, id, tick, fee);","title":"TokenLaunched emits the pool-oriented tick, which is the negated launch tick whenever the token is currency0, so indexers derive the inverse start price for about half of all launches"},{"citation":"resolved","description":"From audit_economics. Ownership deliberately moves in two steps ('so a typo can't lose the admin role', line 496), but the two value-receiving roles move in one step and only reject address(0). These are exactly the roles that must keep working after the owner is renounced (the design relies on the recipient moving itself if IMD's issuer blocks it). Once ownership is renounced the only account that can change feeRecipient is feeRecipient itself (line 464), so a wrong address set by the recipient (typo, a contract that cannot move IMD, a lost key) is final: collectProtocolFees sends every 4% fee from every pool there forever and nobody can redirect it. The same holds for dropRecipient (line 474): 5% of every future launch goes to the wrong address with no recovery. Note also that _collect pays whoever is feeRecipient at collection time, so a recipient change moves fees that accrued before it (collect is permissionless, so the outgoing recipient can flush first). Fix that keeps the recipient's post-renounce autonomy: pendingFeeRecipient/acceptFeeRecipient and the same for drops, so the new address proves it can act before it receives value. Reject address(this) and address(poolManager) as well (see the separate info finding).","line":466,"path":"contracts/src/CourierPad.sol","reproduction":"1) owner: pad.renounceOwnership(). 2) feeRecipient: pad.setFeeRecipient(0x0000000000000000000000000000000000001234) succeeds. 3) feeRecipient: pad.setFeeRecipient(feeRecipient) reverts NotOwner; owner is 0 so nobody else can call it. 4) bob: router.buy(token, 1000e18, 1, now); anyone: pad.collectProtocolFees(IMD). Expected: a way to recover, as the two-step owner transfer gives the admin role. Actual (test/scratch/Judge.t.sol::test_FeeRecipientTypoIsFinal, both orders): IMD.balanceOf(0x1234) == 40e18 and every later fee goes there; treasury: setDropRecipient(0x1234) succeeds and setDropRecipient(treasury) then reverts NotOwner. Accrued-fee case (test_RecipientChangeMovesAccruedFees): 40e18 pending, owner setFeeRecipient(newR), collect: newR receives 40e18, the previous recipient 0.","severity":"low","snippet":"        feeRecipient = feeRecipient_;","title":"setFeeRecipient and setDropRecipient are single-step; after renounceOwnership a mistyped or non-receiving address permanently captures every future fee or drop"},{"citation":"resolved","description":"From audit_economics. _launch transfers TOTAL_SUPPLY - POOL_SUPPLY to dropRecipient and _addLaunchLiquidity then burns whatever balance of that token the pad still holds (line 340). If dropRecipient is the pad, the 5% lands back in the pad and is burned with the rounding buffer; if it is the PoolManager, the tokens sit in the PoolManager with no delta and no claim and are unrecoverable. Only the owner or the current drop recipient can set it, so this is a misconfiguration guard, not an attack, and the launch gives no error. Fix: reject dropRecipient_ == address(this) and == address(poolManager) in setDropRecipient and the constructor (optionally DEAD too).","line":473,"path":"contracts/src/CourierPad.sol","reproduction":"1) owner: pad.setDropRecipient(address(pad)) succeeds. 2) bob: pad.launch(...). Expected: 50,000,000e18 tokens at the treasury. Actual (test/scratch/Judge.t.sol::test_DropRecipientIsPad): PadToken.balanceOf(DEAD) == 50000000000000001000000304 (the 5% plus the buffer), pad balance 0, treasury 0. With setDropRecipient(address(poolManager)): the next launch leaves PoolManager holding 999999999999999998999999704 wei of the token (the pool position plus the stranded 5%).","severity":"info","snippet":"        if (dropRecipient_ == address(0)) revert ZeroAddress();","title":"setDropRecipient accepts the pad itself or the PoolManager: the 5% drop of every later launch is burned or stranded instead of reaching the treasury"},{"citation":"resolved","description":"From audit_math. For amountSpecified = -1 the fee rounds up to 1, so Hooks.beforeSwap computes amountToSwap = -1 + 1 = 0; v4's HookDeltaExceedsSwapAmount only rejects amountToSwap > 0 for exact input, and PoolManager's SwapAmountCannotBeZero ran on the original params before the hook, so Pool.swap runs with amountSpecified 0 and a (0,0) delta. afterSwap sees poolQuote = 0 and passes the full-fill test on line 394 because 0 + 1 == 1, emits Trade with tokenAmount 0, and the hook keeps the 1 wei. For 2..25 wei the same rounding charges 1 wei (50% down to 4%), which is the documented 'never less than 4%' behaviour. Economic impact is 1 wei per call, so informational; recorded because the invariant 'a fee is charged on a swap the pool actually performed' has a zero-swap exception. If wanted, revert in beforeSwap when fee >= amount for exact-in.","line":360,"path":"contracts/src/CourierPad.sol","reproduction":"Launch any token; through PoolSwapTest call swap(key, SwapParams(zeroForOne = quoteIsCurrency0, amountSpecified = -1, limit = MIN_SQRT_PRICE+1 or MAX_SQRT_PRICE-1), settings(false,false), \"\"). Expected: revert (SwapAmountCannotBeZero or PartialFill), since the pool cannot swap 0. Actual (test/scratch/Judge.t.sol::test_OneWeiBuy, all three IMD placements): the call succeeds, the buyer's IMD balance drops by 1, token balance unchanged, pendingProtocolFees[IMD] increases by 1; a 2-wei buy then charges another 1 wei (fee 1, swap 1).","severity":"info","snippet":"            ? FullMath.mulDivRoundingUp(amount, FEE_BPS, BPS)","title":"An exact-in IMD buy of 1 wei executes a zero-amount pool swap: the rounded-up fee consumes the whole input and the full-fill check still passes"},{"citation":"resolved","description":"From audit_math. price() returns IMD wei per whole token as an integer. At MAX_START_TICK (400000) the true value is 1.0001^-400000 * 1e18 = 4.248 wei, which truncates to 4; marketCap multiplies the truncated figure by the supply so the error does not average out. The intermediate mulDiv values do not overflow or vanish anywhere in [-300000, 400000] in either currency order (price at -300000 is 1.067e31 wei, at 400000 it is 4). View only; nothing on-chain consumes price() or marketCap(). If off-chain consumers need accuracy near the top of the range, return the price scaled by a larger constant (e.g. 1e36) or expose sqrtPriceX96.","line":559,"path":"contracts/src/CourierPad.sol","reproduction":"owner: setStartTick(400000); bob: launch. Expected: marketCap about 4.248e9 wei (1e9 tokens x 4.248 wei). Actual (test/scratch/Judge.t.sol::test_PriceResolutionAtMaxTick, both orders): pad.price(token) = 4, pad.marketCap(token) = 3999999999, a 5.8% understatement. At -300000: price = 10670457952892911130929611517541, marketCap = 1.067e40 wei, no overflow.","severity":"info","snippet":"            ? FullMath.mulDiv(FullMath.mulDiv(1e18, Q96, sqrtP), Q96, sqrtP)","title":"price() has 1-wei-per-token resolution, so near the top of the allowed start ticks the reported price and marketCap are several percent low"},{"citation":"resolved","description":"Merged from audit_flow, audit_permissions and audit_economics. CourierPadImdFirstTest pins the mock IMD at 0x1000 and CourierPadImdSecondTest at 2^160-0x1001, so within one suite every PadToken lands on the same side of IMD. On Robinhood Chain IMD (0x5F7B...) sits mid-range, so one pad will hold pools of both orders sharing one pendingProtocolFees[IMD] ledger, one FEE_SLOT transient slot and one ERC-6909 IMD claim balance; that state is never exercised. It was checked here and the accounting is correct (see reproduction), so this is a coverage note, not a defect. Separately, the 1.1e9 bound on the burned remainder is only true near the default tick: at MAX_START_TICK one unit of liquidity is worth ~4.85e8 wei of token, so flooring the liquidity leaves ~1.46e9 wei to burn; the invariant the code guarantees (pool + DEAD == 95% of supply, pad balance 0) still holds. Suggest a mid-range IMD fixture with launches of both orders, and asserting the conservation equation rather than a tick-specific dust bound.","line":211,"path":"contracts/test/CourierPad.t.sol","reproduction":"Mixed order (test/scratch/Judge.t.sol JudgeImdMid::test_MixedOrderFees, IMD etched at 2^159): 12 launches give 5 pools with IMD as currency0 and 7 with IMD as currency1; in each pool an exact-in router buy of 1000e18*(i+1) adds exactly 40e18*(i+1), an exact-out buy, a router sell and an exact-out sell each add at least 4% rounded up; afterwards pendingProtocolFees(IMD) == poolManager.balanceOf(pad, uint160(IMD)) and collectProtocolFees(IMD) pays exactly that to feeRecipient. Dust (test_DustAtEdges): at startTick 400000 token.balanceOf(DEAD) is 1462478968 (IMD as currency0) or 1467510800 (IMD as currency1), above the 1.1e9 asserted at line 211 for the default tick (1000000296 / 1000000304), while balanceOf(pm) + balanceOf(DEAD) == 950000000e18 at every tick tried (-300000, 0, 142600, 400000).","severity":"info","snippet":"        assertLt(token.balanceOf(DEAD), 1.1e9);","title":"Test gap: no suite runs a pad holding pools of both currency orders (what mainnet will have), and the burned-dust bound only holds near the configured start tick"},{"citation":"resolved","description":"Merged from audit_flow, audit_permissions and audit_economics. Powers that exist by design and were verified to be the only ones: (1) setStampFee, any value (a de facto launch pause; see the separate low on the renounce), 0 makes launches free; (2) setStartTick within [-300000, 400000] on the 200 spacing, future launches only (every tick in the range opens: liquidity ~3.1e33 at -300000 against the per-tick cap ~3.8e34, ~1.96e18 at 400000); (3) setFeeRecipient by the owner or the current fee recipient, and setDropRecipient by the owner or the current drop recipient, effective immediately and forever, including after the renounce; a recipient change also moves fees that accrued before it; (4) two-step ownership transfer and an unguarded renounce. Verified limits: no function removes or modifies the launch position (the only modifyLiquidity call is the positive one in _addLaunchLiquidity, reachable only from _launch through the pad's own unlock; unlockCallback is onlyPoolManager and the PoolManager only calls back the unlocker); nothing touches tokenOfPool, launches, launchTick, a PadToken's balances (no owner, mint, tax or blocklist) or the pad's ERC-6909 claims beyond collectProtocolFees sending exactly pendingProtocolFees to feeRecipient; beforeInitialize and beforeAddLiquidity revert for every caller (the pad's own calls skip the hook via v4's noSelfCall), so no second pool on this hook with any fee or spacing, and no outside liquidity; there is no pause on swaps. Consequences for the deployer: FEE_RECIPIENT and TREASURY are the same key in launch.env (0x7221...D566), so a compromise of it redirects 100% of future fees and 5% of every future launch with no recovery after the renounce; the treasury's 5% can be sold into a pool as soon as the first buyer has put IMD in; Trade.trader is tx.origin for swaps that do not come through the two routers; postOffice is immutable, so a migrated PostOffice would need a new pad. Nothing to fix for the design as commissioned; listed so the requester can decide on a fee cap, a timelock, or a multisig behind the recipient keys.","line":464,"path":"contracts/src/CourierPad.sol","reproduction":"test/scratch/Judge.t.sol: test_NoHijack (both orders): pm.initialize of a second pool (same pair, fee 3000, spacing 60, hooks = pad) reverts; PoolModifyLiquidityTest.modifyLiquidity on the launch pool reverts; pad.unlockCallback from a non-PoolManager caller reverts NotPoolManager. After renounceOwnership: prank(feeRecipient) setFeeRecipient(x) and prank(treasury) setDropRecipient(x) succeed and cannot be undone (test_FeeRecipientTypoIsFinal; the project's own test_RecipientsCanMoveTheirOwn asserts the same as intended). setStampFee(22_000_000e18) stops every launch (test_StampFeeLocksLaunches). 40e18 pending, setFeeRecipient(newR), collect: newR gets 40e18 (test_RecipientChangeMovesAccruedFees).","severity":"info","snippet":"        if (msg.sender != owner && msg.sender != feeRecipient) revert NotOwner();","title":"Owner and recipient powers (trust assumptions, for the record): unbounded stampFee, start price anywhere in a ~1e31x range, one-step recipient redirection, no timelock; none reach launched tokens, poo"}],"hash":"894791a4b2f5d3484fe3c72d34adec039123652da5bca5d1b966c62af671a75a","nodeId":"cdd76bbd-7cf9-4161-9757-a88d9c30d347","outcome":"completed","summary":"The review is complete and the findings file is written and validated.\n\n**Outcome.** The specialists' work holds up: every claim reproduced against commit 0b01b0c, and the four reports collapsed into 9 findings in `.imd-findings.json`. No critical or high defects exist, so no finding carries a proof file. Launch liquidity cannot be removed, no pool can be created or extended by outsiders, fee accounting is exact across pools of both currency orders, and nothing can block a launched token from trading.\n\n**What I did.** I read both contracts, the reused routers, the v4 hook and swap paths, the tests and the audit brief, ran the existing suite (68 tests pass), then wrote one scratch suite with 31 tests across three IMD placements (low, high and mid-range address) to reproduce each claim. Scratch tests live under `contracts/test/scratch/` and touch nothing else.\n\n**Findings kept, merged by mechanism:**\n\n- **Low, start price not pinned.** All four specialists reported it. A launch bounds the $STAMP fee but reads `startTick` from storage, so an owner retune sequenced ahead of a pending launch burns the fee and opens the pool at any tick in the range. Reproduced: at tick 400000 the whole supply is worth 4 gwei of IMD and 1 gwei buys 18% of it. Noted that Robinhood Chain has no public mempool, so only the owner's own transaction can cause it.\n- **Low, unbounded fee survives the renounce.** Merged from audit_flow and audit_permissions. A fee above the 21M $STAMP cap plus a renounce ends launching forever, against the renounce NatSpec.\n- **Low, event tick sign.** TokenLaunched emits the pool tick, the negated launch tick whenever the token is currency0.\n- **Low, one-step recipient roles.** After the renounce a mistyped fee or drop recipient is final.\n- **Info.** Drop recipient may be the pad or PoolManager (5% burned or stranded); a 1-wei exact-in buy runs a zero swap; price() truncates to 4 wei at the top tick; no test runs mixed currency orders and the dust bound is tick-specific (1.46e9 at tick 400000); owner and recipient powers as trust assumptions.\n\n**Dropped or adjusted.** Nothing was dropped outright. The audit_permissions trust-assumption entry's fee-pause item moved into the renounce finding, and the three test-gap notes became one.","treeHash":null,"usage":{"cachedInputTokens":1736315,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":34904,"runtime":"claude","turns":27,"wallClockMs":832674}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"7c748c02cd2ee98f","findings":[{"citation":"resolved","description":"_launch reads the start tick from storage at execution time with no parameter from the launcher. The design deliberately protects the launcher from a fee change landing first (`if (fee > maxStampFee) revert FeeChanged(fee)` on line 288, documented in setStampFee as \"a change can't charge a pending launch more\"), but the same race exists for the start price and is not guarded. The owner may legitimately move startTick anywhere in [-300000, 400000]; the launch that was broadcast against one price then burns its full $STAMP fee and opens its pool at the other. At the top of the allowed range the whole 1B supply is worth about 4e9 wei of IMD, at the bottom about 1e22 IMD, so the two ends of the range are economically unrecognisable from one another. Owner powers are a trust assumption and this is not a permission bypass; it is reported because the contract already treats the launch as a commitment the launcher bounds, and the price half of that commitment is missing. Minimal fix that keeps the owner's power: add an `expectedStartTick` (or min/max) parameter to launch and launchWithPermit and revert if `startTick` differs, mirroring FeeChanged.","line":295,"path":"contracts/src/CourierPad.sol","reproduction":"State: pad deployed with startTick 142600 (the 635 IMD market cap used by the tests), stampFee 500e18. Bob (open post office, 500e18 $STAMP approved) submits launch(\"A\",\"A\",\"{}\",500e18). Owner's setStartTick(400000) is mined first. Expected: Bob's launch reverts or opens at the price he saw, as a fee rise would. Actual (test/scratch run, token as currency0): launch succeeds, stamp burned = 500000000000000000000, pad.marketCap(token) = 3999999999 wei, pad.price(token) = 4 wei per token. The same launch a block earlier would have opened at about 635e18 wei market cap.","severity":"low","snippet":"        int24 st = startTick;","title":"Launcher bounds the $STAMP fee but not the start price: an owner setStartTick mined before the launch changes what the burned fee buys"},{"citation":"resolved","description":"`tick` here is `quoteIs0 ? st : -st` (line 312), i.e. currency1-per-currency0 for the pool, while the `launchTick` mapping and `startTick` are documented as tokens-per-IMD and store `st`. Whether a given token is currency0 depends only on its address relative to IMD, so roughly half of all launches emit a tick with the opposite sign of what launchTick returns, and an indexer that derives the start price from the event (the only place the launch tick is emitted) gets the inverse price for those tokens unless it also reads quoteIsCurrency0 and flips the sign. Minimal fix: emit `st` (and, if the pool tick is wanted, add it as a separately named field), or document the field as the pool tick.","line":316,"path":"contracts/src/CourierPad.sol","reproduction":"Deploy with IMD at an address above the token's (IMD = 0xff..ef..., any PadToken address is lower, so quoteIsCurrency0 = false) and startTick 142600; call launch(\"A\",\"A\",\"{}\",500e18). Expected: TokenLaunched.tick == launchTick(token) == 142600. Actual (recorded with vm.recordLogs in a scratch test): event tick = -142600, pad.launchTick(token) = 142600, launches(token).quoteIsCurrency0 = false. With IMD at a low address the event and the mapping agree (142600).","severity":"low","snippet":"        emit TokenLaunched(token, msg.sender, name, symbol, metadata, id, tick, fee);","title":"TokenLaunched emits the pool-oriented tick, which is the negated launch tick when the token is currency0"},{"citation":"resolved","description":"Boundary x precision seam. For amountSpecified = -1 the fee rounds up to 1, so Hooks.beforeSwap computes amountToSwap = -1 + 1 = 0. v4's HookDeltaExceedsSwapAmount check only rejects amountToSwap > 0 for exact-input, and PoolManager's SwapAmountCannotBeZero check ran on the original params before the hook, so Pool.swap executes with amountSpecified 0: the loop body never runs and the pool delta is (0,0). afterSwap then sees poolQuote = 0 and passes the full-fill test on line 394 because 0 + 1 == 1, emits Trade with tokenAmount 0, and the hook keeps the 1 wei. For 2..25 wei the same rounding charges 1 wei (50% down to 4%), which is the documented 'never less than 4%' behaviour. Economic impact is 1 wei per call, so this is informational; it is recorded because the intended invariant 'a fee is charged on a swap the pool actually performed' has a zero-swap exception. If wanted, revert in beforeSwap when `fee >= amount` for exact-in.","line":360,"path":"contracts/src/CourierPad.sol","reproduction":"Launch any token; through PoolSwapTest call swap(key, SwapParams(zeroForOne = quoteIsCurrency0, amountSpecified = -1, limit = MIN_SQRT_PRICE+1 or MAX_SQRT_PRICE-1), settings(false,false), \"\"). Expected: revert (SwapAmountCannotBeZero or PartialFill), since the pool cannot swap 0. Actual (scratch test with vm.expectRevert placed directly before ext.swap): the call succeeds, the buyer's IMD balance drops by 1 wei, token balance unchanged, pendingProtocolFees[IMD] increases by 1, Trade emitted with tokenAmount = 0. Scratch log for exact-in buys: 2 wei -> fee 1, 9 -> 1, 16 -> 1, 23 -> 1, 30 -> 2.","severity":"info","snippet":"            ? FullMath.mulDivRoundingUp(amount, FEE_BPS, BPS)","title":"An exact-in IMD buy of 1 wei is accepted with a zero pool swap: the rounded-up fee consumes the whole input and the full-fill check still passes"},{"citation":"resolved","description":"price() returns IMD wei per whole token as an integer. Within the owner's allowed range the true value at MAX_START_TICK (400000) is 1.0001^-400000 * 1e18 = 4.248 wei, which truncates to 4; marketCap multiplies that truncated figure by the supply so the error does not average out. The intermediate mulDiv values do not overflow or vanish anywhere in [-300000, 400000] in either currency order (checked: price at -300000 is 1.067e31 wei, at 400000 it is 4), so this is purely output resolution at the boundary of the permitted range. View only; nothing on-chain consumes price() or marketCap(). If off-chain consumers need accuracy near the top of the range, return the price scaled by a larger constant (e.g. 1e36) or expose sqrtPriceX96 and let them compute.","line":559,"path":"contracts/src/CourierPad.sol","reproduction":"Owner calls setStartTick(400000); a player launches. Expected: marketCap about 4.248e9 wei (1e9 tokens x 4.248 wei). Actual (scratch test, both currency orders): pad.price(token) = 4, pad.marketCap(token) = 3999999999, a 5.8% understatement. At startTick 399800 the true price is 4.33 wei and the reported one is still 4 (7.7% low).","severity":"info","snippet":"            ? FullMath.mulDiv(FullMath.mulDiv(1e18, Q96, sqrtP), Q96, sqrtP)","title":"price() has 1-wei-per-token resolution, so at the high end of the allowed start ticks the reported price and marketCap are up to several percent low"}],"hash":"d2b24073b36e5364122f055db206a22afdb80a91ba6f5f63b6c8a23bc6e57dac","nodeId":"cae9d67e-f1ac-42ea-bf05-59525cb87f7f","outcome":"completed","summary":"The review is complete. The findings file is written and validated, the scratch tests are removed, and the tree is otherwise clean.\n\n**Outcome.** No critical, high or medium defects in CourierPad or PadToken. The fee math, liquidity math and access story hold up under the Math Precision, Boundary and Numerical Gap checks. Four low or info findings are in `.imd-findings.json`.\n\n**What was verified, with concrete numbers**\n\n- **Fee accounting, both currency orders.** The specified-currency test selects IMD correctly in all four swap modes. Exact-in buys pre-charge 4% rounded up and the full-fill check equals requested exactly. Exact-out sells pay `requested + fee` with fee = 400/9600 of requested. Unspecified-side fees are computed after the swap. A custom router swapping on two pools inside one unlock, mixing exact-in and exact-out, left the pad's ERC6909 IMD claim balance equal to pendingProtocolFees, and collection paid the fee recipient that exact amount. The transient fee slot cannot be interleaved because nothing calls out between beforeSwap and afterSwap.\n- **Start-price bounds.** Launch liquidity at both bounds, both orders, is 3.10e33 against the v4 per-tick cap of 3.83e34 for spacing 200. At the top tick it is 1.96e18. Rounding can ask for at most two wei over the allocation; the 1e9 buffer covers it.\n- **Liquidity lock and hijack.** The pad never calls modifyLiquidity with a negative delta, unlockCallback only reaches the add-liquidity path from the pad's own launch, and v4 skips hooks when the hook itself is the caller, so only the pad can initialize or add liquidity to a pool carrying this hook.\n- **Trading cannot be blocked.** Donations are refused, claims are minted and burned by the pad only, and a sell pushed past the launch price through an external router leaves router sells reverting only while the pool holds no IMD, with buys walking back into range.\n- **Fee and permit at launch.** Fee bounded by maxStampFee, burnFrom is OpenZeppelin's, front-run permits fall back to the allowance, a failed permit without allowance bubbles its reason.\n- **Owner powers.** Bounded start tick, unbounded but launcher-capped fee, recipients, two-step transfer, renounce. Recorded as trust assumptions.\n\n**Findings written**\n\n1. **Low.** The launcher bounds the fee but not the start price. An owner `setStartTick(400000)` mined before a pending launch burns the full 500 $STAMP and opens the pool at a 3,999,999,999 wei market cap. Suggested fix is an expected-tick parameter mirroring FeeChanged.\n2. **Low.** `TokenLaunched` emits the pool tick, which is the negated launch tick when the token is currency0. Confirmed: event tick -142600 against launchTick 142600.\n3. **Info.** A 1 wei exact-in buy passes: the rounded-up fee zeroes the pool amount, the pool swaps nothing, and the full-fill check still passes. The buyer pays 1 wei for 0 tokens.\n4. **Info.** `price()` has integer-wei resolution, so at the top of the allowed range it reports 4 wei against a true 4.248, understating marketCap by 5.8%.\n\n**Not covered.** The fork test against Robinhood Chain was not run (no network at verification). StampRouter and StampEthRouter were read for how they drive the hook but not re-audited, as the brief says they are reused unchanged.","treeHash":null,"usage":{"cachedInputTokens":1227618,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":54915,"runtime":"claude","turns":22,"wallClockMs":871686}}],"verification":[]}