{"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":"ed98e0c7-d552-4c11-844c-8a8395e43fb5","kind":"audit","nodes":[{"acceptedSubmissionHash":"1046b8f477b152fa1dee24e1cbff54aaefd4836dbf1a897a032149b78ef0f890","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":"3c83588113faa683491c0b1807dfa1aa74cca047a50ff713364046dd7bf6eca4","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":"d3974c4ae37c6dd189637e3743778b5be19038294f62513bd7167369296731fc","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"d04ac5c1c938dd39eb1d3d34b53463dc73cc0931e10562b0334ed1388fead37d","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":"480164a4eaaacc912640edcb22e12d6f12f90bf84ccd22926973903fc1cafaa5","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":"Audit the contracts in AdamNakamoto/delivermail as they are.","parentJobId":null,"planHash":"4c4babc7311e5f9f7cd261529ea1331de70984a4db92fbc7d05ecc71a6758d06","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"ed98e0c7-d552-4c11-844c-8a8395e43fb5","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":"52019","feedbackHash":"939c77d462548433d374815a8580d02a46fbc908c459c15fd873eba72174024b","nodeKey":"audit_economics","submissionHash":"1046b8f477b152fa1dee24e1cbff54aaefd4836dbf1a897a032149b78ef0f890","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51245","feedbackHash":"6092db0b0372416f3421491ed52182c7f350714f9658710a669955ecdf474c52","nodeKey":"audit_flow","submissionHash":"3c83588113faa683491c0b1807dfa1aa74cca047a50ff713364046dd7bf6eca4","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52391","feedbackHash":"05a059f8702c2faaa763ba5cb23fa19fc1d592d0ffe12717b5c27ce0010f91ad","nodeKey":"audit_judge","submissionHash":"d3974c4ae37c6dd189637e3743778b5be19038294f62513bd7167369296731fc","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52200","feedbackHash":"60be2542d453ba6386f39d035c9d6203c38ab25e41f099eb8821416cfc38edda","nodeKey":"audit_math","submissionHash":"d04ac5c1c938dd39eb1d3d34b53463dc73cc0931e10562b0334ed1388fead37d","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52159","feedbackHash":"1b143dc0104ad68c08f533548cdc84c58bb348a173241ef1289edfdf2b3711e3","nodeKey":"audit_permissions","submissionHash":"480164a4eaaacc912640edcb22e12d6f12f90bf84ccd22926973903fc1cafaa5","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"bc4a40163598060c2d2907e4156c7c67f4f8c56dbcd8d478ff0cf96a21a12611","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"be3be4cc237417f9","findings":[{"citation":"resolved","description":"The suite (107 tests) never reaches the OverTotal revert, nor AllocationExhausted (line 136). These are the two caps that bound minting when the poster under-declares a day's total or posts more than the play allocation; both are correct on this code (early 80% claims book the full `amount` into `d.claimed`, so a 20% burn cannot be used to overdraw a day), but no test pins them, so a future change to _settle or claimNow could silently drop them. Economic review of DeliveryRewards, MailHook/MailRouter, MailOffice, MessengerNFT, Wardrobe and MailToken otherwise found no fund-loss, invariant or flow-gap defect: fee accounting in all four swap modes nets the hook's deltas to zero and claims always equal pendingProtocolFees; the 21M cap and the 16.8M play allocation are enforced on every mint path; one-character and once-per-reward-day rules hold across transfers and offices; MAIL spends burn at least 75% and respect maxCost.","line":214,"path":"p2e/contracts/src/DeliveryRewards.sol","reproduction":"Deploy DeliveryRewards(mail, owner, poster, 1 days, 16_800_000e18/102, 51); set it as minter. Warp to startTime + 1 day. Build a two-leaf tree: leaf(alice, 0, 100e18), leaf(bob, 0, 100e18); postRoot(0, root, 150e18) (total deliberately below the leaf sum). Warp to claimableFrom(0). alice.claimNow(0, 100e18, proofA) -> alice receives 80e18, days_(0).claimed == 100e18 (the full amount, not 80e18). bob.claimNow(0, 100e18, proofB) -> expected and actual: revert OverTotal(). Expected by the guarantee: yes; covered by the existing suite: no (grep for OverTotal and AllocationExhausted in p2e/contracts/test returns nothing). A test asserting this is in the reviewer's scratch file test/scratch/EconEdges.t.sol::test_EarlyClaimsCountFullAgainstDayTotal, which passes on the current code.","severity":"info","snippet":"        if (d.claimed + amount > d.total) revert OverTotal();","title":"Guarantee 6 'claims for a day never exceed that day's total' (OverTotal) has no test; the guard holds but is unexercised"},{"citation":"resolved","description":"test_PartialFillIsRejected covers only the exact-in buy branch of this line (exactIn == true). The exact-out sell branch (ETH is the specified output, seller asks for more ETH than the pool holds) is the one where an unrejected partial fill would charge the up-front fee ceil(Y/24) on the requested Y while the pool pays less, i.e. a fee above 4%. The branch is correct on this code (verified: a 2 ETH exact-out sell against a pool holding 0.96 ETH reverts inside the PoolManager with MailHook.PartialFill and leaves pendingProtocolFees unchanged; a 0.5 ETH exact-out sell pays exactly 0.5 ETH to the seller and books fee == ceil(0.5e18/24)), but nothing in the suite pins it.","line":281,"path":"p2e/contracts/src/MailHook.sol","reproduction":"Open the pool as in test/MailHook.t.sol setUp (START_MCAP 20 ether, LAUNCH 4.2M). alice: router.buy{value: 1 ether} (pool now holds 0.96 ETH, hook claims 0.04 ETH). alice via PoolSwapTest: swap(key, SwapParams(zeroForOne=false, amountSpecified=+2e18, sqrtPriceLimitX96=MAX_SQRT_PRICE-1)). Expected and actual: revert (WrappedError wrapping MailHook.PartialFill 0xa9e35b2f); pendingProtocolFees(ETH) unchanged at 0.04 ether. Then swap(key, SwapParams(false, +0.5e18, MAX_SQRT_PRICE-1)): alice.balance rises by exactly 0.5 ether and pendingProtocolFees rises by (0.5e18 + 23) / 24. Reviewer's scratch test: test/scratch/EconEdges.t.sol::test_ExactOutSellPartialFillReverts (passes on the current code).","severity":"info","snippet":"            if (exactIn ? poolQuote + fee != requested : poolQuote != requested + fee) revert PartialFill();","title":"Guarantee 17 'partial fills are rejected in every direction and mode' is only tested for exact-in buys; the exact-out sell path is untested"}],"hash":"1046b8f477b152fa1dee24e1cbff54aaefd4836dbf1a897a032149b78ef0f890","nodeId":"f1ccdf54-68ad-4959-8399-3ca1f0996348","outcome":"completed","summary":"The review is complete and the findings file is written and validated. The snippets match the tree at the quoted lines, and no tracked file was changed. Only the scratch test directory is new, which is mine and not kept.\n\n**Result: no economic, invariant, or flow-gap defect survived verification.** The findings file holds two informational test-coverage items.\n\n**What I checked, against the Economic Security, Invariant and Flow Gap guides**\n\n- **MailHook fee accounting.** Traced all four swap modes through v4's BeforeSwapDelta and afterSwap return-delta paths. The hook's deltas net to zero on every path, the fee is ceil(4%) of the ETH side in each mode, partial fills on the ETH-specified side revert, and the hook's ERC-6909 claims always equal `pendingProtocolFees`. Collection inside another caller's unlock settles cleanly. Nobody but the hook can initialize a pool on it or add liquidity, and the hook has no removal path.\n- **MailToken and DeliveryRewards caps.** Every mint path goes through `_mintCapped`. Posted totals are bounded per day and by the 16.8M allocation. Early 80% claims book the full amount against the day's total, so the burn cannot overdraw a day. Revoke, pause, and claim windows are consistent between the views and the write paths.\n- **Office, NFT and Wardrobe.** One-character and once-per-reward-day rules hold across transfers and across an office change, including the same-wallet re-assign exception. Every MAIL spend burns at least 75% rounded up and respects the caller's maxCost. Locked Messengers cannot move. Launch and deploy parameters (start tick, liquidity bounds, halving sum) are within range.\n\n**Findings written**\n\n| Severity | Item |\n| --- | --- |\n| Info | `OverTotal` and `AllocationExhausted` guards are never exercised by the suite. |\n| Info | Partial-fill rejection is tested only for exact-in buys, not exact-out sells. |\n\nBoth are confirmed correct on the current code by a scratch test I ran, which passes. They are reported because the guides treat unexercised guarantees as a finding of their own.\n\n**Scope notes.** The game server and client are out of scope and were read only to see which on-chain views they consume. Slither and fork tests were not run. Trust assumptions documented in the audit notes, such as the owner-chosen reveal block and the admin's ability to raise a Wardrobe cap, were not reported as defects.","treeHash":null,"usage":{"cachedInputTokens":2709759,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":63755,"runtime":"claude","turns":39,"wallClockMs":1264964}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"911c99a95f538613","findings":[],"hash":"3c83588113faa683491c0b1807dfa1aa74cca047a50ff713364046dd7bf6eca4","nodeId":"a954be88-39dc-4a6a-98e1-7b4082a9f101","outcome":"completed","summary":"**Result: no substantiated defect in the control flow of the audited contracts.** The findings file `.imd-findings.json` holds an empty list, which is the honest outcome of this pass, not a skipped one.\n\n**What was covered, in depth, in the assigned area**\n\n- **Execution Trace.** Every state-changing entry point was traced from caller to final state: the four swap modes through `MailHook.beforeSwap` and `afterSwap` against the pinned v4-core (`Hooks.afterSwap` delta composition, `noSelfCall` on initialize and add-liquidity, claim mint and burn netting to zero), the router's unlock callback (only reachable from the router's own `unlock`, so `user` cannot be spoofed), the rewards post, revoke, open, mature and claim timeline, the office assign, unassign, upgrade and level-up chains into `MessengerNFT.setLocked` and `raiseLevel`, and the mint, close, reveal sequence. Parameter divergence, stale reads, partial updates, sentinel paths, interleaving and mid-flight setter changes all end in a revert or in the documented behaviour. Transient fee slot handling and revert atomicity were checked.\n- **Periphery.** `SafeTransfer`, `PermitHelper`, `DeployLib` (tick rounding, salt mining against the 14-bit mask, sqrt), `MessengerArt`'s SSTORE2 writer and reader, the 16/12/9-byte base64 joining argument, and the deploy script's two stages. All hold.\n- **First Principles.** The one-character and once-per-reward-day couplings (`characterOf`, `assignedBy`, `locked`, `lockedBy`, `playingOf`, `lastPlayedDay`) have exactly one writer pair and stay in sync across an office change. Server-side metering is per wallet per day, so swapping Messengers within a day adds no deliveries.\n\n**Checks run**\n\n- The project suite: 107 tests pass, 1 fork test skipped as expected without `FORK_TESTS`.\n- Live reads on Robinhood Chain: the PoolManager, Chainlink feed, CREATE2 factory and transfer validator all have code. The validator is OpenSea's strict registry: an unauthorised operator is rejected and holder moves bypass it, so the royalty guarantee holds without a registration step.\n\n**Not covered.** The game server and client were read only where the contracts depend on them. No fork run and no long fuzz campaign were performed in the time budget. A clean pass is not proof of absence.","treeHash":null,"usage":{"cachedInputTokens":1020688,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":59619,"runtime":"claude","turns":32,"wallClockMs":1001922}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"6b37e4ab65246705","findings":[{"citation":"resolved","description":"Trust-gap (access x asymmetry): the two things a player buys in MAIL through the office are treated differently when the owner exercises MessengerNFT.setOffice. Messenger levels are written to MessengerNFT.levelOf and survive a change of office; office tiers (up to 168,000 MAIL for the full ladder, 75% of it burned) live only in MailOffice.tierOf of the specific office contract, as does the 0.005 ETH open gate. A new MailOffice starts every wallet at tier 0: staminaOf, apprenticeStaminaOf and capacityOf all read 0, assign reverts NotOpen, and the wallet must pay openPrice again and re-buy every tier at full price while the old office keeps the ETH and the burned MAIL. AUDIT.md documents setOffice only as 'changing the office never strands a Messenger' and lists the office owner's powers as unable to 'move anyone's NFT or MAIL'; it does not say that the NFT owner's setOffice zeroes every wallet's paid tier and re-charges the gate. Fix options that keep the design: document the loss as a trust assumption, or make the new office read tiers from the old one (an `importTier`/`previousOffice` path that copies tierOf and lastUpgradeAt once per wallet), or keep tierOf on the NFT like levelOf.","line":49,"path":"p2e/contracts/src/MailOffice.sol","reproduction":"State: alice opened an office (0.005 ETH), approved MAIL, called office.upgrade(7_000e18) -> office.tierOf(alice)==2, office.staminaOf(alice)==14 with Messenger #1 assigned. Owner deploys office2 = new MailOffice(mail, nft, rewards, treasury, owner) and calls nft.setOffice(office2). Expected (per AUDIT.md trust assumptions): a change of office does not cost players anything. Actual: office2.tierOf(alice)==0, office2.staminaOf(alice)==0, office2.apprenticeStaminaOf(alice)==0, office2.assign(1) reverts NotOpen; after office2.open{value: 0.005 ether} and office.unassign(), office2.tierOf(alice)==1 and the next tier costs office2.upgradeCost(2)==7_000e18 again. Confirmed by test_OfficeMigrationLosesPaidTiers in test/scratch/Review.t.sol.","severity":"low","snippet":"    mapping(address => uint8) public tierOf;","title":"setOffice migrates the lock record but not the office tiers players paid for in MAIL"},{"citation":"resolved","description":"Access-control transparency: MessengerNFT.setOffice (the address that may lock any Messenger and raise levels), MessengerNFT.setTreasury (mint payments), MailOffice.setTreasury (25% of every MAIL spend and withdrawn ETH), MailOffice.setOpenPrice (the sybil gate) and Wardrobe.setTreasury write their state with no event, while sibling setters in the same contracts (setPrice, setMaxLevel, setTransferValidator, setUpgradeCost, setLevelCost, openTiers, ItemListed) do emit. AUDIT.md relies on the office change being 'visible on-chain', but nothing indexable marks it; a player, the game server or a watcher cannot subscribe to the change and must poll storage. Fix: emit OfficeSet(address), TreasurySet(address) and OpenPriceSet(uint256) from each setter.","line":248,"path":"p2e/contracts/src/MessengerNFT.sol","reproduction":"With owner as caller: vm.recordLogs(); nft.setOffice(address(office2)); nft.setTreasury(alice); office.setTreasury(alice); office.setOpenPrice(1); wardrobe.setTreasury(alice); vm.getRecordedLogs().length == 0. Expected: one event per privileged state change, as the other owner setters in these contracts do. Actual: zero logs for all five calls. Confirmed by test_SettersEmitNoEvent in test/scratch/Review.t.sol.","severity":"info","snippet":"    function setOffice(address office_) external onlyOwner {","title":"Privileged setters that change where value goes or who may lock emit no event"}],"hash":"480164a4eaaacc912640edcb22e12d6f12f90bf84ccd22926973903fc1cafaa5","nodeId":"2c0e8779-c067-4af3-9880-0f1f09fc1958","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds the two substantiated findings. The repository tree is unchanged apart from the scratch test and the findings file (the `lib/` install is gitignored).\n\n**Scope reviewed.** All nine contracts in `p2e/contracts/src`, the deploy script, and the ten test files, against the Access Control, Asymmetry and Trust Gap guides plus the entry-point inventory. I compiled the project and ran the existing suite (107 tests, all pass) and a scratch test for the two findings.\n\n**Result: the permission model holds on every entry point I traced.** Each state-changing function has the guard its documented actor needs: the poster, owner, office, locking office, pool manager and `minterAdmin` checks are all enforced, the callbacks (`unlockCallback`, `beforeSwap`, `afterSwap`) can only be reached from the pool manager via the hook's own `unlock` or a real swap, nobody else can initialize a pool or add liquidity on the hook, and the two-step ownership and fee-recipient handovers cannot be captured by a third party. The lock/unlock, assign/unassign, post/revoke, claim/claimNow and the two fee-branch pairs are symmetric in what they write and check. No critical, high or medium defect was found, so no finding carries a proof file.\n\nFindings written:\n\n- **Low, trust gap on `setOffice`.** Messenger levels bought in MAIL survive an office change because they live on the NFT, but office tiers (up to 168,000 MAIL, 75% burned) and the 0.005 ETH gate live only in the specific `MailOffice` contract. After the NFT owner points at a new office, every wallet reads tier 0, cannot assign until it pays the gate again, and must re-buy its tiers. The audit notes document the office change only as \"never strands a Messenger\". Reproduced in `test/scratch/Review.t.sol`.\n- **Info, silent privileged setters.** `setOffice`, three `setTreasury` functions and `setOpenPrice` emit no event, unlike their sibling setters, so the office change the audit notes call \"visible on-chain\" is not indexable. Reproduced with zero recorded logs across all five calls.\n\n**Leads checked and dropped.** A wallet rotating several Messengers within one reward day gains nothing because the server meters paid deliveries per wallet and day. The reveal's `blockhash` on an Arbitrum-style chain and the owner's pause-then-no-revoke corner are already documented trust assumptions. The fee logic was not re-derived beyond confirming the branch pairs charge symmetrically; it has three prior re-checks and a dedicated suite.","treeHash":null,"usage":{"cachedInputTokens":1569673,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":42833,"runtime":"claude","turns":33,"wallClockMs":770770}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fdbedee33322a6ef","findings":[{"citation":"resolved","description":"claimNow is documented as paying 80% before maturity and burning 20%. The burn is computed with floor division, so the account keeps the remainder: below 5 wei nothing is burned at all, and every early claim under-burns by up to 1 wei. MailOffice._spend and Wardrobe.buy round their 75% burn UP for exactly this reason (IMD audit finding 6), but DeliveryRewards does not. The dust is negligible at 18 decimals unless the server's Merkle allocation contains dust amounts. Fix: round the burn up, e.g. burned = (amount * EARLY_BURN_BPS + 9_999) / 10_000, which keeps the paid share at or below 80%.","line":189,"path":"p2e/contracts/src/DeliveryRewards.sol","reproduction":"The poster posts a root for day 0 that contains the leaf (alice, 0, 4). After claimableFrom(0) and before maturesAt(0), alice calls claimNow(0, 4, proof). Expected: at most 80% paid (3 wei), at least 1 wei burned. Actual: burned = 4*2000/10000 = 0, so alice is minted 4 wei (100%) and ClaimedEarly(0, alice, 4, 0) is emitted. With amount = 9 the burn is 1 wei (11%), not 20%.","severity":"info","snippet":"        uint256 burned = amount * EARLY_BURN_BPS / 10_000;","title":"Early-claim burn rounds down, so tiny early claims pay more than 80% (up to 100%)"}],"hash":"d04ac5c1c938dd39eb1d3d34b53463dc73cc0931e10562b0334ed1388fead37d","nodeId":"ca0dbe07-86f1-40d9-acbc-e83c94bbb233","outcome":"completed","summary":"I found no critical, high or medium math defects in the contracts. The review produced one informational finding, recorded in `.imd-findings.json`. I didn't run any Foundry tests: the dependency folder (`p2e/contracts/lib/`) isn't in the tree, and installing it was off-limits. Every check below was done by reading the code and working the arithmetic by hand with concrete values.\n\n**Finding (info):** `p2e/contracts/src/DeliveryRewards.sol:189`. When a player claims early with `claimNow`, the 20% burn is rounded down, so the player keeps the leftover wei.\n- **Example:** a claim of 4 wei burns nothing and pays 100%. A claim of 9 wei burns 1 wei, which is 11% instead of 20%.\n- **Why it matters:** `MailOffice._spend` and `Wardrobe.buy` already round their burns up, after the earlier IMD audit finding 6. This contract doesn't, so the three are inconsistent.\n- **Fix:** round the burn up with `(amount * EARLY_BURN_BPS + 9_999) / 10_000`.\n- **Impact:** at most one wei per claim, unless the server's reward lists contain tiny amounts.\n\n**Checked and found correct:**\n- **`MailHook` swap fee:** all four swap directions charge 4%, rounded up. The full-fill check matches how Uniswap v4 behaves with a 0% pool fee, and the 1e6-wei minimum keeps the rounding bounded.\n- **`MailHook` launch liquidity:** the liquidity formula for MAIL as the second currency is right. The 1e9 buffer covers the pool's upward rounding of what it is owed. At the ±400000 tick limits and the supply limits, the liquidity stays above zero and under the per-tick cap.\n- **`MailHook.price` / `marketCap`:** neither overflows across the whole tick range. Rounding to whole wei doesn't matter at the deploy setting ($2,000 market cap, about 3e10 wei per MAIL). The clamp at the launch price points the right way.\n- **`DeliveryRewards` budget:** the halving shifts are safe for any era. 51 days × 2 × (16.8M / 102) stays within the 16.8M play allocation. The boundary between `revokeRoot` and the claim opening is consistent at exactly `postedAt + dayLength`. Early burns are minted before burning, so they still count against the supply cap.\n- **`MailOffice` / `MessengerNFT`:** the tier and level indexes stay in bounds, the `uint8` loop can't overflow, and the stored `day + 1` \"played today\" rule holds at day boundaries. The trait rolls reuse overlapping bits of one random number, but the bias is negligible.\n- **`MessengerArt` / `tokenURI`:** the base64 joining works. Every piece is padded to a multiple of 9 bytes, the seal requires stored sizes in multiples of 16, and the JSON head is padded to a multiple of 3.\n- **`MailToken`, `Wardrobe`, `MailRouter`:** the caps, rounded-up burns and amount bounds all hold.\n\n**Outside the math scope, not followed up:** the reveal seed mixes in `blockhash` on an Arbitrum-style chain, where that value is documented as not cryptographically secure. I didn't verify it on-chain, so it isn't reported as a finding.","treeHash":null,"usage":{"cachedInputTokens":495768,"inputTokens":16,"model":"claude-opus-5-5","outputTokens":16831,"runtime":"claude","turns":13,"wallClockMs":181362}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b886845c01761aa1","findings":[{"citation":"resolved","description":"Messenger levels are stored on MessengerNFT (levelOf) and survive a change of office, but office tiers live only in the MailOffice instance that sold them (tierOf, lastUpgradeAt), as does the 0.005 ETH open gate. A fresh MailOffice installed with MessengerNFT.setOffice starts every wallet at tier 0: staminaOf, apprenticeStaminaOf and capacityOf read 0, assign reverts NotOpen, and the wallet must pay openPrice again and re-buy the whole ladder (up to 168,000 MAIL, 75% burned) at full price while the old office keeps the ETH and the burned MAIL is gone. The owner is trusted and the migration is an owner action, so this is a trust-gap rather than a permission bypass, but AUDIT.md describes setOffice only as 'changing the office never strands a Messenger' and lists what the office owner can't do ('move anyone's NFT or MAIL'); it does not say that a migration voids every paid tier and re-charges the gate. Reproduced by the judge (test/scratch/Judge.t.sol::test_OfficeMigrationLosesPaidTiers, passes on this code, i.e. the loss happens). Merged from the permissions specialist; the economics specialist reported nothing on this path. Fix options that keep the design: document the loss as a trust assumption, or let a new office import tierOf/lastUpgradeAt once per wallet from a `previousOffice`, or keep tierOf on the NFT next to levelOf.","line":49,"path":"p2e/contracts/src/MailOffice.sol","reproduction":"State: alice has Messenger #1, 100,000 MAIL and an approval for the office. alice: office.open{value: 0.005 ether}(0); office.upgrade(7_000e18); office.assign(1). Now office.tierOf(alice)==2 and office.staminaOf(alice)==14. Owner: office2 = new MailOffice(mail, nft, rewards, treasury, owner); nft.setOffice(office2). Expected (per AUDIT.md's description of setOffice): the wallet's paid state follows the player. Actual: office2.tierOf(alice)==0, office2.staminaOf(alice)==0, office2.apprenticeStaminaOf(alice)==0, office2.capacityOf(alice)==0, office2.assign(1) reverts NotOpen; after office.unassign() and office2.open{value: 0.005 ether}(0) the wallet is at tier 1 and office2.upgradeCost(2)==7_000e18 is due again; address(office).balance still holds the first 0.005 ETH and mail.totalSupply() shows the 5,250 MAIL burned by the first upgrade is not restored.","severity":"low","snippet":"    mapping(address => uint8) public tierOf;","title":"MessengerNFT.setOffice moves the lock but not the office tiers players paid for in MAIL, nor the ETH gate"},{"citation":"resolved","description":"MessengerNFT.setOffice (the contract allowed to lock any Messenger and raise levels), MessengerNFT.setTreasury (mint payments), MailOffice.setTreasury (25% of every MAIL spend plus withdrawn ETH), MailOffice.setOpenPrice (the sybil gate) and Wardrobe.setTreasury write storage with no event, while the sibling setters in the same contracts (setPrice, setMaxLevel, setTransferValidator, setUpgradeCost, setLevelCost, openTiers, list) all emit. AUDIT.md relies on the office change being 'visible on-chain', but nothing indexable marks it: a player, the game server or a monitor cannot subscribe to the change and has to poll storage. Reproduced by the judge (test/scratch/Judge.t.sol::test_SettersEmitNoEvent). Fix: emit OfficeSet(address), TreasurySet(address) and OpenPriceSet(uint256) from each setter.","line":248,"path":"p2e/contracts/src/MessengerNFT.sol","reproduction":"With owner as caller: vm.recordLogs(); nft.setOffice(address(office2)); nft.setTreasury(alice); office.setTreasury(alice); office.setOpenPrice(1); wardrobe.setTreasury(alice); vm.getRecordedLogs().length. Expected: one event per privileged state change, as the other owner setters in these contracts do. Actual: 0 logs for all five calls, while nft.office()==office2 and office.openPrice()==1 confirm the state did change.","severity":"info","snippet":"    function setOffice(address office_) external onlyOwner {","title":"Privileged setters that change where value goes or who may lock emit no event"},{"citation":"resolved","description":"The 107-test suite never reaches the OverTotal revert nor AllocationExhausted (line 136): grep for either name in p2e/contracts/test returns nothing. These are the two caps that bound minting when the poster under-declares a day's total or posts more than the 16.8M play allocation. Both are correct on this code: an early 80% claim books the full `amount` into d.claimed, so a 20% burn cannot be used to overdraw a day. But nothing pins them, so a future change to _settle or claimNow (for instance booking only the paid 80%) would silently drop a stated guarantee. Reproduced by the judge: test/scratch/Judge.t.sol::test_OverTotalReachable_EarlyClaimsCountFull passes on the current code and would be the missing test. Fix: add that scenario and a postRoot sequence that exhausts PLAY_ALLOCATION to the suite.","line":214,"path":"p2e/contracts/src/DeliveryRewards.sol","reproduction":"Deploy DeliveryRewards(mail, owner, poster, 1 days, 16_800_000e18/102, 51) as minter; warp to startTime + 1 day. Two-leaf tree: leaf(alice, 0, 100e18), leaf(bob, 0, 100e18); poster: postRoot(0, root, 150e18) (total below the leaf sum). Warp to claimableFrom(0). alice.claimNow(0, 100e18, proofA): alice receives 80e18 and days_(0).claimed == 100e18 (the full amount, not 80e18). bob.claimNow(0, 100e18, proofB): expected and actual revert OverTotal(). Covered by the existing suite: no.","severity":"info","snippet":"        if (d.claimed + amount > d.total) revert OverTotal();","title":"Guarantee 6 'claims for a day never exceed that day's total' (OverTotal) and the AllocationExhausted cap have no test"},{"citation":"resolved","description":"test_PartialFillIsRejected in test/MailHook.t.sol exercises only the exactIn == true branch of this line (an exact-in buy stopped by a price limit). The exact-out sell branch (ETH is the specified output and the seller asks for more ETH than the pool holds) is the one where an unrejected partial fill would matter most: beforeSwap already charged ceil(Y/24) on the requested Y, so a short fill would pay a fee above 4% of what the pool actually delivered. The branch is correct on this code, reproduced by the judge (test/scratch/Judge.t.sol::test_ExactOutSellPartialFillReverts passes), but nothing in the suite pins it. Fix: add that scenario next to test_PartialFillIsRejected.","line":281,"path":"p2e/contracts/src/MailHook.sol","reproduction":"Open the pool as in test/MailHook.t.sol setUp (START_MCAP 20 ether, LAUNCH 4.2M). alice: router.buy{value: 1 ether}(mail, 1, now) -> pool holds 0.96 ETH, pendingProtocolFees(ETH) == 0.04 ether. alice via PoolSwapTest: swap(key, SwapParams(zeroForOne=false, amountSpecified=+2e18, MAX_SQRT_PRICE-1)). Expected and actual: revert (MailHook.PartialFill wrapped by the PoolManager); pendingProtocolFees unchanged at 0.04 ether. Then swap(key, SwapParams(false, +0.5e18, MAX_SQRT_PRICE-1)): alice.balance rises by exactly 0.5 ether and pendingProtocolFees rises by (0.5e18 + 23) / 24.","severity":"info","snippet":"            if (exactIn ? poolQuote + fee != requested : poolQuote != requested + fee) revert PartialFill();","title":"Guarantee 17 'partial fills are rejected in every direction and mode' is only tested for exact-in buys; the exact-out sell branch is untested"}],"hash":"d3974c4ae37c6dd189637e3743778b5be19038294f62513bd7167369296731fc","nodeId":"df7cb8b8-582c-4736-a5e5-e3a00315bb1f","outcome":"completed","summary":"The review is complete and the findings file is written and validated.\n\n**Outcome.** Four findings kept, one dropped, no new defects found. All four kept findings are substantiated by the scratch test `p2e/contracts/test/scratch/Judge.t.sol`, which passes on the current code for each stated reason. The repository's own suite also passes in full.\n\n| Suite | Result |\n| --- | --- |\n| Project suite (`forge test`) | 107 passed, 0 failed, 1 skipped (fork test) |\n| Judge reproductions | 5 of 5 specialist claims reproduced |\n| Independent probes | 2 of 2 guards held |\n\n**Kept, in `.imd-findings.json`:**\n- **Low, MailOffice.sol line 49.** A new office installed with `setOffice` zeroes every wallet's paid tier and re-charges the ETH gate. The lock record and Messenger levels migrate, the tiers and gate do not, and the audit notes do not disclose this. Owner-triggered, so it is a trust gap rather than a bypass.\n- **Info, MessengerNFT.sol line 248.** Five privileged setters across the NFT, office and wardrobe emit no event. Confirmed with recorded logs: zero events for five state changes.\n- **Info, DeliveryRewards.sol line 214.** The day-total cap and the play-allocation cap are never reached by any test. The guards are correct on this code, and an early 80% claim correctly books the full amount.\n- **Info, MailHook.sol line 281.** Partial-fill rejection is only tested for exact-in buys. The exact-out sell branch is correct and was reproduced, but is unpinned.\n\n**Dropped.** The math specialist's early-claim rounding finding reproduces exactly as stated, but the audit notes already accept it in the trust assumptions as a round-down of at most 1 wei in the player's favour. It is a documented design choice, not a defect.\n\n**Merging.** No two specialists reported the same mechanism, so nothing needed merging. The flow specialist reported nothing.\n\n**Own pass.** I checked the one-character and once-per-day rules across an office migration followed by a transfer, and that the pause covers the batch claim functions. Both held. Nothing in the hook's fee accounting, the router's settlement, the token cap, the NFT sale and reveal, or the art sealing turned up a defect beyond what the specialists found.\n\n**Limits.** No critical or high finding exists, so no proof files were attached. The fork test against Robinhood Chain was not run. Everything under `test/scratch/` is mine and is not part of the submission.","treeHash":null,"usage":{"cachedInputTokens":1570265,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":27934,"runtime":"claude","turns":35,"wallClockMs":503662}}],"verification":[]}