{"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":"ea514609-097d-4b62-a4f4-76f8f4e9594d","kind":"audit","nodes":[{"acceptedSubmissionHash":"949cc8e781d2fb6a1db2de18d57d5ccbbe47aac8350af49923e98e199cdb7b94","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":"fe5ba8af9a2535686a46061d00829bd31a96687d623b47f3c735dff40a4d9721","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":"54062dd39a90d3bd9924d3b73bce1c746b0c19db9eca81cfca67cb2bca931a53","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"5a5ea766034c79cc4c884b530fcf1126bdc9855d5ea769c5a94f638c9e0e399f","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":"18d4bd5fb5a83ee5f25993d551082bc8b8df348a7dff515b1d29830e78bfb561","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":"Courier is a game on Robinhood Chain (4663). Players buy post offices with ETH, put Courier NFTs on duty and earn $STAMP, which trades in one Uniswap v4 pool against IMD. Read AUDIT.md first: it describes the system, the guarantees, every admin power and the known, accepted limits.\n\nContracts (contracts/src):\n- StampHook: pool owner and v4 hook. openPool (owner, once) locks a 2.1M $STAMP single-sided launch allocation that can never be removed; every swap in the pool pays 4% of its IMD side to the protocol through return deltas, held as ERC-6909 claims until collectProtocolFees.\n- StampRouter, StampEthRouter: buy/sell with IMD (permit sells), or with ETH through the hookless IMD/ETH pool.\n- StampToken: $STAMP, 21M cap, only PostOffice mints, ownership renounced at deploy.\n- PostOffice: offices, couriers on duty, reward-per-power emissions with halvings, levels, $STAMP spending (75% burned), referrals.\n- CourierNFT: 3,333 NFTs minted for ETH, commit-reveal traits, couriers locked while on duty.\n- Deploy: contracts/script/DeployMainnet.s.sol, DeployLib.sol.\nOut of scope: the view-only art contracts (CourierRenderer, CourierSVG, CourierTraits), DeployFork.s.sol (dev only) and web/.\n\nLook hardest at:\n1. The fee: exactly 4% on every swap in the pool, through any router, direction and exact-in/out mode; never skipped, never overcharged; partial fills; the hook's IMD claims always equal pendingProtocolFees.\n2. Locked liquidity: nobody can remove it, add liquidity, open another pool on the hook, or block openPool.\n3. Supply: $STAMP can never exceed 21M; nothing can change balances, the minter or transfers after deploy.\n4. PostOffice accounting: never pays more than was emitted, or for time a courier wasn't on duty, including across halvings.\n5. Couriers: one on duty can't be transferred; traits can't be learned or influenced before the mint ends.\n6. Routers move only the caller's tokens and enforce deadlines and minimum outputs; no admin power reaches user funds (scanner flags: honeypot, hidden owner, owner can change balance).\n\nTests: cd contracts; git submodule update --init --recursive; forge test (43 tests). Fork test: forge test --match-contract StampHookForkTest --fork-url https://robinhood.drpc.org","parentJobId":null,"planHash":"35005e3f71354e77ff3e5ffd6ae60053f7479f5f4003d59a294d190e24f117e0","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"ea514609-097d-4b62-a4f4-76f8f4e9594d","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":"51557","feedbackHash":"85f65494bec0bced83106b0468d2c1d1e5c55558d41d43ce6a0bbf08b14bd3e3","nodeKey":"audit_economics","submissionHash":"949cc8e781d2fb6a1db2de18d57d5ccbbe47aac8350af49923e98e199cdb7b94","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51515","feedbackHash":"4f6e6ea627589e0d41e090534f1b30eb6ee8456f02d22d6daa8a72e8406616c1","nodeKey":"audit_flow","submissionHash":"fe5ba8af9a2535686a46061d00829bd31a96687d623b47f3c735dff40a4d9721","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50989","feedbackHash":"72cc983f65d70e06daa9edf5654a1970a9f5c4852489006fff4d53d8dd1742b9","nodeKey":"audit_judge","submissionHash":"54062dd39a90d3bd9924d3b73bce1c746b0c19db9eca81cfca67cb2bca931a53","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52182","feedbackHash":"348508a2007a98a5a9ae2c02116c8902b66882628426d3d94836243672bf3ddd","nodeKey":"audit_math","submissionHash":"5a5ea766034c79cc4c884b530fcf1126bdc9855d5ea769c5a94f638c9e0e399f","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52289","feedbackHash":"66b0b6ff8ed19e243893deeb29b4cba173821d6af73049c3c82fe26750765c95","nodeKey":"audit_permissions","submissionHash":"18d4bd5fb5a83ee5f25993d551082bc8b8df348a7dff515b1d29830e78bfb561","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"23954e2179a6fb20affdf27fb9029271e918ab2ccc22420e82473ccba5cb8f35","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ee9fbaf2480d1034","findings":[{"citation":"resolved","description":"Access-control asymmetry across the three admin surfaces. StampHook implements a two-step owner transfer explicitly 'so a typo can't lose the admin role' and PostOffice inherits Ownable2Step, but CourierNFT inherits plain OpenZeppelin Ownable: `transferOwnership(x)` takes effect immediately and `renounceOwnership()` is exposed. Unlike the other two contracts, losing this owner is not merely losing settings: `reveal(secret)` is `onlyOwner` and is the only writer of `seed`. While `seed == 0`, `rideOf()` reverts `NotRevealed`, and PostOffice.assign / levelUp / unassign all call `rideOf`, so no courier can ever be put on duty or levelled; the game is permanently reduced to 60-power trainees and the 3,333 NFTs sold for ETH are useless in the game they were sold for. The same irreversible outcome follows if the reveal secret itself is lost, because there is no fallback reveal path (e.g. a permissionless reveal from a later blockhash after a deadline). The owner is trusted, so this is an operational-risk asymmetry rather than an exploit, but the blast radius is far larger than for the two contracts that were given two-step protection.","line":19,"path":"contracts/src/CourierNFT.sol","reproduction":"State: sale closed, seed == 0 (reveal not yet called), players hold minted couriers and offices. Owner calls `nft.transferOwnership(0x...dead)` (a typo, or a transfer to an address whose key is later lost) or `nft.renounceOwnership()`. Then: `nft.reveal(SECRET)` from the old owner reverts `OwnableUnauthorizedAccount`; no one else can call it. `office.assign(1)` from the courier's owner reverts `CourierNFT.NotRevealed()` forever; `office.levelUp(1)` likewise. Expected: a mistaken one-step transfer should be recoverable (as it is for StampHook and PostOffice), and the reveal should not have a single irreplaceable key. Actual: the mint/reveal and every courier mechanic are bricked. Verified in a scratch Foundry test (transferOwnership to 0xdead before reveal, then reveal reverts and assign reverts NotRevealed). Fix: inherit Ownable2Step (matching PostOffice), and consider dropping `onlyOwner` from `reveal` since the commit check already authenticates the caller's knowledge of the secret, and/or add a permissionless fallback reveal after a long deadline so a lost secret cannot freeze the game.","severity":"low","snippet":"contract CourierNFT is ERC721, ERC2981, Ownable {","title":"CourierNFT uses single-step Ownable (plus inherited renounceOwnership) while the reveal that unlocks the whole game is owner-only and irreplaceable"},{"citation":"resolved","description":"Trust-gap (access x economics, race amplifier). `levelUp` computes `cost = levelCost(lvl)` from `levelCostBase` and `upgradeOffice` reads `tiers[next].upgradeCost`, both of which the owner can change at any time with no upper bound, cooldown or timelock (`setLevelCostBase`, `setTierUpgradeCost`). The player's transaction carries no `maxCost`, and `_spend` burns 75% and sends 25% to `treasury` via `burnFrom`/`transferFrom` up to the allowance the player gave the PostOffice (the UI pattern and the project's own tests approve `type(uint256).max`). So a player who signs a levelUp expecting 25 $STAMP can have their entire approved balance burned/taken if a cost change lands first, and 25% of it goes to the owner-controlled treasury. AUDIT.md section 5 accepts that owner-set costs change the economy 'but never balances'; this path does move a player's balance beyond what they consented to, which is why it is reported as a trust-boundary gap rather than accepted. It requires an owner action (or a cost change coincidentally landing before a queued transaction), so severity is low; it is still the one admin power that reaches a user's $STAMP directly.","line":221,"path":"contracts/src/PostOffice.sol","reproduction":"State: alice has claimed 1,000+ $STAMP and approved PostOffice for type(uint256).max; `office.levelCost(0)` reads 25e18. Sequence: (1) owner (or a pending owner tx) calls `office.setLevelCostBase(alice's balance)`; (2) alice's already-submitted `office.levelUp(1)` executes. Expected: alice pays 25 $STAMP (what she saw) or the call reverts. Actual: `_spend(cost)` burns 75% and transfers 25% of alice's whole balance to treasury; `stamp.balanceOf(alice) == 0`. Verified in a scratch Foundry test. Same shape for `upgradeOffice` via `setTierUpgradeCost`. Fix: add a `maxCost` argument to `levelUp` and `upgradeOffice` (revert if cost > maxCost), and/or bound the setters (e.g. max 2x per change with a cooldown) so a queued player transaction can never pay more than it displayed.","severity":"low","snippet":"        _spend(cost);","title":"levelUp and upgradeOffice pull an owner-settable $STAMP cost at execution time with no caller-supplied maximum"},{"citation":"resolved","description":"Asymmetry between accrual and settlement. Rewards accrue per block into `o.pending` with no record of the referral rate in force while they accrued; `claim` applies the current `referralBps` to the whole pending amount, and `_spend` applies the current `burnBps` at spend time. An owner call to `setReferralBps` (up to 10%) therefore changes the split of value that other users already earned but have not yet claimed; the delta goes to the referrer, another user, not to the owner. The retroactive effect is bounded (max 10% of pending, 7.5 points more than the launch rate) and admin-triggered, so this is informational and a documentation point rather than a defect, but it is exactly the 'admin setter alters the destination of in-flight value' pattern.","line":259,"path":"contracts/src/PostOffice.sol","reproduction":"State: bob has an office; alice opened hers with referrer = bob; referralBps == 250. Warp 1,000 blocks so alice's pendingRewards == P. Owner calls `office.setReferralBps(1000)`. Alice calls `claim()`. Expected (if rates were snapshotted at accrual): bob receives P*250/10000. Actual: bob receives P*1000/10000 and alice receives 10% less than she had accrued under the 2.5% rate. Verified in a scratch Foundry test. Fix: either document that rate changes apply to unclaimed balances, or checkpoint each office (settle pending into a claimable bucket at the old rate) before changing the rate.","severity":"info","snippet":"            refAmount = amount * referralBps / BPS;","title":"Referral and burn rates are applied at claim/spend time, so a rate change retroactively re-splits rewards that were already accrued"},{"citation":"resolved","description":"Documentation/behaviour mismatch in the router's permit path. `sellWithPermit` (line 91) and `sellForEthWithPermit` document '`value` >= `tokenAmount`', suggesting a user can sign one permit for a larger value and sell part of it. `PermitHelper.permit` calls `permit(msg.sender, router, amount=tokenAmount, deadline, v, r, s)`: an EIP-2612 signature over a larger value does not verify for this value, the call reverts, and the catch branch then requires an existing allowance >= amount, which a first-time permit user does not have, so the sell reverts `PermitFailed`. No funds are at risk (the signature is simply unusable through this router), but a front end that follows the NatSpec will produce failing sells. The 'front-ran permit still sells' guarantee itself holds.","line":34,"path":"contracts/src/StampRouter.sol","reproduction":"User signs an EIP-2612 permit for (owner=user, spender=router, value=1000e18, nonce=0, deadline=D) and calls `router.sellWithPermit(stamp, 500e18, 1, D, v, r, s)` with no prior allowance. Expected per NatSpec: the sell of 500 succeeds. Actual: `permit(user, router, 500e18, ...)` reverts ERC2612InvalidSigner; `allowance(user, router) == 0 < 500e18`; the router reverts `PermitFailed`. Verified with vm.sign in a scratch Foundry test (permit succeeds only when called with the signed value 1000e18). Fix: add a `permitValue` parameter (pass it to `permit`, then sell `tokenAmount <= permitValue`), or change the NatSpec to say the signed value must equal `tokenAmount`.","severity":"info","snippet":"        try IERC20Permit(token).permit(msg.sender, address(this), amount, deadline, v, r, s) {}","title":"sellWithPermit NatSpec says the permit value may exceed tokenAmount, but PermitHelper submits the permit with value == tokenAmount, so such permits fail"}],"hash":"18d4bd5fb5a83ee5f25993d551082bc8b8df348a7dff515b1d29830e78bfb561","nodeId":"5421bb4c-9d12-4c69-9d9a-94343a9192e3","outcome":"completed","summary":"The review is complete. The findings file validates against the tree, and the only working-tree change besides it is the scratch test directory.\n\n**Outcome: no critical, high or medium defects.** The six \"look hardest\" areas held up under both code tracing and empirical probes. I recorded two low and two informational findings in `.imd-findings.json`.\n\n**What I verified empirically** (scratch tests under `contracts/test/scratch/`, 14 tests, all passing, both currency orderings):\n- The hook charges exactly 4% of the IMD side in all four swap modes through a generic v4 router, rounding in the trader's favour. A fuzz run of 256 cases confirmed the hook's ERC-6909 IMD claims equal `pendingProtocolFees` after every swap and after collection.\n- Collection works when the PoolManager is unlocked by a third party. A price-limited exact-in buy reverts `PartialFill` as documented.\n- Nobody can add liquidity or remove the launch position. The hook's only unlock actions are the one-time add and fee collection, and the callback is reachable only from the hook's own unlock.\n- PostOffice pays nothing for the off-duty window, applies the halving across the era boundary, and claims sum exactly to totalEmitted.\n- Supply, minter, transfers and the NFT duty lock have no admin path. Routers only pull from the caller and the callback cannot be forged.\n\n**Findings**\n- **Low, CourierNFT ownership is single-step.** StampHook and PostOffice use two-step transfer, CourierNFT uses plain Ownable with `renounceOwnership`. A mistaken transfer before `reveal` makes the reveal impossible forever, so `rideOf` reverts and no courier can ever be assigned or levelled. The same outcome follows if the secret is lost, since there is no fallback reveal.\n- **Low, uncapped spend costs.** `levelUp` and `upgradeOffice` pull an owner-settable cost at execution with no caller maximum. A cost change landing before a queued player transaction burns the player's approved balance, with 25% going to the treasury. This is the one admin power that reaches a player's $STAMP directly.\n- **Info, retroactive rates.** Referral and burn rates apply at claim and spend time to amounts accrued under earlier rates.\n- **Info, permit docs.** `sellWithPermit` says the signed value may exceed the sell amount, but the helper submits the permit with value equal to the sell amount, so such permits fail.\n\n**Not run.** The fork test needs network access and was skipped. Slither was not available. The halving-era probe initially misfired because via-IR rematerialises `block.timestamp` across `vm.warp`; that is a Foundry artefact, not a contract issue, and the corrected probe passes.","treeHash":null,"usage":{"cachedInputTokens":2976418,"inputTokens":674,"model":"claude-fable-5-1","outputTokens":67983,"runtime":"claude","turns":47,"wallClockMs":1450309}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e36579e0223ff908","findings":[{"citation":"resolved","description":"levelUp pulls levelCost(lvl) and upgradeOffice pulls tiers[next].upgradeCost from msg.sender at execution time, with no caller-supplied maximum. The owner can rewrite both bases at any moment with setLevelCostBase (line 316) and setTierUpgradeCost (line 306): no cap, no delay. The web client (web/chain.js ensureAllowance) approves the office for type(uint256).max, so whatever the cost is when the call lands is taken: burnBps burned, the rest to the treasury the owner also sets. AUDIT.md guarantee 8 and section 5 state that no admin power can move a player's $STAMP and that owner-set costs 'never' reach balances; this path does. The ETH-paid actions already have the right guard (openOffice and CourierNFT.mint require msg.value == price * qty, so a price change makes the player's call revert); the $STAMP-paid actions lack the equivalent. The owner is a single EOA until the planned multisig move, and a compromised owner key could combine setTreasury + setBurnBps(0) + setLevelCostBase(huge) to take every in-flight levelUp caller's whole approved balance. Reported by audit_economics (medium) and audit_permissions (low); merged. Fix, keeping the owner powers: add a maxCost argument to levelUp and upgradeOffice and revert if the live cost exceeds it (mirroring WrongPayment), or apply cost changes only after a delay.","line":221,"path":"contracts/src/PostOffice.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {StampToken} from \"src/StampToken.sol\";\nimport {CourierNFT} from \"src/CourierNFT.sol\";\nimport {PostOffice} from \"src/PostOffice.sol\";\n\n/// PostOffice owner can change STAMP-denominated costs at any time, and `levelUp` / `upgradeOffice`\n/// carry no caller-side bound: a call a player sent for \"25 STAMP\" pulls whatever the cost is when it\n/// executes, up to the player's allowance (the web client approves type(uint256).max).\n///\n/// Fails on the current code. Passes with either fix: a `maxCost` argument on the two spend functions\n/// (the test then calls the bounded variant with the quoted cost and expects it to revert or charge at\n/// most that), or cost changes that only take effect after a delay.\ncontract OwnerCostPullTest is Test {\n    StampToken stamp;\n    CourierNFT nft;\n    PostOffice office;\n\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    address alice = makeAddr(\"alice\");\n\n    uint256 constant SECRET = 0xC0FFEE;\n\n    function setUp() public {\n        vm.roll(100);\n        vm.warp(1_800_000_000);\n        vm.startPrank(owner);\n        stamp = new StampToken(owner, address(0), 0);\n        nft = new CourierNFT(owner, treasury, 0.003 ether, keccak256(abi.encode(SECRET)));\n        office = new PostOffice(stamp, nft, 1000, 2.5e18, 0.005 ether, treasury, owner);\n        stamp.setMinter(address(office));\n        nft.setGame(address(office));\n        office.addTier(2, 3, 0);\n        office.addTier(4, 7, 100e18);\n        nft.setSaleOpen(true);\n        vm.stopPrank();\n        vm.deal(alice, 10 ether);\n\n        vm.prank(alice);\n        nft.mint{value: 0.003 ether}(1);\n        vm.startPrank(owner);\n        nft.setSaleOpen(false);\n        nft.reveal(SECRET);\n        vm.stopPrank();\n\n        vm.prank(alice);\n        office.openOffice{value: 0.005 ether}(address(0));\n        // alice earns ~10,000 STAMP and, like the web client, approves the office for max.\n        vm.warp(1_800_000_000 + 4000);\n        vm.startPrank(alice);\n        office.claim();\n        stamp.approve(address(office), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function _callBounded(bytes memory unbounded, bytes memory bounded) internal {\n        vm.startPrank(alice);\n        (bool ok,) = address(office).call(unbounded);\n        if (!ok) {\n            // Signature changed by the fix: the bounded call must revert or charge at most the quoted cost.\n            (ok,) = address(office).call(bounded);\n        }\n        vm.stopPrank();\n    }\n\n    /// Expected: a level-up costs levelCost(0) = 25 STAMP, the amount shown to alice when she sends it.\n    /// Actual: the owner raises levelCostBase before alice's call executes and the same `levelUp(1)`\n    /// pulls alice's whole balance (75% burned, 25% to the treasury).\n    function test_OwnerCanDrainPlayerStampThroughLevelUp() public {\n        uint256 balanceBefore = stamp.balanceOf(alice);\n        uint256 quoted = office.levelCost(0);\n        assertEq(quoted, 25e18, \"quoted cost\");\n\n        vm.prank(owner);\n        office.setLevelCostBase(balanceBefore);\n\n        _callBounded(\n            abi.encodeWithSignature(\"levelUp(uint256)\", 1),\n            abi.encodeWithSignature(\"levelUp(uint256,uint256)\", 1, quoted)\n        );\n\n        uint256 paid = balanceBefore - stamp.balanceOf(alice);\n        assertLe(paid, quoted, \"player paid more than the cost they signed for\");\n    }\n\n    /// Same with the office tier upgrade cost.\n    function test_OwnerCanDrainPlayerStampThroughUpgradeOffice() public {\n        uint256 balanceBefore = stamp.balanceOf(alice);\n        (,, uint256 quoted) = office.tiers(1);\n        assertEq(quoted, 100e18, \"quoted cost\");\n\n        vm.prank(owner);\n        office.setTierUpgradeCost(1, balanceBefore);\n\n        _callBounded(\n            abi.encodeWithSignature(\"upgradeOffice()\"),\n            abi.encodeWithSignature(\"upgradeOffice(uint256)\", quoted)\n        );\n\n        uint256 paid = balanceBefore - stamp.balanceOf(alice);\n        assertLe(paid, quoted, \"player paid more than the cost they signed for\");\n    }\n}","reproduction":"Reproduced with the attached test (forge test --match-path test/scratch/Proof_cf80b5135a67.t.sol). State: tiers as in DeployMainnet; alice has an office, one revealed courier, ~10,000 $STAMP claimed and has approved PostOffice for type(uint256).max. office.levelCost(0) == 25e18. Owner calls setLevelCostBase(alice's balance). Alice calls levelUp(1). Expected: alice pays 25 $STAMP or the call reverts. Actual: _spend burns 75% and sends 25% of alice's whole balance to the treasury; her balance is 0. Test fails: 'player paid more than the cost they signed for: 9999999999999999999999 > 25000000000000000000'. Same with setTierUpgradeCost(1, balance) then upgradeOffice() (quoted 100e18).","severity":"medium","snippet":"        _spend(cost);","title":"PostOffice: owner can change $STAMP costs between a player's quote and execution; levelUp/upgradeOffice pull the live cost up to the player's allowance"},{"citation":"resolved","description":"AUDIT.md guarantee 5 says total claimed <= totalEmitted and that no rounding lets a claim exceed what was earned. _checkpoint credits floor(power * acc / 1e18) - rewardDebt and _addPower/_removePower (lines 358, 365) then reset rewardDebt = floor(power_new * acc / 1e18). The fraction discarded by that floor is recovered by the office at its next checkpoint, so an office gains up to 1 wei above its exact share per power change (assign, unassign, levelUp on duty), while constant-power offices only lose fractions. After a few changes the sum of claims lands above totalEmitted. The 21M cap still holds (claim clamps to MAX_SUPPLY - totalMinted and StampToken.mint reverts past it), so the impact is a few wei of over-emission and a broken stated invariant; the repo's testFuzz_ClaimsNeverExceedEmission does not vary power so it never hits it. Fix: keep the debt in undivided units (rewardDebt = power * acc; credit (power * acc - rewardDebt) / PRECISION) so each segment is floored once, or track totalClaimed and clamp claims to totalEmitted - totalClaimed.","line":381,"path":"contracts/src/PostOffice.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {StampToken} from \"src/StampToken.sol\";\nimport {CourierNFT} from \"src/CourierNFT.sol\";\nimport {PostOffice} from \"src/PostOffice.sol\";\n\n/// PostOffice: total claimed can exceed totalEmitted. Each power change re-floors rewardDebt, so an office can gain\n/// up to 1 wei per change on top of its exact share, while nothing offsets it. The brief's guarantee\n/// \"total claimed <= totalEmitted\" (AUDIT.md section 3.5) does not hold.\ncontract RewardRoundingTest is Test {\n    StampToken stamp;\n    CourierNFT nft;\n    PostOffice office;\n\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n\n    uint256 constant SECRET = 0xC0FFEE;\n    uint256 constant T0 = 1_800_000_000;\n\n    function setUp() public {\n        vm.roll(100);\n        vm.warp(T0);\n        vm.startPrank(owner);\n        stamp = new StampToken(owner, address(0), 0);\n        nft = new CourierNFT(owner, treasury, 0, keccak256(abi.encode(SECRET)));\n        // 1 virtual block per second, 2.25 $STAMP per block (mainnet initialReward).\n        office = new PostOffice(stamp, nft, 1000, 2.25e18, 0, treasury, owner);\n        stamp.setMinter(address(office));\n        nft.setGame(address(office));\n        office.addTier(2, 3, 0); // Kiosk\n        nft.setSaleOpen(true);\n        vm.stopPrank();\n        for (uint256 i = 0; i < 4; i++) {\n            vm.prank(bob);\n            nft.mint(10);\n        }\n        vm.startPrank(owner);\n        nft.setSaleOpen(false);\n        nft.reveal(SECRET);\n        vm.stopPrank();\n    }\n\n    function _find(address who, uint8 ride) internal view returns (uint256) {\n        for (uint256 id = 1; id <= nft.totalMinted(); id++) {\n            if (nft.ownerOf(id) == who && nft.rideOf(id) == ride) return id;\n        }\n        revert(\"no courier with that ride\");\n    }\n\n    function test_ClaimsExceedTotalEmitted() public {\n        uint256 foot = _find(bob, 0); // 100 power\n        uint256 skate = _find(bob, 1); // 160 power\n\n        // t0: alice 60 (trainee), bob 60 + foot = 160. totalPower 220.\n        vm.prank(alice);\n        office.openOffice(address(0));\n        vm.startPrank(bob);\n        office.openOffice(address(0));\n        office.assign(foot);\n        vm.stopPrank();\n\n        // 2 blocks, then bob swaps foot for skate: 220. totalPower 280.\n        vm.warp(T0 + 2);\n        vm.startPrank(bob);\n        office.unassign(foot);\n        office.assign(skate);\n        vm.stopPrank();\n\n        // 2 blocks, then bob swaps back to foot: 160. totalPower 220.\n        vm.warp(T0 + 4);\n        vm.startPrank(bob);\n        office.unassign(skate);\n        office.assign(foot);\n        vm.stopPrank();\n\n        // 2 blocks, then everyone claims.\n        vm.warp(T0 + 6);\n        vm.prank(alice);\n        office.claim();\n        vm.prank(bob);\n        office.claim();\n\n        // 6 blocks x 2.25 = 13.5 $STAMP emitted; 13.5 $STAMP + 1 wei minted.\n        assertEq(office.totalEmitted(), 13.5e18);\n        assertLe(stamp.totalMinted(), office.totalEmitted(), \"claimed more than emitted\");\n    }\n}","reproduction":"Reproduced with the attached test (forge test --match-path test/scratch/Proof_7ce0d47618c0.t.sol). blockTimeMs 1000, initialReward 2.25e18, one tier. t0: alice opens an office (60), bob opens one and assigns an On Foot courier (160); totalPower 220. +2 blocks: bob unassigns foot, assigns a Skateboard (220); totalPower 280. +2 blocks: bob swaps back to foot; totalPower 220. +2 blocks: both claim. Expected: stamp.totalMinted() <= office.totalEmitted() == 13.5e18. Actual: totalMinted == 13500000000000000001. Test fails 'claimed more than emitted: 13500000000000000001 > 13500000000000000000'.","severity":"low","snippet":"            o.pending += o.power * accRewardPerPower / PRECISION - o.rewardDebt;","title":"PostOffice: total claimed can exceed totalEmitted by dust because every power change re-floors rewardDebt in the office's favour"},{"citation":"resolved","description":"All liquidity sits on one side of the start tick. An exact-in sell asking for more IMD than the pool holds takes everything the position can pay, then keeps iterating through zero-liquidity ticks until sqrtPriceLimitX96. Both project routers pass MIN_SQRT_PRICE + 1 / MAX_SQRT_PRICE - 1 (StampRouter.sol:127, StampEthRouter.sol:177), so slot0 ends at the extreme tick. The trade itself is fine (the seller only gives up the $STAMP the pool paid for, the fee is 4% of the real output), but afterwards price() evaluates 1e18 * Q96^2 / sqrtP^2 with sqrtP at the limit and returns 0, marketCap() returns 0, and the Trade event logs the limit price, until the next buy walks the same empty ticks back (about 16 extra bitmap words of gas). Because the launch is single-sided and players mint $STAMP in the game, sells larger than the pool's IMD are the normal early state and anyone can trigger it for free; the deploy script and web/ read these views. Verified in both currency orderings (sqrtP ends at 1461446703485210103287273052203988822378723970341 with IMD as currency0, 4295128740 with IMD as currency1). Fix: cap the sell-side price limit in the routers at the launch sqrt price (nothing beyond the locked range can fill), and/or clamp price() to the launch price when slot0 is past the start tick on the empty side.","line":375,"path":"contracts/src/StampHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\n\nimport {StampHook} from \"src/StampHook.sol\";\nimport {StampRouter} from \"src/StampRouter.sol\";\nimport {StampToken} from \"src/StampToken.sol\";\nimport {DeployLib} from \"script/DeployLib.sol\";\n\ncontract MockIMD3 {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amt) external {\n        balanceOf[to] += amt;\n    }\n\n    function approve(address s, uint256 amt) external returns (bool) {\n        allowance[msg.sender][s] = amt;\n        return true;\n    }\n\n    function transfer(address to, uint256 amt) external returns (bool) {\n        balanceOf[msg.sender] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\n/// A sell that outruns the pool's IMD keeps walking the price through empty ticks to the router's limit. Afterwards\n/// StampHook.price() / marketCap() report 0 (and the Trade event logs the limit price) until the next buy, although\n/// the only liquidity sits at the launch price.\ncontract PriceWalkTest is Test {\n    PoolManager pm;\n    MockIMD3 imd;\n    StampHook hook;\n    StampToken stamp;\n    StampRouter router;\n\n    address owner = makeAddr(\"owner\");\n    address feeRecipient = makeAddr(\"feeRecipient\");\n    address alice = makeAddr(\"alice\");\n\n    uint256 constant LAUNCH = 2_100_000e18;\n    uint256 constant START_MCAP = 330e18;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        imd = new MockIMD3();\n\n        bytes memory initCode = abi.encodePacked(\n            type(StampHook).creationCode,\n            abi.encode(\n                pm, address(imd), owner, feeRecipient, DeployLib.startTickForMarketCap(START_MCAP, 21_000_000e18), LAUNCH,\n                StampHook.ImdEthPool(10_000, 100, address(0))\n            )\n        );\n        (bytes32 salt, address expected) = DeployLib.mineSalt(address(this), 0x28CC, initCode, 0);\n        address deployed;\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n        require(deployed == expected, \"hook address\");\n        hook = StampHook(deployed);\n        router = StampRouter(hook.router());\n\n        stamp = new StampToken(owner, address(hook), LAUNCH);\n        vm.prank(owner);\n        hook.openPool(address(stamp));\n\n        imd.mint(alice, 1_000e18);\n        vm.startPrank(alice);\n        imd.approve(address(router), type(uint256).max);\n        stamp.approve(address(router), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function test_SellPastThePoolsImdKeepsThePrice() public {\n        uint256 launchPrice = hook.price(address(stamp));\n        uint256 launchMcap = hook.marketCap(address(stamp));\n        assertGt(launchPrice, 0);\n\n        // One small buy puts 0.96 IMD in the pool.\n        vm.prank(alice);\n        uint256 got = router.buy(address(stamp), 1e18, 1, block.timestamp);\n\n        // A player sells more $STAMP than that IMD covers: the pool takes what it can pay for ...\n        deal(address(stamp), alice, got * 10);\n        vm.prank(alice);\n        uint256 imdOut = router.sell(address(stamp), got * 10, 1, block.timestamp);\n        assertEq(imdOut, 0.96e18 * 96 / 100);\n\n        // ... and the only liquidity left is the launch allocation at the launch price, so that is the price.\n        assertEq(hook.price(address(stamp)), launchPrice, \"price() after the sell\");\n        assertEq(hook.marketCap(address(stamp)), launchMcap, \"marketCap() after the sell\");\n    }\n}","reproduction":"Reproduced with the attached test (forge test --match-path test/scratch/Proof_9f0c8d1e6c9c.t.sol). Fresh pool at a 330 IMD market cap (price() == 15737769455531). alice buys with 1e18 IMD through StampRouter (pool holds 0.96e18 IMD), then sells 10x the $STAMP she got with minQuoteOut 1: the call succeeds and returns 0.9216e18 IMD. Expected: price() == 15737769455531, marketCap() == 330493158566151000000. Actual: slot0.sqrtPriceX96 == MAX_SQRT_PRICE - 1, price() == 0, marketCap() == 0. Test fails 'price() after the sell: 0 != 15737769455531'. The same happens through a generic v4 router (PoolSwapTest) selling into the untouched pool.","severity":"low","snippet":"    function price(address t) public view returns (uint256) {","title":"StampHook.price()/marketCap() read 0 after a sell outruns the pool's IMD: the swap walks empty ticks to the router's price limit"},{"citation":"resolved","description":"StampHook implements a two-step owner transfer 'so a typo can't lose the admin role' and PostOffice inherits Ownable2Step, but CourierNFT inherits plain Ownable: transferOwnership takes effect immediately and renounceOwnership is exposed. Losing this owner before reveal() is not merely losing settings: reveal is the only writer of seed, rideOf() reverts NotRevealed while seed == 0, and PostOffice.assign, unassign and levelUp all call rideOf, so no courier can ever go on duty or be levelled; the 3,333 NFTs sold for ETH are useless in the game, and setGame/setRenderer/withdraw (the mint ETH) are lost too. The launch plan moves ownership to a multisig after the mint, which is exactly the window in which reveal has not yet been called. This is an operational-hardening asymmetry rather than a bypass (the owner is trusted), but its blast radius is the whole game. Reported by audit_flow and audit_permissions; merged. Fix that preserves the design: inherit Ownable2Step as PostOffice does; optionally block renounceOwnership while seed == 0, or add a permissionless fallback reveal after a long deadline.","line":19,"path":"contracts/src/CourierNFT.sol","reproduction":"Scratch test (test/scratch/JudgeGame.t.sol, test_NftOwnershipTypoBricksReveal): sale closed, seed == 0, one token minted to alice. Owner calls nft.transferOwnership(address(1)). Expected (per the design goal stated for the other two contracts): pending until accepted; the deployer can still reveal. Actual: ownership moves immediately; the deployer's nft.reveal(SECRET) reverts OwnableUnauthorizedAccount(owner); alice's office.assign(1) reverts CourierNFT.NotRevealed() and nobody can ever change that. renounceOwnership() before reveal gives the same state.","severity":"low","snippet":"contract CourierNFT is ERC721, ERC2981, Ownable {","title":"CourierNFT uses single-step Ownable (with renounceOwnership) while the owner-only, one-shot reveal() unlocks every courier mechanic"},{"citation":"resolved","description":"PermitHelper.permit always submits the permit with value == amount (the tokenAmount being sold). The NatSpec on sellWithPermit (StampRouter.sol:91, '`value` >= `tokenAmount`') and the mirrored sellForEthWithPermit (StampEthRouter.sol:99-111) promise that a permit signed for a larger value is acceptable. It is not: the EIP-712 digest includes value, so OZ ERC20Permit reverts ERC2612InvalidSigner, the catch branch finds allowance < amount and the sell reverts PermitFailed. A front end that signs a 'max' or rounded-up permit, or a user who signs once for their balance and sells in parts, has every sell fail. No funds at risk; the front-run case (same signature already consumed) is handled correctly. Reported by audit_flow (low) and audit_permissions (info); merged. Fix: add a permitValue parameter passed to permit() (keep the allowance >= tokenAmount fallback), or change the NatSpec on both routers to say the permit must be signed for exactly tokenAmount.","line":34,"path":"contracts/src/StampRouter.sol","reproduction":"Scratch test (test/scratch/JudgeHook.t.sol, test_PermitLargerValueFails, both currency orderings): signer holds G $STAMP, allowance(signer, router) == 0, signs an EIP-2612 permit (owner=signer, spender=router, value=2*G, nonce=nonces(signer), deadline=now+100) and calls router.sellWithPermit(stamp, G, 1, deadline, v, r, s). Expected per NatSpec: the sell succeeds. Actual: revert PermitFailed(). The same call with a permit signed for exactly G succeeds.","severity":"low","snippet":"        try IERC20Permit(token).permit(msg.sender, address(this), amount, deadline, v, r, s) {}","title":"sellWithPermit / sellForEthWithPermit reject a permit signed for value > tokenAmount, contrary to their NatSpec"},{"citation":"resolved","description":"Both fee formulas (here and on poolQuote at line 279) use floor division, so an IMD leg below 25 wei (24 wei for exact-out sells) pays 0 and _chargeFee returns early; larger swaps pay up to 1 wei under 4%, never over. AUDIT.md guarantee 1 says rounding never skips the fee; it does for dust. Economically irrelevant (splitting a trade into 24-wei pieces costs far more gas than the fee saved). Reported by audit_economics and audit_math; merged. Verified independently that the fee is otherwise exactly 4% in all four modes (exact-in/out, buy/sell), both currency orderings, and that the hook's ERC-6909 claims equal pendingProtocolFees after every swap (1,000-run fuzz, test/scratch/JudgeHook.t.sol). If exactness matters, round up with FullMath.mulDivRoundingUp (over-charge of at most 1 wei).","line":242,"path":"contracts/src/StampHook.sol","reproduction":"Scratch test test_DustFeeIsZero (both orderings): after one buy, an exact-in buy of 24 wei IMD through PoolSwapTest. Expected per guarantee 1: a nonzero fee. Actual: pendingProtocolFees unchanged (fee 0); the same swap with 25 wei charges 1 wei.","severity":"info","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"StampHook: the 4% fee rounds down, so swaps whose IMD side is under 25 wei pay no fee"},{"citation":"resolved","description":"_collect burns and takes exactly pendingProtocolFees[quote], never the hook's actual ERC-6909 balance. Anyone can poolManager.transfer(hook, imdId, x) or mint x to the hook inside their own unlock, after which the hook holds pending + x and x is stuck forever (no function burns more than pending). Guarantee 2 states equality; the invariant that holds is claims >= pending, so the fee flow is never under-backed and no user or protocol funds are at risk. Reported by audit_economics and audit_flow; merged. If the equality is wanted, _collect can sweep poolManager.balanceOf(address(this), id).","line":312,"path":"contracts/src/StampHook.sol","reproduction":"Scratch test test_DonatedClaimsAreStuck (both orderings): after a 1,000 IMD exact-in buy pending == 40e18 and claims == 40e18. A contract unlocks, syncs IMD, transfers 5e18 to the PoolManager, settles and mints 5e18 claims to the hook. Now claims == 45e18, pending == 40e18. collectProtocolFees(IMD) sends 40e18 to feeRecipient; claims == 5e18 remain and no call can move them.","severity":"info","snippet":"        uint256 amount = pendingProtocolFees[quote];","title":"StampHook: ERC-6909 IMD claims pushed to the hook by third parties are unrecoverable, so claims can exceed pendingProtocolFees"},{"citation":"resolved","description":"The view the web client shows players omits two things claim applies: the referrer's referralBps share (2.5% default, up to 10%) and the clamp to MAX_SUPPLY - totalMinted, after which o.pending is zeroed and the excess dropped. Players see more than they receive. Either document the view as pre-referral or add a claimable(address) view mirroring claim.","line":286,"path":"contracts/src/PostOffice.sol","reproduction":"Scratch test test_PendingViewVsClaimWithReferralChange and the repo's test_ReferrerGetsTwoAndAHalfPercent: bob opens an office, alice opens one with referrer bob, warp; pendingRewards(alice) == P. alice claims. Expected per the view: P to alice. Actual: P - P*referralBps/10000 to alice, the rest to bob.","severity":"info","snippet":"        return o.pending + o.power * acc / PRECISION - o.rewardDebt;","title":"PostOffice.pendingRewards reports the gross amount; claim mints less (referral cut, supply-cap clamp)"},{"citation":"resolved","description":"Rewards accrue into o.pending with no record of the rate in force; claim applies the current referralBps to the whole pending amount and _spend applies the current burnBps. An owner call to setReferralBps (max 10%) changes the split of value other users already earned but have not claimed; the delta goes to the referrer, not the owner. Bounded and admin-triggered, so informational: document it, or settle pending at the old rate before changing it.","line":259,"path":"contracts/src/PostOffice.sol","reproduction":"Scratch test test_PendingViewVsClaimWithReferralChange: alice's office referred by bob, referralBps 250, warp until pendingRewards(alice) == P. Owner calls setReferralBps(1000). alice claims. Expected if rates were snapshotted at accrual: bob gets P*250/10000. Actual: bob gets P*1000/10000 and alice P*9000/10000.","severity":"info","snippet":"            refAmount = amount * referralBps / BPS;","title":"PostOffice: referral and burn rates are applied at claim/spend time, so a rate change re-splits rewards already accrued"},{"citation":"resolved","description":"Era 0 (2.25 $STAMP per 1.1 s virtual block) begins when PostOffice is deployed, which DeployMainnet does in the same run that opens the pool, before the NFT sale and reveal. assign calls rideOf, which reverts until the reveal, so until then only trainees (60 power per 0.005 ETH office) earn and the whole block reward goes to whoever opened offices first: about 176,700 $STAMP per day in total regardless of how many offices exist, immediately sellable into the pool. A 14-day mint and reveal would distribute roughly 2.47M $STAMP (13% of the 18.9M emission budget) this way. A design consequence rather than a code defect; reported so the launch sequence can be confirmed (for example deploying PostOffice, or adding its first tier, only after the reveal).","line":128,"path":"contracts/src/PostOffice.sol","reproduction":"Scratch test test_TraineeEarnsEverythingBeforeReveal (blockTimeMs 1100, initialReward 2.25e18): one player opens an office right after deployment, nobody else does; warp 1 day (78,545 virtual blocks). pendingRewards(player) == 176726250000000000000000 (176,726 $STAMP), claimable, while office.assign(1) reverts NotRevealed.","severity":"info","snippet":"        startTime = block.timestamp;","title":"PostOffice: the emission clock starts at deployment, so trainee-only offices collect the full block reward until the reveal"},{"citation":"resolved","description":"The BadTick guard only checks spacing and the usable-tick bound. For very negative start ticks the liquidity computed in _addLaunchLiquidity exceeds int128 or tickSpacingToMaxLiquidityPerTick(200), so poolManager.modifyLiquidity reverts and openPool can never succeed; the 2.1M $STAMP minted to the hook by StampToken's constructor has no other way out. Reaching it needs a grossly wrong START_MCAP input (about 1e20 IMD per $STAMP) and the forge simulation would fail before broadcasting, so informational. Fix: compute the launch liquidity in the constructor and revert BadTick if it overflows, or tighten the limit to |startTick| <= 400_000.","line":139,"path":"contracts/src/StampHook.sol","reproduction":"Scratch test JudgeBadTick.test_Sweep: deploy StampHook with startTick in {-887000, -700000, -500000} (constructor accepts), deploy StampToken(owner, hook, 2.1M), call openPool as owner. Expected: pool opened. Actual: openPool reverts for all three (TickLiquidityOverflow / SafeCastOverflow); -400000, 110600 and 887000 succeed.","severity":"info","snippet":"        if (startTick_ % TICK_SPACING != 0 || startTick_ > limit || startTick_ < -limit) revert BadTick();","title":"StampHook constructor accepts start ticks for which openPool always reverts (liquidity overflow), stranding the launch allocation"},{"citation":"resolved","description":"Whether IMD or $STAMP is currency0 on mainnet depends on the deployer's nonce-derived StampToken address versus 0x5F7B...7127 and is unknown until deploy. In the suite MockIMD lands at 0x2e23...470b and StampToken at 0xD6Bb...FBfF, so only the IMD-as-currency0 branch of _addLaunchLiquidity (the mulDiv(sqrtL, sqrtU, Q96) formula, the [tick, maxUsableTick] range) and the mirrored sign handling in the hook and routers run. test_EveryRouteAndDirectionIsTaxed asserts exact equality only for the exact-in buy (assertGt elsewhere). No test triggers PartialFill, exercises sellWithPermit / sellForEthWithPermit, or collects fees inside another contract's unlock. I re-ran these in both orderings (test/scratch/JudgeHook.t.sol: 1,000-run fuzz of exact fee equality in all four modes, PartialFill, permit) and they pass, so this is a coverage gap, not a defect. Fix: vm.etch a mock IMD at a low and a high address and run the hook/router suite as two contracts; replace assertGt with the exact formulas.","line":105,"path":"contracts/test/StampHook.t.sol","reproduction":"forge test --match-contract StampHookTest -vvvv shows MockIMD at 0x2e234DAe75C793f67A35089C9d99245E1C58470b and StampToken at 0xD6BbDE9174b1CdAa358d2Cf4D57D1a9F7178FBfF in every run, so IMD is always currency0. Etching the mock IMD at address(type(uint160).max) flips the ordering and the branch at StampHook.sol:213 runs with no assertion in the repo covering it.","severity":"info","snippet":"        stamp = new StampToken(owner, address(hook), LAUNCH);","title":"Test suite never exercises the $STAMP-as-currency0 ordering, exact fee equality for three of four swap modes, PartialFill, permit sells or collection during a foreign unlock"}],"hash":"54062dd39a90d3bd9924d3b73bce1c746b0c19db9eca81cfca67cb2bca931a53","nodeId":"61c12b70-5bdd-436d-99bc-4c4a8d789440","outcome":"completed","summary":"All 12 findings are written to `.imd-findings.json`, every snippet matched its line in the tree, and the three attached proofs each fail on the current code for the stated reason.\n\n**What I did**\n\n- Ran the full suite (43 pass) and the three specialist proofs under `test/scratch/`. All three fail exactly as described: owner cost pull, reward rounding, price walk.\n- Wrote my own scratch tests covering the areas the brief asks to look hardest at, run in both currency orderings (IMD etched at a low and a high address). A 1,000-run fuzz confirms the fee is exactly 4% in all four swap modes and that the hook's ERC-6909 claims equal pending fees after every swap. PartialFill, locked liquidity, dust rounding, donated claims, over-value permits, the bad-tick sweep and the NFT ownership brick all reproduced as the specialists described.\n- Confirmed the v4 `noSelfCall` semantics that let the hook add its own liquidity while rejecting everyone else, and that the repo test only ever runs with IMD as currency0.\n\n**Findings kept, after merging duplicates**\n\n| Severity | Finding | Proof |\n|---|---|---|\n| medium | Owner can change $STAMP costs between quote and execution; levelUp/upgradeOffice pull the live cost up to the max allowance the web client grants | yes |\n| low | Reward-debt re-flooring lets total claimed exceed totalEmitted by wei | yes |\n| low | price()/marketCap() read 0 after a sell walks empty ticks to the router limit | yes |\n| low | CourierNFT single-step Ownable while reveal() is owner-only and irreplaceable (merged from flow + permissions) | |\n| low | Permit sells reject value > tokenAmount, contrary to NatSpec (merged from flow + permissions) | |\n| info | Fee rounds to zero under 25 wei (merged from economics + math) | |\n| info | Donated ERC-6909 claims are stuck; claims ≥ pending, not equal (merged from economics + flow) | |\n| info | pendingRewards is gross; claim pays net of referral and cap | |\n| info | Referral/burn rates applied at claim time re-split accrued rewards | |\n| info | Emission clock starts at deploy; trainee-only offices earn everything before reveal | |\n| info | Constructor accepts start ticks at which openPool always reverts | |\n| info | Test coverage gaps: STAMP-as-currency0 branch, exact fee equality, PartialFill, permits | |\n\nNothing was dropped: every specialist finding reproduced. I did not find defects beyond theirs. The guarantees on locked liquidity, supply cap, courier locking, router custody and fee exactness held under my own tests. The contracts and configuration were not modified; the only new paths are the findings file and my scratch tests under `contracts/test/scratch/`, which the task designates as disposable.","treeHash":null,"usage":{"cachedInputTokens":3370610,"inputTokens":770,"model":"claude-fable-5-1","outputTokens":51778,"runtime":"claude","turns":47,"wallClockMs":1045937}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d11ea2b5e05fa7a8","findings":[{"citation":"resolved","description":"AUDIT.md section 3.5 guarantees `total claimed <= totalEmitted` and that `no rounding lets a claim exceed what was earned`. The accounting does not keep that promise. `_checkpoint` credits `floor(power * acc / 1e18) - rewardDebt`, and every power change (`_addPower` / `_removePower`, so assign, unassign and levelUp on duty) re-floors `rewardDebt = floor(power_new * acc / 1e18)` (lines 358 and 365). The fraction discarded at that reset is recovered by the office at its next checkpoint, so one office can receive up to 1 wei above its exact share per power change, while nothing in the system compensates. Offices with constant power only lose fractions, so with a few changes the sum of claims lands above `totalEmitted`. The 21M cap still holds because `claim` clamps to `MAX_SUPPLY - totalMinted` and `StampToken.mint` reverts past the cap, so the impact is dust: `totalMinted` can exceed `launch + totalEmitted` by a few wei, which breaks the stated invariant and the repo's own `testFuzz_ClaimsNeverExceedEmission` property (its action set is too narrow to hit it). Fix: keep the debt in undivided units so each segment is floored once and never rounds in the office's favour, e.g. store `rewardDebt = o.power * accRewardPerPower` and credit `(o.power * accRewardPerPower - o.rewardDebt) / PRECISION`; the proof test passes with that change. Alternatively track `totalClaimed` and clamp each claim to `totalEmitted - totalClaimed`.","line":381,"path":"contracts/src/PostOffice.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {StampToken} from \"src/StampToken.sol\";\nimport {CourierNFT} from \"src/CourierNFT.sol\";\nimport {PostOffice} from \"src/PostOffice.sol\";\n\n/// PostOffice: total claimed can exceed totalEmitted. Each power change re-floors rewardDebt, so an office can gain\n/// up to 1 wei per change on top of its exact share, while nothing offsets it. The brief's guarantee\n/// \"total claimed <= totalEmitted\" (AUDIT.md section 3.5) does not hold.\ncontract RewardRoundingTest is Test {\n    StampToken stamp;\n    CourierNFT nft;\n    PostOffice office;\n\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n\n    uint256 constant SECRET = 0xC0FFEE;\n    uint256 constant T0 = 1_800_000_000;\n\n    function setUp() public {\n        vm.roll(100);\n        vm.warp(T0);\n        vm.startPrank(owner);\n        stamp = new StampToken(owner, address(0), 0);\n        nft = new CourierNFT(owner, treasury, 0, keccak256(abi.encode(SECRET)));\n        // 1 virtual block per second, 2.25 $STAMP per block (mainnet initialReward).\n        office = new PostOffice(stamp, nft, 1000, 2.25e18, 0, treasury, owner);\n        stamp.setMinter(address(office));\n        nft.setGame(address(office));\n        office.addTier(2, 3, 0); // Kiosk\n        nft.setSaleOpen(true);\n        vm.stopPrank();\n        for (uint256 i = 0; i < 4; i++) {\n            vm.prank(bob);\n            nft.mint(10);\n        }\n        vm.startPrank(owner);\n        nft.setSaleOpen(false);\n        nft.reveal(SECRET);\n        vm.stopPrank();\n    }\n\n    function _find(address who, uint8 ride) internal view returns (uint256) {\n        for (uint256 id = 1; id <= nft.totalMinted(); id++) {\n            if (nft.ownerOf(id) == who && nft.rideOf(id) == ride) return id;\n        }\n        revert(\"no courier with that ride\");\n    }\n\n    function test_ClaimsExceedTotalEmitted() public {\n        uint256 foot = _find(bob, 0); // 100 power\n        uint256 skate = _find(bob, 1); // 160 power\n\n        // t0: alice 60 (trainee), bob 60 + foot = 160. totalPower 220.\n        vm.prank(alice);\n        office.openOffice(address(0));\n        vm.startPrank(bob);\n        office.openOffice(address(0));\n        office.assign(foot);\n        vm.stopPrank();\n\n        // 2 blocks, then bob swaps foot for skate: 220. totalPower 280.\n        vm.warp(T0 + 2);\n        vm.startPrank(bob);\n        office.unassign(foot);\n        office.assign(skate);\n        vm.stopPrank();\n\n        // 2 blocks, then bob swaps back to foot: 160. totalPower 220.\n        vm.warp(T0 + 4);\n        vm.startPrank(bob);\n        office.unassign(skate);\n        office.assign(foot);\n        vm.stopPrank();\n\n        // 2 blocks, then everyone claims.\n        vm.warp(T0 + 6);\n        vm.prank(alice);\n        office.claim();\n        vm.prank(bob);\n        office.claim();\n\n        // 6 blocks x 2.25 = 13.5 $STAMP emitted; 13.5 $STAMP + 1 wei minted.\n        assertEq(office.totalEmitted(), 13.5e18);\n        assertLe(stamp.totalMinted(), office.totalEmitted(), \"claimed more than emitted\");\n    }\n}","reproduction":"blockTimeMs 1000, initialReward 2.25e18 (mainnet value), one Kiosk tier. t0: alice opens an office (power 60); bob opens an office and assigns an On Foot courier (power 160); totalPower 220. After 2 virtual blocks bob unassigns the foot courier and assigns a Skateboard (220), totalPower 280. After 2 more blocks bob swaps back to the foot courier (160), totalPower 220. After 2 more blocks both claim. Arithmetic: acc1 = floor(4.5e36/220) = 20454545454545454545454545454545454; bob's debt reset at 220 power = floor(220*acc1/1e18) = 4499999999999999999 (0.99999999999999988 wei discarded). acc2 = acc1 + floor(4.5e36/280) = 36525974025974025974025974025974025; bob is credited floor(220*acc2/1e18) - 4499999999999999999 = 3535714285714285715 against an exact share of 3535714285714285714.29 (+0.71 wei); debt reset at 160 = 5844155844155844155. acc3 = acc2 + floor(4.5e36/220) = 56980519480519480519480519480519479; bob's last segment credits 9116883116883116883 - 5844155844155844155 = 3272727272727272728 against 3272727272727272727.27 (+0.73 wei). Bob total 10081168831168831170, alice floor(60*acc3/1e18) = 3418831168831168831. Expected: totalMinted <= totalEmitted = 6 * 2.25e18 = 13500000000000000000. Actual: totalMinted = 13500000000000000001. Run: forge test --match-path test/scratch/RewardRounding.t.sol -> fails `claimed more than emitted: 13500000000000000001 > 13500000000000000000`.","severity":"low","snippet":"            o.pending += o.power * accRewardPerPower / PRECISION - o.rewardDebt;","title":"PostOffice: total claimed can exceed totalEmitted (reward-debt floor asymmetry)"},{"citation":"resolved","description":"All of the pool's liquidity sits at or below the launch price (the single-sided position ends at `startTick`). An exact-in sell that asks for more IMD than the pool holds takes everything the position can pay, then keeps iterating through zero-liquidity ticks until it reaches the swap's `sqrtPriceLimitX96`. Both project routers pass `MIN_SQRT_PRICE + 1` / `MAX_SQRT_PRICE - 1` (StampRouter.sol:127, StampEthRouter.sol:177), so slot0 ends at the extreme tick. Nothing is lost on the trade itself (the seller gives up only the $STAMP the pool paid for, the fee is 4% of the actual output), but afterwards `price()` evaluates `1e18 * Q96^2 / sqrtP^2` with sqrtP = MAX_SQRT_PRICE - 1 (or `sqrtP^2 / Q96^2` with MIN+1 when $STAMP is currency0) and returns 0, `marketCap()` returns 0, and the `Trade` event logs that limit price. The state persists until the next buy, which has to walk the same empty ticks back (about 16 extra bitmap words of gas). Since the launch is single-sided and players mint $STAMP in the game, `sells larger than the pool's IMD` are the normal early state, not an attack, and anyone can also do it deliberately for free. The deploy script and `web/` read these views. Fix: have the routers cap the sell-side price limit at the launch sqrt price (`TickMath.getSqrtPriceAtTick` of the pool's start tick, the top of the locked range, nothing beyond it can ever fill), and/or clamp `price()` to the launch price when slot0 is past the start tick on the empty side. Either makes the proof test pass.","line":375,"path":"contracts/src/StampHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\n\nimport {StampHook} from \"src/StampHook.sol\";\nimport {StampRouter} from \"src/StampRouter.sol\";\nimport {StampToken} from \"src/StampToken.sol\";\nimport {DeployLib} from \"script/DeployLib.sol\";\n\ncontract MockIMD3 {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amt) external {\n        balanceOf[to] += amt;\n    }\n\n    function approve(address s, uint256 amt) external returns (bool) {\n        allowance[msg.sender][s] = amt;\n        return true;\n    }\n\n    function transfer(address to, uint256 amt) external returns (bool) {\n        balanceOf[msg.sender] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\n/// A sell that outruns the pool's IMD keeps walking the price through empty ticks to the router's limit. Afterwards\n/// StampHook.price() / marketCap() report 0 (and the Trade event logs the limit price) until the next buy, although\n/// the only liquidity sits at the launch price.\ncontract PriceWalkTest is Test {\n    PoolManager pm;\n    MockIMD3 imd;\n    StampHook hook;\n    StampToken stamp;\n    StampRouter router;\n\n    address owner = makeAddr(\"owner\");\n    address feeRecipient = makeAddr(\"feeRecipient\");\n    address alice = makeAddr(\"alice\");\n\n    uint256 constant LAUNCH = 2_100_000e18;\n    uint256 constant START_MCAP = 330e18;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        imd = new MockIMD3();\n\n        bytes memory initCode = abi.encodePacked(\n            type(StampHook).creationCode,\n            abi.encode(\n                pm, address(imd), owner, feeRecipient, DeployLib.startTickForMarketCap(START_MCAP, 21_000_000e18), LAUNCH,\n                StampHook.ImdEthPool(10_000, 100, address(0))\n            )\n        );\n        (bytes32 salt, address expected) = DeployLib.mineSalt(address(this), 0x28CC, initCode, 0);\n        address deployed;\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n        require(deployed == expected, \"hook address\");\n        hook = StampHook(deployed);\n        router = StampRouter(hook.router());\n\n        stamp = new StampToken(owner, address(hook), LAUNCH);\n        vm.prank(owner);\n        hook.openPool(address(stamp));\n\n        imd.mint(alice, 1_000e18);\n        vm.startPrank(alice);\n        imd.approve(address(router), type(uint256).max);\n        stamp.approve(address(router), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function test_SellPastThePoolsImdKeepsThePrice() public {\n        uint256 launchPrice = hook.price(address(stamp));\n        uint256 launchMcap = hook.marketCap(address(stamp));\n        assertGt(launchPrice, 0);\n\n        // One small buy puts 0.96 IMD in the pool.\n        vm.prank(alice);\n        uint256 got = router.buy(address(stamp), 1e18, 1, block.timestamp);\n\n        // A player sells more $STAMP than that IMD covers: the pool takes what it can pay for ...\n        deal(address(stamp), alice, got * 10);\n        vm.prank(alice);\n        uint256 imdOut = router.sell(address(stamp), got * 10, 1, block.timestamp);\n        assertEq(imdOut, 0.96e18 * 96 / 100);\n\n        // ... and the only liquidity left is the launch allocation at the launch price, so that is the price.\n        assertEq(hook.price(address(stamp)), launchPrice, \"price() after the sell\");\n        assertEq(hook.marketCap(address(stamp)), launchMcap, \"marketCap() after the sell\");\n    }\n}","reproduction":"Fresh pool at a 330 IMD market cap (price() = 15737769455531 wei IMD per $STAMP). alice buys with 1e18 IMD through StampRouter: the pool now holds 0.96e18 IMD. alice then sells 10x the $STAMP she got through StampRouter.sell (minQuoteOut 1): the call succeeds, she receives 0.9216e18 IMD (0.96e18 minus 4%), only the $STAMP the pool could pay for is taken. Expected: slot0 stays at the top of the locked range, price() == 15737769455531, marketCap() == 330493158566151000000. Actual: slot0.sqrtPriceX96 == 1461446703485210103287273052203988822378723970341 (MAX_SQRT_PRICE - 1), price() == 0, marketCap() == 0, Trade.sqrtPriceX96 == that limit. Same result through a generic v4 router (PoolSwapTest) selling 1000e18 $STAMP into the untouched pool. Run: forge test --match-path test/scratch/PriceWalk.t.sol -> fails `price() after the sell: 0 != 15737769455531`.","severity":"low","snippet":"    function price(address t) public view returns (uint256) {","title":"StampHook.price()/marketCap() read 0 after a sell outruns the pool's IMD (price walks through empty ticks to the router limit)"},{"citation":"resolved","description":"The 4% fee is computed with floor division in both branches (`amount * 400 / 10000`, `amount * 400 / 9600`, and the same two formulas on `poolQuote` at line 279). AUDIT.md section 3.1 says no path, `rounding` included, skips the fee. Below 25 wei of IMD (exact-in buy, exact-in sell gross output, exact-out buy pool quote) or 24 wei (exact-out sell) the fee truncates to 0, `_chargeFee` returns early and the swap runs untaxed; above that the protocol loses under 1 wei per swap. Economically irrelevant (24 wei of IMD buys ~1.5e6 wei of $STAMP, far below the gas spent), so this only documents that the guarantee holds up to rounding. If exactness matters, round the fee up (`FullMath.mulDivRoundingUp`): the protocol then gains at most 1 wei per swap instead of losing it, and a trader is never overcharged by more than 1 wei.","line":242,"path":"contracts/src/StampHook.sol","reproduction":"Exact-in buy of 24 wei IMD through a generic v4 router on the fresh pool: pendingProtocolFees stays unchanged (expected 4% of 24 = 0.96 -> 0 charged), the trader receives 1524993 wei of $STAMP. The same swap with 25 wei charges 1 wei. Observed with test/scratch/Dust.t.sol: `fee 0`, `stamp out 1524993`, `fee at 25 1`.","severity":"info","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"Hook fee rounds down: swaps whose IMD side is under 25 wei pay no fee at all"}],"hash":"5a5ea766034c79cc4c884b530fcf1126bdc9855d5ea769c5a94f638c9e0e399f","nodeId":"b5cd6962-7b44-447b-af53-7f063a765efa","outcome":"completed","summary":"The review is complete. The findings file at the repository root holds three substantiated findings, two of them with Foundry proofs that fail on the current code and pass with the proposed fix. No repository file was changed; only `contracts/test/scratch/` and `.imd-findings.json` were added.\n\n**What I checked, in depth.** The math across StampHook, both routers, PostOffice, StampToken, CourierNFT and the deploy helpers, against the Math Precision, Boundary and Numerical Gap guides. I traced the v4 delta flow for all four swap modes by hand and then fuzzed it with a generic v4 router: fee equals floor(4%) of the IMD side in every mode, the trader's IMD moves by exactly the requested amount, and the hook's ERC-6909 claims always equal `pendingProtocolFees`. The repo's tests only ever exercise $STAMP as currency1, so I forced the currency0 ordering (a coin flip on mainnet, since it depends on the deployer nonce) and re-ran the fuzz plus the launch liquidity numbers. That branch is also correct. The launch liquidity formula rounds down so the owed amount never exceeds the allocation, the halving sum stays under the cap, and the start-tick conversion is right.\n\n**Findings:**\n\n- **Low, PostOffice.sol:381.** Total claimed can exceed `totalEmitted`, which the brief lists as a guarantee. Each power change re-floors `rewardDebt`, and the discarded fraction comes back to that office at its next checkpoint. A six-block sequence (bob swaps a foot courier for a skateboard and back) mints 13.5 STAMP plus 1 wei against 13.5 STAMP emitted. The 21M cap still holds through the clamp in `claim`. Fix verified: store the debt undivided and floor once per segment.\n- **Low, StampHook.sol:375.** A sell that outruns the pool's IMD keeps walking empty ticks to the router's extreme price limit. Afterwards `price()` and `marketCap()` return 0 and the Trade event logs the limit price until the next buy. This is the normal early state of a single-sided launch, not an attack. Fix verified: clamp `price()` to the launch price on the empty side, or cap the routers' sell limit there.\n- **Info, StampHook.sol:242.** The fee rounds down, so a swap whose IMD side is under 25 wei pays nothing. Gas dwarfs the value, so it only qualifies the \"never skipped\" guarantee.\n\n**Not found.** No way to overcharge the fee, skip it materially, remove or add liquidity, exceed 21M, pay for off-duty time, or reach user funds through admin powers. The ETH router's refund and remainder paths are exact.\n\nOne testing note for the author: with `via_ir` on, repeated `vm.warp(block.timestamp + n)` inside one test only advances the clock once. The repo's tests use it once per function so they pass, but it will mislead anyone extending the reward tests.","treeHash":null,"usage":{"cachedInputTokens":3730027,"inputTokens":770,"model":"claude-fable-5-1","outputTokens":93526,"runtime":"claude","turns":51,"wallClockMs":1740671}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"559cfaaab2c0d013","findings":[{"citation":"resolved","description":"`levelUp` and `upgradeOffice` pull `levelCost(lvl)` / `tiers[next].upgradeCost` from `msg.sender` with no caller-supplied bound, and the owner can rewrite both bases at any time with `setLevelCostBase` (line 316) and `setTierUpgradeCost` (line 306) with no cap and no delay. The web client (web/chain.js:192) approves the office for `type(uint256).max`, so whatever the cost is at execution time is taken: 75% burned via `burnFrom`, 25% to the treasury the owner controls. AUDIT.md section 3.8 and section 5 promise that owner-set prices and costs 'never' reach balances and that no admin power can move a player's $STAMP; this path moves it. The ETH-paid actions are already protected the right way (`openOffice` and `CourierNFT.mint` require `msg.value == price * qty`, so a price change makes the player's call revert instead of overcharging); the $STAMP-paid actions lack the equivalent guard. The owner is a single EOA until the planned multisig move, and the sequencer orders transactions, so the owner does not need to front-run: any `levelUp`/`upgradeOffice` sent after a silent cost change, or in flight when the change lands, pays the new cost. Fix (keeps the intended owner powers): add a `maxCost` argument to `levelUp` and `upgradeOffice` and revert if the current cost exceeds it (mirroring `WrongPayment`), or make cost changes take effect only after a delay so a player always sees the cost they will pay.","line":221,"path":"contracts/src/PostOffice.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {StampToken} from \"src/StampToken.sol\";\nimport {CourierNFT} from \"src/CourierNFT.sol\";\nimport {PostOffice} from \"src/PostOffice.sol\";\n\n/// PostOffice owner can change STAMP-denominated costs at any time, and `levelUp` / `upgradeOffice`\n/// carry no caller-side bound: a call a player sent for \"25 STAMP\" pulls whatever the cost is when it\n/// executes, up to the player's allowance (the web client approves type(uint256).max).\n///\n/// Fails on the current code. Passes with either fix: a `maxCost` argument on the two spend functions\n/// (the test then calls the bounded variant with the quoted cost and expects it to revert or charge at\n/// most that), or cost changes that only take effect after a delay.\ncontract OwnerCostPullTest is Test {\n    StampToken stamp;\n    CourierNFT nft;\n    PostOffice office;\n\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    address alice = makeAddr(\"alice\");\n\n    uint256 constant SECRET = 0xC0FFEE;\n\n    function setUp() public {\n        vm.roll(100);\n        vm.warp(1_800_000_000);\n        vm.startPrank(owner);\n        stamp = new StampToken(owner, address(0), 0);\n        nft = new CourierNFT(owner, treasury, 0.003 ether, keccak256(abi.encode(SECRET)));\n        office = new PostOffice(stamp, nft, 1000, 2.5e18, 0.005 ether, treasury, owner);\n        stamp.setMinter(address(office));\n        nft.setGame(address(office));\n        office.addTier(2, 3, 0);\n        office.addTier(4, 7, 100e18);\n        nft.setSaleOpen(true);\n        vm.stopPrank();\n        vm.deal(alice, 10 ether);\n\n        vm.prank(alice);\n        nft.mint{value: 0.003 ether}(1);\n        vm.startPrank(owner);\n        nft.setSaleOpen(false);\n        nft.reveal(SECRET);\n        vm.stopPrank();\n\n        vm.prank(alice);\n        office.openOffice{value: 0.005 ether}(address(0));\n        // alice earns ~10,000 STAMP and, like the web client, approves the office for max.\n        vm.warp(1_800_000_000 + 4000);\n        vm.startPrank(alice);\n        office.claim();\n        stamp.approve(address(office), type(uint256).max);\n        vm.stopPrank();\n    }\n\n    function _callBounded(bytes memory unbounded, bytes memory bounded) internal {\n        vm.startPrank(alice);\n        (bool ok,) = address(office).call(unbounded);\n        if (!ok) {\n            // Signature changed by the fix: the bounded call must revert or charge at most the quoted cost.\n            (ok,) = address(office).call(bounded);\n        }\n        vm.stopPrank();\n    }\n\n    /// Expected: a level-up costs levelCost(0) = 25 STAMP, the amount shown to alice when she sends it.\n    /// Actual: the owner raises levelCostBase before alice's call executes and the same `levelUp(1)`\n    /// pulls alice's whole balance (75% burned, 25% to the treasury).\n    function test_OwnerCanDrainPlayerStampThroughLevelUp() public {\n        uint256 balanceBefore = stamp.balanceOf(alice);\n        uint256 quoted = office.levelCost(0);\n        assertEq(quoted, 25e18, \"quoted cost\");\n\n        vm.prank(owner);\n        office.setLevelCostBase(balanceBefore);\n\n        _callBounded(\n            abi.encodeWithSignature(\"levelUp(uint256)\", 1),\n            abi.encodeWithSignature(\"levelUp(uint256,uint256)\", 1, quoted)\n        );\n\n        uint256 paid = balanceBefore - stamp.balanceOf(alice);\n        assertLe(paid, quoted, \"player paid more than the cost they signed for\");\n    }\n\n    /// Same with the office tier upgrade cost.\n    function test_OwnerCanDrainPlayerStampThroughUpgradeOffice() public {\n        uint256 balanceBefore = stamp.balanceOf(alice);\n        (,, uint256 quoted) = office.tiers(1);\n        assertEq(quoted, 100e18, \"quoted cost\");\n\n        vm.prank(owner);\n        office.setTierUpgradeCost(1, balanceBefore);\n\n        _callBounded(\n            abi.encodeWithSignature(\"upgradeOffice()\"),\n            abi.encodeWithSignature(\"upgradeOffice(uint256)\", quoted)\n        );\n\n        uint256 paid = balanceBefore - stamp.balanceOf(alice);\n        assertLe(paid, quoted, \"player paid more than the cost they signed for\");\n    }\n}","reproduction":"State: office tiers as in DeployMainnet; alice has an office, 1 revealed courier, 9,999 $STAMP claimed and has approved PostOffice for max (as web/chain.js does). `office.levelCost(0)` returns 25e18. Owner calls `setLevelCostBase(9_999e18)`. Alice calls `levelUp(1)`. Expected: alice pays 25 $STAMP, or the call reverts because the cost changed. Actual: `_spend(9_999e18)` burns 7,499.25 $STAMP from alice and sends 2,499.75 to the treasury; alice's balance goes to 0. Same with `setTierUpgradeCost(1, 9_999e18)` followed by alice's `upgradeOffice()` (quoted 100e18, actual 9,999e18). The attached test fails on the current code with 'player paid more than the cost they signed for: 9999999999999999999999 > 25000000000000000000'.","severity":"medium","snippet":"        _spend(cost);","title":"PostOffice: owner can change STAMP costs between a player's quote and execution, pulling up to the player's full allowance"},{"citation":"resolved","description":"Both fee formulas (here and line 279) round down, so a swap whose IMD side is under 25 wei pays no fee and a larger swap pays slightly under 4% (never over). Guarantee 1 says the fee is 'never skipped'; it is skipped for dust. Economically irrelevant: splitting a trade into 24-wei pieces costs far more gas than the fee saved, and the under-charge on normal sizes is below 1 wei per swap. Rounding the fee up (`(amount * FEE_BPS + BPS - 1) / BPS`) would make the guarantee exact at the cost of over-charging by at most 1 wei.","line":242,"path":"contracts/src/StampHook.sol","reproduction":"After one buy to put IMD in the pool, an exact-in buy of 24 wei IMD through any router (`SwapParams(zeroForOne, -24, limit)`): expected fee 0.96 wei rounded somehow, actual `pendingProtocolFees` unchanged (fee 0); verified in a scratch test (test_TinySwapFeeZero).","severity":"info","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"StampHook: the 4% fee truncates to zero for IMD legs below 25 wei"},{"citation":"resolved","description":"`_collect` burns and takes exactly `pendingProtocolFees[quote]`, never the hook's actual ERC-6909 balance. Anyone can `PoolManager.transfer(hook, imdId, x)` (or `mint` to the hook inside their own unlock callback), after which the hook holds `pending + x` claims and `x` is stuck forever. Guarantee 2 states the claims 'always equal' `pendingProtocolFees`; the invariant that actually holds is claims >= pending, and the fee flow is never under-backed, so no user or protocol funds are at risk. If the requester wants the equality, `_collect` can sweep `poolManager.balanceOf(address(this), id)` instead of the counter.","line":312,"path":"contracts/src/StampHook.sol","reproduction":"alice swaps with `takeClaims = true` to receive IMD claims, then `pm.transfer(address(hook), uint256(uint160(IMD)), aliceClaims)`. Expected (per guarantee 2): hook claims == pendingProtocolFees. Actual: `pm.balanceOf(hook, id) == pendingProtocolFees + aliceClaims`; after `collectProtocolFees(IMD)` the hook still holds `aliceClaims` with no function able to move them (scratch test test_DonatedClaimsStuck).","severity":"info","snippet":"        uint256 amount = pendingProtocolFees[quote];","title":"StampHook: ERC-6909 IMD claims sent to the hook by anyone are unrecoverable, so claims can exceed pendingProtocolFees"},{"citation":"resolved","description":"The view the web client shows players (web/chain.js reads `pendingRewards`) omits two things `claim` applies: the referrer's `referralBps` share (2.5% default, owner-adjustable up to 10% and applied to already-accrued pending) and the clamp to `MAX_SUPPLY - totalMinted`, after which `o.pending` is zeroed and the excess is dropped. Players see more than they receive. Either document the view as pre-referral or add a `claimable(address)` view that mirrors `claim`.","line":286,"path":"contracts/src/PostOffice.sol","reproduction":"bob opens an office, alice opens one with referrer bob; warp 100 blocks; `pendingRewards(alice)` = 125e18. alice calls `claim()`: expected 125e18 minted to alice per the view, actual 121.875e18 to alice and 3.125e18 to bob (existing test test_ReferrerGetsTwoAndAHalfPercent shows the split). At the cap: with pending 10.5M and room 0 the view shows 10.5M and `claim` mints 0 and zeroes pending (scratch test test_ClaimAtCapWithReferral).","severity":"info","snippet":"        return o.pending + o.power * acc / PRECISION - o.rewardDebt;","title":"PostOffice: pendingRewards reports the gross amount; claim mints less (referral cut, supply-cap clamp)"},{"citation":"resolved","description":"Era 0 (2.25 $STAMP per 1.1 s virtual block, 4.2M blocks ~ 53.5 days) begins when PostOffice is deployed, which DeployMainnet does in the same run that opens the pool and before the NFT sale and `reveal`. `assign` calls `rideOf`, which reverts until the reveal, so until then only trainees (60 power per 0.005 ETH office) earn, and the whole block reward goes to whoever opened offices first: about 176,700 $STAMP per day in total regardless of how many offices exist. If the mint and reveal take 14 days, roughly 2.47M $STAMP (13% of the 18.9M emission budget, 26% of era 0) is distributed to trainee offices at 0.005 ETH each and is immediately sellable into the pool. This is a design consequence rather than a code defect, and AUDIT.md section 5 only covers the case of no power on duty; it is reported so the requester can confirm the intended launch sequence (for example deploying PostOffice, or adding its first tier, only after the reveal).","line":128,"path":"contracts/src/PostOffice.sol","reproduction":"Deploy as DeployMainnet does; one player calls `openOffice{value: 0.005 ether}(address(0))` right after deployment, nobody else opens an office; warp 1 day (78,545 virtual blocks). `pendingRewards(player)` = 78,545 * 2.25e18 = 176,726e18 $STAMP, claimable and sellable while no courier can be assigned (`assign` reverts `NotRevealed`). Existing test test_OpenOfficeStartsATraineeEarningEverything shows the single-office 100% share.","severity":"info","snippet":"        startTime = block.timestamp;","title":"PostOffice: the emission clock starts at deployment, so trainee-only offices collect the full block reward before any courier can go on duty"}],"hash":"949cc8e781d2fb6a1db2de18d57d5ccbbe47aac8350af49923e98e199cdb7b94","nodeId":"ddf14488-d895-483d-8566-c71eeb85a39d","outcome":"completed","summary":"The review is complete. All five findings anchor to the tree, and nothing tracked was modified. The findings file is at `.imd-findings.json` in the repository root (the `.imd-findings.json` entry doesn't show in git status because `.gitignore` covers it; the file exists and parses).\n\n**Outcome.** One Medium defect and four substantiated informational notes. The hook, the routers, the token and the courier lock all held under every probe I ran. No critical or high finding.\n\n**The Medium finding (with a failing Foundry proof).** The PostOffice owner can change the $STAMP-denominated costs at any time, and `levelUp` and `upgradeOffice` carry no caller-side bound. The official client approves the office for an unlimited allowance, so a player's call pulls whatever the cost is when it executes. The reproduction quotes 25 $STAMP, the owner raises the base, and the same call takes the player's entire 9,999 $STAMP balance. This contradicts the brief's guarantee that owner-set costs never reach balances. The ETH-paid paths already do this right by requiring exact payment. The suggested fix is a `maxCost` argument or delayed-effect setters, and the proof test tolerates either.\n\n**What I verified as sound, by test rather than reading.**\n- Fee: exactly 4% in all four swap modes, through both routers and a generic v4 router, under both currency orderings (IMD below and above $STAMP). Partial fills on the IMD-specified side revert; on the unspecified side they charge proportionally. The hook's ERC-6909 claims matched `pendingProtocolFees` after every swap, donation and collection, including collection while a third party held the unlock.\n- Locked liquidity: outside liquidity, pool reuse and position removal all revert. A pre-launch $STAMP donation is burned, not blocking.\n- Supply: capped and clamped; the schedule truncates 79,800 wei under 21M, as documented.\n- Accounting: hand-computed rewards across a halving with mid-era assign, level and unassign matched to the wei, and minted never exceeded emitted.\n- Couriers: locked on duty, levels follow the NFT, traits unreadable before reveal.\n- Live chain: the fork test passes against Robinhood Chain, and the IMD token is a LayerZero OFT with a standard ERC-20 core, so no fee-on-transfer or blocklist risk to the settle path.\n\n**Informational notes.** The fee rounds to zero below 25 wei IMD. Donated ERC-6909 claims make the hook's balance exceed, not equal, the pending counter and are stuck. The `pendingRewards` view omits the referral cut and the cap clamp that `claim` applies. The emission clock starts at deployment, so trainee-only offices collect roughly 176,700 $STAMP per day until the reveal, which is a launch-sequencing decision for the requester to confirm.\n\n**Test limitations.** No Slither or long fuzz campaigns were run (not provided). One probe surfaced a via-IR artefact where a cached `block.timestamp` is re-read after `vm.warp`; it affects only tests, never a real transaction.","treeHash":null,"usage":{"cachedInputTokens":4422770,"inputTokens":706,"model":"claude-fable-5-1","outputTokens":94000,"runtime":"claude","turns":53,"wallClockMs":1573876}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ea89e16822824c6f","findings":[{"citation":"resolved","description":"PermitHelper.permit always calls permit() with value == amount (the tokenAmount being sold). The NatSpec on sellWithPermit (StampRouter.sol:91, `(value >= tokenAmount)`) and the mirrored sellForEthWithPermit in StampEthRouter promise that a permit signed for a larger value is acceptable. It is not: with a different value the EIP-712 digest differs, ecrecover yields another address, OZ's ERC20Permit reverts ERC2612InvalidSigner, the catch branch finds allowance(user, router) < amount and the sell reverts PermitFailed. The front-running case (same signature already consumed) is handled correctly; the over-value case the docs advertise is not. Any front end that signs a 'max' or rounded-up permit, or a user who signs once for their full balance and sells in parts, has every sell fail. No funds at risk; sells fail until the user signs an exact-value permit. Fix: add a `permitValue` parameter (pass it to permit(), keep the allowance >= tokenAmount fallback) or change the NatSpec on both routers to say the permit must be signed for exactly tokenAmount.","line":34,"path":"contracts/src/StampRouter.sol","reproduction":"State: alice holds G $STAMP (bought via router.buy) and has set allowance(alice, router) = 0. Alice signs an EIP-2612 permit for (owner=alice, spender=router, value=2*G, nonce=nonces(alice), deadline=now+100). Call router.sellWithPermit(stamp, G, 1, now+100, v, r, s). Expected per NatSpec: the sell succeeds (value 2G >= tokenAmount G). Actual: permit() reverts ERC2612InvalidSigner (value in the digest is G, not 2G), allowance is 0 < G, the call reverts PermitFailed(). Verified in a scratch Foundry test (test_PermitSignedForLargerValueFails): vm.expectRevert(PermitFailed) passes. The same holds for StampEthRouter.sellForEthWithPermit (StampEthRouter.sol:111).","severity":"low","snippet":"        try IERC20Permit(token).permit(msg.sender, address(this), amount, deadline, v, r, s) {}","title":"Permit sells reject a signature whose value exceeds tokenAmount, contrary to the documented `value >= tokenAmount`"},{"citation":"resolved","description":"StampHook and PostOffice use a two-step handover (AUDIT.md section 4, StampHook.sol:346 'so a typo can't lose the admin role'), but CourierNFT inherits plain Ownable, whose transferOwnership(newOwner) takes effect immediately and whose renounceOwnership() is public to the owner. The launch plan moves every owner role to a multisig after the mint, which is exactly the window in which reveal(secret) has not yet been called (reveal requires the sale closed, and the sale is closed after the mint). If the NFT owner role lands on an address nobody controls before reveal(), seed stays 0 forever: rideOf() reverts NotRevealed (CourierNFT.sol:100), PostOffice.assign() and unassign() call rideOf() and revert for every token, tokenURI stays the sealed envelope, and no admin can repair it (setGame, setRenderer, withdraw are also lost; the mint ETH in the contract becomes unrecoverable). This is an operational hardening gap, not a bypass, but it is the one admin mistake the rest of the system is explicitly designed to prevent and its blast radius is the whole game. Fix: inherit Ownable2Step (as PostOffice does) so the new owner must call acceptOwnership(); optionally block renounceOwnership() while seed == 0.","line":19,"path":"contracts/src/CourierNFT.sol","reproduction":"State: sale closed, seed == 0, owner = deployer EOA, N tokens minted. Owner calls nft.transferOwnership(0x0000000000000000000000000000000000000001) (a typo for the multisig). Expected (per the design goal stated for the other contracts): the handover is pending until the new owner accepts, the deployer can still call reveal. Actual: Ownable transfers immediately; the deployer's next reveal(SECRET) reverts OwnableUnauthorizedAccount; nobody controls 0x...01, so reveal can never be called; nft.rideOf(1) reverts NotRevealed forever and office.assign(1) reverts for every holder. Likewise owner.renounceOwnership() before reveal() yields the same permanent state.","severity":"low","snippet":"contract CourierNFT is ERC721, ERC2981, Ownable {","title":"CourierNFT uses single-step Ownable; a mistaken transferOwnership or renounceOwnership before reveal() permanently bricks reveal and therefore every assign()"},{"citation":"resolved","description":"The constructor's BadTick guard only checks spacing and the usable-tick bound. The liquidity added in _addLaunchLiquidity is amount * Q96 / (sqrtU - sqrtL) (STAMP as currency1) or the mirrored expression (STAMP as currency0); when the start price is extremely low in $STAMP-per-IMD terms the denominator is tiny and the liquidity exceeds int128 / tickSpacingToMaxLiquidityPerTick(200) (~3.8e34), so poolManager.modifyLiquidity reverts with SafeCastOverflow or TickLiquidityOverflow. Sweeping startTick over the accepted range in both currency orderings: every tick <= -500_000 reverts in openPool, -400_000 and above succeed (scratch test test_Sweep). A hook deployed with such a tick is a valid contract that can never open its pool; the 2.1M $STAMP minted to it by StampToken's constructor is stuck (the hook has no transfer path other than openPool). DeployMainnet derives the tick from a USD market cap, so hitting this needs a price of roughly 1e20 IMD per $STAMP, i.e. a grossly wrong START_MCAP / IMD price input, and the forge simulation would fail at step 4 before broadcasting. Reported for completeness because the guard's comment and the audit brief present openPool as always reachable once the constructor accepts the tick. Fix: compute the launch liquidity in the constructor (same formulas as _addLaunchLiquidity) and revert BadTick if it exceeds int128 max or Pool.tickSpacingToMaxLiquidityPerTick(TICK_SPACING); or simply tighten the limit to |startTick| <= 400_000.","line":139,"path":"contracts/src/StampHook.sol","reproduction":"Deploy StampHook(pm, IMD, owner, feeRecipient, startTick=-500000, launchSupply=2_100_000e18, imdEthPool) at a mined address (constructor accepts: -500000 % 200 == 0 and |tick| <= 887000). Deploy StampToken(owner, hook, 2_100_000e18). Call hook.openPool(stamp) as owner. Expected: pool initialized, 2.1M $STAMP locked. Actual: revert TickLiquidityOverflow(int24) (0xb8e3c385) with IMD as currency0, or SafeCastOverflow (0x93dafdf1) at -700000 and below in either ordering; token stays 0, the 2.1M $STAMP remains in the hook with no way out. At startTick=-400000 and every tested tick up to +887000 openPool succeeds.","severity":"info","snippet":"        if (startTick_ % TICK_SPACING != 0 || startTick_ > limit || startTick_ < -limit) revert BadTick();","title":"StampHook constructor accepts start ticks for which openPool() always reverts (liquidity overflow), stranding the launch allocation in the hook"},{"citation":"resolved","description":"pendingProtocolFees is the only quantity _collect burns and takes. Anyone can poolManager.mint(hook, imdId, x) inside their own unlock (after settling x IMD) or poolManager.transfer(hook, imdId, x) from existing claims. The hook then holds pendingProtocolFees + x claims; collectProtocolFees pays feeRecipient exactly pendingProtocolFees and leaves x claims in the hook forever (no function burns more than pending). Nothing is lost by the protocol and solvency is unaffected (claims >= pending always holds), but AUDIT.md guarantee 2 states equality, and value a donor sends to the fee pool is stranded rather than collected. Fix: in _collect burn and take poolManager.balanceOf(address(this), id) (which is >= pending) and reset pending; or document that only pendingProtocolFees is ever collected.","line":315,"path":"contracts/src/StampHook.sol","reproduction":"After one 1,000 IMD exact-in buy: pendingProtocolFees[IMD] = 40e18, pm.balanceOf(hook, imdId) = 40e18. A contract calls pm.unlock, in its callback sync(IMD), transfers 5e18 IMD to the PoolManager, settle(), then pm.mint(hook, imdId, 5e18). Now pm.balanceOf(hook, imdId) = 45e18 while pendingProtocolFees = 40e18. hook.collectProtocolFees(IMD) sends 40e18 to feeRecipient; pm.balanceOf(hook, imdId) = 5e18 remains and no call can move it (scratch test test_ClaimsDonationBreaksEquality logs: claims 45e18 / pending 40e18, after collect claims 5e18).","severity":"info","snippet":"        poolManager.burn(address(this), Currency.wrap(quote).toId(), amount);","title":"Third parties can push ERC-6909 IMD claims onto the hook; they are unrecoverable and break the documented claims == pendingProtocolFees equality"},{"citation":"resolved","description":"Whether IMD or $STAMP is currency0 on mainnet is decided by the deployer's nonce-derived StampToken address versus 0x5F7B...7127 and is unknown until deploy. The suite only ever gets the ordering that `new StampToken` happens to produce from the test contract, so the tokenIs1 == false branch of _addLaunchLiquidity (StampHook.sol:213, the mulDiv(sqrtL, sqrtU, Q96) formula, the range [tick, maxUsableTick], in-range liquidity at launch) and the mirrored sign handling in beforeSwap/afterSwap/StampRouter/StampEthRouter run untested. test_EveryRouteAndDirectionIsTaxed asserts exact equality only for the exact-in buy; exact-out buy and exact-in sell use assertGt, so a 3.9% or 4.1% fee would pass. No test triggers PartialFill, calls collectProtocolFees while another contract holds the unlock, exercises sellWithPermit / sellForEthWithPermit, or sends ETH through buyWithEth when the IMD/ETH leg under-fills. I re-ran all of these in scratch tests in both orderings (fuzzed fee equality for all four modes, PartialFill on an over-sized exact-out sell, collection inside a foreign unlock, permit front-run) and they pass, so this is a coverage gap rather than a defect; it is listed because the mainnet ordering cannot be chosen and the brief asks which edges are untested. Fix: vm.etch a mock IMD at 0x11..11 and at 0xFF..FF (or deploy $STAMP via CREATE2 with both orderings) and run the hook/router suite as two contracts; replace assertGt with the exact formulas (exact-in: amt*400/10000; exact-out IMD: amt*400/9600; unspecified: poolQuote*400/10000 or poolQuote*400/9600).","line":105,"path":"contracts/test/StampHook.t.sol","reproduction":"forge test --match-contract StampHookTest -vv prints the same currency ordering in every run (IMD = currency0 here because the MockIMD is created before the hook and the token). Reproduce the gap by changing line 105 to deploy $STAMP at an address below the mock IMD (e.g. vm.etch the MockIMD runtime code at 0xFFfF...FfF and point imd at it): the launch, fee, partial-fill and router paths now take the other branch and have no assertions covering them. A scratch suite doing exactly this (FeeProbeImdLow / FeeProbeImdHigh) passes on the current code, so no revision is needed beyond adding the tests.","severity":"info","snippet":"        stamp = new StampToken(owner, address(hook), LAUNCH);","title":"Test suite never exercises the $STAMP-as-currency0 ordering, exact fee equality for three of the four swap modes, PartialFill, or collection during a foreign unlock"}],"hash":"fe5ba8af9a2535686a46061d00829bd31a96687d623b47f3c735dff40a4d9721","nodeId":"618b50d9-3b25-45db-b1bd-aad2d4259933","outcome":"completed","summary":"The review is complete and the findings file is written and validated against the tree.\n\n**Outcome.** No critical or high defects. The core guarantees held under targeted fuzzing: the 4% fee is exact in all four swap modes through a generic v4 router in both possible currency orderings, partial fills on the IMD-specified side revert, fee collection works inside a foreign unlock, launch liquidity cannot be added to or removed, PostOffice claims never exceed emissions across halvings and never pay for pre-duty time, locked couriers cannot move, and the routers leave no residue and only move the caller's tokens. The existing 43 tests pass.\n\n**Findings recorded in `.imd-findings.json`** (two low, three info):\n\n- **Low, permit sells.** The permit helper always submits `value == tokenAmount`, so a signature for a larger value fails with PermitFailed, contradicting the NatSpec on both routers. Reproduced with an over-value signature.\n- **Low, CourierNFT ownership.** CourierNFT is single-step Ownable while the hook and PostOffice are two-step. A typo'd transfer or a renounce before `reveal` permanently prevents the reveal, which bricks `assign` for every courier.\n- **Info, start-tick guard.** The hook constructor accepts start ticks at or below about -472,000 for which `openPool` always reverts with a liquidity overflow, stranding the launch allocation in the hook. Unrealistic price, caught by script simulation.\n- **Info, stranded claims.** Anyone can mint or transfer ERC-6909 IMD claims to the hook. They are never collected, so the documented equality between claims and pending fees only holds as an inequality.\n- **Info, test coverage.** The suite never runs the $STAMP-as-currency0 ordering, which mainnet may produce, and asserts exact fee equality for only one of the four swap modes.\n\n**Not verified here.** The fork test against Robinhood Chain could not run because the sandbox denied `robinhood.drpc.org`, so the live IMD token's transfer behaviour and the deployed PoolManager were not checked in this session. Scratch tests live under `contracts/test/scratch/` and no source or configuration file was changed.","treeHash":null,"usage":{"cachedInputTokens":3958811,"inputTokens":738,"model":"claude-fable-5-1","outputTokens":85410,"runtime":"claude","turns":52,"wallClockMs":1579980}}],"verification":[]}