{"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":"5ccf142c-8a7b-4d45-a88b-776b6a68286a","kind":"audit","nodes":[{"acceptedSubmissionHash":"0b0c487618dc3843a05edde4eca6ce268918aee4dad4def58be972ac782ea77d","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":"9c637d6801ac5462e2822e5c584e25676d74373986b9b7e011c248eb2773e1a0","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":"bea714c1822eb5bd6d123fc6ffdd8ec8fb41e2a5837f46acca738ff7013e2c3c","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":"020c7e8318a1c7ad21d0649d4e6e5f49c3bd17625a34c9179b90a63820e18b99","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":"4c535a98670c8e1369345cc61805d5f124b952220a2283f2279328b169a82e7d","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":"5ccf142c-8a7b-4d45-a88b-776b6a68286a","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":"52682","feedbackHash":"a8ebe4ceec04f33ee20fcda2af8eb77290acba7f8b8cf81c4305e9d1c29dfc15","nodeKey":"audit_economics","submissionHash":"0b0c487618dc3843a05edde4eca6ce268918aee4dad4def58be972ac782ea77d","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52151","feedbackHash":"3acf902919ceede7557e439cf07119cd2a05149c4a24511e3fd1a9cb9f83de9b","nodeKey":"audit_flow","submissionHash":"9c637d6801ac5462e2822e5c584e25676d74373986b9b7e011c248eb2773e1a0","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52164","feedbackHash":"2835399189103630beedf46fbc5a438abbeeab44f119411be484479547d437bb","nodeKey":"audit_judge","submissionHash":"bea714c1822eb5bd6d123fc6ffdd8ec8fb41e2a5837f46acca738ff7013e2c3c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52265","feedbackHash":"fbb550e1c301177f0022282ecaf24dfc73bc85b70cfab286c49e0144a98cebab","nodeKey":"audit_math","submissionHash":"020c7e8318a1c7ad21d0649d4e6e5f49c3bd17625a34c9179b90a63820e18b99","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52114","feedbackHash":"95b5bc14c18a0963aca8c910a1a306d6afbb844d66b0304a00ca307d9db73f1f","nodeKey":"audit_permissions","submissionHash":"4c535a98670c8e1369345cc61805d5f124b952220a2283f2279328b169a82e7d","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"903495604133c0a8ab96d36248c7eaf678afdb133d5a58f28c0f2203b6939154","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"8db4eebdd7bd2744","findings":[{"citation":"resolved","description":"Boundary x invariant seam. The invariant stated in the contract docs (lines 23-25) and in p2e/AUDIT.md guarantee 8 is that a Messenger plays for at most one wallet per reward day (the fix for IMD finding 3). `assign` only records the reward day on which the NFT JOINED a wallet (`lastAssignedDay[id] = currentDay + 1`) and `unassign` records nothing. An assignment made on day D stays live through day D+1, so wallet A plays day D+1 with the NFT's full `staminaOf` (12 + 2/level) while `lastAssignedDay[id]` still says D+1 (i.e. day D). When A calls `unassign()` and transfers the NFT during day D+1, wallet B's `assign(id)` sees `lastAssignedDay[id] == D+1 != (D+1)+1` and succeeds. `MailOffice.staminaOf(B)` then also returns the NFT's full stamina for the remainder of day D+1, and the game server (p2e/server/src/game.js `pay`, which meters `entry.paid < p.stamina` per wallet per day from `profileOf`) pays both wallets 12+ full-point deliveries from the same NFT in one reward day. The existing test `test_F3_OneMessengerOneWalletPerRewardDay` only covers the case where A re-assigns on day D+1 (which re-stamps the day); the plain unassign-and-hand-over path on the day after joining is unguarded. The comparator also only tests equality with the join day, so a Messenger assigned on day D and left assigned for a week stamps nothing for days D+1..D+6 and can be handed over on any of them.","line":159,"path":"p2e/contracts/src/MailOffice.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {MailToken} from \"src/MailToken.sol\";\nimport {MessengerNFT} from \"src/MessengerNFT.sol\";\nimport {MailOffice, IRewardDay} from \"src/MailOffice.sol\";\nimport {DeliveryRewards} from \"src/DeliveryRewards.sol\";\nimport {DevTransferValidator} from \"src/DevTransferValidator.sol\";\n\n/// A Messenger assigned on day D keeps playing for wallet A into day D+1. If A unassigns during D+1\n/// and hands the NFT to B, B may assign it in the same reward day D+1, because `assign` only compares\n/// the day the NFT last JOINED a wallet (`lastAssignedDay`) and never records the day it LEFT one.\n/// The NFT then pays full stamina for two wallets within one reward day, which is exactly what the\n/// once-per-reward-day rule (IMD audit finding 3) was meant to prevent.\ncontract AssignDayBoundaryTest is Test {\n    MailToken mail;\n    MessengerNFT nft;\n    MailOffice office;\n    DeliveryRewards rewards;\n\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    address liquidity = makeAddr(\"liquidity\");\n    address poster = makeAddr(\"poster\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n\n    uint256 constant DAILY = uint256(16_800_000e18) / 102;\n\n    function setUp() public {\n        vm.startPrank(owner);\n        mail = new MailToken(liquidity, owner);\n        nft = new MessengerNFT(\n            owner, treasury, owner, 0.01 ether, keccak256(abi.encode(bytes32(\"s\"))), address(new DevTransferValidator()), \"ipfs://m/\"\n        );\n        rewards = new DeliveryRewards(mail, owner, poster, 1 days, DAILY, 51);\n        office = new MailOffice(mail, nft, IRewardDay(address(rewards)), treasury, owner);\n        mail.setMinter(address(rewards));\n        nft.setOffice(address(office));\n        nft.ownerMint(alice, 1);\n        vm.stopPrank();\n        vm.deal(alice, 1 ether);\n        vm.deal(bob, 1 ether);\n    }\n\n    function test_MessengerServesTwoWalletsInOneRewardDay() public {\n        uint256 t0 = rewards.startTime();\n        vm.warp(t0 + 12 hours); // reward day 0\n        vm.prank(alice);\n        office.open{value: 0.005 ether}(address(0));\n        vm.prank(bob);\n        office.open{value: 0.005 ether}(address(0));\n        vm.prank(alice);\n        office.assign(1); // joins alice on day 0\n\n        // Reward day 1: alice still holds the assignment and plays with 12 full-point deliveries.\n        vm.warp(t0 + 1 days + 1 hours);\n        assertEq(rewards.currentDay(), 1);\n        assertEq(office.staminaOf(alice), 12, \"alice plays day 1 with the Messenger\");\n\n        // Later the same reward day alice releases it and hands it to bob.\n        vm.warp(t0 + 1 days + 12 hours);\n        vm.startPrank(alice);\n        office.unassign();\n        nft.transferFrom(alice, bob, 1);\n        vm.stopPrank();\n\n        // Expected: the Messenger already served a wallet in reward day 1, so joining bob must wait\n        // for day 2 (TooSoon). Actual: it joins bob in day 1 and pays him 12 more full deliveries.\n        vm.prank(bob);\n        vm.expectRevert(MailOffice.TooSoon.selector);\n        office.assign(1);\n        assertEq(office.staminaOf(bob), 0, \"the Messenger must not pay a second wallet in day 1\");\n    }\n}","reproduction":"Setup as in test/P2E.t.sol (dayLength 1 day). t = startTime + 12h (day 0): alice.open, bob.open, alice.assign(1). t = startTime + 1 day + 1h (day 1): office.staminaOf(alice) == 12 (alice plays day 1). t = startTime + 1 day + 12h (still day 1): alice.unassign(); nft.transferFrom(alice, bob, 1); bob.assign(1). Expected: revert TooSoon (NFT #1 already served a wallet in reward day 1). Actual: succeeds; office.characterOf(bob) == 1 and office.staminaOf(bob) == 12 in the same reward day in which alice was already paid for it. Fix (verified against the full suite plus this test, 40/40 pass): in `unassign` write `lastAssignedDay[id] = clock.currentDay() + 1` (the day the Messenger last served a wallet), and add `mapping(uint256 => address) lastWalletOf` set in `assign`, relaxing the check to `if (lastAssignedDay[id] == dayPlusOne && lastWalletOf[id] != msg.sender) revert TooSoon();` so the same wallet may still re-assign on the same day (as test_F3 expects) but a different wallet must wait for the next reward day.","severity":"medium","snippet":"        if (lastAssignedDay[id] == dayPlusOne) revert TooSoon();\n        lastAssignedDay[id] = dayPlusOne;","title":"assign() keys the once-per-reward-day rule on the day a Messenger joined, not the day it last served, so one NFT pays full stamina for two wallets in the same reward day"},{"citation":"resolved","description":"Boundary (sentinel-branch) defect in the fix for IMD finding 7. `reveal` (line 169) treats `saleClosedAt != 0` as proof that a sale opened and later closed, but `setSaleOpen(false)` writes `saleClosedAt = block.number` unconditionally, including when `saleOpen` is already false and no sale has ever opened. One owner call that is a no-op on `saleOpen` therefore satisfies the gate, and `reveal` in the next block fixes the seed: after that `ownerMint` and `setSaleOpen(true)` revert `AlreadyRevealed` forever, with `totalMinted == 0` and no way to ever mint the 3,333 Messengers. p2e/AUDIT.md guarantee 13 ('the reveal needs a sale that opened and closed') and the test `test_F7_NoRevealBeforeASale` (which only covers the never-closed case) both assume the gate is sound. Trigger is owner-only (a mis-click on the admin UI, or a script that 'closes' the sale defensively before launch), so this is a hardening finding, not an attack path; it is reported because it contradicts a stated guarantee that the previous audit fix was meant to establish.","line":144,"path":"p2e/contracts/src/MessengerNFT.sol","reproduction":"Fresh MessengerNFT (saleOpen false, saleClosedAt 0, totalMinted 0). Block 10: owner calls setSaleOpen(false). Actual: saleClosedAt == 10. Block 11: owner calls reveal(secret) with the right secret. Expected: revert RevealTooEarly (no sale ever opened). Actual: succeeds, seed != 0; afterwards ownerMint(owner, 1) reverts AlreadyRevealed and setSaleOpen(true) reverts AlreadyRevealed; totalMinted stays 0 permanently. Verified with a scratch Foundry test. Fix: only stamp the close when a sale is actually being closed, e.g. `if (!open && saleOpen) saleClosedAt = block.number;` (or revert when `open == saleOpen`). Optionally also require `totalMinted != 0` in `reveal`, since revealing with nothing minted can never be intended.","severity":"low","snippet":"        if (!open) saleClosedAt = block.number;","title":"setSaleOpen(false) on a sale that never opened stamps saleClosedAt, so reveal() passes its 'a sale opened and closed' gate and ends all minting with zero tokens sold"},{"citation":"resolved","description":"Sentinel-address boundary. `MailOffice`'s constructor rejects a zero treasury (`ZeroAddress`, line 86-88), but `setTreasury` (line 262-264) does not, and neither `MessengerNFT.setTreasury` (line 153-155), the `MessengerNFT` constructor (line 114) nor the `Wardrobe` constructor (line 41-44) check it at all. Two concrete consequences once `treasury == address(0)`: (1) `MailOffice.withdraw()` executes `address(0).call{value: balance}(\"\")`, which succeeds and irrecoverably burns all collected office-opening ETH; `MessengerNFT.mint` likewise forwards every buyer's `msg.value` to address(0) and the mint still succeeds, so the whole public sale's proceeds are burned while buyers receive tokens. (2) `MailOffice._spend` and `Wardrobe.buy` call `mail.transferFrom(payer, treasury, amount - burnPart)`, and OpenZeppelin's `_transfer` reverts `ERC20InvalidReceiver(address(0))`, so every `upgrade`, `levelUp` and `buy` reverts until the treasury is re-set (a liveness break, no fund loss). Owner-only trigger (a mistaken call), so hardening only; reported because the project already guards the same value in one constructor and treats the ETH proceeds as treasury revenue.","line":263,"path":"p2e/contracts/src/MailOffice.sol","reproduction":"MailOffice deployed as in test/P2E.t.sol; alice calls open{value: 0.005 ether}(address(0)) so the office holds 0.005 ETH. Owner calls office.setTreasury(address(0)) (no revert), then office.withdraw(). Expected: revert (zero treasury) or the ETH reaching a treasury. Actual: ok == true, address(this).balance == 0, address(0).balance increased by 0.005 ether (unrecoverable). Then alice, with MAIL approved, calls office.upgrade(7_000e18): actual revert ERC20InvalidReceiver(0x0) from transferFrom; expected tier 2. Same for MessengerNFT: owner setTreasury(address(0)), setSaleOpen(true); bob mint{value: 0.01 ether}(1) succeeds and the 0.01 ETH is sent to address(0). Fix: `if (t == address(0)) revert ZeroAddress();` in `MailOffice.setTreasury`, `MessengerNFT.setTreasury` and the `MessengerNFT`/`Wardrobe` constructors (Wardrobe needs the error added).","severity":"low","snippet":"        treasury = t;","title":"Treasury setters accept address(0): withdraw() and mint() then burn ETH to the zero address and every MAIL spend reverts"}],"hash":"020c7e8318a1c7ad21d0649d4e6e5f49c3bd17625a34c9179b90a63820e18b99","nodeId":"a7985976-20ce-4cb1-80de-4ce8961dca95","outcome":"completed","summary":"The review is complete. The findings file is written at the repository root with three substantiated findings, and the proof test for the top finding fails on the current code and passes with the proposed fix.\n\n**Scope and method.** I audited all five in-scope contracts under `p2e/contracts/src/` against the Math Precision, Boundary and Numerical Gap guides first, then the rest of the checklist. The suite compiled with forge-std and OpenZeppelin 5.4.0 cloned outside the repository. The existing 39 tests pass. I also fuzzed the burn rounding, the early-claim split, the halving schedule and the maturity boundary in a scratch test. Those all held, so the pure arithmetic is sound and nothing in that area is reported.\n\n**Findings in `.imd-findings.json`:**\n\n- **Medium, `p2e/contracts/src/MailOffice.sol:159`.** The once-per-reward-day rule only records the day a Messenger joined a wallet, never the day it left. A wallet that assigned on day D plays through day D+1, unassigns, hands the NFT over, and the receiver assigns it the same day. Both wallets then get the NFT's full stamina in one reward day, which is exactly what IMD finding 3 was meant to stop. Proof test under `test/scratch/AssignDayBoundary.t.sol` fails now. The proposed fix (stamp the day on unassign, and let only the same wallet re-join that day) passes all 40 tests in a throwaway copy.\n- **Low, `p2e/contracts/src/MessengerNFT.sol:144`.** Closing a sale that never opened still stamps the close block, so the reveal gate from IMD finding 7 passes and reveal permanently ends minting with zero tokens sold. Owner-triggered, but it contradicts guarantee 13.\n- **Low, `p2e/contracts/src/MailOffice.sol:263`.** Treasury setters and two constructors accept the zero address. ETH from office openings and NFT mints is then burned to address zero, and every MAIL spend reverts. Owner-triggered hardening item; the office constructor already guards the same value.\n\n**Not reported, with reasons.** The sub-5-wei early-claim burn truncating to zero is documented as accepted. Admin cost retunes are protected by the caller's maxCost. The OpenSea validator address on chain 4663 could not be verified from here and remains an unverified launch parameter.","treeHash":null,"usage":{"cachedInputTokens":1637436,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":40290,"runtime":"claude","turns":41,"wallClockMs":584190}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a0f4c3f900dedd4a","findings":[{"citation":"resolved","description":"`assign` blocks a Messenger from JOINING more than one wallet per reward day, but a Messenger that joined wallet A on an earlier day and is held across the day boundary is A's character for the whole of the next day until A calls `unassign`. Nothing records that the Messenger served a wallet today, so after `unassign` + `transferFrom` a second wallet B may `assign` it in the same reward day (its first join that day). The game server meters paid deliveries per (wallet, day) against `profileOf`/`staminaOf` read for each wallet (p2e/server/src/game.js `pay`: `entry.paid < p.stamina + p.apprenticeStamina`; p2e/server/src/chain.js `playerOf`), so A plays the Messenger's 12 (+tier) paid deliveries in the morning and B plays another 12 (+tier) in the afternoon. Alternating holder/next each day, a 12-stamina Messenger backs 24 full-point paid deliveries every day forever, for the price of one extra office (0.005 ETH) and three transactions a day. The day's budget is split pro rata by points (rules.js `rateCapPoints`), so every honest player is diluted by the doubled share; the sybil earns roughly 2x per Messenger. This is the same economic hole IMD finding 3 meant to close ('so one NFT can't be passed around to play for several wallets in a day', MailOffice.sol lines 24-25, AUDIT.md guarantee 8); the fix only moved the window.","line":159,"path":"p2e/contracts/src/MailOffice.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {MailToken} from \"src/MailToken.sol\";\nimport {MessengerNFT} from \"src/MessengerNFT.sol\";\nimport {MailOffice, IRewardDay} from \"src/MailOffice.sol\";\nimport {DeliveryRewards} from \"src/DeliveryRewards.sol\";\nimport {DevTransferValidator} from \"src/DevTransferValidator.sol\";\n\n/// One Messenger NFT is the character of two wallets within the same reward day, every day.\n/// `assign` only limits how often a Messenger JOINS a wallet per reward day; it ignores that the\n/// Messenger was already another wallet's character earlier in that same day (assigned on an\n/// earlier day and held across the day boundary). The game server meters paid deliveries per\n/// (wallet, day), so each wallet gets the Messenger's full stamina: 24 paid deliveries a day from\n/// a 12-stamina Messenger.\ncontract MessengerDoublePlayTest is Test {\n    MailToken mail;\n    MessengerNFT nft;\n    MailOffice office;\n    DeliveryRewards rewards;\n\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    address liquidity = makeAddr(\"liquidity\");\n    address poster = makeAddr(\"poster\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n\n    uint256 constant DAILY = uint256(16_800_000e18) / 102;\n\n    function setUp() public {\n        vm.startPrank(owner);\n        mail = new MailToken(liquidity, owner);\n        nft = new MessengerNFT(\n            owner, treasury, owner, 0.01 ether, keccak256(abi.encode(keccak256(\"s\"))), address(new DevTransferValidator()), \"ipfs://m/\"\n        );\n        rewards = new DeliveryRewards(mail, owner, poster, 1 days, DAILY, 51);\n        office = new MailOffice(mail, nft, IRewardDay(address(rewards)), treasury, owner);\n        mail.setMinter(address(rewards));\n        nft.setOffice(address(office));\n        nft.ownerMint(alice, 1);\n        vm.stopPrank();\n        vm.deal(alice, 1 ether);\n        vm.deal(bob, 1 ether);\n        vm.prank(alice);\n        office.open{value: 0.005 ether}(address(0));\n        vm.prank(bob);\n        office.open{value: 0.005 ether}(address(0));\n    }\n\n    /// Day 0: alice assigns. Day 1 (mid-day): alice has already had the Messenger's stamina for day 1,\n    /// hands it to bob, and bob assigns it in the same reward day: two wallets, one Messenger, one day.\n    function test_OneMessengerIsTwoWalletsCharacterInOneRewardDay() public {\n        uint256 t0 = rewards.startTime();\n        vm.warp(t0 + 12 hours);\n        vm.prank(alice);\n        office.assign(1);\n        assertEq(office.staminaOf(alice), 12);\n\n        vm.warp(t0 + 1 days + 12 hours); // reward day 1; alice is still playing Messenger #1\n        assertEq(rewards.currentDay(), 1);\n        assertEq(office.characterOf(alice), 1);\n        assertEq(office.staminaOf(alice), 12); // alice's day-1 stamina comes from #1\n\n        vm.startPrank(alice);\n        office.unassign();\n        nft.transferFrom(alice, bob, 1);\n        vm.stopPrank();\n\n        // Expected: #1 already served a wallet in reward day 1, so it can't serve a second one today.\n        vm.prank(bob);\n        vm.expectRevert();\n        office.assign(1);\n    }\n\n    /// The pattern repeats indefinitely: every reward day the Messenger serves the wallet that held it\n    /// overnight and then a second wallet, so a 12-stamina Messenger backs 24 paid deliveries a day.\n    function test_DoublePlayRepeatsEveryDay() public {\n        uint256 t0 = rewards.startTime();\n        vm.warp(t0 + 12 hours);\n        vm.prank(alice);\n        office.assign(1);\n\n        address holder = alice;\n        address next = bob;\n        for (uint256 day = 1; day <= 5; ++day) {\n            vm.warp(t0 + day * 1 days + 12 hours);\n            // `holder` has had the Messenger's stamina for this reward day.\n            assertEq(office.staminaOf(holder), 12);\n            vm.startPrank(holder);\n            office.unassign();\n            nft.transferFrom(holder, next, 1);\n            vm.stopPrank();\n            vm.prank(next);\n            (bool ok,) = address(office).call(abi.encodeCall(MailOffice.assign, (1)));\n            // Expected: the second wallet of the day can't take the Messenger's stamina too.\n            assertFalse(ok, \"a second wallet got the Messenger's stamina in the same reward day\");\n            if (!ok) return;\n            (holder, next) = (next, holder);\n        }\n    }\n}","reproduction":"State: alice and bob each `open` an office; alice owns Messenger #1. t = startTime + 12h (day 0): alice `assign(1)`. t = startTime + 36h (day 1): `staminaOf(alice) == 12` (alice is playing #1 in day 1); alice `unassign()`, `transferFrom(alice, bob, 1)`; bob `assign(1)`. Expected: revert (TooSoon), #1 already served a wallet in reward day 1. Actual: succeeds, `characterOf(bob) == 1`, `staminaOf(bob) == 12`, so both alice and bob are credited 12 paid deliveries from #1 on day 1. Repeating with the roles swapped on day 2, 3, ... succeeds every day (see test_DoublePlayRepeatsEveryDay). Fix: record the reward day on `unassign` (e.g. `lastAssignedDay[id] = clock.currentDay() + 1`, or a separate `lastServedDay[id]`) so a Messenger that was any wallet's character during the current reward day cannot join another wallet before the next day; adjust test_F3 accordingly if same-wallet re-assign on the same day should stay allowed.","severity":"medium","snippet":"        if (lastAssignedDay[id] == dayPlusOne) revert TooSoon();","title":"One Messenger NFT still plays for two wallets in the same reward day (assign only limits joins, not service)"},{"citation":"resolved","description":"AUDIT.md names `revokeRoot` 'before anyone has claimed from it (e.g. a leaked key)' as the mitigation for a wrong or malicious root, but nothing delays claims after `postRoot`: `claimNow` is open the moment the root lands. Two unprivileged amplifiers make the mitigation unreachable. (1) Leaked poster key: the holder posts a root allocating the whole budget of a closed day to themselves and calls `claimNow` in the same transaction (or the same block), so `days_[day].claimed != 0` before the owner can act; 80% of that day's budget (about 131,765 MAIL at launch) is minted irrevocably per unposted closed day. (2) Honest poster bug: if the server posts a day with a wrong split, any over-allocated player who claims immediately (a race the public ledger invites) freezes the root; the day can't be re-posted (`RootExists`) and the under-allocated players' share of that day is lost for good. Because `postRoot` and `claimNow` are both callable in one block there is no state in which the documented 'revoke before claims' guarantee can be relied on.","line":130,"path":"p2e/contracts/src/DeliveryRewards.sol","reproduction":"State: day 0 closed (block.timestamp >= startTime + 1 days), no root for day 0, attacker holds the poster key (or the poster posts a wrong root). Tx: `postRoot(0, R, DAILY)` where R = leaf(attacker, 0, DAILY); then in the same block `claimNow(0, DAILY, [])` from attacker. Result: attacker receives 0.8 * DAILY = 131,764.7 MAIL, `days_[0].claimed == DAILY`. Owner then calls `pause()` and `revokeRoot(0)`. Expected (per AUDIT.md mitigations): the bad root is withdrawn. Actual: `revokeRoot(0)` reverts `AlreadyClaimedFrom`, and `postRoot(0, ...)` reverts `RootExists`; the day's budget is spent. Fix: store the posting day in `Day` (e.g. `postedDay = currentDay()`) and have `_settle` revert while `currentDay() <= postedDay` (or until a short delay has passed), so every root has at least one reward day in which `revokeRoot` works; alternatively let the owner revoke a root while paused even after claims, subtracting what was already claimed.","severity":"low","snippet":"        if (days_[day].claimed != 0) revert AlreadyClaimedFrom();","title":"revokeRoot has no window: a root can be claimed from in the same transaction it is posted, so a bad root can never be withdrawn"},{"citation":"resolved","description":"The MailOffice constructor rejects a zero treasury, but `setTreasury` does not, and `_spend` sends the 25% remainder with `mail.transferFrom(from, treasury, amount - burnPart)`, which reverts in OpenZeppelin ERC20 (`ERC20InvalidReceiver`) when `treasury == address(0)`. The same applies to `Wardrobe.setTreasury` (Wardrobe.sol line 82) and the Wardrobe constructor, which checks neither `mail_` nor `treasury_`. A fat-fingered zero (or a treasury contract that cannot hold ERC20) halts all MAIL sinks until corrected; no funds are lost, which is why this is informational rather than a vulnerability. The ETH `withdraw` path would likewise send the office's ETH to address(0) and burn it.","line":263,"path":"p2e/contracts/src/MailOffice.sol","reproduction":"State: alice has an open office, 200,000 MAIL and an unlimited approval to the office. Owner calls `setTreasury(address(0))` (succeeds). alice calls `upgrade(7_000e18)`. Expected: either `setTreasury` rejects the zero address or the upgrade still works. Actual: `upgrade` reverts with `ERC20InvalidReceiver(0x0)` from `transferFrom`; `levelUp` and `Wardrobe.buy` revert the same way after `Wardrobe.setTreasury(address(0))`; `withdraw()` would send the collected ETH to address(0). Fix: `if (t == address(0)) revert ZeroAddress();` in both setters and zero checks in the Wardrobe constructor.","severity":"info","snippet":"        treasury = t;","title":"setTreasury accepts address(0) and then every upgrade, levelUp and Wardrobe purchase reverts"}],"hash":"0b0c487618dc3843a05edde4eca6ce268918aee4dad4def58be972ac782ea77d","nodeId":"e420ba71-4f37-4556-bc7c-368328e046f6","outcome":"completed","summary":"Both confirmations pass, so all three findings are substantiated. The review is complete and `.imd-findings.json` holds the results. The only repository change is that findings file; the contracts were built and tested in a temporary copy outside the tree.\n\n**Outcome: 3 findings, one medium with a Foundry proof, one low, one informational.**\n\n- **Medium, MailOffice.sol:159.** One Messenger NFT still plays for two wallets in the same reward day. `assign` only limits how often an NFT joins a wallet per day. An NFT assigned on an earlier day serves wallet A all morning, then after `unassign` and a transfer, wallet B assigns it the same day as its first join. The server meters paid deliveries per wallet per day, so a 12-stamina Messenger backs 24 paid deliveries every day by alternating holders. This is the economic hole the earlier audit's finding 3 meant to close. The attached proof test fails on the current code and passes once `unassign` records the reward day.\n- **Low, DeliveryRewards.sol:130.** `revokeRoot` has no usable window. A root can be posted and claimed from in the same block, so a leaked poster key or a wrong server root can never be withdrawn. One day's budget is affected per incident. Fix: block claims until the reward day after posting.\n- **Info, MailOffice.sol:263.** `setTreasury` accepts the zero address, after which every upgrade, level-up and Wardrobe purchase reverts. Wardrobe has the same gap in its setter and constructor. No funds are lost.\n\n**What I verified and found sound.** MAIL cap accounting including the minted-then-burned early-claim share, the daily budget and halving sum against the 16.8M allocation, per-root claim flags, maturity boundaries, the 75% burn rounding, tier and level caps, the upgrade cooldown, NFT lock coupling across office migration, and Merkle leaf domain separation. I also confirmed on Robinhood Chain that the planned transfer validator address holds OpenSea's strict registry: holder transfers pass and unauthorized operators revert, so royalty enforcement behaves as stated.\n\n**Limits.** The game server and client were read only to substantiate the medium finding and remain out of scope. No fuzzing or static analysis ran beyond the project's 39 tests and my scratch tests.","treeHash":null,"usage":{"cachedInputTokens":1697178,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":40214,"runtime":"claude","turns":37,"wallClockMs":632036}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"918f8261a6fd589c","findings":[{"citation":"resolved","description":"Trust gap (access x asymmetry). AUDIT.md lists `revokeRoot` before claims, together with pause and poster rotation, as the mitigation for a wrong or malicious root, and the NatSpec on revokeRoot says it \"withdraws a bad root before anyone has claimed from it (e.g. a leaked key)\". But `claimNow` (DeliveryRewards.sol:157) has no delay after `postRoot`: the only time gate in the contract is `maturesAt` on the *full* claim, and the early path pays 80% at once. So (a) whoever holds the poster key can drive it from a contract that calls `postRoot` and `claimNow` in the same transaction; `days_[day].claimed` is already non-zero when the owner's `revokeRoot` lands and it reverts `AlreadyClaimedFrom`; pausing afterwards changes nothing because the tokens were minted. (b) In the honest case, a root the server posted by mistake (wrong split) is locked in by the first player who claims, which any player can do in the very next block, before the owner can pause and revoke. The owner can only revoke if it pauses *before* the server posts, which defeats the purpose. The accepted limit in AUDIT.md (\"a leaked poster key can misallocate the budgets of closed days that have no root yet, until the owner pauses and rotates it\") assumes a reaction window that does not exist; the exposure is one full day's budget (about 164,706 MAIL at launch) per closed day without a root, of which 80% is paid to the poster-controlled leaf, irreversibly. Fix: give the owner a real window, e.g. record `postedAt[day]` in `postRoot` and make `_settle` revert while `block.timestamp < postedAt[day] + DISPUTE_WINDOW` (an hour or a few hours), or keep `claimNow` but require that a root be at least one reward day old. Both are design changes to the claim-now promise (\"claim now\" becomes \"claim after the dispute window\"); the scope decision is the requester's. Alternatively, drop the claim from the mitigation list and state that a bad root is final once posted.","line":130,"path":"p2e/contracts/src/DeliveryRewards.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {MailToken} from \"src/MailToken.sol\";\nimport {DeliveryRewards} from \"src/DeliveryRewards.sol\";\n\n/// The poster can post a root and claim from it in the same transaction, so there is no moment at which\n/// `revokeRoot` (\"withdraws a bad root before anyone has claimed from it, e.g. a leaked key\") can run.\ncontract RoguePoster {\n    DeliveryRewards immutable rewards;\n\n    constructor(DeliveryRewards r) {\n        rewards = r;\n    }\n\n    /// Posts a root paying the whole budget to this contract and claims it at once (80% paid, 20% burned).\n    function postAndClaim(uint256 day, uint256 total) external {\n        bytes32 root = rewards.leaf(address(this), day, total); // single-leaf tree: root == leaf\n        rewards.postRoot(day, root, total);\n        bytes32[] memory proof = new bytes32[](0);\n        // Low-level so the test also passes if the fix makes an immediate claim revert.\n        address(rewards).call(abi.encodeWithSelector(DeliveryRewards.claimNow.selector, day, total, proof));\n    }\n}\n\ncontract AtomicPostAndClaimTest is Test {\n    MailToken mail;\n    DeliveryRewards rewards;\n    RoguePoster poster;\n    address owner = makeAddr(\"owner\");\n    address liquidity = makeAddr(\"liquidity\");\n    uint256 constant DAILY = uint256(16_800_000e18) / 102;\n\n    function setUp() public {\n        mail = new MailToken(liquidity, owner);\n        // The poster key is held by a contract (any leaked EOA key can deploy one and drive it the same way).\n        address posterAddr = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1);\n        rewards = new DeliveryRewards(mail, owner, posterAddr, 1 days, DAILY, 51);\n        poster = new RoguePoster(rewards);\n        assertEq(address(poster), posterAddr);\n        vm.prank(owner);\n        mail.setMinter(address(rewards));\n    }\n\n    function test_OwnerCanStillRevokeARootRightAfterItWasPosted() public {\n        vm.warp(rewards.startTime() + 1 days + 1); // day 0 has closed\n        poster.postAndClaim(0, DAILY);\n\n        // Expected: the owner, reacting to the bad root, can withdraw it and nothing was minted from it.\n        // Actual: the claim already went through in the posting transaction and `revokeRoot` reverts\n        // AlreadyClaimedFrom; 80% of the day's budget is in the poster's hands.\n        vm.prank(owner);\n        rewards.revokeRoot(0);\n        assertEq(mail.balanceOf(address(poster)), 0, \"nothing may be claimed before the owner could react\");\n    }\n}","reproduction":"State: DeliveryRewards deployed with poster = a contract (or an EOA whose key leaked, which deploys one), day 0 closed. Input: the poster contract calls postRoot(0, leaf(poster, 0, dailyBudget(0)), dailyBudget(0)) and claimNow(0, dailyBudget(0), []) in one transaction. Expected (per NatSpec and AUDIT.md): the owner can call revokeRoot(0) before any claim and nothing is minted from the bad root. Actual: 80% of the day's budget (131,764 MAIL at launch) is minted to the poster in the posting transaction, 20% is burned, and revokeRoot(0) reverts AlreadyClaimedFrom. The scratch test AtomicPostAndClaim.t.sol reproduces this and fails on the current code with AlreadyClaimedFrom().","severity":"low","snippet":"        if (days_[day].claimed != 0) revert AlreadyClaimedFrom();","title":"revokeRoot has no window: a root can be posted and claimed from in one transaction, so the documented 'withdraw a bad root before anyone has claimed' mitigation never applies"},{"citation":"resolved","description":"Access control / asymmetry between the open and close branches. AUDIT.md guarantee 13 says \"the reveal needs a sale that opened and closed\", and the comment at reveal() (line 168) says \"saleClosedAt is 0 until a sale has opened and closed\". Neither is enforced: `setSaleOpen(false)` writes `saleClosedAt = block.number` unconditionally, including when `saleOpen` was already false, i.e. when no sale ever opened. One block later `reveal(secret)` passes every check on line 169 (`saleOpen` false, `saleClosedAt != 0`, `block.number > saleClosedAt`), fixes the seed, and from then on `ownerMint` reverts AlreadyRevealed and `setSaleOpen(true)` reverts AlreadyRevealed: the collection is sealed with 0 (or only owner-minted) tokens and the public sale can never happen, which is exactly the outcome finding 7 of the previous audit was meant to exclude. The existing test_F7_NoRevealBeforeASale only checks reveal before any setSaleOpen call. Fix: stamp the close only when a sale is actually being closed, e.g. `if (!open && saleOpen) saleClosedAt = block.number;` evaluated before `saleOpen = open`, or keep an explicit `saleEverOpened` flag that `reveal` requires.","line":144,"path":"p2e/contracts/src/MessengerNFT.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {MessengerNFT} from \"src/MessengerNFT.sol\";\nimport {DevTransferValidator} from \"src/DevTransferValidator.sol\";\n\n/// AUDIT.md guarantee 13: \"the reveal needs a sale that opened and closed\". `setSaleOpen(false)` with no\n/// sale ever opened still stamps `saleClosedAt`, so `reveal` goes through and ends all minting.\ncontract RevealWithoutSaleTest is Test {\n    MessengerNFT nft;\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    bytes32 constant SECRET = keccak256(\"messenger reveal secret\");\n\n    function setUp() public {\n        vm.startPrank(owner);\n        nft = new MessengerNFT(\n            owner, treasury, address(0xBEEF), 0.01 ether, keccak256(abi.encode(SECRET)), address(new DevTransferValidator()), \"ipfs://m/\"\n        );\n        vm.stopPrank();\n    }\n\n    function test_RevealNeedsASaleThatActuallyOpened() public {\n        vm.startPrank(owner);\n        // Never opened: this call is a no-op on `saleOpen` but still stamps `saleClosedAt`.\n        nft.setSaleOpen(false);\n        vm.roll(block.number + 1);\n        vm.expectRevert(MessengerNFT.RevealTooEarly.selector);\n        nft.reveal(SECRET);\n        vm.stopPrank();\n\n        // Minting must still be possible: no sale has happened yet.\n        assertEq(nft.seed(), 0, \"seed fixed before any sale\");\n        vm.prank(owner);\n        nft.ownerMint(owner, 1);\n        assertEq(nft.totalMinted(), 1);\n    }\n}","reproduction":"State: fresh MessengerNFT, sale never opened, seed == 0. Input: owner calls setSaleOpen(false), then after one block reveal(secret) with the committed secret. Expected: reveal reverts RevealTooEarly because no sale has opened and closed. Actual: reveal succeeds, seed != 0, and ownerMint / setSaleOpen(true) now revert AlreadyRevealed forever. The scratch test RevealWithoutSale.t.sol fails on the current code with 'next call did not revert as expected'.","severity":"low","snippet":"        if (!open) saleClosedAt = block.number;","title":"setSaleOpen(false) before any sale stamps saleClosedAt, so reveal() succeeds without a sale ever opening (guarantee 13 and the finding-7 fix do not hold)"},{"citation":"resolved","description":"Asymmetry between sibling constructors and setters. MailOffice's constructor (MailOffice.sol:86) reverts ZeroAddress on a zero treasury; MessengerNFT's constructor stores `treasury_` unchecked and `setTreasury` (line 153) likewise, and Wardrobe's constructor (Wardrobe.sol:42) and `setTreasury` are unchecked too. The consequence differs by contract: in MailOffice and Wardrobe a zero treasury makes `_spend`/`buy` revert (OpenZeppelin's ERC20 rejects transfers to address(0)), so upgrades and purchases are blocked until it is fixed, which is loud and recoverable. In MessengerNFT the sale keeps working: `mint` (line 130) does `treasury.call{value: msg.value}(\"\")`, a low-level call to an address with no code, which returns true, so every mint succeeds and the ETH is sent to address(0) and lost. `MailOffice.withdraw` (line 267) has the same silent-loss shape if `setTreasury(0)` was called. Guarantee 12 (\"the public sale ... forwards it to the treasury\") is not met in that state. Fix: reject address(0) in MessengerNFT's constructor and setTreasury (and in Wardrobe's and MailOffice's setTreasury for consistency), matching the check MailOffice's constructor already does.","line":114,"path":"p2e/contracts/src/MessengerNFT.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {MessengerNFT} from \"src/MessengerNFT.sol\";\nimport {DevTransferValidator} from \"src/DevTransferValidator.sol\";\n\n/// MailOffice's constructor rejects a zero treasury; MessengerNFT's constructor and `setTreasury` do not.\n/// With `treasury == address(0)`, `mint` forwards every buyer's ETH to the zero address: the low-level\n/// call succeeds (no code there) and the sale proceeds are destroyed instead of reaching the project.\ncontract ZeroTreasuryTest is Test {\n    address owner = makeAddr(\"owner\");\n    bytes32 constant SECRET = keccak256(\"messenger reveal secret\");\n\n    function test_NFTRejectsZeroTreasuryInsteadOfBurningSaleProceeds() public {\n        vm.startPrank(owner);\n        address validator = address(new DevTransferValidator());\n        // Expected: deployment with a zero treasury reverts, like MailOffice's constructor does.\n        vm.expectRevert();\n        new MessengerNFT(owner, address(0), address(0xBEEF), 0.01 ether, keccak256(abi.encode(SECRET)), validator, \"ipfs://m/\");\n\n        MessengerNFT nft = new MessengerNFT(\n            owner, makeAddr(\"treasury\"), address(0xBEEF), 0.01 ether, keccak256(abi.encode(SECRET)), validator, \"ipfs://m/\"\n        );\n        // Expected: the treasury can't be pointed at the zero address later either.\n        vm.expectRevert();\n        nft.setTreasury(address(0));\n        vm.stopPrank();\n    }\n}","reproduction":"State: MessengerNFT deployed with treasury_ = address(0) (or setTreasury(address(0)) later), sale open, price 0.01 ETH. Input: a buyer calls mint{value: 0.01 ether}(1). Expected: the deployment or setter reverts so this state can't exist, or the mint reverts because the proceeds can't be forwarded. Actual: the mint succeeds, address(0)'s balance rises by 0.01 ETH, the project never receives it. The scratch test ZeroTreasury.t.sol asserts the constructor and setTreasury reject address(0); it fails on the current code with 'next call did not revert as expected'.","severity":"low","snippet":"        treasury = treasury_;","title":"MessengerNFT accepts a zero treasury (constructor and setTreasury) while MailOffice rejects it; mint() then forwards every buyer's ETH to address(0) and the call succeeds"},{"citation":"resolved","description":"The once-per-reward-day rule exists so one NFT cannot play for two wallets in one day (audit finding 3), but it is keyed only on the token, not on who last assigned it. `unassign` clears characterOf/assignedBy and unlocks, yet leaves lastAssignedDay; the same wallet's `assign` on the same day then reverts TooSoon. A player who unassigns by mistake, or to let the Apprentice play briefly, loses their Messenger's stamina for the rest of the day; the server-side per-wallet `paid` counter already prevents any extra stamina from re-assigning. If this is intended, document it in AUDIT.md guarantee 8 (currently worded as an anti-pass-around rule); otherwise record the last assignee and only reject a *different* wallet on the same day.","line":159,"path":"p2e/contracts/src/MailOffice.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {MailToken} from \"src/MailToken.sol\";\nimport {MessengerNFT} from \"src/MessengerNFT.sol\";\nimport {MailOffice, IRewardDay} from \"src/MailOffice.sol\";\nimport {DeliveryRewards} from \"src/DeliveryRewards.sol\";\nimport {DevTransferValidator} from \"src/DevTransferValidator.sol\";\n\n/// `assign` and `unassign` are not symmetric: after an `unassign`, the same wallet can't re-assign its own\n/// Messenger until the next reward day, although the anti-pass-around rule only needs to stop a second wallet.\ncontract ReassignSameWalletTest is Test {\n    MailToken mail;\n    MessengerNFT nft;\n    MailOffice office;\n    DeliveryRewards rewards;\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n\n    function setUp() public {\n        vm.startPrank(owner);\n        mail = new MailToken(owner, owner);\n        nft = new MessengerNFT(owner, treasury, address(0xBEEF), 0.01 ether, bytes32(uint256(1)), address(new DevTransferValidator()), \"ipfs://m/\");\n        rewards = new DeliveryRewards(mail, owner, owner, 1 days, 1e18, 51);\n        office = new MailOffice(mail, nft, IRewardDay(address(rewards)), treasury, owner);\n        nft.setOffice(address(office));\n        nft.ownerMint(alice, 1);\n        vm.stopPrank();\n        vm.deal(alice, 1 ether);\n        vm.deal(bob, 1 ether);\n    }\n\n    function test_SameWalletCanReassignItsOwnMessengerTheSameDay() public {\n        vm.startPrank(alice);\n        office.open{value: 0.005 ether}(address(0));\n        office.assign(1);\n        office.unassign(); // e.g. a mis-click, or to let the Apprentice play for a moment\n        // Expected: alice plays her own Messenger again. Actual: TooSoon until the next reward day.\n        office.assign(1);\n        assertEq(office.characterOf(alice), 1);\n        assertTrue(nft.locked(1));\n        office.unassign();\n        vm.stopPrank();\n\n        // The rule that matters still holds: a second wallet can't take the same Messenger today.\n        vm.prank(alice);\n        nft.transferFrom(alice, bob, 1);\n        vm.startPrank(bob);\n        office.open{value: 0.005 ether}(address(0));\n        vm.expectRevert(MailOffice.TooSoon.selector);\n        office.assign(1);\n        vm.stopPrank();\n    }\n}","reproduction":"State: alice has an open office and owns Messenger 1, reward day d. Input: alice calls assign(1), unassign(), assign(1) within day d. Expected (if the rule is anti-pass-around only): the second assign succeeds. Actual: TooSoon. The scratch test ReassignSameWallet.t.sol fails on the current code with TooSoon() and also checks that a second wallet (bob) is still rejected the same day.","severity":"info","snippet":"        if (lastAssignedDay[id] == dayPlusOne) revert TooSoon();","title":"assign/unassign asymmetry: after unassign, the same wallet cannot re-assign its own Messenger until the next reward day (TooSoon)"},{"citation":"resolved","description":"Access-control inventory note. The lock branch records `lockedBy[id] = msg.sender`; the unlock branch accepts either the locking office or whatever `office` currently is. AUDIT.md's trust assumption says a malicious office \"could lock or level any Messenger (and unlock the ones it locked)\"; actually a newly set office can unlock any locked token, which deletes `lockedBy` while the old MailOffice still has `characterOf[wallet] = id` and `assignedBy[id] = wallet`. The token then becomes transferable while the old office still reports it as that wallet's character (stamina), and if the holder sells it the old wallet can no longer `unassign` (the old office is neither `office` nor `lockedBy`), so it is stuck with a character it doesn't own. This requires the NFT owner to point `office` at a contract that calls setLocked(id,false) for tokens it didn't lock, so it is a trust assumption rather than a bypass; the honest MailOffice only unlocks its own characterOf. Either tighten the unlock to `msg.sender == lockedBy[id]` only (the current office never needs to unlock a token it didn't lock) or correct the AUDIT.md wording.","line":220,"path":"p2e/contracts/src/MessengerNFT.sol","reproduction":"State: office1 locked token 1 for alice (assign); owner calls setOffice(office2) where office2 is any contract that calls messengers.setLocked(1, false). Input: office2 unlocks token 1. Expected per AUDIT.md: NotOffice (only office1 can unlock what it locked). Actual: unlock succeeds, lockedBy[1] is deleted; office1.characterOf(alice) still equals 1 and office1.staminaOf(alice) still counts the token; after alice transfers token 1 to bob, alice's office1.unassign() reverts NotOffice (office1 is neither office nor lockedBy) and alice can never assign another Messenger in office1.","severity":"info","snippet":"            if (msg.sender != office && msg.sender != lockedBy[id]) revert NotOffice();","title":"The current office can unlock Messengers locked by a previous office, wider than the documented 'unlock the ones it locked'"}],"hash":"4c535a98670c8e1369345cc61805d5f124b952220a2283f2279328b169a82e7d","nodeId":"d8cd4719-a5b4-4964-a9c2-1632a4c01326","outcome":"completed","summary":"The review is complete. The findings are in `.imd-findings.json` at the repository root, and the four reproduction tests are under `p2e/contracts/test/scratch/`. No contract or configuration file was changed.\n\n**Outcome: no critical or high defect in the permission model.** The five contracts in scope hold their documented guards on every state-changing entry point I inventoried: minting is confined to the one-shot minter, roots to the poster, locks and levels to the office, and every owner power matches the admin table in the audit brief. I found three low and two informational issues, all at the seams between paired functions or between the code and its stated guarantees.\n\n- **Low, DeliveryRewards.sol:130.** The revoke-before-claims mitigation has no window. A poster can post a root and call the early claim in the same transaction, so 80% of a day's budget is minted before the owner can act and `revokeRoot` then reverts. An honest wrong root is likewise locked in by the first player who claims. Fixing this needs a dispute window on claims, which is a design decision.\n- **Low, MessengerNFT.sol:144.** Calling `setSaleOpen(false)` before any sale stamps the close block, so `reveal` succeeds without a sale ever opening and seals the collection. This is the gap left by the previous audit's finding 7 fix and contradicts guarantee 13.\n- **Low, MessengerNFT.sol:114.** The NFT accepts a zero treasury in the constructor and setter while MailOffice rejects it. With a zero treasury the sale keeps succeeding and every buyer's ETH is forwarded to the zero address.\n- **Info, MailOffice.sol:159.** After `unassign`, the same wallet cannot re-assign its own Messenger until the next reward day. The anti-pass-around rule only needs to stop a second wallet.\n- **Info, MessengerNFT.sol:220.** The current office can unlock tokens locked by a previous office, wider than the brief's \"unlock the ones it locked\". It only matters if the owner installs an office that does so, but it leaves the old office's records pointing at a transferable token.\n\nEach finding names the failing state and input, and the first four carry a Foundry test that fails on the current code and passes under a minimal fix, which I checked in an out-of-tree copy. Checks I ran: the full existing suite passes on the pinned commit, and the scratch tests fail there for the stated reasons. Not run: Slither and long fuzzing, which the task does not provide. The game server and the local validator stayed out of scope except where I confirmed the server meters paid deliveries per wallet per day, so swapping characters within a day yields no extra stamina on-chain or off.","treeHash":null,"usage":{"cachedInputTokens":1619535,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":41363,"runtime":"claude","turns":36,"wallClockMs":645430}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"cdeffb0cd839cbf7","findings":[{"citation":"resolved","description":"AUDIT.md guarantee 13 says the reveal needs a sale that opened and closed, and the comment at MessengerNFT.sol:168 says saleClosedAt is 0 until a sale has opened and closed. Neither is enforced: setSaleOpen(false) does not check that the sale is open, so calling it on the fresh contract (saleOpen == false) writes saleClosedAt = block.number. One block later reveal(secret) passes the `saleOpen || saleClosedAt == 0 || block.number <= saleClosedAt` guard with zero tokens sold. After that `seed != 0`, so setSaleOpen(true) reverts AlreadyRevealed and ownerMint reverts AlreadyRevealed: none of the 3,333 Messengers can ever be minted, and the office/levels economy that depends on them has no supply. This is owner-only (two owner calls, close-then-reveal, in the wrong order), so it is a bricking footgun rather than a theft path, but it is exactly the state the IMD finding-7 fix (RevealTooEarly) was meant to exclude and the existing test test_F7_NoRevealBeforeASale does not cover it (it only tries reveal with saleClosedAt still 0). Fix: only stamp the close block when a sale is actually open, e.g. `if (!open && saleOpen) saleClosedAt = block.number;` (or require `totalMinted > 0` in reveal). The proof test fails on the current code and passes with that one-line change; the existing 39 tests still pass with it.","line":144,"path":"p2e/contracts/src/MessengerNFT.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {MessengerNFT} from \"src/MessengerNFT.sol\";\nimport {DevTransferValidator} from \"src/DevTransferValidator.sol\";\n\n/// Guarantee 13 (AUDIT.md): \"the reveal needs a sale that opened and closed\". The inline comment at\n/// MessengerNFT.sol:168 says saleClosedAt is 0 until a sale has opened and closed. Neither holds:\n/// setSaleOpen(false) on a sale that was never open stamps saleClosedAt, so reveal() succeeds with\n/// no sale ever opened, and from then on nothing can be minted (sale can't reopen, ownerMint reverts).\ncontract RevealWithoutSaleTest is Test {\n    MessengerNFT nft;\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    bytes32 constant SECRET = keccak256(\"messenger reveal secret\");\n\n    function setUp() public {\n        vm.prank(owner);\n        nft = new MessengerNFT(owner, treasury, makeAddr(\"dev\"), 0.01 ether, keccak256(abi.encode(SECRET)), address(new DevTransferValidator()), \"ipfs://m/\");\n    }\n\n    function test_RevealNeedsASaleThatOpenedAndClosed() public {\n        vm.startPrank(owner);\n        assertFalse(nft.saleOpen());\n        nft.setSaleOpen(false); // the sale was never open; this must not count as \"opened and closed\"\n        vm.roll(block.number + 1);\n        // Expected (guarantee 13): RevealTooEarly, since no sale has ever opened.\n        // Actual on the current code: reveal succeeds and the collection can never be minted.\n        vm.expectRevert(MessengerNFT.RevealTooEarly.selector);\n        nft.reveal(SECRET);\n        vm.stopPrank();\n        assertEq(nft.seed(), 0);\n    }\n}","reproduction":"Fresh MessengerNFT (saleOpen=false, saleClosedAt=0, totalMinted=0). Owner: setSaleOpen(false) -> saleClosedAt = current block (expected: no-op/revert, sale was never open). vm.roll(+1). Owner: reveal(SECRET) -> expected RevealTooEarly (guarantee 13); actual: succeeds, seed != 0. Then setSaleOpen(true) -> AlreadyRevealed, ownerMint(owner,1) -> AlreadyRevealed, totalMinted stays 0 forever. Run: forge test --match-path test/scratch/RevealWithoutSale.t.sol (fails now; passes with the guard `!open && saleOpen`).","severity":"low","snippet":"        if (!open) saleClosedAt = block.number;","title":"reveal() works with no sale ever opened: setSaleOpen(false) stamps saleClosedAt on a sale that was never open, then minting is dead forever"},{"citation":"resolved","description":"MailOffice's constructor reverts ZeroAddress for a zero treasury, but setTreasury stores any value. With treasury == address(0), _spend's `mail.transferFrom(from, treasury, amount - burnPart)` reverts inside OpenZeppelin ERC20 with ERC20InvalidReceiver(address(0)), so upgrade() and levelUp() revert for every player until the owner sets a non-zero treasury again (ETH withdraw() also sends the office's balance to address(0) and burns it). The same asymmetry exists in Wardrobe (constructor at Wardrobe.sol:43 and setTreasury at Wardrobe.sol:82 take zero; buy() then reverts the same way), in MessengerNFT.setTreasury (MessengerNFT.sol:154: mint() then forwards every buyer's ETH to address(0), silently burning sale proceeds; the scratch test shows 0.01 ETH landing at address(0)), and in DeliveryRewards.setPoster (DeliveryRewards.sol:193 accepts zero although the constructor rejects it, which silently disables postRoot). All are owner-only and reversible except the burned ETH, so impact is operational: a one-wrong-argument transaction halts the MAIL sinks or loses mint revenue. Fix: `if (t == address(0)) revert ZeroAddress();` in each setter (and in Wardrobe's constructor), matching the checks the constructors already make.","line":263,"path":"p2e/contracts/src/MailOffice.sol","reproduction":"Setup: alice has an open office and MAIL approved. Owner calls office.setTreasury(address(0)) (succeeds). alice calls office.upgrade(7_000e18): expected tier 2 with 75% burned / 25% to treasury; actual revert ERC20InvalidReceiver(0x0). Same with wardrobe.setTreasury(0) then buy(1,1,100e18) -> ERC20InvalidReceiver. nft.setTreasury(0); setSaleOpen(true); alice mint{value:0.01 ether}(1) -> succeeds and address(0).balance grows by 0.01 ether. rewards.setPoster(0) -> accepted, poster()==0. Verified with test/scratch/TreasuryZero.t.sol (4 passing tests that assert the wrong behaviour).","severity":"low","snippet":"        treasury = t;","title":"setTreasury(address(0)) is accepted and bricks every MAIL spend (upgrade, levelUp) until reset; constructors reject zero but the setters do not"},{"citation":"resolved","description":"Trust-assumption note, not a bypass. reveal() derives the seed from the owner's secret and blockhash(block.number - 1), both known to the owner at the start of the block it reveals in, and ownerMint is still allowed in that block (it only blocks once seed != 0). So after closing the sale the owner can compute the exact seed, scan ids totalMinted+1..3333 for the rarest traits (e.g. the 2% Golden Letter, which unlocks every job type), ownerMint exactly up to that id and then reveal, in one block or one bundle. The AUDIT.md accepted limit only says the owner 'could choose among a few candidate seeds'; it does not state that the owner can mint tokens with known traits after the sale. Buyers remain unable to influence or predict the seed, so guarantee 13 holds for them. If this power is not intended, a minimal fix that keeps ownerMint for giveaways is to forbid ownerMint once saleClosedAt != 0 (minting only while a sale has never closed), or to bind the seed to a block fixed at close time (blockhash(saleClosedAt + 1), revealed within 256 blocks) so no minting can happen after the seed is determined.","line":136,"path":"p2e/contracts/src/MessengerNFT.sol","reproduction":"Owner: setSaleOpen(true); setSaleOpen(false); roll one block. Compute predicted = keccak256(abi.encode(SECRET, blockhash(block.number - 1))) | 1; find the first id with (keccak256(abi.encode(predicted, id)) >> 64) % 1000 >= 980; ownerMint(owner, id); reveal(SECRET). Result: seed == predicted and traitsOf(id).carry == 4 (Golden Letter), chosen before the on-chain reveal. Verified with test/scratch/OwnerKnowsSeed.t.sol.","severity":"info","snippet":"        if (seed != 0) revert AlreadyRevealed(); // traits are knowable after reveal: no more minting","title":"Owner can compute the reveal seed before calling reveal() and ownerMint ids with chosen traits in the same block (power beyond the documented 'choose among a few seeds')"},{"citation":"resolved","description":"Ownable2Step (OpenZeppelin 5.4) keeps Ownable.renounceOwnership() as a single-step call; none of the five contracts override it, and no test exercises it. On MailOffice the only way ETH leaves the contract is withdraw(), which is onlyOwner, so a renounce (accidental or a compromised owner key) permanently locks every 0.005 ETH office fee collected since the last withdraw, and freezes costs, tiers and the treasury address. On DeliveryRewards a renounce removes pause/unpause, revokeRoot and poster rotation, which are the documented mitigations for a leaked poster key. This is an owner footgun rather than a user-facing bypass. Fix: override renounceOwnership to revert (or make withdraw() permissionless since it already pays only the treasury).","line":266,"path":"p2e/contracts/src/MailOffice.sol","reproduction":"Office with 3 open wallets holds 0.015 ETH. Owner calls office.renounceOwnership() (succeeds, owner()==address(0)). Any later office.withdraw() reverts OwnableUnauthorizedAccount; setTreasury/setOpenPrice/openTiers also revert. Expected: an ownerless office should still be able to pay the treasury, or renounce should be impossible.","severity":"info","snippet":"    function withdraw() external onlyOwner {","title":"renounceOwnership() is reachable on every Ownable2Step contract; on MailOffice it strands collected office ETH because withdraw() is onlyOwner"}],"hash":"9c637d6801ac5462e2822e5c584e25676d74373986b9b7e011c248eb2773e1a0","nodeId":"eec0d5b5-1bb8-4f4c-8529-7c9b478225c0","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds four substantiated findings. No source files were changed; the only new paths are the findings file and `test/scratch/`.\n\n**What I reviewed.** All six contracts in `p2e/contracts/src`, traced against the Execution Trace, Periphery and First Principles checks: every state-changing entry point and its guard, the reveal state machine, the lock/assign/unassign coupling between the office and the NFT, the Merkle claim paths including revoke and repost, the budget and cap arithmetic, and the server's leaf encoding and one-leaf-per-account rule. The existing 39 tests pass. The core guarantees in AUDIT.md hold: supply cap, one minter, per-root claim flags, maturity and early-burn math, 75% burn rounding, lock enforcement on transfers, and the day-based assign limit.\n\n**Findings, by severity:**\n\n- **Low, with proof:** `setSaleOpen(false)` on a sale that was never open stamps the close block, so the owner can `reveal` with zero tokens sold. After that the sale can never reopen and `ownerMint` reverts, leaving all 3,333 Messengers unmintable. This contradicts guarantee 13 and the fix for the earlier IMD finding 7. The scratch test fails on the current code and passes with a one-line guard. See `p2e/contracts/src/MessengerNFT.sol:144`.\n- **Low:** `setTreasury(address(0))` is accepted by the office, Wardrobe and NFT, though the office constructor rejects it. Every upgrade, level-up and cosmetic purchase then reverts inside the token transfer, and NFT mint proceeds are sent to the zero address. The rewards contract's `setPoster` has the same gap. See `p2e/contracts/src/MailOffice.sol:263`.\n- **Info:** The owner can compute the reveal seed one block after closing the sale and `ownerMint` ids with chosen traits before revealing. This is broader than the accepted limit written in AUDIT.md. Reproduced in a scratch test.\n- **Info:** `renounceOwnership` is reachable and untested. On the office it permanently strands collected ETH, since `withdraw` is owner-only.\n\n**Not reported.** The office-migration edge where a new office could unlock a token the old one locked needs a non-standard office contract, so it is a trust assumption, not a defect. The transfer-validator address and ERC721-C registration on Robinhood Chain could not be verified offline and remain an open deployment item.","treeHash":null,"usage":{"cachedInputTokens":1648877,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":43920,"runtime":"claude","turns":34,"wallClockMs":695081}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"63458a1bd2b2d376","findings":[{"citation":"resolved","description":"MailOffice.assign stores lastAssignedDay[id] = currentDay()+1 and rejects a join only when that value equals today's stamp. unassign() deletes characterOf/assignedBy and unlocks the token but never records that the Messenger served a wallet today. A Messenger assigned on day D is wallet A's character through day D+1 (staminaOf(A) returns its full 12 + tier stamina), and when A unassigns during D+1 and transfers it, wallet B's assign(id) sees lastAssignedDay[id] == D+1 != D+2 and succeeds, so B also gets staminaOf(B) == 12 in day D+1. The game server meters paid deliveries per (wallet, day) from profileOf (p2e/server/src/game.js pay: entry.paid < p.stamina + p.apprenticeStamina), so one 12-stamina Messenger backs 24 full-point paid deliveries in one reward day, and alternating the two wallets repeats it every day (confirmed for 5 consecutive days). Each day's budget is split pro rata by points, so every honest player is diluted by the doubled share; the cost to the sybil is one extra office (0.005 ETH) and three transactions a day. This is the hole IMD finding 3 meant to close (contract doc lines 23-25: 'so one NFT can't be passed around to play for several wallets in a day'; AUDIT.md guarantee 8); the fix only moved the window to the join day, and test_F3 covers only the case where A re-assigns on day D+1 (which re-stamps it). Merged from audit_economics b7252a98 and audit_math 1c36ea7c (same mechanism, same line). Fix (verified: both supplied proofs pass and all 39 existing tests still pass): in unassign() write lastAssignedDay[id] = clock.currentDay() + 1, add mapping(uint256 => address) lastWalletOf set in assign(), and relax the check to `if (lastAssignedDay[id] == dayPlusOne && lastWalletOf[id] != msg.sender) revert TooSoon();` so the same wallet may re-assign on the same day (as test_F3 expects) but a different wallet must wait for the next reward day.","line":159,"path":"p2e/contracts/src/MailOffice.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {MailToken} from \"src/MailToken.sol\";\nimport {MessengerNFT} from \"src/MessengerNFT.sol\";\nimport {MailOffice, IRewardDay} from \"src/MailOffice.sol\";\nimport {DeliveryRewards} from \"src/DeliveryRewards.sol\";\nimport {DevTransferValidator} from \"src/DevTransferValidator.sol\";\n\n/// One Messenger NFT is the character of two wallets within the same reward day, every day.\n/// `assign` only limits how often a Messenger JOINS a wallet per reward day; it ignores that the\n/// Messenger was already another wallet's character earlier in that same day (assigned on an\n/// earlier day and held across the day boundary). The game server meters paid deliveries per\n/// (wallet, day), so each wallet gets the Messenger's full stamina: 24 paid deliveries a day from\n/// a 12-stamina Messenger.\ncontract MessengerDoublePlayTest is Test {\n    MailToken mail;\n    MessengerNFT nft;\n    MailOffice office;\n    DeliveryRewards rewards;\n\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    address liquidity = makeAddr(\"liquidity\");\n    address poster = makeAddr(\"poster\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n\n    uint256 constant DAILY = uint256(16_800_000e18) / 102;\n\n    function setUp() public {\n        vm.startPrank(owner);\n        mail = new MailToken(liquidity, owner);\n        nft = new MessengerNFT(\n            owner, treasury, owner, 0.01 ether, keccak256(abi.encode(keccak256(\"s\"))), address(new DevTransferValidator()), \"ipfs://m/\"\n        );\n        rewards = new DeliveryRewards(mail, owner, poster, 1 days, DAILY, 51);\n        office = new MailOffice(mail, nft, IRewardDay(address(rewards)), treasury, owner);\n        mail.setMinter(address(rewards));\n        nft.setOffice(address(office));\n        nft.ownerMint(alice, 1);\n        vm.stopPrank();\n        vm.deal(alice, 1 ether);\n        vm.deal(bob, 1 ether);\n        vm.prank(alice);\n        office.open{value: 0.005 ether}(address(0));\n        vm.prank(bob);\n        office.open{value: 0.005 ether}(address(0));\n    }\n\n    /// Day 0: alice assigns. Day 1 (mid-day): alice has already had the Messenger's stamina for day 1,\n    /// hands it to bob, and bob assigns it in the same reward day: two wallets, one Messenger, one day.\n    function test_OneMessengerIsTwoWalletsCharacterInOneRewardDay() public {\n        uint256 t0 = rewards.startTime();\n        vm.warp(t0 + 12 hours);\n        vm.prank(alice);\n        office.assign(1);\n        assertEq(office.staminaOf(alice), 12);\n\n        vm.warp(t0 + 1 days + 12 hours); // reward day 1; alice is still playing Messenger #1\n        assertEq(rewards.currentDay(), 1);\n        assertEq(office.characterOf(alice), 1);\n        assertEq(office.staminaOf(alice), 12); // alice's day-1 stamina comes from #1\n\n        vm.startPrank(alice);\n        office.unassign();\n        nft.transferFrom(alice, bob, 1);\n        vm.stopPrank();\n\n        // Expected: #1 already served a wallet in reward day 1, so it can't serve a second one today.\n        vm.prank(bob);\n        vm.expectRevert();\n        office.assign(1);\n    }\n\n    /// The pattern repeats indefinitely: every reward day the Messenger serves the wallet that held it\n    /// overnight and then a second wallet, so a 12-stamina Messenger backs 24 paid deliveries a day.\n    function test_DoublePlayRepeatsEveryDay() public {\n        uint256 t0 = rewards.startTime();\n        vm.warp(t0 + 12 hours);\n        vm.prank(alice);\n        office.assign(1);\n\n        address holder = alice;\n        address next = bob;\n        for (uint256 day = 1; day <= 5; ++day) {\n            vm.warp(t0 + day * 1 days + 12 hours);\n            // `holder` has had the Messenger's stamina for this reward day.\n            assertEq(office.staminaOf(holder), 12);\n            vm.startPrank(holder);\n            office.unassign();\n            nft.transferFrom(holder, next, 1);\n            vm.stopPrank();\n            vm.prank(next);\n            (bool ok,) = address(office).call(abi.encodeCall(MailOffice.assign, (1)));\n            // Expected: the second wallet of the day can't take the Messenger's stamina too.\n            assertFalse(ok, \"a second wallet got the Messenger's stamina in the same reward day\");\n            if (!ok) return;\n            (holder, next) = (next, holder);\n        }\n    }\n}","reproduction":"Setup as test/P2E.t.sol (dayLength 1 day); alice and bob each open an office, alice owns Messenger #1. t = startTime + 12h (day 0): alice.assign(1). t = startTime + 1 day + 12h (day 1): rewards.currentDay() == 1 and office.staminaOf(alice) == 12 (alice is credited the Messenger's stamina for day 1). Then alice.unassign(); nft.transferFrom(alice, bob, 1); bob.assign(1). Expected: revert TooSoon (#1 already served a wallet in reward day 1). Actual: succeeds, office.characterOf(bob) == 1 and office.staminaOf(bob) == 12 in the same reward day. Repeating with roles swapped on days 2..5 succeeds each day. Run: forge test --match-path test/scratch/Proof_b7252a982bea.t.sol (both tests fail on the current code: 'next call did not revert as expected' and 'a second wallet got the Messenger's stamina in the same reward day'; both pass with the fix above).","severity":"medium","snippet":"        if (lastAssignedDay[id] == dayPlusOne) revert TooSoon();","title":"One Messenger NFT plays for two wallets in the same reward day: assign() keys the once-per-day rule on the join day only and unassign() records nothing"},{"citation":"resolved","description":"revokeRoot reverts AlreadyClaimedFrom once days_[day].claimed != 0, and claimNow (line 157) has no delay after postRoot: the only time gate is maturesAt on the full claim, while the early path pays 80% at once. AUDIT.md lists 'revokeRoot before claims' as a mitigation for a wrong or malicious root and the NatSpec says it 'withdraws a bad root before anyone has claimed from it (e.g. a leaked key)', but there is no state in which that holds against an adversary: (a) whoever holds the poster key can drive it from a contract that calls postRoot and then claimNow in one transaction, so 80% of the day's budget (131,764.7 MAIL at launch, per closed day without a root) is minted to the poster's leaf before the owner can act and revokeRoot/postRoot for that day revert AlreadyClaimedFrom/RootExists for good; (b) in the honest case, a root the server posted with a wrong split is locked in by the first over-allocated player who claims in the next block, and the under-allocated players' share of that day is lost. The accepted limit in AUDIT.md ('until the owner pauses and rotates it') assumes a reaction window that does not exist. Merged from audit_economics 38c2a707 and audit_permissions e6ca794d (same mechanism, same line). Fix options, both design changes the requester must decide: record the posting day in Day and have _settle revert while currentDay() <= postedDay (or until a short dispute window has passed), which gives every root at least one reward day in which revokeRoot works (verified: the supplied proof passes, but 4 existing tests that claimNow right after posting then need a one-day warp); or let the owner revoke a root while paused even after claims, subtracting what was already claimed; or drop the claim from the mitigation list and state that a root is final once posted.","line":130,"path":"p2e/contracts/src/DeliveryRewards.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {MailToken} from \"src/MailToken.sol\";\nimport {DeliveryRewards} from \"src/DeliveryRewards.sol\";\n\n/// The poster can post a root and claim from it in the same transaction, so there is no moment at which\n/// `revokeRoot` (\"withdraws a bad root before anyone has claimed from it, e.g. a leaked key\") can run.\ncontract RoguePoster {\n    DeliveryRewards immutable rewards;\n\n    constructor(DeliveryRewards r) {\n        rewards = r;\n    }\n\n    /// Posts a root paying the whole budget to this contract and claims it at once (80% paid, 20% burned).\n    function postAndClaim(uint256 day, uint256 total) external {\n        bytes32 root = rewards.leaf(address(this), day, total); // single-leaf tree: root == leaf\n        rewards.postRoot(day, root, total);\n        bytes32[] memory proof = new bytes32[](0);\n        // Low-level so the test also passes if the fix makes an immediate claim revert.\n        address(rewards).call(abi.encodeWithSelector(DeliveryRewards.claimNow.selector, day, total, proof));\n    }\n}\n\ncontract AtomicPostAndClaimTest is Test {\n    MailToken mail;\n    DeliveryRewards rewards;\n    RoguePoster poster;\n    address owner = makeAddr(\"owner\");\n    address liquidity = makeAddr(\"liquidity\");\n    uint256 constant DAILY = uint256(16_800_000e18) / 102;\n\n    function setUp() public {\n        mail = new MailToken(liquidity, owner);\n        // The poster key is held by a contract (any leaked EOA key can deploy one and drive it the same way).\n        address posterAddr = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1);\n        rewards = new DeliveryRewards(mail, owner, posterAddr, 1 days, DAILY, 51);\n        poster = new RoguePoster(rewards);\n        assertEq(address(poster), posterAddr);\n        vm.prank(owner);\n        mail.setMinter(address(rewards));\n    }\n\n    function test_OwnerCanStillRevokeARootRightAfterItWasPosted() public {\n        vm.warp(rewards.startTime() + 1 days + 1); // day 0 has closed\n        poster.postAndClaim(0, DAILY);\n\n        // Expected: the owner, reacting to the bad root, can withdraw it and nothing was minted from it.\n        // Actual: the claim already went through in the posting transaction and `revokeRoot` reverts\n        // AlreadyClaimedFrom; 80% of the day's budget is in the poster's hands.\n        vm.prank(owner);\n        rewards.revokeRoot(0);\n        assertEq(mail.balanceOf(address(poster)), 0, \"nothing may be claimed before the owner could react\");\n    }\n}","reproduction":"State: DeliveryRewards with poster = a contract (any leaked EOA key can deploy and drive one), day 0 closed (block.timestamp >= startTime + 1 days). Input: the poster contract calls postRoot(0, leaf(poster, 0, dailyBudget(0)), dailyBudget(0)) and claimNow(0, dailyBudget(0), []) in one transaction. Expected (NatSpec, AUDIT.md): the owner can revokeRoot(0) before any claim and nothing is minted from the bad root. Actual: 131,764.7 MAIL (80% of 164,705.9) is minted to the poster in the posting transaction, 20% burned, and revokeRoot(0) reverts AlreadyClaimedFrom. Honest-poster variant (test/scratch/Judge.t.sol test_RevokeBlockedByImmediateClaim): poster EOA posts a single-leaf root for alice, alice calls claimNow in the same block, then revokeRoot(0) reverts AlreadyClaimedFrom and postRoot(0, ...) reverts RootExists. Run: forge test --match-path test/scratch/Proof_e6ca794da741.t.sol (fails on the current code with AlreadyClaimedFrom()).","severity":"low","snippet":"        if (days_[day].claimed != 0) revert AlreadyClaimedFrom();","title":"revokeRoot has no window: a root can be posted and claimed from in the same transaction, so the documented 'withdraw a bad root before anyone has claimed' mitigation can never be relied on"},{"citation":"resolved","description":"reveal (line 169) treats saleClosedAt != 0 as proof that a sale opened and later closed (comment at line 168: 'saleClosedAt is 0 until a sale has opened and closed'), but setSaleOpen(false) writes saleClosedAt = block.number unconditionally, including when saleOpen is already false and no sale ever opened. One owner call that is a no-op on saleOpen therefore satisfies the gate, and reveal in the next block fixes the seed; after that ownerMint and setSaleOpen(true) revert AlreadyRevealed forever with totalMinted == 0, so none of the 3,333 Messengers can ever be minted and the office/levels economy has no supply. This contradicts AUDIT.md guarantee 13 and is the state the IMD finding-7 fix (RevealTooEarly) was meant to exclude; test_F7_NoRevealBeforeASale only covers reveal with saleClosedAt still 0. Owner-only trigger (a defensive 'close' before launch, or two admin calls in the wrong order), so a bricking footgun rather than an attack path. Merged from audit_flow e29512c6, audit_math dcaf75da and audit_permissions 30c485f0. Fix: stamp the close only when a sale is actually being closed, `if (!open && saleOpen) saleClosedAt = block.number;` evaluated before `saleOpen = open;` (verified: the supplied proof passes and all 39 existing tests still pass); optionally also require totalMinted != 0 in reveal.","line":144,"path":"p2e/contracts/src/MessengerNFT.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {MessengerNFT} from \"src/MessengerNFT.sol\";\nimport {DevTransferValidator} from \"src/DevTransferValidator.sol\";\n\n/// Guarantee 13 (AUDIT.md): \"the reveal needs a sale that opened and closed\". The inline comment at\n/// MessengerNFT.sol:168 says saleClosedAt is 0 until a sale has opened and closed. Neither holds:\n/// setSaleOpen(false) on a sale that was never open stamps saleClosedAt, so reveal() succeeds with\n/// no sale ever opened, and from then on nothing can be minted (sale can't reopen, ownerMint reverts).\ncontract RevealWithoutSaleTest is Test {\n    MessengerNFT nft;\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    bytes32 constant SECRET = keccak256(\"messenger reveal secret\");\n\n    function setUp() public {\n        vm.prank(owner);\n        nft = new MessengerNFT(owner, treasury, makeAddr(\"dev\"), 0.01 ether, keccak256(abi.encode(SECRET)), address(new DevTransferValidator()), \"ipfs://m/\");\n    }\n\n    function test_RevealNeedsASaleThatOpenedAndClosed() public {\n        vm.startPrank(owner);\n        assertFalse(nft.saleOpen());\n        nft.setSaleOpen(false); // the sale was never open; this must not count as \"opened and closed\"\n        vm.roll(block.number + 1);\n        // Expected (guarantee 13): RevealTooEarly, since no sale has ever opened.\n        // Actual on the current code: reveal succeeds and the collection can never be minted.\n        vm.expectRevert(MessengerNFT.RevealTooEarly.selector);\n        nft.reveal(SECRET);\n        vm.stopPrank();\n        assertEq(nft.seed(), 0);\n    }\n}","reproduction":"Fresh MessengerNFT (saleOpen false, saleClosedAt 0, totalMinted 0). Owner calls setSaleOpen(false): actual saleClosedAt == block.number (expected: no-op or revert, the sale was never open). vm.roll(+1). Owner calls reveal(SECRET) with the committed secret. Expected: revert RevealTooEarly (guarantee 13). Actual: succeeds, seed != 0; afterwards setSaleOpen(true) and ownerMint(owner, 1) revert AlreadyRevealed and totalMinted stays 0 forever. Run: forge test --match-path test/scratch/Proof_e29512c6f268.t.sol (fails on the current code with 'next call did not revert as expected'; passes with the one-line guard).","severity":"low","snippet":"        if (!open) saleClosedAt = block.number;","title":"setSaleOpen(false) on a sale that never opened stamps saleClosedAt, so reveal() passes its 'a sale opened and closed' gate and ends all minting with zero tokens sold"},{"citation":"resolved","description":"MailOffice's constructor reverts ZeroAddress on a zero treasury (line 86), but MailOffice.setTreasury stores any value, and MessengerNFT.setTreasury (line 153), the MessengerNFT constructor (line 114), Wardrobe.setTreasury (line 82) and the Wardrobe constructor (line 42) check nothing. DeliveryRewards.setPoster (line 192) likewise accepts address(0) although its constructor rejects it, which silently disables postRoot. Consequences once treasury == address(0): (1) MailOffice.withdraw executes address(0).call{value: balance}(''), which succeeds and irrecoverably burns all collected office-opening ETH; MessengerNFT.mint forwards every buyer's msg.value to address(0) and the mint still succeeds, so the public sale's proceeds are burned while buyers receive tokens (guarantee 12 'forwards it to the treasury' not met). (2) MailOffice._spend and Wardrobe.buy call mail.transferFrom(payer, treasury, amount - burnPart), which OpenZeppelin's ERC20 rejects with ERC20InvalidReceiver(address(0)), so every upgrade, levelUp and buy reverts until the treasury is re-set (liveness only, recoverable). Owner-only trigger (one wrong argument), so hardening; reported because the project already guards the same value in one constructor and the ETH loss is permanent. Merged from audit_flow 4f7f0242, audit_economics 58f68b2c, audit_math b9b66f1d and audit_permissions b5b593a1. Fix: `if (t == address(0)) revert ZeroAddress();` in MailOffice.setTreasury, MessengerNFT.setTreasury, Wardrobe.setTreasury (add the error to Wardrobe), the MessengerNFT and Wardrobe constructors, and DeliveryRewards.setPoster.","line":263,"path":"p2e/contracts/src/MailOffice.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {MailToken} from \"src/MailToken.sol\";\nimport {MessengerNFT} from \"src/MessengerNFT.sol\";\nimport {MailOffice, IRewardDay} from \"src/MailOffice.sol\";\nimport {DeliveryRewards} from \"src/DeliveryRewards.sol\";\nimport {DevTransferValidator} from \"src/DevTransferValidator.sol\";\nimport {Wardrobe} from \"src/Wardrobe.sol\";\n\n/// The treasury setters accept address(0) although MailOffice's constructor rejects it. With a zero\n/// treasury, MailOffice.withdraw and MessengerNFT.mint forward ETH to address(0) (a successful call that\n/// burns the funds) and every MAIL spend (upgrade, levelUp, Wardrobe.buy) reverts in the ERC20 with\n/// ERC20InvalidReceiver. Expected: each setter rejects address(0), as the constructor does.\ncontract ZeroTreasuryTest is Test {\n    MailToken mail;\n    MessengerNFT nft;\n    MailOffice office;\n    DeliveryRewards rewards;\n    Wardrobe wardrobe;\n\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    address liquidity = makeAddr(\"liquidity\");\n    address poster = makeAddr(\"poster\");\n    uint256 constant DAILY = uint256(16_800_000e18) / 102;\n\n    function setUp() public {\n        vm.startPrank(owner);\n        mail = new MailToken(liquidity, owner);\n        nft = new MessengerNFT(owner, treasury, owner, 0.01 ether, keccak256(abi.encode(keccak256(\"s\"))), address(new DevTransferValidator()), \"ipfs://m/\");\n        rewards = new DeliveryRewards(mail, owner, poster, 1 days, DAILY, 51);\n        office = new MailOffice(mail, nft, IRewardDay(address(rewards)), treasury, owner);\n        wardrobe = new Wardrobe(mail, treasury, owner, \"ipfs://w/{id}.json\");\n        vm.stopPrank();\n    }\n\n    function test_OfficeSetTreasuryRejectsZero() public {\n        vm.prank(owner);\n        vm.expectRevert();\n        office.setTreasury(address(0));\n        assertEq(office.treasury(), treasury);\n    }\n\n    function test_NftSetTreasuryRejectsZero() public {\n        vm.prank(owner);\n        vm.expectRevert();\n        nft.setTreasury(address(0));\n        assertEq(nft.treasury(), treasury);\n    }\n\n    function test_WardrobeSetTreasuryRejectsZero() public {\n        vm.prank(owner);\n        vm.expectRevert();\n        wardrobe.setTreasury(address(0));\n        assertEq(wardrobe.treasury(), treasury);\n    }\n}","reproduction":"Setup as test/P2E.t.sol; alice has an open office (office holds 0.005 ETH), 200,000 MAIL and an unlimited approval; wardrobe item 1 listed at 100 MAIL. (a) owner: office.setTreasury(address(0)) succeeds; alice: office.upgrade(7_000e18) -> expected tier 2, actual revert ERC20InvalidReceiver(0x0). (b) owner: office.withdraw() -> ok, office balance 0, address(0).balance grows by 0.005 ether. (c) owner: wardrobe.setTreasury(address(0)); alice: buy(1,1,100e18) -> revert ERC20InvalidReceiver(0x0). (d) owner: nft.setTreasury(address(0)); setSaleOpen(true); bob: mint{value: 0.01 ether}(1) succeeds, bob owns token 3, address(0).balance grows by 0.01 ether. (e) new MessengerNFT(..., treasury_ = address(0), ...) and new Wardrobe(mail, address(0), ...) deploy; rewards.setPoster(address(0)) succeeds and poster() == 0. All five observed in test/scratch/Judge.t.sol (test_ZeroTreasury_*, test_SetPosterZeroAccepted). Proof: forge test --match-path test/scratch/ZeroTreasury.t.sol (3 tests expecting the setters to reject address(0) fail on the current code with 'next call did not revert as expected' and pass with the zero checks added).","severity":"low","snippet":"        treasury = t;","title":"Treasury setters (and the MessengerNFT/Wardrobe constructors) accept address(0): withdraw() and mint() then burn ETH to the zero address and every MAIL spend reverts"},{"citation":"resolved","description":"Trust-assumption note, not a bypass. reveal derives the seed from the owner's secret and blockhash(block.number - 1), both known to the owner at the start of the reveal block, and ownerMint is still allowed in that block because it only blocks once seed != 0. After closing the sale the owner can compute the exact seed, scan ids totalMinted+1..3333 for the rarest traits (the 2% Golden Letter unlocks every job type on the game server), ownerMint exactly up to that id and reveal in the same block or bundle. AUDIT.md's accepted limit only says the owner 'could choose among a few candidate seeds'; it does not say the owner can mint tokens with known traits after the sale. Buyers still cannot influence or predict the seed, so guarantee 13 holds for them. If this power is not intended, a minimal fix that keeps giveaways is to forbid ownerMint once saleClosedAt != 0, or to bind the seed to a block fixed at close time (blockhash(saleClosedAt + 1), revealed within 256 blocks) so no minting can happen after the seed is determined; otherwise widen the AUDIT.md wording. From audit_flow 7eab2195.","line":136,"path":"p2e/contracts/src/MessengerNFT.sol","reproduction":"Setup as test/P2E.t.sol (2 tokens owner-minted to alice). Owner: setSaleOpen(true); setSaleOpen(false); roll 10 blocks. Compute predicted = keccak256(abi.encode(SECRET, blockhash(block.number - 1))) | 1; find the first id > totalMinted with (keccak256(abi.encode(predicted, id)) >> 64) % 1000 >= 980; ownerMint(owner, id - totalMinted); reveal(SECRET). Result: nft.seed() == predicted and nft.traitsOf(id).carry == 4 (Golden Letter), chosen before the on-chain reveal. Observed in test/scratch/Judge.t.sol test_OwnerPredictsSeedAndMintsGoldenLetter.","severity":"info","snippet":"        if (seed != 0) revert AlreadyRevealed(); // traits are knowable after reveal: no more minting","title":"Owner can compute the reveal seed before calling reveal() and ownerMint ids with chosen traits in the same block, wider than the documented 'choose among a few candidate seeds'"},{"citation":"resolved","description":"Ownable2Step (OpenZeppelin 5.4) keeps Ownable.renounceOwnership as a single-step call; none of the five contracts override it and no test exercises it. On MailOffice the only way ETH leaves is withdraw(), which is onlyOwner, so a renounce (accidental, or by a compromised owner key) permanently locks every 0.005 ETH office fee collected since the last withdraw and freezes costs, tiers and the treasury address. On DeliveryRewards a renounce removes pause/unpause, revokeRoot and poster rotation, which AUDIT.md lists as the mitigations for a leaked poster key. Owner footgun, not a user-facing bypass. Fix: override renounceOwnership to revert, or make withdraw() permissionless since it already pays only the treasury. From audit_flow 8a16e550.","line":266,"path":"p2e/contracts/src/MailOffice.sol","reproduction":"Office holds 0.005 ETH from alice's open(). Owner calls office.renounceOwnership(): succeeds, owner() == address(0). Owner (or anyone) then calls office.withdraw(): reverts OwnableUnauthorizedAccount; setTreasury/setOpenPrice/openTiers revert the same way, so the ETH is stuck. Owner calls rewards.renounceOwnership() then rewards.pause(): reverts OwnableUnauthorizedAccount. Expected: an ownerless office can still pay the treasury, or renounce is impossible. Observed in test/scratch/Judge.t.sol test_RenounceStrandsOfficeEth.","severity":"info","snippet":"    function withdraw() external onlyOwner {","title":"renounceOwnership() is reachable on every Ownable2Step contract; on MailOffice it strands collected office ETH because withdraw() is onlyOwner"},{"citation":"resolved","description":"The once-per-reward-day rule exists so one NFT cannot play for two wallets in one day, but it is keyed only on the token, not on who last assigned it. unassign clears characterOf/assignedBy and unlocks, yet leaves lastAssignedDay, so the same wallet's assign on the same day reverts TooSoon. A player who unassigns by mistake, or briefly to let the Apprentice play, loses the Messenger's stamina for the rest of the day; the server-side per-wallet paid counter already prevents any extra stamina from re-assigning. Distinct from the medium double-play finding (that one is too lax across wallets; this one is too strict within a wallet) but the same root cause and the same fix resolves both: record lastWalletOf[id] in assign and only reject a different wallet on the same day (verified: with that fix this scenario succeeds while bob is still rejected the same day). If the strictness is intended, document it in AUDIT.md guarantee 8, which is worded as an anti-pass-around rule. From audit_permissions f49bf6f4.","line":168,"path":"p2e/contracts/src/MailOffice.sol","reproduction":"alice has an open office and owns Messenger 1; t = startTime + 12h (day 0). alice calls assign(1), unassign(), assign(1) in the same reward day. Expected (anti-pass-around rule only): the second assign succeeds. Actual: the second assign reverts TooSoon. Observed in test/scratch/Judge.t.sol test_SameWalletReassignSameDayTooSoon.","severity":"info","snippet":"    function unassign() external nonReentrant {","title":"After unassign(), the same wallet cannot re-assign its own Messenger until the next reward day (TooSoon), because the rule is keyed on the token only"},{"citation":"resolved","description":"The lock branch records lockedBy[id] = msg.sender; the unlock branch accepts either the locking office or whatever office currently is. AUDIT.md's trust assumption says a malicious office 'could lock or level any Messenger (and unlock the ones it locked)'; actually a newly set office can unlock any locked token, which deletes lockedBy while the old MailOffice still has characterOf[wallet] = id and assignedBy[id] = wallet. The token becomes transferable while the old office still reports it as that wallet's character (staminaOf), and once the holder sells it the old wallet can no longer unassign (the old office is neither office nor lockedBy, so setLocked(id, false) reverts NotOffice), leaving it with a characterOf it does not own and unable to assign another Messenger in that office. Requires the NFT owner to point office at a contract that unlocks tokens it did not lock, so this is a trust assumption rather than a bypass; the honest MailOffice only unlocks its own characterOf. Fix: tighten the unlock branch to msg.sender == lockedBy[id] (the current office never needs to unlock a token it did not lock), or correct the AUDIT.md wording. From audit_permissions ddff43bc.","line":220,"path":"p2e/contracts/src/MessengerNFT.sol","reproduction":"alice opens an office and assigns token 1 (lockedBy[1] == office). Owner calls nft.setOffice(U) where U is any contract that calls messengers.setLocked(1, false). U.unlock(1): expected per AUDIT.md NotOffice; actual succeeds, locked[1] false, lockedBy[1] == 0, while office.characterOf(alice) == 1 and office.staminaOf(alice) == 12. alice transfers token 1 to bob (succeeds, no longer locked), then alice calls office.unassign(): reverts NotOffice; office.characterOf(alice) stays 1. Observed in test/scratch/Judge.t.sol test_NewOfficeUnlocksOldOfficeLock.","severity":"info","snippet":"            if (msg.sender != office && msg.sender != lockedBy[id]) revert NotOffice();","title":"The current office can unlock Messengers locked by a previous office, wider than the documented 'unlock the ones it locked', and that strands the old office's assignment"}],"hash":"bea714c1822eb5bd6d123fc6ffdd8ec8fb41e2a5837f46acca738ff7013e2c3c","nodeId":"26a3b626-26ca-4f6b-8a25-8983770b5b4b","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds 8 findings, all reproduced against the pinned code.\n\n**What I did.** Since `lib/` is absent and off limits, I built a copy of the contracts in a temp directory with OpenZeppelin v5.4.0 and forge-std, where the project's 39 tests pass. I ran all four specialist proofs there, wrote my own scratch tests for the findings without proofs, and applied the proposed fixes in a second copy to confirm each proof passes once fixed. The specialist proofs and my scratch tests are also under `p2e/contracts/test/scratch/`, which is untracked.\n\n**Merging.** The 15 specialist findings collapsed into 8. Four were reported by up to four specialists each:\n\n- Double play across wallets (economics + math) → one medium.\n- revokeRoot has no window (economics + permissions) → one low.\n- Reveal without a sale (flow + math + permissions) → one low.\n- Zero treasury accepted (all four, plus setPoster zero) → one low.\n\nNothing was dropped. Every claim reproduced, including the two trust-assumption notes and the renounceOwnership footgun.\n\n**Findings kept, by severity.**\n\n- **Medium, MailOffice.sol:159.** A Messenger assigned on day D serves wallet A into day D+1, and after unassign and transfer a second wallet assigns it the same day. One 12-stamina NFT backs 24 paid deliveries a day, repeatable daily. Proof attached; the lastWalletOf fix makes it pass with all 39 existing tests green.\n- **Low, DeliveryRewards.sol:130.** postRoot and claimNow run in one transaction, so a leaked poster key mints 80% of a day's budget before revokeRoot can ever apply. Proof attached. A one-day claim delay fixes it but breaks four existing tests that claim right after posting, so I flagged it as a design decision.\n- **Low, MessengerNFT.sol:144.** setSaleOpen(false) on a never-opened sale stamps saleClosedAt, so reveal succeeds with zero tokens sold and minting is dead forever. Proof attached; the one-line guard passes everything.\n- **Low, MailOffice.sol:263.** Treasury setters and two constructors accept address(0). ETH from withdraw and mint is then burned to the zero address, and every MAIL spend reverts. I wrote the proof myself and confirmed it fails now and passes with zero checks.\n- **Info (4).** Owner can compute the seed and ownerMint chosen-trait ids before reveal, renounceOwnership strands office ETH, same-wallet re-assign is blocked the same day, and a new office can unlock an old office's locks and strand that assignment.\n\nScope note: the game server and DevTransferValidator are out of scope per AUDIT.md, and I only read the server to confirm how stamina is metered for the medium finding.","treeHash":null,"usage":{"cachedInputTokens":1325149,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":26029,"runtime":"claude","turns":30,"wallClockMs":498974}}],"verification":[]}