{"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":"df86f727-72f7-40ae-9e05-585361fc9f3a","kind":"audit","nodes":[{"acceptedSubmissionHash":"669d0f1663fdd1e1fbfce191399e947f1a6618117c60c4e80a3ce11dc516988e","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":"1e1b8725b7aa76667cb34f5bb8ef8b2126931f8a227112a42b3e725dcfaf7c80","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":"b8f38d463b51580d7ac8b03cd5bd4670474c7bd79558a98821d176d3246048a2","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":"3922ba81da89a1802adcf155e453d15d5b84f0d665f041c9c7d7ab1c48b9df83","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":"a64ea0cd63262fb0108a0dd997710f022eb39d43a0c1a5b3b3910d9d3bd78a34","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":"df86f727-72f7-40ae-9e05-585361fc9f3a","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":"52129","feedbackHash":"7665c0386018e440e257b7c69877b83c587c3a6f8aeb21c038073c4a3e7ffa4d","nodeKey":"audit_economics","submissionHash":"669d0f1663fdd1e1fbfce191399e947f1a6618117c60c4e80a3ce11dc516988e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"53924","feedbackHash":"0f2de9b564ca882257e70e59425a43af552792b8e43e2029bd598769a33e29bc","nodeKey":"audit_flow","submissionHash":"1e1b8725b7aa76667cb34f5bb8ef8b2126931f8a227112a42b3e725dcfaf7c80","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51488","feedbackHash":"29f64aee7459b1cdfce3f2cd2cc8c560b9bf72743b0947ca0c2c4decd531b47a","nodeKey":"audit_judge","submissionHash":"b8f38d463b51580d7ac8b03cd5bd4670474c7bd79558a98821d176d3246048a2","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52152","feedbackHash":"850ff1a9faa291391655492bcae1f23dc6cdb2a1d31a7b4efc4e5ad0bfefe466","nodeKey":"audit_math","submissionHash":"3922ba81da89a1802adcf155e453d15d5b84f0d665f041c9c7d7ab1c48b9df83","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51481","feedbackHash":"54daf651b11eb42a454af7a65e003f059a1e0b0509b5b5e5afe2d110641195fb","nodeKey":"audit_permissions","submissionHash":"a64ea0cd63262fb0108a0dd997710f022eb39d43a0c1a5b3b3910d9d3bd78a34","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"4a9b5961b74557fec5700256ebd696c902d90c7c454f1c56966cd8d946b734c5","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a7ac9f7a7687c25e","findings":[{"citation":"resolved","description":"The once-per-day guard in postRoot is the stored root being non-zero. A post with root == bytes32(0) passes every check (day closed, total <= dailyBudget, totalPosted + total <= PLAY_ALLOCATION), adds `total` to `totalPosted` and writes days_[day] = Day(0, total, 0), yet leaves the day looking unposted. The same day can therefore be posted again and again, each time adding up to dailyBudget(day) to totalPosted. revokeRoot(day) subtracts only days_[day].total, which is the LAST total written, so every earlier zero-root total is subtracted from the mintable allocation forever. No function can lower totalPosted otherwise, DeliveryRewards is the token's sole, one-shot minter (MailToken.setMinter works once) and is not upgradeable, so the stranded amount is unrecoverable play emission. AUDIT.md states the accepted damage from a leaked poster key is 'misallocate the budgets of closed days that have no root yet, until the owner pauses and rotates it', with revokeRoot as a mitigation; this path exceeds that bound: one closed day is enough, and revokeRoot cannot undo it. Guarantee 4 ('only once per day') and the intent of guarantee 5 are broken. The honest server never posts a zero root (ledger.settle returns null when there is no weight), so this is reachable only with the poster key, but the poster key is the threat the design explicitly models. Fix: in postRoot revert when root == bytes32(0) (or track posting with a separate flag/`total != 0` check), and consider having revokeRoot refuse days whose root is zero. Either fix keeps the agreed design.","line":91,"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/// Finding: postRoot's once-per-day guard is `days_[day].root != 0`, so a post with root == bytes32(0)\n/// never marks the day as posted. Each such post adds `total` to `totalPosted`, but `days_[day]` is\n/// overwritten, so `revokeRoot` can give back only the LAST total. Every earlier total is permanently\n/// subtracted from the mintable play allocation.\n///\n/// Invariant this test asserts (fix-agnostic): after `revokeRoot(day)` on a day nobody claimed from,\n/// `totalPosted` is back to what it was before any post for that day.\ncontract ZeroRootDoublePostTest is Test {\n    MailToken mail;\n    DeliveryRewards rewards;\n\n    address owner = makeAddr(\"owner\");\n    address poster = makeAddr(\"poster\");\n    address liquidity = makeAddr(\"liquidity\");\n\n    uint256 constant DAILY = uint256(16_800_000e18) / 102;\n\n    function setUp() public {\n        vm.startPrank(owner);\n        mail = new MailToken(liquidity);\n        rewards = new DeliveryRewards(mail, owner, poster, 1 days, DAILY, 51);\n        mail.setMinter(address(rewards));\n        vm.stopPrank();\n    }\n\n    function test_RevokeRestoresTotalPostedAfterZeroRootPosts() public {\n        vm.warp(block.timestamp + 1 days); // day 0 is closed\n        uint256 before = rewards.totalPosted();\n        assertEq(before, 0);\n\n        // Two posts for the same day with the zero root. On correct code the second (or both) revert;\n        // either way the day must end up contributing nothing once revoked.\n        vm.startPrank(poster);\n        (bool ok1,) = address(rewards).call(\n            abi.encodeCall(DeliveryRewards.postRoot, (0, bytes32(0), DAILY))\n        );\n        (bool ok2,) = address(rewards).call(\n            abi.encodeCall(DeliveryRewards.postRoot, (0, bytes32(0), DAILY))\n        );\n        vm.stopPrank();\n        // Document what happened on this code: both calls succeed (expected: at most one does).\n        emit log_named_uint(\"first post ok\", ok1 ? 1 : 0);\n        emit log_named_uint(\"second post ok\", ok2 ? 1 : 0);\n        emit log_named_uint(\"totalPosted after two zero-root posts\", rewards.totalPosted());\n\n        // Nobody could claim from a zero root, so the owner withdraws the day.\n        vm.prank(owner);\n        rewards.revokeRoot(0);\n\n        // Expected: the day contributes nothing once revoked. Actual on this code: DAILY is stranded.\n        assertEq(rewards.totalPosted(), before, \"revokeRoot must give back everything posted for the day\");\n    }\n\n    function test_LeakedPosterCanBurnWholePlayAllocation() public {\n        vm.warp(block.timestamp + 1 days);\n        vm.startPrank(poster);\n        // 102 zero-root posts for the single closed day 0, each within that day's budget.\n        for (uint256 i; i < 102; ++i) {\n            (bool ok,) = address(rewards).call(abi.encodeCall(DeliveryRewards.postRoot, (0, bytes32(0), DAILY)));\n            if (!ok) break;\n        }\n        vm.stopPrank();\n        vm.prank(owner);\n        rewards.revokeRoot(0);\n        // Expected: an honest root for day 0 can still be posted at the full budget AND the allocation\n        // left for the remaining schedule is untouched. Actual: ~16.6M of the 16.8M is gone for good.\n        assertLe(rewards.totalPosted(), DAILY, \"stranded totalPosted exceeds one day's budget\");\n    }\n}","reproduction":"Deploy as in test/P2E.t.sol (DAILY = 16_800_000e18 / 102, halving 51, 1-day length). vm.warp(+1 day) so day 0 is closed. As poster call postRoot(0, bytes32(0), DAILY) twice: expected the second call reverts RootExists; actual both succeed and totalPosted == 2*DAILY (329411764705882352941176). Owner calls revokeRoot(0): expected totalPosted returns to 0; actual totalPosted == DAILY (164705882352941176470588) and nothing can ever lower it. Scaled up: 102 such posts for day 0 bring totalPosted to 16799999999999999999999976; after revokeRoot(0) it is 16635294117647058823529388, so every later honest postRoot beyond one more day reverts AllocationExhausted and about 16.6M MAIL of play rewards can never be minted. Proof test: test/scratch/ZeroRootDoublePost.t.sol (both tests fail on this code; both pass with `if (root == bytes32(0)) revert NoRoot();` added to postRoot).","severity":"medium","snippet":"        if (days_[day].root != bytes32(0)) revert RootExists();","title":"postRoot with root == bytes32(0) bypasses the once-per-day guard and permanently strands totalPosted; a leaked poster key can burn the whole 16.8M play allocation"},{"citation":"resolved","description":"The design (README, AUDIT.md guarantee 8, the contract header) relies on 'a Messenger can join a wallet at most once per 24 h, so one NFT can't play for several wallets in a day'. The cooldown is a sliding window measured from lastAssignedAt[id], while the reward day that the server meters stamina against (DeliveryRewards.currentDay, server `entry.paid` per wallet per day) is a fixed window from startTime. The two are not aligned, so the inference does not hold: a Messenger assigned to wallet A at time T gives A its full `staminaOf` for every reward day it is held, and at T + 24h, which falls in the middle of a reward day A has already been paid for, it can be unassigned, transferred and assigned to wallet B, who then has a fresh `staminaOf` of 12 for the rest of that same reward day. Repeated daily (A -> B -> C ..., or A <-> B with two wallets alternating every 24h), one NFT yields two wallets' worth of full-point paid deliveries per reward day at the one-time cost of one extra 0.005 ETH office, instead of the one Messenger's 12. Because the day's budget is split by points, the extra points are taken from every other player's share. The level/tier bonuses travel with the NFT and the second office's tier respectively. Fix options that preserve the design: (a) in MailOffice.assign, reject an assign if the token was already assigned to any wallet during the current reward day (store `lastAssignedDay[id] = (block.timestamp - startTime) / dayLength` using the rewards contract's parameters, or let the owner set a day anchor) so a token can join at most one wallet per reward day; or (b) have the server meter paid deliveries per token id rather than per wallet (out of scope here but equivalent). Option (a) is a few lines and keeps the 24h wording honest.","line":141,"path":"p2e/contracts/src/MailOffice.sol","reproduction":"Deploy as in test/P2E.t.sol; t0 = rewards.startTime(). vm.warp(t0 + 12 hours) (reward day 0): alice opens an office and assign(1); bob opens an office. vm.warp(t0 + 36 hours): rewards.currentDay() == 1 and office.staminaOf(alice) == 12, i.e. alice has had 12 paid deliveries available for the first 12h of reward day 1. alice calls unassign(), transferFrom(alice, bob, 1); bob calls assign(1): expected TooSoon (the token already played for a wallet in reward day 1); actual the call succeeds (36h - 12h == ASSIGN_COOLDOWN), rewards.currentDay() is still 1 and office.staminaOf(bob) == 12 while office.staminaOf(alice) == 0. The server's per-wallet `entry.paid` for bob is 0, so the same NFT is paid for up to 24 deliveries in reward day 1. Verified with a Foundry test (test_OneMessengerTwoWalletsSameRewardDay) that asserts exactly these values on this code.","severity":"medium","snippet":"        if (last != 0 && block.timestamp < last + ASSIGN_COOLDOWN) revert TooSoon();","title":"Sliding 24h assign cooldown is not aligned to the reward day: one Messenger gives full stamina to two wallets in the same reward day, every day"},{"citation":"resolved","description":"AUDIT.md says costs are owner-adjustable 'so the upgrade ladder can track a dollar target as the price moves', i.e. setUpgradeCost / setLevelCost / Wardrobe.list are expected to be called routinely while players are transacting. upgrade(), levelUp(id) and Wardrobe.buy(id, amount) take no maximum-cost argument and spend whatever the storage value is when the transaction executes. The test suite and the natural client flow approve type(uint256).max to the office (test/P2E.t.sol lines 218, 254, 290, 342, 402), so a player who signed an upgrade expecting 7,000 MAIL pays whatever the retune set if the owner's transaction is mined first (same block or earlier). No malice is needed; ordinary retune timing is enough, and the 75% burn makes the overpayment unrecoverable even by the treasury. The same applies to Wardrobe.list re-pricing an item between a buyer's quote and their buy. Fix preserving the design: add a `maxCost` (or `expectedTier`/`expectedCost`) parameter to upgrade(), levelUp() and buy() and revert when the current cost exceeds it; clients pass the quoted price.","line":126,"path":"p2e/contracts/src/MailOffice.sol","reproduction":"Deploy as in test/P2E.t.sol. Fund alice with 100,000e18 MAIL; alice opens an office and approve(office, type(uint256).max); the UI shows upgradeCost(2) == 7,000e18 and alice submits upgrade(). Before it is mined the owner's setUpgradeCost(2, 70_000e18) (a retune to a new dollar target) is included. Expected: alice's upgrade reverts or pays 7,000e18. Actual: upgrade() succeeds, burns 52,500e18 from alice and sends 17,500e18 to the treasury; alice's balance drops by 70,000e18 for a tier she agreed to buy for 7,000e18. Identical sequence with setLevelCost(1, x) before levelUp(1), and with Wardrobe.list(id, newPrice, cap) before buy(id, n).","severity":"low","snippet":"        uint256 cost = upgradeCost[tier + 1];","title":"upgrade(), levelUp() and Wardrobe.buy() read the owner-adjustable price at execution time with no caller-supplied bound; a routine retune that lands first charges the player a different amount than th"},{"citation":"resolved","description":"The 25 tests cover the happy paths and the documented reverts, but never: (1) post a root of bytes32(0) or post the same day twice (the RootExists path is untested, so the zero-root bypass went unnoticed); (2) assert totalPosted after revokeRoot when more than one post touched the day; (3) call claimMany at all, including mismatched array lengths or a paused contract; (4) place assign/unassign across a DeliveryRewards day boundary (all office tests use t0 + n days from an arbitrary block.timestamp and never read rewards.currentDay()); (5) call upgrade()/levelUp() after a cost change, or buy() after a re-list; (6) call reveal() with saleClosedAt == 0 (sale never opened) or ownerMint/mint in the same block as reveal. Adding these cases would have caught findings 1 and 2 and pins the invariants AUDIT.md lists (guarantees 4, 5, 8).","line":466,"path":"p2e/contracts/test/P2E.t.sol","reproduction":"Run `forge test --match-test 'test_PauseAndRevoke|test_ClaimFlow|test_DailyBudgetCap'`: none posts bytes32(0), none posts the same day twice, none asserts totalPosted after a second post. Concrete missing assertion: after postRoot(0, 0x0, DAILY) twice and revokeRoot(0), totalPosted should equal 0 (actual on this code: DAILY). Concrete missing assertion: with t0 = rewards.startTime(), assign at t0 + 12h, then at t0 + 36h unassign/transfer/assign by a second wallet should fail if one Messenger may only play for one wallet per reward day (actual: succeeds).","severity":"info","snippet":"    function test_PauseAndRevoke() public {","title":"Test suite does not exercise the edges where the two defects above live: zero root, repeated postRoot for one day, revokeRoot accounting, claimMany, reward-day boundaries around assign"}],"hash":"1e1b8725b7aa76667cb34f5bb8ef8b2126931f8a227112a42b3e725dcfaf7c80","nodeId":"247879c9-bdcf-4cf5-9435-ecc8e227d917","outcome":"completed","summary":"The review is complete. The findings file is written and validated, and no repository file outside `.imd-findings.json` and `test/scratch/` was touched.\n\n**What I reviewed.** All five in-scope contracts in `p2e/contracts/src` plus the dev validator and the test suite, with the Execution Trace, Periphery and First Principles passes from the Pashov guide, cross-checked against the entry-point inventory, the x-ray threat profiles and the eth-security checklist. The server's settlement and stamina metering were read only to judge contract behaviour. Build and the existing 25 tests ran in a copy outside the repo with OpenZeppelin 5.4.0 and forge-std installed there, since `lib/` is off limits. All 25 pass.\n\n**Findings written to `.imd-findings.json` (4):**\n\n- **Medium, with proof:** `postRoot` treats a zero stored root as \"not yet posted\", so posting `bytes32(0)` for a closed day can be repeated without limit. Each post adds to `totalPosted`, but `revokeRoot` only refunds the last one. A leaked poster key can strand about 16.6M of the 16.8M play allocation permanently from a single closed day, far beyond the bound AUDIT.md states for that threat. The proof test in `p2e/contracts/test/scratch/ZeroRootDoublePost.t.sol` fails on this code and passes with a one-line `root == 0` revert, which I confirmed in the copy.\n- **Medium:** the 24-hour assign cooldown is a sliding window, while reward days are fixed windows from `startTime`. One Messenger can be relayed between two wallets so both get full stamina in the same reward day, every day, for one extra 0.005 ETH office. Verified with a Foundry test asserting the exact stamina values. No proof attached because the fix may legitimately live in the server's per-token metering instead of the contract.\n- **Low:** `upgrade`, `levelUp` and `Wardrobe.buy` take no caller-supplied price bound. A routine owner retune that lands first charges players whatever the new cost is, and the 75% burn makes it unrecoverable.\n- **Info:** the test suite never posts a zero root, posts a day twice, asserts `totalPosted` after revoke, calls `claimMany`, or crosses a reward-day boundary around assign.\n\n**Leads checked and dropped.** Swapping the office address leaves Messengers locked by the old office, but a holder can self-rescue by assigning and unassigning in the new office, so I did not report it. Token cap, minter one-shot, Merkle leaf encoding, claim idempotence, lock enforcement, royalty validator call shape, reveal gating and the halving sum all held under concrete inputs.\n\n**Open items, not findings.** Whether the Limit Break validator exists at the planned address on Robinhood Chain could not be checked without a chain table. The `blockhash` reveal mixing on an Arbitrum-style chain behaves as documented but is pseudo-random rather than L1-derived.","treeHash":null,"usage":{"cachedInputTokens":1348022,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":38964,"runtime":"claude","turns":35,"wallClockMs":608859}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"7c748c02cd2ee98f","findings":[{"citation":"resolved","description":"Boundary (zero sentinel) x invariant. `RootExists` uses `root != 0` as the test for \"this day was posted\", but `postRoot` accepts `root == bytes32(0)` as a value. A post with a zero root writes `days_[day] = Day(0, total, 0)` and adds `total` to `totalPosted`, yet the day still looks unposted, so the same day can be posted again and again. Each call adds `total` to `totalPosted` (bounded only by `dailyBudget(day)` per call and `PLAY_ALLOCATION` overall), while `days_[day].total` keeps only the latest value. `revokeRoot(day)` therefore subtracts only that one `total`; the rest is stuck forever, because nothing else ever decreases `totalPosted`. Guarantee 4 (\"only once per day\") is broken and guarantee 5's recovery path (`revokeRoot`) no longer covers it. The poster key is a hot settlement key that AUDIT.md explicitly expects can leak; its listed mitigations (revoke before claims, pause, rotate) assume the damage is revocable. With this path a leaked or buggy poster bricks all future reward minting in one transaction: after `totalPosted` reaches ~16.8M every honest `postRoot` with `total > 0` reverts `AllocationExhausted`, the 16.8M play allocation can never be minted, and `MailToken.setMinter` is one-shot so a replacement `DeliveryRewards` cannot be wired either. Fix: reject `root == bytes32(0)` in `postRoot` (e.g. `if (root == bytes32(0)) revert NoRoot();`), or track posting with a separate flag / `total != 0` check; the proof test passes with either.","line":91,"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/// Finding: postRoot treats root == bytes32(0) as \"no root\", so the RootExists guard never fires for a\n/// zero root. The same day can be posted again and again, each call adding `total` to totalPosted while\n/// days_[day].total only remembers the latest one. revokeRoot can give back only that latest total, so\n/// the play allocation is consumed irreversibly and later honest roots revert with AllocationExhausted.\ncontract ZeroRootRepostTest is Test {\n    MailToken mail;\n    DeliveryRewards rewards;\n\n    address owner = makeAddr(\"owner\");\n    address poster = makeAddr(\"poster\");\n    address liquidity = makeAddr(\"liquidity\");\n    address alice = makeAddr(\"alice\");\n\n    uint256 constant DAILY = uint256(16_800_000e18) / 102;\n    uint256 constant HALVING_DAYS = 51;\n\n    function setUp() public {\n        vm.startPrank(owner);\n        mail = new MailToken(liquidity);\n        rewards = new DeliveryRewards(mail, owner, poster, 1 days, DAILY, HALVING_DAYS);\n        mail.setMinter(address(rewards));\n        vm.stopPrank();\n    }\n\n    function test_ZeroRootRepostExhaustsAllocationIrreversibly() public {\n        vm.warp(block.timestamp + 3 days); // days 0, 1 and 2 are closed\n\n        // The poster key posts day 0 with a zero root as often as it likes (one tx): each call passes\n        // RootExists because days_[0].root stays zero. 102 x DAILY = 16,799,999.99 MAIL < 16.8M.\n        uint256 posts;\n        vm.startPrank(poster);\n        for (uint256 i; i < 102; ++i) {\n            try rewards.postRoot(0, bytes32(0), DAILY) {\n                ++posts;\n            } catch {\n                break;\n            }\n        }\n        vm.stopPrank();\n        emit log_named_uint(\"zero-root posts of day 0 accepted\", posts);\n\n        // The owner revokes the bad day. Expected: totalPosted returns to 0 (only one day was ever posted\n        // and it is now revoked). Actual: 101 x DAILY is stuck in totalPosted with nothing to revoke.\n        vm.prank(owner);\n        rewards.revokeRoot(0);\n        assertEq(rewards.totalPosted(), 0, \"revoking the only posted day must release everything it added\");\n\n        // Expected after the fix: honest roots for days 0, 1 and 2 post normally.\n        vm.startPrank(poster);\n        rewards.postRoot(0, rewards.leaf(alice, 0, DAILY), DAILY);\n        rewards.postRoot(1, rewards.leaf(alice, 1, DAILY), DAILY);\n        rewards.postRoot(2, rewards.leaf(alice, 2, DAILY), DAILY);\n        vm.stopPrank();\n        assertEq(rewards.totalPosted(), 3 * DAILY);\n    }\n}","reproduction":"Deploy as in the project tests (dayLength 1 day, initialDaily = 16.8M/102, halvingDays 51); warp 3 days. As poster call `postRoot(0, bytes32(0), DAILY)` 102 times in a loop. Expected: the second call reverts `RootExists`. Actual: all 102 succeed, `totalPosted` = 16,799,999.99e18; the 103rd reverts `AllocationExhausted`. Owner calls `revokeRoot(0)`: expected `totalPosted` back to 0, actual 16,635,294.1e18 (101 x DAILY stuck). Posting honest roots for days 0, 1, 2 then fails on day 2 with `AllocationExhausted`, and no admin function can ever lower `totalPosted` again.","severity":"high","snippet":"        if (days_[day].root != bytes32(0)) revert RootExists();","title":"postRoot never detects an existing zero root: a day can be re-posted without limit and the play allocation is consumed irreversibly"},{"citation":"resolved","description":"Boundary at deployment (divide by an unconstrained edge value). `dayLength` and `halvingDays` are immutable and used as divisors in `currentDay()` (`(block.timestamp - startTime) / dayLength`) and `dailyBudget()` (`day / halvingDays`). The constructor does not check either for zero (nor `initialDaily` for zero, which silently makes every budget 0). With a zero value every `postRoot` reverts with panic 0x12, `currentDay()`/`dailyBudget()` views revert, and there is no setter. Because `MailToken.setMinter` can be called once and only by the deployer, a launch that wires a misconfigured DeliveryRewards as the minter permanently strands the 16.8M play allocation: no contract can ever mint it. The launch has no deploy script yet (AUDIT.md), so these parameters are typed by hand. Fix: in the constructor `require(dayLength_ != 0 && halvingDays_ != 0 && initialDaily_ != 0)` (custom error), which costs nothing at runtime.","line":63,"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/// Finding: the DeliveryRewards constructor accepts dayLength == 0 and halvingDays == 0. Both are\n/// immutable divisors, so currentDay() / dailyBudget() panic with division by zero and postRoot can never\n/// succeed. Because MailToken.setMinter is one-shot, wiring such a deployment as the minter strands the\n/// whole 16.8M play allocation with no admin path to recover.\ncontract RewardsConstructorBoundsTest is Test {\n    MailToken mail;\n    address owner = makeAddr(\"owner\");\n    address poster = makeAddr(\"poster\");\n    address liquidity = makeAddr(\"liquidity\");\n\n    uint256 constant DAILY = uint256(16_800_000e18) / 102;\n\n    function setUp() public {\n        vm.prank(owner);\n        mail = new MailToken(liquidity);\n    }\n\n    function test_ZeroHalvingDaysIsRejectedAtDeploy() public {\n        // Expected: the constructor refuses a zero halving period. Actual: it deploys, and every\n        // postRoot (and dailyBudget view) reverts with a division-by-zero panic forever.\n        vm.prank(owner);\n        try new DeliveryRewards(mail, owner, poster, 1 days, DAILY, 0) returns (DeliveryRewards r) {\n            vm.warp(block.timestamp + 1 days);\n            vm.prank(poster);\n            r.postRoot(0, bytes32(uint256(1)), 1); // panics: day / halvingDays with halvingDays == 0\n            fail(\"deployment with halvingDays == 0 must not be accepted\");\n        } catch {}\n    }\n\n    function test_ZeroDayLengthIsRejectedAtDeploy() public {\n        vm.prank(owner);\n        try new DeliveryRewards(mail, owner, poster, 0, DAILY, 51) returns (DeliveryRewards r) {\n            vm.warp(block.timestamp + 1 days);\n            vm.prank(poster);\n            r.postRoot(0, bytes32(uint256(1)), 1); // panics: (now - start) / dayLength with dayLength == 0\n            fail(\"deployment with dayLength == 0 must not be accepted\");\n        } catch {}\n    }\n}","reproduction":"`new DeliveryRewards(mail, owner, poster, 1 days, DAILY, 0)` deploys. Warp 1 day; as poster call `postRoot(0, bytes32(uint256(1)), 1)`. Expected: a usable rewards contract or a constructor revert. Actual: `panic: division or modulo by zero (0x12)` from `dailyBudget(0)`; same with `dayLength_ = 0` from `currentDay()`. Every later call panics the same way; `dailyBudget(anyDay)` also panics so the server cannot even read the budget.","severity":"medium","snippet":"        dayLength = dayLength_;\n        initialDaily = initialDaily_;\n        halvingDays = halvingDays_;","title":"DeliveryRewards constructor accepts zero dayLength / halvingDays; the immutable divisors make postRoot panic forever and the one-shot minter cannot be re-pointed"},{"citation":"resolved","description":"Boundary (zero input) x invariant. `claim` sets `claimed[day][account] = true` for any proven leaf, including `amount == 0`, but adds 0 to `d.claimed`. `revokeRoot` decides \"nothing claimed yet\" from `d.claimed != 0`, so a day someone already claimed from (with a zero leaf) is still revocable, contradicting guarantee 7. Worse, `revokeRoot` deletes `days_[day]` but not the per-account `claimed[day][*]` flags, so after the corrected root is posted every account that claimed a zero leaf from the bad root is rejected with `AlreadyClaimed` and loses that day's rewards. The honest server filters zero amounts (`ledger.js` line 56), so this needs a bad root, which is exactly the root `revokeRoot` exists to withdraw. Fix: treat a zero-amount leaf as invalid in `claim` (`if (amount == 0) revert`), and/or keep a claimant counter per day for the revoke gate; clearing `claimed[day][...]` on revoke is not possible with a mapping, so the zero-amount rejection is the minimal fix.","line":101,"path":"p2e/contracts/src/DeliveryRewards.sol","reproduction":"Post root for day 0 containing leaves (alice, 0, 0) and (bob, 0, 5e18), total 5e18. Anyone calls `claim(alice, 0, 0, proof)`: succeeds, `claimed(0, alice) == true`, `days_(0).claimed == 0`. Owner calls `revokeRoot(0)`: expected `AlreadyClaimedFrom`, actual success. Poster posts corrected root with (alice, 0, 3e18), (bob, 0, 2e18). `claim(alice, 0, 3e18, proof)`: expected mint of 3e18, actual revert `AlreadyClaimed`. Verified with a scratch Foundry test.","severity":"low","snippet":"        if (days_[day].claimed != 0) revert AlreadyClaimedFrom();","title":"revokeRoot gate counts claimed amount, not claimants: a zero-amount claim marks an account claimed but leaves the day revocable, and the flag survives the re-post"},{"citation":"resolved","description":"Precision (rounding direction). Guarantee 11 and 16 say every MAIL spend burns exactly 75%. `burnPart = amount * 7500 / 10_000` truncates, and the treasury gets `amount - burnPart`, i.e. the rounding always favours the treasury over the burn. `setUpgradeCost`, `setLevelCost` and `Wardrobe.list` only forbid zero, not amounts that are not multiples of 4 wei (the same formula is at `Wardrobe.sol:52`). With the launch ladder (all multiples of 1e18) the split is exact; the deviation appears as soon as costs are re-tuned to a dollar target (e.g. 7_000e18 + 1 burns 5_250e18 and sends 1_750e18 + 1). The amount is at most 3 wei per spend so the economic effect is nil, but the stated invariant is not exact, and a 1 wei price lists an item that burns nothing. Fix: round the burn up (`(amount * BURN_BPS + 9_999) / 10_000`) or require `amount % 4 == 0` in the setters.","line":252,"path":"p2e/contracts/src/MailOffice.sol","reproduction":"Owner calls `office.setUpgradeCost(2, 3)`; alice opens an office, approves, calls `upgrade()`. Expected: 75% of 3 burned. Actual: totalSupply drops by 2, treasury receives 1 (66.7% burned). Owner calls `wardrobe.list(1, 1, 0)`; alice buys 1. Expected 75% burned, actual burned 0, treasury 1 (0% burned). Both verified with a scratch Foundry test.","severity":"low","snippet":"        uint256 burnPart = amount * BURN_BPS / 10_000;","title":"75% burn rounds down: owner-set costs or prices not divisible by 4 burn less than 75% and the treasury receives the remainder"},{"citation":"resolved","description":"Boundary (zero sentinel). `saleClosedAt` is only written by `setSaleOpen(false)`, so before the sale was ever opened it holds its default 0 and `block.number <= 0` is false for every real block. `reveal` then succeeds with no sale having happened. Once `seed != 0`, `setSaleOpen(true)` and `ownerMint` revert `AlreadyRevealed` by design, so a reveal sent too early (an operator running the launch runbook out of order, or a reveal transaction scheduled before the sale) leaves the collection at 0 of 3,333 forever, with no recovery short of redeploying the NFT, the office and every integration pointing at it. AUDIT.md accepts \"unsold NFTs stay unminted\" after a closed sale; the never-opened case is a degenerate boundary of that rule, not a stated intent. Fix: require that a sale actually took place before reveal, e.g. `if (saleClosedAt == 0) revert RevealTooEarly();` or `if (totalMinted == 0) revert RevealTooEarly();`.","line":163,"path":"p2e/contracts/src/MessengerNFT.sol","reproduction":"Deploy MessengerNFT with any commit; never call `setSaleOpen(true)`; advance one block; owner calls `reveal(secret)`. Expected: `RevealTooEarly`. Actual: success, `seed != 0`; afterwards `setSaleOpen(true)` and `ownerMint(owner, 1)` both revert `AlreadyRevealed` and `totalMinted` stays 0 permanently. Verified with a scratch Foundry test.","severity":"low","snippet":"        if (saleOpen || block.number <= saleClosedAt) revert RevealTooEarly();","title":"reveal's \"after the sale closed\" guard degenerates when the sale never opened (saleClosedAt == 0): a pre-sale reveal permanently disables all minting"}],"hash":"3922ba81da89a1802adcf155e453d15d5b84f0d665f041c9c7d7ab1c48b9df83","nodeId":"f6f3b35b-7e38-4621-845e-2d124e58b61b","outcome":"completed","summary":"Review complete. The findings file holds five substantiated defects, two of them with failing Foundry proofs.\n\n**Written to** `.imd-findings.json` (proof tests also copied to `p2e/contracts/test/scratch/`):\n\n- **High, DeliveryRewards.postRoot line 91.** The \"already posted\" check reads `root != 0`, but a zero root is accepted as input. The poster key can post the same day repeatedly, each call adding the day's budget to `totalPosted` while the day record remembers only the last one. 102 posts in one transaction consume the whole 16.8M play allocation, `revokeRoot` recovers only one day's worth, and since `setMinter` is one-shot no replacement contract can ever mint the rest. Proof test fails on the current code and passes once zero roots are rejected.\n- **Medium, DeliveryRewards constructor line 63.** `dayLength` and `halvingDays` are immutable divisors with no zero check. A misconfigured deploy panics on every `postRoot` and `dailyBudget` call forever, and the one-shot minter makes it unrecoverable. Proof test included.\n- **Low, DeliveryRewards.revokeRoot line 101.** The gate counts claimed amount, not claimants. A zero-amount leaf claim marks the account claimed while leaving the day revocable, and the per-account flag survives the re-post, so that account loses the corrected day's reward.\n- **Low, MailOffice line 252 (same formula in Wardrobe line 52).** The 75% burn rounds down in the treasury's favour whenever an owner-set cost or price is not a multiple of 4 wei. Cost of 3 burns 2, price of 1 burns nothing. Dust only, but the \"exactly 75%\" guarantee is not exact.\n- **Low, MessengerNFT.reveal line 163.** With the sale never opened, `saleClosedAt` is 0 and the \"after close\" guard passes, so an early reveal permanently disables all minting at 0 of 3,333.\n\n**Coverage.** All five in-scope contracts were walked against the Math Precision, Boundary and Numerical Gap guides: every division, cast, BPS split, array index, cooldown comparison, zero or sentinel branch, payable path and external call site. The halving schedule, supply caps, tier and level bounds, cooldown boundaries, Merkle leaf encoding and the uint64/uint128 packing in Wardrobe checked out with no defect. No lib directory exists in the repository, so compilation and all tests ran in a scratch workspace outside the tree with forge-std and OpenZeppelin 5.4.0 installed there. The repository tree is unchanged apart from the findings file and the scratch tests.","treeHash":null,"usage":{"cachedInputTokens":1393813,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":30537,"runtime":"claude","turns":30,"wallClockMs":471107}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"63c29c49a249ab7e","findings":[{"citation":"resolved","description":"postRoot uses `days_[day].root != 0` to mean 'already posted', but it never rejects `root == bytes32(0)`. A post with a zero root stores nothing that marks the day as used, yet it still adds `total` to `totalPosted`. The same closed day can then be posted again and again, each time up to dailyBudget(day). This breaks guarantee 4 (a day is posted only once) and every post burns play allocation that can never be claimed, because claim() reverts NoRoot while the root is 0. revokeRoot(day) subtracts only the last stored total (`days_[day].total`), so the earlier posts can never be recovered. AUDIT.md says a leaked poster key can misallocate only 'the budgets of closed days that have no root yet'. With this bug, about 102 transactions with that key (each posting day 0 at its ~164,706 MAIL budget) push totalPosted to 16.8M. After that every later postRoot reverts AllocationExhausted, so rewards are bricked for good: the schedule is immutable, and pausing or rotating the poster does not reduce totalPosted. A server bug that posts an empty-tree root of 0 with a nonzero total does the same damage by accident. Fix: in postRoot, revert when `root == bytes32(0)` (or keep a separate `posted` flag per day). If a zero total must be allowed, require a nonzero root anyway.","line":91,"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\ncontract ZeroRootTest is Test {\n    MailToken mail;\n    DeliveryRewards rewards;\n    address owner = makeAddr(\"owner\");\n    address poster = makeAddr(\"poster\");\n    address alice = makeAddr(\"alice\");\n    uint256 constant DAILY = uint256(16_800_000e18) / 102;\n\n    function setUp() public {\n        vm.startPrank(owner);\n        mail = new MailToken(makeAddr(\"liquidity\"));\n        rewards = new DeliveryRewards(mail, owner, poster, 1 days, DAILY, 51);\n        mail.setMinter(address(rewards));\n        vm.stopPrank();\n        vm.warp(block.timestamp + 2 days); // days 0 and 1 are closed\n    }\n\n    /// Guarantee 4/5: a day is posted once, so one day can never consume more than its budget.\n    function test_ZeroRootLetsOneDayBePostedRepeatedly() public {\n        uint256 budget = rewards.dailyBudget(0);\n        for (uint256 i; i < 5; ++i) {\n            vm.prank(poster);\n            try rewards.postRoot(0, bytes32(0), budget) {} catch {}\n        }\n        // Day 0 has no claimable root, yet the play allocation was charged 5x its budget.\n        assertLe(rewards.totalPosted(), budget, \"day 0 consumed more than its budget\");\n    }\n}","reproduction":"Deploy with DAILY = 16.8M/102 and halvingDays 51, then warp 2 days. The poster calls postRoot(0, bytes32(0), dailyBudget(0)) 5 times. Expected: the 2nd call reverts RootExists and totalPosted <= 164,705.88 MAIL. Actual: all 5 succeed and totalPosted = 823,529.41 MAIL, none of it claimable. Repeating it ~102 times makes every future postRoot revert AllocationExhausted. The proof fails on the current code and passes once postRoot rejects a zero root.","severity":"high","snippet":"        if (days_[day].root != bytes32(0)) revert RootExists();","title":"postRoot accepts a zero root, so one day can be posted again and again and use up the whole 16.8M play allocation for good"},{"citation":"resolved","description":"revokeRoot is meant to undo a bad root before any claim (the leaked-key mitigation in AUDIT.md, guarantee 7). It decides whether anything was claimed by checking `days_[day].claimed != 0`, which counts amounts, not claims. A zero-amount leaf can be claimed by anyone for any account (claim is permissionless). That sets `claimed[day][account] = true` (line 115) but leaves d.claimed at 0, so the revoke is still allowed. revokeRoot deletes only days_[day]. The per-account `claimed` flags survive, so once the owner posts the correct root, those accounts revert AlreadyClaimed for that day forever. Someone with a leaked poster key can post a bad root containing (victim, day, 0) leaves for many players, claim them in a batch through claimMany, and wait for the owner to 'fix' it with a revoke. Every listed player then permanently loses that day's reward. The rest of the day's budget stays posted in totalPosted but is never minted. Fix: key the claim flags to the root (`claimed[root][account]`) or to a per-day epoch that revokeRoot increments. Alternatively, count claims rather than amounts and block revoke after any claim. The proof passes with `claimed[d.root][account]`.","line":103,"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\ncontract RevokeClaimedTest is Test {\n    MailToken mail;\n    DeliveryRewards rewards;\n    address owner = makeAddr(\"owner\");\n    address poster = makeAddr(\"poster\");\n    address alice = makeAddr(\"alice\");\n    uint256 constant DAILY = uint256(16_800_000e18) / 102;\n\n    function setUp() public {\n        vm.startPrank(owner);\n        mail = new MailToken(makeAddr(\"liquidity\"));\n        rewards = new DeliveryRewards(mail, owner, poster, 1 days, DAILY, 51);\n        mail.setMinter(address(rewards));\n        vm.stopPrank();\n        vm.warp(block.timestamp + 2 days); // days 0 and 1 are closed\n    }\n\n    /// Guarantee 7: revokeRoot before any claim must fully undo the bad root.\n    function test_RevokeLeavesClaimedFlagsAndBlocksCorrectRoot() public {\n        // Bad root (e.g. leaked poster key): single leaf (alice, 0, 0). A single-leaf root is the leaf itself.\n        bytes32 badRoot = rewards.leaf(alice, 0, 0);\n        vm.prank(poster);\n        rewards.postRoot(0, badRoot, 0);\n        // Anyone may submit a claim for alice: it mints 0 and marks her as claimed.\n        rewards.claim(alice, 0, 0, new bytes32[](0));\n        // d.claimed is still 0, so the owner can revoke and the correct root is posted.\n        vm.prank(owner);\n        rewards.revokeRoot(0);\n        bytes32 goodRoot = rewards.leaf(alice, 0, 100e18);\n        vm.prank(poster);\n        rewards.postRoot(0, goodRoot, 100e18);\n        // Alice should now be able to claim her 100 MAIL; instead claim reverts AlreadyClaimed.\n        rewards.claim(alice, 0, 100e18, new bytes32[](0));\n        assertEq(mail.balanceOf(alice), 100e18);\n    }\n}","reproduction":"The poster calls postRoot(0, leaf(alice,0,0), 0), which is a single-leaf tree. Anyone calls claim(alice, 0, 0, []), which succeeds and sets claimed[0][alice] = true while d.claimed stays 0. The owner calls revokeRoot(0), which succeeds. The poster calls postRoot(0, leaf(alice,0,100e18), 100e18). Then claim(alice, 0, 100e18, []) is called. Expected: alice gets 100 MAIL. Actual: the call reverts AlreadyClaimed().","severity":"medium","snippet":"        delete days_[day];","title":"revokeRoot leaves claimed[day][account] flags set, so accounts claimed under a revoked root can never claim the corrected root"},{"citation":"resolved","description":"upgrade() reads upgradeCost[tier+1] at execution time, and levelUp() does the same with levelCost[next] (line 164). Neither takes a maximum cost from the user. AUDIT.md says the owner adjusts these costs 'as the price moves' to keep a dollar target, so repricing is a planned, regular operation. Any user transaction pending when setUpgradeCost or setLevelCost lands pays the new price. Users who gave the office a standing or unlimited allowance cannot bound the charge. 75% of it is burned and cannot be refunded. Wardrobe.buy (Wardrobe.sol:45) has the same gap with list() repricing. Fix: add a `maxCost` parameter (and an optional deadline) to upgrade, levelUp and buy, and revert if the current cost is higher.","line":126,"path":"p2e/contracts/src/MailOffice.sol","reproduction":"Alice is at tier 4 and has approved the office for type(uint256).max MAIL. She sends upgrade(), expecting 49,000 MAIL. In the same block, ordered before her transaction, the owner calls setUpgradeCost(5, 98_000e18). Expected: the upgrade reverts or charges at most the 49,000 she agreed to. Actual: it charges 98,000 MAIL (73,500 burned, 24,500 to the treasury).","severity":"low","snippet":"        uint256 cost = upgradeCost[tier + 1];","title":"upgrade() and levelUp() charge whatever the cost is when the transaction lands, with no maximum the user can set, and costs are repriced routinely"},{"citation":"resolved","description":"Guarantees 11 and 16 promise exactly 75% burned. burnPart = cost*7500/10000 rounds down, and the remainder goes to the treasury. Wardrobe prices are any uint128 the owner lists, so the burn falls short whenever price*amount is not a multiple of 4 wei. MailOffice._spend has the same rounding, but the default costs there are whole e18 values, so it is exact in practice. The economic impact is negligible (under 1 wei per purchase), but the guarantee as written does not hold. Fix: round the burn up (`(cost * BURN_BPS + 9_999) / 10_000`), or require prices to be multiples of 4 wei.","line":52,"path":"p2e/contracts/src/Wardrobe.sol","reproduction":"The owner calls list(1, 1, 0) to price item 1 at 1 wei. Alice approves and calls buy(1, 1). Expected: 0.75 wei burned (impossible, so it should round toward the burn). Actual: burnPart = 0, nothing is burned, and 1 wei goes to the treasury. With price 3 wei: 2 wei burned (66.7%).","severity":"info","snippet":"        uint256 burnPart = cost * BURN_BPS / 10_000;","title":"The 75% burn rounds down, so a Wardrobe purchase whose cost is not a multiple of 4 wei burns less than 75%"}],"hash":"669d0f1663fdd1e1fbfce191399e947f1a6618117c60c4e80a3ce11dc516988e","nodeId":"3e6be68d-051e-4014-b15b-7eeb7f311798","outcome":"completed","summary":"I found four economic defects and wrote them to `.imd-findings.json`. Both proof tests fail on the code as it is and pass once the fix is applied. I ran them in a scratch copy outside the repo, because the repo has no `lib/` installed; the repository itself is unchanged.\n\n1. **High: a zero root lets one day be posted over and over** (`p2e/contracts/src/DeliveryRewards.sol:91`). `postRoot` treats \"root is not zero\" as \"already posted\" but never rejects a zero root. Each post with a zero root still adds to `totalPosted`, and nothing can be claimed from it. Posting day 0 five times at its budget charges about 823,529 MAIL against the 16.8M allocation instead of 164,706. Repeating it about 102 times makes every later `postRoot` revert with `AllocationExhausted`. `revokeRoot` only gets back the last post's total, so rewards stay broken for good. This goes further than the damage AUDIT.md accepts from a leaked poster key. **Fix:** reject `root == 0`.\n\n2. **Medium: `revokeRoot` doesn't clear claim flags** (`DeliveryRewards.sol:103`). The \"has anything been claimed\" check counts amounts, not claims. So after a zero-amount claim, which anyone can submit for any account, the owner can still revoke the root. But the account's claimed flag for that day survives the revoke. Once the corrected root is posted, that account's claim reverts with `AlreadyClaimed` for good. **Fix:** key the claim flags to the root (or a per-day counter), or block revoking after any claim.\n\n3. **Low: no price limit on upgrades, levels or Wardrobe items** (`MailOffice.sol:126`, plus `levelUp` and `Wardrobe.buy`). These charge whatever the cost is when the transaction lands. The owner reprices regularly by design, so a user with an unlimited approval can pay double if a repricing lands first, and 75% of it is burned. **Fix:** add a `maxCost` parameter.\n\n4. **Info: the 75% burn rounds down** (`Wardrobe.sol:52`). When the cost isn't a multiple of 4 wei, less than 75% is burned. For example, a 1-wei item burns nothing. The money at stake is tiny, but it breaks the \"exactly 75%\" guarantee as written.\n\n**Coverage:**\n- **Economics, in full:** supply cap and minter, halving schedule and total cap, posting, claiming and revoking, office tiers, levels and cooldowns, the one-character rule, MAIL spend splits, Wardrobe caps, and NFT sale payment and supply.\n- **Checked and found nothing:** the cap math, double claims, the tier and level caps, the assignment and upgrade cooldowns, and locked-NFT transfers.\n- **Not written up as findings:** owner and poster powers that AUDIT.md lists as accepted, and the server-side split rules, which are out of scope.\n- **Not run:** the project's own 25 tests and the end-to-end script.","treeHash":null,"usage":{"cachedInputTokens":756717,"inputTokens":28,"model":"claude-opus-5-5","outputTokens":16557,"runtime":"claude","turns":18,"wallClockMs":211163}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3f91b58cf7cd2d45","findings":[{"citation":"resolved","description":"Asymmetry between claim() and revokeRoot(): claim() writes two coupled slots, claimed[day][account] = true and d.claimed += amount, while revokeRoot() deletes only days_[day] (line 103) and guards on the SUM d.claimed, not on whether any claim flag was set. A root that contains zero-amount leaves (the compromised-poster-key case that revokeRoot exists for, per the function's own comment and AUDIT.md's mitigation list, or a server export that includes 0-point players) lets anyone call the permissionless claim(account, day, 0, proof) for each victim: the flag is set, d.claimed stays 0, the owner's revokeRoot still succeeds, and when the honest root for that day is re-posted every flagged account hits AlreadyClaimed. Their earned MAIL for that day is never minted and cannot be recovered because claimed[] has no reset path. Context: the same permissionless claim also means a key holder can post and claim in one transaction, so the revoke window is zero against the attacker themself; AUDIT.md accepts that loss but not the loss of honest players' allocations. Fix: scope claim flags to the posted root (mapping(bytes32 root => mapping(address => bool)) or add a per-day version counter bumped by revokeRoot and key claimed by (day, version)), and additionally reject amount == 0 in claim() so a flag can never be set without d.claimed growing.","line":101,"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/// Finding: DeliveryRewards.revokeRoot deletes days_[day] but never clears claimed[day][*], and its\n/// \"nothing claimed yet\" guard compares the SUM of claimed amounts (d.claimed) to zero rather than the\n/// number of claims. A root containing zero-amount leaves (the compromised-poster case revokeRoot\n/// exists for) lets anyone set claimed[day][victim] = true while d.claimed stays 0. The owner can still\n/// revoke, the honest root is re-posted, and the victim's real allocation for that day is unclaimable\n/// forever (AlreadyClaimed), so it is never minted to them.\n///\n/// Fails on the current code (claim reverts AlreadyClaimed). Passes once claimed flags are scoped to the\n/// posted root (or a per-day version) so a revoke invalidates them, or once zero-amount claims are\n/// rejected AND revokeRoot refuses while any claim flag was set.\ncontract RevokeKeepsClaimedFlagsTest is Test {\n    MailToken mail;\n    DeliveryRewards rewards;\n\n    address owner = makeAddr(\"owner\");\n    address liquidity = makeAddr(\"liquidity\");\n    address poster = makeAddr(\"poster\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address griefer = makeAddr(\"griefer\");\n\n    uint256 constant DAILY = uint256(16_800_000e18) / 102;\n\n    function setUp() public {\n        vm.startPrank(owner);\n        mail = new MailToken(liquidity);\n        rewards = new DeliveryRewards(mail, owner, poster, 1 days, DAILY, 51);\n        mail.setMinter(address(rewards));\n        vm.stopPrank();\n    }\n\n    function _root(bytes32 la, bytes32 lb) internal pure returns (bytes32) {\n        return la < lb ? keccak256(abi.encode(la, lb)) : keccak256(abi.encode(lb, la));\n    }\n\n    function test_RevokedRootLeavesVictimUnableToClaimHonestRoot() public {\n        vm.warp(block.timestamp + 1 days); // day 0 is closed\n\n        // 1. A bad root for day 0 (leaked poster key or server bug) contains a zero-amount leaf for alice.\n        bytes32 badA = rewards.leaf(alice, 0, 0);\n        bytes32 badB = rewards.leaf(bob, 0, 0);\n        vm.prank(poster);\n        rewards.postRoot(0, _root(badA, badB), 0);\n\n        // 2. Anyone (claim is permissionless) burns alice's claim flag for day 0. Nothing is minted.\n        bytes32[] memory proof = new bytes32[](1);\n        proof[0] = badB;\n        vm.prank(griefer);\n        rewards.claim(alice, 0, 0, proof);\n        assertTrue(rewards.claimed(0, alice));\n        (, , uint256 claimedSum) = rewards.days_(0);\n        assertEq(claimedSum, 0);\n\n        // 3. The owner notices the bad root and revokes it: the guard passes because the SUM is zero.\n        vm.prank(owner);\n        rewards.revokeRoot(0);\n        assertEq(rewards.totalPosted(), 0);\n\n        // 4. The honest root for day 0 is posted: alice really earned 25,000 MAIL.\n        uint256 aAmt = 25_000e18;\n        uint256 bAmt = 15_000e18;\n        bytes32 la = rewards.leaf(alice, 0, aAmt);\n        bytes32 lb = rewards.leaf(bob, 0, bAmt);\n        vm.prank(poster);\n        rewards.postRoot(0, _root(la, lb), aAmt + bAmt);\n\n        // 5. Expected: alice can claim her 25,000 MAIL from the honest root.\n        //    Actual on current code: AlreadyClaimed, and the tokens are never mintable to her.\n        bytes32[] memory honest = new bytes32[](1);\n        honest[0] = lb;\n        rewards.claim(alice, 0, aAmt, honest);\n        assertEq(mail.balanceOf(alice), aAmt);\n    }\n}","reproduction":"State: DeliveryRewards deployed (dayLength 1 day, DAILY = 16.8M/102), minter set, day 0 closed. 1) poster posts root R_bad for day 0 built from leaves leaf(alice,0,0) and leaf(bob,0,0), total 0 (passes OverBudget). 2) griefer (any address) calls claim(alice, 0, 0, [leaf(bob,0,0)]) -> succeeds; claimed(0, alice) == true; days_(0).claimed == 0. 3) owner calls revokeRoot(0) -> succeeds because days_[0].claimed == 0; totalPosted back to 0. 4) poster posts honest root for day 0: leaf(alice,0,25_000e18), leaf(bob,0,15_000e18), total 40_000e18. 5) claim(alice, 0, 25_000e18, [leaf(bob,0,15_000e18)]). Expected: alice receives 25,000 MAIL. Actual: revert AlreadyClaimed(); mail.balanceOf(alice) stays 0 forever for day 0. Run: cd p2e/contracts && forge test --match-path test/scratch/RevokeKeepsClaimedFlags.t.sol (fails with AlreadyClaimed()).","severity":"medium","snippet":"        if (days_[day].claimed != 0) revert AlreadyClaimedFrom();","title":"revokeRoot keeps claimed[day][*] flags; zero-amount leaves make a day's honest allocation permanently unclaimable"},{"citation":"resolved","description":"Trust-gap seam (access x economics x asymmetry). MessengerNFT.mint (MessengerNFT.sol:123) requires msg.value == price * quantity, so an owner repricing that lands before a buyer's transaction makes it revert instead of charging a different amount. The three MAIL-denominated purchases do the opposite: MailOffice.upgrade reads upgradeCost[tier + 1] at execution (line 126), MailOffice.levelUp reads levelCost[next] (line 164) and Wardrobe.buy reads it.price (Wardrobe.sol:51), and none of them accepts a maximum the caller agreed to. Costs are explicitly designed to be retuned by the owner to track a dollar target, and the project's own tests approve type(uint256).max to the office, so the realistic honest race is: a player signs upgrade() at 7,000 MAIL, setUpgradeCost(2, 70_000e18) is mined first, and the player pays 70,000 MAIL of which 52,500 is burned irreversibly. No malice is required; any reordering around a documented admin action produces the loss, and the victim has no way to express consent to a price. Fix: add a caller-supplied bound (upgrade(uint256 maxCost), levelUp(uint256 id, uint256 maxCost), buy(uint256 id, uint256 amount, uint256 maxCost)) and revert when cost > maxCost; or, keeping the current signatures, make cost changes take effect only after a delay (store pending cost + effectiveAt, e.g. 24 h, and read the effective one in _spend) so a signed transaction can never meet a price it could not have seen. Either preserves owner-adjustable pricing; only the consent check is added. The attached test passes unchanged with the delay fix and needs only the marked call updated for the parameter fix.","line":126,"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} from \"src/MailOffice.sol\";\nimport {DevTransferValidator} from \"src/DevTransferValidator.sol\";\n\n/// Finding (asymmetry, ETH path vs MAIL path): MessengerNFT.mint pins what the buyer pays\n/// (msg.value == price * quantity, so a repricing makes the buyer's tx revert), but MailOffice.upgrade,\n/// MailOffice.levelUp and Wardrobe.buy read the owner-adjustable price at execution time and take no\n/// maximum the caller is willing to pay. Costs are documented as being retuned to track a dollar target.\n/// A user's pending upgrade() that lands after setUpgradeCost pays the new price in full, 75% of it burned\n/// irreversibly, with no way to express \"I agreed to 7,000\".\n///\n/// Fails on the current code (alice pays 70,000 instead of reverting or paying 7,000). Passes once the\n/// spend functions take a caller-supplied bound (e.g. upgrade(uint256 maxCost)) and revert when exceeded.\n/// To run against a fixed API, update the call on the marked line to pass the bound.\ncontract UnboundedMailSpendTest is Test {\n    MailToken mail;\n    MessengerNFT nft;\n    MailOffice office;\n\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    address liquidity = makeAddr(\"liquidity\");\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.startPrank(owner);\n        mail = new MailToken(liquidity);\n        nft = new MessengerNFT(owner, treasury, owner, 0.01 ether, bytes32(uint256(1)), address(new DevTransferValidator()), \"ipfs://x/\");\n        office = new MailOffice(mail, nft, treasury, owner);\n        nft.setOffice(address(office));\n        vm.stopPrank();\n        vm.deal(alice, 1 ether);\n        vm.prank(liquidity);\n        mail.transfer(alice, 100_000e18);\n    }\n\n    function test_PendingUpgradePaysRepricedCostWithoutConsent() public {\n        vm.startPrank(alice);\n        office.open{value: 0.005 ether}(address(0));\n        mail.approve(address(office), type(uint256).max); // the pattern the project's own tests use\n        vm.stopPrank();\n        uint256 agreed = office.upgradeCost(2); // 7,000 MAIL at the time alice signs\n        assertEq(agreed, 7_000e18);\n\n        // The owner's documented repricing lands first (honest retune after a price move, or any reorder).\n        vm.prank(owner);\n        office.setUpgradeCost(2, 70_000e18);\n\n        // alice's already-signed tx executes. Expected: it reverts (or charges at most what she agreed to).\n        // Actual: it silently charges 70,000 MAIL, of which 52,500 is burned.\n        uint256 before = mail.balanceOf(alice);\n        vm.prank(alice);\n        office.upgrade(); // MARKED: with a fixed API this becomes office.upgrade(agreed) and must revert\n        uint256 paid = before - mail.balanceOf(alice);\n        assertLe(paid, agreed, \"charged more MAIL than the caller agreed to\");\n    }\n}","reproduction":"State: alice has an open office (tier 1), 100,000 MAIL, and approve(office, type(uint256).max). upgradeCost(2) == 7_000e18. 1) owner calls setUpgradeCost(2, 70_000e18) (a documented retune). 2) alice's previously signed upgrade() executes. Expected: revert, or a charge of at most the 7,000 MAIL she agreed to. Actual: balanceOf(alice) drops by 70_000e18 (52,500 burned, 17,500 to treasury), tierOf(alice) == 2. Run: cd p2e/contracts && forge test --match-path test/scratch/UnboundedMailSpend.t.sol (fails: charged 70000e18 > 7000e18). The same sequence applies to levelUp via setLevelCost and to Wardrobe.buy via list(id, newPrice, cap).","severity":"low","snippet":"        uint256 cost = upgradeCost[tier + 1];\n        _spend(msg.sender, cost);","title":"MAIL-priced actions (upgrade, levelUp, Wardrobe.buy) take no caller-side cost bound while the ETH mint path pins the price exactly"},{"citation":"resolved","description":"Access coupling gap between MessengerNFT.setLocked (gated on msg.sender == office, a single mutable slot) and MailOffice, which records assignedBy/characterOf but has no way to release a token once it is no longer the office. The lock is written by one authority and may only be cleared by whoever currently holds the office slot; nothing records which office locked a token, and the old office has no recovery path. After a legitimate migration (new ladder, bug fix) every assigned Messenger stays locked: unassign() on the old office reverts with NotOffice, transferFrom reverts IsLocked, and the new office does not know the assignment. The only escape is for each holder to open an office in the new contract (0.005 ETH) and assign then unassign there, which also resets the 24 h join cooldown tracked by the old office. This contradicts guarantee 9 ('unassign unlocks it') for any holder at migration time. AUDIT.md accepts a malicious office; it does not describe this lock-up on an honest office change. Fix: record the locker (mapping(uint256 => address) lockedBy) and let either the current office or the recorded locker clear the lock; or have setOffice refuse while any token is locked; or add an owner-only emergency unlock that MailOffice.unassign can rely on.","line":201,"path":"p2e/contracts/src/MessengerNFT.sol","reproduction":"State: alice owns Messenger #1, opened an office, called assign(1); nft.locked(1) == true. 1) owner deploys MailOffice office2 and calls nft.setOffice(office2). 2) alice calls office.unassign(). Expected: characterOf(alice) cleared and locked(1) == false. Actual: revert NotOffice() from setLocked; locked(1) stays true; nft.transferFrom(alice, bob, 1) reverts IsLocked(); office2.characterOf(alice) == 0. Run: cd p2e/contracts && forge test --match-path test/scratch/OfficeMigrationLocksTokens.t.sol (the test asserts the stranded state and passes on the current code).","severity":"low","snippet":"        office = office_;","title":"setOffice strands every Messenger locked by the previous office: unassign reverts NotOffice and the token can never move"},{"citation":"resolved","description":"Guarantee 2 states setMinter 'works once'. The guard is 'already set' == 'minter is non-zero', so a call with address(0) passes both checks, writes nothing useful, and the function remains callable any number of times until a non-zero address is given. Only the deployer can trigger it and no third party gains access, so this is a correctness note on the stated guarantee rather than a bypass. Fix: add a separate bool minterSet (or revert on minter_ == address(0)).","line":36,"path":"p2e/contracts/src/MailToken.sol","reproduction":"deployer: new MailToken(liq); setMinter(address(0)) -> succeeds (expected: revert or consume the one-shot); setMinter(address(0)) -> succeeds again; setMinter(0xBEEF) -> succeeds, minter() == 0xBEEF. Run: cd p2e/contracts && forge test --match-path test/scratch/SetMinterZero.t.sol (asserts the three calls succeed).","severity":"info","snippet":"        if (minter != address(0)) revert MinterAlreadySet();","title":"setMinter's one-shot guard is keyed on the zero sentinel, so setMinter(address(0)) succeeds and leaves the function callable"},{"citation":"resolved","description":"Temporal/deployment check from the x-ray guide: who holds each role once deployment ends. _deployer is the immutable msg.sender of the constructor, there is no owner, and setMinter has no other caller. The repository has no mainnet deploy script; the IMD launch path and the audit request describe creating everything in one factory transaction. If MailToken is created by a factory or deployment helper contract, _deployer is that contract, and unless it calls setMinter(rewards) in the same flow the minter is unset forever: DeliveryRewards.claim reverts NotMinter on every call and the whole play allocation is unreachable, while the 4.2M liquidity mint has already happened. Nothing is lost to a third party; the risk is an irrecoverable launch. Fix: take the minter (or an admin allowed to set it once) as a constructor argument, or have the deploy script assert minter() == address(rewards) before handing out any token.","line":29,"path":"p2e/contracts/src/MailToken.sol","reproduction":"State: a contract F runs `new MailToken(liq)` then `new DeliveryRewards(mail, owner, poster, 86400, DAILY, 51)` and returns without calling mail.setMinter. Afterwards the EOA owner calls mail.setMinter(rewards). Expected (per deploy wiring): minter becomes rewards. Actual: revert NotMinter() because msg.sender != F; minter() stays address(0); any later rewards.claim(...) reverts NotMinter() from MailToken.mint.","severity":"info","snippet":"        _deployer = msg.sender;","title":"Minter can only ever be set by the deploying account; a factory or multisig-script deployment that does not call setMinter in-flight makes the 16.8M play allocation unmintable"}],"hash":"a64ea0cd63262fb0108a0dd997710f022eb39d43a0c1a5b3b3910d9d3bd78a34","nodeId":"4f8056e2-b96e-44f3-ad3a-6a5e47ce23d9","outcome":"completed","summary":"The review is complete. The findings file is written at the repository root with five entries, and no contract or configuration file was changed. Only the scratch tests under `p2e/contracts/test/scratch/` were added, which the assignment allows and does not keep.\n\n**What I reviewed.** All five in-scope contracts plus the test stand-in validator, against the Access Control, Asymmetry and Trust Gap guides. I enumerated every state-changing entry point and its guard, diffed each paired operation (assign/unassign, lock/unlock, postRoot/revokeRoot/claim, mint/ownerMint, user spend vs admin setter), and checked each admin setter for in-flight value it could redirect. The core permission model holds: every writer is owner-, poster-, office- or minter-gated, the two-step ownership is used throughout, and `_spend` only ever pulls from `msg.sender`, so no contract can be made to spend another user's allowance. The one-character rule, the 24-hour cooldowns and the tier and level caps have no bypass I could construct.\n\n**Findings, most severe first.**\n\n- **Medium, DeliveryRewards.revokeRoot (line 101).** Revoking deletes the day but not the per-account claimed flags, and the \"nothing claimed\" guard checks the claimed sum rather than whether any claim happened. A bad root with zero-amount leaves lets anyone set a victim's flag at no cost, the owner can still revoke, and the victim can never claim the honest re-posted root. Proof test attached and failing on current code.\n- **Low, MailOffice.upgrade (line 126), also levelUp and Wardrobe.buy.** The ETH mint path pins the exact price, but the MAIL paths read the owner-adjustable cost at execution with no caller bound. A documented repricing that lands first charges the full new cost, 75 percent of it burned. Proof test attached and failing.\n- **Low, MessengerNFT.setOffice (line 201).** Changing the office strands every Messenger it locked: the holder's unassign reverts, the token cannot move, and the new office has no record. Reproduced by a passing observation test.\n- **Info, MailToken.setMinter (line 36).** Passing the zero address does not consume the one-shot, contradicting the \"works once\" guarantee. No third party gains access.\n- **Info, MailToken constructor (line 29).** Only the deploying account can ever set the minter. A factory deployment that does not call setMinter in the same flow leaves the 16.8M play allocation permanently unmintable.\n\n**Trust assumptions I documented but did not report as defects.** The owner can replace the transfer validator with any contract, including a permissive one, so royalty enforcement is owner-removable in practice. The owner can set the office to any address and so lock, unlock or level any token. Ownership can still be renounced on every contract, freezing caps and pausing. A leaked poster key can post and claim in one transaction, so the revoke window against the key holder is zero; AUDIT.md accepts that loss.\n\n**Coverage limits.** I ran the project's 25 tests and my four scratch tests with Foundry only. No static analyser or fuzzing beyond the existing fuzz test ran. Arithmetic, reveal randomness and the off-chain server were outside the assigned area and were not reviewed in depth.","treeHash":null,"usage":{"cachedInputTokens":757122,"inputTokens":258,"model":"claude-fable-5-1","outputTokens":32669,"runtime":"claude","turns":29,"wallClockMs":552750}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"5835e48821d8827d","findings":[{"citation":"resolved","description":"postRoot decides 'already posted' from days_[day].root != 0 but never rejects root == bytes32(0). A zero-root post passes every check, adds total to totalPosted and writes days_[day] = Day(0, total, 0), yet the day still reads as unposted, so the same closed day can be posted again and again (guarantee 4 broken). days_[day].total keeps only the latest value, so revokeRoot(day) gives back only that one total; nothing else ever lowers totalPosted. One transaction from the poster key (a hot settlement key AUDIT.md expects can leak; its listed mitigations revokeRoot/pause/rotate all assume damage is revocable) can push totalPosted to ~16.8M with 102 posts of day 0, after which every honest postRoot with total > 0 reverts AllocationExhausted forever. MailToken.setMinter is one-shot, so a replacement DeliveryRewards cannot be wired: the play allocation can never be minted. Reported by three specialists (math, flow, economics); merged. Fix: in postRoot add `if (root == bytes32(0)) revert NoRoot();` (or track posting with a separate flag). The attached proof fails on this code and passes with that one line (verified against a patched copy).","line":91,"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/// Finding: postRoot treats root == bytes32(0) as \"no root\", so the RootExists guard never fires for a\n/// zero root. The same day can be posted again and again, each call adding `total` to totalPosted while\n/// days_[day].total only remembers the latest one. revokeRoot can give back only that latest total, so\n/// the play allocation is consumed irreversibly and later honest roots revert with AllocationExhausted.\ncontract ZeroRootRepostTest is Test {\n    MailToken mail;\n    DeliveryRewards rewards;\n\n    address owner = makeAddr(\"owner\");\n    address poster = makeAddr(\"poster\");\n    address liquidity = makeAddr(\"liquidity\");\n    address alice = makeAddr(\"alice\");\n\n    uint256 constant DAILY = uint256(16_800_000e18) / 102;\n    uint256 constant HALVING_DAYS = 51;\n\n    function setUp() public {\n        vm.startPrank(owner);\n        mail = new MailToken(liquidity);\n        rewards = new DeliveryRewards(mail, owner, poster, 1 days, DAILY, HALVING_DAYS);\n        mail.setMinter(address(rewards));\n        vm.stopPrank();\n    }\n\n    function test_ZeroRootRepostExhaustsAllocationIrreversibly() public {\n        vm.warp(block.timestamp + 3 days); // days 0, 1 and 2 are closed\n\n        // The poster key posts day 0 with a zero root as often as it likes (one tx): each call passes\n        // RootExists because days_[0].root stays zero. 102 x DAILY = 16,799,999.99 MAIL < 16.8M.\n        uint256 posts;\n        vm.startPrank(poster);\n        for (uint256 i; i < 102; ++i) {\n            try rewards.postRoot(0, bytes32(0), DAILY) {\n                ++posts;\n            } catch {\n                break;\n            }\n        }\n        vm.stopPrank();\n        emit log_named_uint(\"zero-root posts of day 0 accepted\", posts);\n\n        // The owner revokes the bad day. Expected: totalPosted returns to 0 (only one day was ever posted\n        // and it is now revoked). Actual: 101 x DAILY is stuck in totalPosted with nothing to revoke.\n        vm.prank(owner);\n        rewards.revokeRoot(0);\n        assertEq(rewards.totalPosted(), 0, \"revoking the only posted day must release everything it added\");\n\n        // Expected after the fix: honest roots for days 0, 1 and 2 post normally.\n        vm.startPrank(poster);\n        rewards.postRoot(0, rewards.leaf(alice, 0, DAILY), DAILY);\n        rewards.postRoot(1, rewards.leaf(alice, 1, DAILY), DAILY);\n        rewards.postRoot(2, rewards.leaf(alice, 2, DAILY), DAILY);\n        vm.stopPrank();\n        assertEq(rewards.totalPosted(), 3 * DAILY);\n    }\n}","reproduction":"Deploy as in test/P2E.t.sol (dayLength 1 day, DAILY = 16.8M/102, halvingDays 51); warp 3 days. As poster call postRoot(0, bytes32(0), DAILY) in a loop. Expected: the second call reverts RootExists. Actual: all 102 succeed, totalPosted = 16,799,999.99e18, the 103rd reverts AllocationExhausted. Owner calls revokeRoot(0): expected totalPosted back to 0, actual 16,635,294.1e18 (101 x DAILY stranded). Honest postRoot(0..2, leaf, DAILY) then fails with AllocationExhausted. Run: cd p2e/contracts && forge test --match-path test/scratch/ZeroRootRepost.t.sol -> FAIL 'revoking the only posted day must release everything it added: 16635294117647058823529388 != 0'.","severity":"high","snippet":"        if (days_[day].root != bytes32(0)) revert RootExists();","title":"postRoot accepts root == bytes32(0): the once-per-day guard never fires, a day can be re-posted without limit and the stranded totalPosted bricks the 16.8M play allocation"},{"citation":"resolved","description":"claim writes two coupled slots, claimed[day][account] = true and d.claimed += amount, and accepts amount == 0. revokeRoot decides 'nothing claimed yet' from d.claimed != 0 (an amount sum, not a claimant count) and deletes only days_[day]; the per-account flags survive. So a bad root with zero-amount leaves (exactly the leaked-poster-key case revokeRoot exists for, or a server export including 0-point players) lets anyone call the permissionless claim(victim, day, 0, proof) for each listed account: the flag is set, d.claimed stays 0, the owner's revoke still succeeds (contradicting guarantee 7), and once the corrected root is posted every flagged account reverts AlreadyClaimed for that day forever. Their earned MAIL for the day is never minted and there is no reset path. Reported by three specialists (math low, economics medium, permissions medium); merged. Fix: key the claim flag by root (mapping(bytes32 => mapping(address => bool))) or by a per-day version bumped in revokeRoot, and additionally reject amount == 0 in claim. The attached proof is fix-agnostic (the zero claim is a low-level call) and passes with the root-keyed mapping (verified against a patched copy).","line":101,"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/// Finding: revokeRoot gates on the claimed SUM (days_[day].claimed != 0) and deletes only days_[day],\n/// so a zero-amount leaf claimed from a bad root sets claimed[day][account] without blocking the revoke,\n/// and the flag survives the re-post: the account can never claim the corrected root for that day.\n/// Fix-agnostic: the zero claim is attempted with a low-level call (a fix may reject it), and the test\n/// only asserts that after revoke + honest re-post the honest claim pays out.\ncontract RevokeKeepsClaimedFlagsTest is Test {\n    MailToken mail;\n    DeliveryRewards rewards;\n\n    address owner = makeAddr(\"owner\");\n    address poster = makeAddr(\"poster\");\n    address liquidity = makeAddr(\"liquidity\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address griefer = makeAddr(\"griefer\");\n\n    uint256 constant DAILY = uint256(16_800_000e18) / 102;\n\n    function setUp() public {\n        vm.startPrank(owner);\n        mail = new MailToken(liquidity);\n        rewards = new DeliveryRewards(mail, owner, poster, 1 days, DAILY, 51);\n        mail.setMinter(address(rewards));\n        vm.stopPrank();\n        vm.warp(block.timestamp + 1 days); // day 0 is closed\n    }\n\n    function _tree(address a, uint256 aAmt, address b, uint256 bAmt) internal view returns (bytes32 root, bytes32 la, bytes32 lb) {\n        la = rewards.leaf(a, 0, aAmt);\n        lb = rewards.leaf(b, 0, bAmt);\n        root = la < lb ? keccak256(abi.encode(la, lb)) : keccak256(abi.encode(lb, la));\n    }\n\n    function test_HonestClaimSurvivesRevokeOfZeroLeafRoot() public {\n        // 1. A bad root (leaked key or a server export with 0-point players) with zero-amount leaves.\n        (bytes32 bad,, bytes32 lbBad) = _tree(alice, 0, bob, 0);\n        vm.prank(poster);\n        rewards.postRoot(0, bad, 0);\n\n        // 2. Anyone claims the zero leaf for alice. On this code it succeeds and flags her.\n        bytes32[] memory p = new bytes32[](1);\n        p[0] = lbBad;\n        vm.prank(griefer);\n        (bool ok,) = address(rewards).call(abi.encodeCall(DeliveryRewards.claim, (alice, 0, 0, p)));\n        emit log_named_uint(\"zero-amount claim accepted\", ok ? 1 : 0);\n\n        // 3. Owner withdraws the bad root (nothing was minted, so the revoke must be allowed or the\n        //    zero claim must have been rejected; either way the honest flow below must work).\n        vm.prank(owner);\n        (bool revoked,) = address(rewards).call(abi.encodeCall(DeliveryRewards.revokeRoot, (0)));\n        emit log_named_uint(\"revoke accepted\", revoked ? 1 : 0);\n        assertTrue(revoked, \"revokeRoot must be possible when nothing was minted from the day\");\n\n        // 4. Poster posts the honest root for the same day.\n        (bytes32 good,, bytes32 lbGood) = _tree(alice, 25_000e18, bob, 15_000e18);\n        vm.prank(poster);\n        rewards.postRoot(0, good, 40_000e18);\n\n        // 5. Expected: alice receives 25,000 MAIL. Actual on this code: revert AlreadyClaimed().\n        p[0] = lbGood;\n        rewards.claim(alice, 0, 25_000e18, p);\n        assertEq(mail.balanceOf(alice), 25_000e18, \"honest allocation must be claimable after a revoke\");\n    }\n}","reproduction":"Day 0 closed. 1) poster posts root R_bad built from leaf(alice,0,0) and leaf(bob,0,0), total 0. 2) griefer calls claim(alice, 0, 0, [leaf(bob,0,0)]) -> succeeds; claimed(0, alice) == true; days_(0).claimed == 0. 3) owner calls revokeRoot(0) -> succeeds. 4) poster posts honest root with leaf(alice,0,25_000e18), leaf(bob,0,15_000e18), total 40_000e18. 5) claim(alice, 0, 25_000e18, [leaf(bob,0,15_000e18)]). Expected: alice receives 25,000 MAIL. Actual: revert AlreadyClaimed(). Run: cd p2e/contracts && forge test --match-path test/scratch/RevokeKeepsClaimedFlags.t.sol -> FAIL AlreadyClaimed().","severity":"medium","snippet":"        if (days_[day].claimed != 0) revert AlreadyClaimedFrom();\n        totalPosted -= days_[day].total;\n        delete days_[day];","title":"revokeRoot gates on the claimed sum and keeps claimed[day][*] flags: a zero-amount leaf claimed from a bad root permanently blocks that account's honest claim for the day"},{"citation":"resolved","description":"The contract header, README and AUDIT.md guarantee 8 rely on 'a Messenger can join a wallet at most once per 24 h, so one NFT can't play for several wallets in a day'. The cooldown is a sliding window from lastAssignedAt[id], while the reward day the server meters stamina against (DeliveryRewards.currentDay, fixed windows from startTime; server game.js reads profileOf at sign-in and ledger.js counts entry.paid per wallet per day) is a fixed window. A Messenger assigned to wallet A at startTime + 12h gives A its full staminaOf for every reward day; at +36h (mid reward day 1, after A used its 12 paid deliveries) it can be unassigned, transferred and assigned to wallet B, who signs in with a fresh stamina of 12 and paid == 0 for the same reward day. Repeated daily (A <-> B alternating) one NFT yields two wallets' worth of full-point paid deliveries per reward day for the one-time cost of a second 0.005 ETH office, and since the day's budget is split by points the surplus comes out of every other player's share. Fix preserving the design: in assign, also reject a token that already joined any wallet during the current reward day (store lastAssignedDay[id] computed from the rewards contract's startTime/dayLength, or an owner-set day anchor), so a token joins at most one wallet per reward day.","line":141,"path":"p2e/contracts/src/MailOffice.sol","reproduction":"Deploy as in test/P2E.t.sol; t0 = rewards.startTime(). vm.warp(t0 + 12 hours): alice opens an office and assign(1); bob opens an office. vm.warp(t0 + 36 hours): rewards.currentDay() == 1 and office.staminaOf(alice) == 12. alice calls unassign(), transferFrom(alice, bob, 1); bob calls assign(1). Expected: TooSoon (the token already played for a wallet in reward day 1). Actual: succeeds (36h - 12h == ASSIGN_COOLDOWN), rewards.currentDay() still 1, office.staminaOf(bob) == 12, office.staminaOf(alice) == 0. Run: cd p2e/contracts && forge test --match-path test/scratch/Judge.t.sol --match-test test_OneMessengerTwoWalletsSameRewardDay (passes on this code, asserting the exploit state).","severity":"medium","snippet":"        if (last != 0 && block.timestamp < last + ASSIGN_COOLDOWN) revert TooSoon();\n        lastAssignedAt[id] = block.timestamp;","title":"Sliding 24h assign cooldown is not aligned to the reward day: one Messenger gives full stamina to two wallets in the same reward day, every day"},{"citation":"resolved","description":"dayLength and halvingDays are immutable and used as divisors in currentDay() ((block.timestamp - startTime) / dayLength) and dailyBudget() (day / halvingDays); initialDaily == 0 silently makes every budget 0. The constructor checks none of them. With a zero value every postRoot reverts with panic 0x12 and there is no setter. Because MailToken.setMinter works once and only for the deployer, a launch that wires such a DeliveryRewards as the minter permanently strands the 16.8M play allocation. AUDIT.md says there is no mainnet deploy script yet, so these parameters are typed by hand. Likelihood is operator error only, impact is an unrecoverable launch. Fix: in the constructor revert when dayLength_ == 0, halvingDays_ == 0 or initialDaily_ == 0 (custom error); zero runtime cost. The attached specialist proof fails on this code with the panic and passes once the constructor reverts.","line":63,"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/// Finding: the DeliveryRewards constructor accepts dayLength == 0 and halvingDays == 0. Both are\n/// immutable divisors, so currentDay() / dailyBudget() panic with division by zero and postRoot can never\n/// succeed. Because MailToken.setMinter is one-shot, wiring such a deployment as the minter strands the\n/// whole 16.8M play allocation with no admin path to recover.\ncontract RewardsConstructorBoundsTest is Test {\n    MailToken mail;\n    address owner = makeAddr(\"owner\");\n    address poster = makeAddr(\"poster\");\n    address liquidity = makeAddr(\"liquidity\");\n\n    uint256 constant DAILY = uint256(16_800_000e18) / 102;\n\n    function setUp() public {\n        vm.prank(owner);\n        mail = new MailToken(liquidity);\n    }\n\n    function test_ZeroHalvingDaysIsRejectedAtDeploy() public {\n        // Expected: the constructor refuses a zero halving period. Actual: it deploys, and every\n        // postRoot (and dailyBudget view) reverts with a division-by-zero panic forever.\n        vm.prank(owner);\n        try new DeliveryRewards(mail, owner, poster, 1 days, DAILY, 0) returns (DeliveryRewards r) {\n            vm.warp(block.timestamp + 1 days);\n            vm.prank(poster);\n            r.postRoot(0, bytes32(uint256(1)), 1); // panics: day / halvingDays with halvingDays == 0\n            fail(\"deployment with halvingDays == 0 must not be accepted\");\n        } catch {}\n    }\n\n    function test_ZeroDayLengthIsRejectedAtDeploy() public {\n        vm.prank(owner);\n        try new DeliveryRewards(mail, owner, poster, 0, DAILY, 51) returns (DeliveryRewards r) {\n            vm.warp(block.timestamp + 1 days);\n            vm.prank(poster);\n            r.postRoot(0, bytes32(uint256(1)), 1); // panics: (now - start) / dayLength with dayLength == 0\n            fail(\"deployment with dayLength == 0 must not be accepted\");\n        } catch {}\n    }\n}","reproduction":"new DeliveryRewards(mail, owner, poster, 1 days, DAILY, 0) deploys. Warp 1 day; as poster call postRoot(0, bytes32(uint256(1)), 1). Expected: a constructor revert. Actual: panic: division or modulo by zero (0x12) from dailyBudget(0); dailyBudget(anyDay) view panics too. Same with dayLength_ = 0 from currentDay(). Run: cd p2e/contracts && forge test --match-path test/scratch/CtorBounds.t.sol -> both tests FAIL with panic 0x12.","severity":"medium","snippet":"        dayLength = dayLength_;\n        initialDaily = initialDaily_;\n        halvingDays = halvingDays_;","title":"DeliveryRewards constructor accepts dayLength == 0 or halvingDays == 0: the immutable divisors make every postRoot and the budget views panic forever, and the one-shot minter cannot be re-pointed"},{"citation":"resolved","description":"AUDIT.md says costs are owner-adjustable so the ladder can track a dollar target as the price moves, i.e. setUpgradeCost / setLevelCost / Wardrobe.list are routine operations while players transact. upgrade() reads upgradeCost[tier + 1], levelUp() reads levelCost[next] (line 164) and Wardrobe.buy reads it.price (Wardrobe.sol:51) at execution, and none takes a maximum the caller agreed to, while MessengerNFT.mint pins msg.value == price * quantity. The project's own tests approve type(uint256).max to the office, so a player who signed upgrade() at 7,000 MAIL pays whatever a retune mined first set, and 75% of the overpayment is burned irreversibly. No malice is needed; ordinary retune timing suffices. Reported by three specialists; merged. Fix: add a maxCost parameter to upgrade, levelUp and buy and revert when the current cost exceeds it (or make cost changes take effect after a delay).","line":126,"path":"p2e/contracts/src/MailOffice.sol","reproduction":"alice has an open office, 100,000 MAIL and approve(office, type(uint256).max); upgradeCost(2) == 7_000e18. Owner calls setUpgradeCost(2, 70_000e18); then alice's upgrade() executes. Expected: revert or a charge of at most 7,000e18. Actual: balanceOf(alice) drops by 70_000e18 (52,500 burned, 17,500 to treasury), tierOf(alice) == 2. Run: cd p2e/contracts && forge test --match-path test/scratch/Judge.t.sol --match-test test_UpgradeChargesRetunedPrice (asserts alice is left with 30,000e18 and treasury has 17,500e18 on this code). Same sequence with setLevelCost before levelUp and list() before buy().","severity":"low","snippet":"        uint256 cost = upgradeCost[tier + 1];\n        _spend(msg.sender, cost);","title":"upgrade(), levelUp() and Wardrobe.buy() charge whatever the owner-adjustable cost is at execution time, with no caller-supplied maximum"},{"citation":"resolved","description":"Guarantees 11 and 16 say every MAIL spend burns exactly 75%. burnPart = amount * 7500 / 10_000 truncates and the treasury gets amount - burnPart, so rounding always favours the treasury over the burn; the same formula is at Wardrobe.sol:52. setUpgradeCost, setLevelCost and Wardrobe.list only forbid zero, not amounts that are not multiples of 4 wei. The launch ladder (whole e18 values) is exact; the deviation appears as soon as a cost is retuned to a dollar target or an item is priced in odd wei. The amount is at most 3 wei per spend, so the economic effect is nil, but the stated invariant does not hold and a 1 wei price burns nothing. Reported by two specialists (math low, economics info); merged. Fix: round the burn up ((amount * BURN_BPS + 9_999) / 10_000) or require amount % 4 == 0 in the setters.","line":252,"path":"p2e/contracts/src/MailOffice.sol","reproduction":"Owner calls office.setUpgradeCost(2, 3); alice opens an office, approves, calls upgrade(). Expected: 75% of 3 burned. Actual: totalSupply drops by 2, treasury receives 1 (66.7% burned). Owner calls wardrobe.list(1, 1, 0); alice buys 1. Expected 75% burned; actual 0 burned, 1 wei to treasury. Run: cd p2e/contracts && forge test --match-path test/scratch/Judge.t.sol --match-test test_BurnRoundsDown (asserts those values on this code).","severity":"low","snippet":"        uint256 burnPart = amount * BURN_BPS / 10_000;\n        mail.burnFrom(from, burnPart);\n        mail.transferFrom(from, treasury, amount - burnPart);","title":"The 75% burn rounds down in MailOffice._spend and Wardrobe.buy: costs not divisible by 4 wei burn less than 75% and the treasury receives the remainder"},{"citation":"resolved","description":"saleClosedAt is only written by setSaleOpen(false), so before the sale was ever opened it is 0 and block.number <= 0 is false for every real block: reveal passes with no sale having happened. Once seed != 0, setSaleOpen(true) and ownerMint revert AlreadyRevealed by design, so a reveal sent out of order by the launch operator leaves the collection at its pre-sale count (0, or whatever was owner-minted) forever, with no recovery short of redeploying the NFT, the office and every integration. AUDIT.md accepts 'unsold NFTs stay unminted' after a closed sale; the never-opened case is a degenerate boundary, not a stated intent. Fix: require that a sale took place, e.g. `if (saleClosedAt == 0) revert RevealTooEarly();`.","line":163,"path":"p2e/contracts/src/MessengerNFT.sol","reproduction":"Deploy MessengerNFT with any commit; never call setSaleOpen(true); advance one block; owner calls reveal(secret). Expected: RevealTooEarly. Actual: success, seed != 0; afterwards setSaleOpen(true) and ownerMint(owner, 1) both revert AlreadyRevealed and totalMinted stays at its pre-reveal value permanently. Run: cd p2e/contracts && forge test --match-path test/scratch/Judge.t.sol --match-test test_RevealBeforeSaleOpened (asserts this on the current code).","severity":"low","snippet":"        if (saleOpen || block.number <= saleClosedAt) revert RevealTooEarly();","title":"reveal() succeeds when the sale never opened (saleClosedAt == 0), after which no Messenger can ever be minted"},{"citation":"resolved","description":"setLocked is gated on msg.sender == office, a single mutable slot, and nothing records which office locked a token. After an honest office migration (new ladder, bug fix) every assigned Messenger stays locked: unassign() on the old office reverts NotOffice from setLocked, transferFrom reverts IsLocked, and the new office has no record of the assignment. The holder's only escape is to pay 0.005 ETH for an office in the new contract and assign then unassign there (or the owner toggles the office slot back and forth). This contradicts guarantee 9 ('unassign unlocks it') for every holder at migration time. Fix: record the locker (lockedBy[id]) and let the current office or the recorded locker clear a lock, or let setOffice refuse while tokens are locked, or add an owner-only unlock.","line":201,"path":"p2e/contracts/src/MessengerNFT.sol","reproduction":"alice owns Messenger #1, opened an office, called assign(1); nft.locked(1) == true. Owner deploys office2 and calls nft.setOffice(office2). alice calls office.unassign(). Expected: locked(1) == false. Actual: revert NotOffice(); locked(1) stays true; nft.transferFrom(alice, bob, 1) reverts IsLocked(). Only office2.open{value: 0.005 ether}(0); office2.assign(1); office2.unassign() unlocks it. Run: cd p2e/contracts && forge test --match-path test/scratch/Judge.t.sol --match-test test_SetOfficeStrandsLocked (asserts the stranded state and the paid recovery on this code).","severity":"low","snippet":"        office = office_;\n    }","title":"setOffice strands every Messenger locked by the previous office: unassign on the old office reverts NotOffice and the token cannot move until the holder pays for a second office"},{"citation":"resolved","description":"Guarantee 2 says setMinter 'works once'. The guard is minter != address(0), so a call with address(0) passes, writes nothing useful and the function stays callable until a non-zero address is given. Only the deployer can trigger it and no third party gains access, so this is a correctness note on the stated guarantee rather than a bypass. Fix: add a bool minterSet or revert on minter_ == address(0).","line":36,"path":"p2e/contracts/src/MailToken.sol","reproduction":"deployer: new MailToken(liq); setMinter(address(0)) -> succeeds (expected: revert or consume the one shot); setMinter(address(0)) -> succeeds again; setMinter(0xBEEF) -> succeeds, minter() == 0xBEEF. Run: cd p2e/contracts && forge test --match-path test/scratch/Judge.t.sol --match-test test_SetMinterZero.","severity":"info","snippet":"        if (minter != address(0)) revert MinterAlreadySet();","title":"setMinter's one-shot guard is keyed on the zero sentinel: setMinter(address(0)) succeeds and leaves the function callable"},{"citation":"resolved","description":"_deployer is the immutable msg.sender of the constructor, there is no owner and setMinter has no other caller. The repository has no mainnet deploy script, and the IMD launch path creates everything in one factory transaction. If MailToken is created by a factory or deployment helper, _deployer is that contract, and unless it calls setMinter(rewards) in the same flow the minter is unset forever: DeliveryRewards.claim reverts NotMinter on every call while the 4.2M liquidity mint has already happened. Nothing is lost to a third party; the risk is an irrecoverable launch. Fix: take the minter (or a one-time admin) as a constructor argument, or have the deploy script assert minter() == address(rewards) before anything else is handed out.","line":29,"path":"p2e/contracts/src/MailToken.sol","reproduction":"A contract F runs new MailToken(liq) then new DeliveryRewards(mail, owner, poster, 86400, DAILY, 51) and returns without calling mail.setMinter. The EOA owner then calls mail.setMinter(rewards). Expected (per deploy wiring): minter becomes rewards. Actual: revert NotMinter() because msg.sender != F; minter() stays address(0). Run: cd p2e/contracts && forge test --match-path test/scratch/Judge.t.sol --match-test test_FactoryDeployCannotSetMinter.","severity":"info","snippet":"        _deployer = msg.sender;","title":"Only the constructor's msg.sender can ever call setMinter: a factory or helper-contract deployment that does not wire the minter in-flight leaves the play allocation unmintable"},{"citation":"resolved","description":"The 25 tests cover happy paths and documented reverts but never: post bytes32(0) or post the same day twice (RootExists is never hit); assert totalPosted after revokeRoot when more than one post touched the day; claim a zero-amount leaf or re-claim after a revoke and re-post; call claimMany at all; place assign/unassign across a DeliveryRewards day boundary (office tests use an arbitrary block.timestamp and never read rewards.currentDay()); call upgrade()/levelUp()/buy() after a cost change; call reveal() with saleClosedAt == 0; construct DeliveryRewards with zero parameters. Adding these cases pins guarantees 4, 5, 7 and 8 and would have caught the high and medium findings above.","line":466,"path":"p2e/contracts/test/P2E.t.sol","reproduction":"forge test --match-test 'test_PauseAndRevoke|test_ClaimFlow|test_DailyBudgetCap': none posts bytes32(0), none posts a day twice, none asserts totalPosted after a second post. Missing assertion: after postRoot(0, 0x0, DAILY) twice and revokeRoot(0), totalPosted should be 0 (actual on this code: DAILY). Missing assertion: with t0 = rewards.startTime(), assign at t0 + 12h then at t0 + 36h unassign/transfer/assign by a second wallet should fail if one Messenger may play for only one wallet per reward day (actual: succeeds).","severity":"info","snippet":"    function test_PauseAndRevoke() public {","title":"Test suite does not exercise the edges where the defects above live: zero root, repeated postRoot for one day, revokeRoot accounting and claim flags, claimMany, reward-day boundaries around assign, co"}],"hash":"b8f38d463b51580d7ac8b03cd5bd4670474c7bd79558a98821d176d3246048a2","nodeId":"d9d43cfc-4793-4664-a352-4389b99a4483","outcome":"completed","summary":"The review is complete and the findings file is written at the repository root.\n\n**Outcome:** 11 findings kept from the 18 specialist reports, every one reproduced against the code with Foundry tests under test/scratch/. The three zero-root reports, the three claimed-flag reports, the three max-cost reports and the two burn-rounding reports were each merged into one finding. Nothing was dropped as false: every specialist claim reproduced. No source files were changed. Dependencies were installed into the gitignored lib/ directory only for checking.\n\n**Kept, by severity:**\n- **High.** postRoot accepts a zero root, so the once-per-day guard never fires. Repeated posts strand totalPosted, and after about 102 posts the 16.8M play allocation can never be minted. The specialist proof fails on this code and passes against a patched copy with a one-line zero-root rejection.\n- **Medium.** revokeRoot checks the claimed sum, not claimants, and keeps the per-account flags. A zero-amount leaf claimed from a bad root permanently blocks that account's honest claim after re-post. I wrote a fix-agnostic proof that fails now and passes with root-keyed flags.\n- **Medium.** The sliding 24h assign cooldown is not aligned to the reward day. I confirmed in the server code that stamina is read at sign-in and paid deliveries are metered per wallet per reward day, so one Messenger yields two wallets' full stamina in one reward day.\n- **Medium.** The DeliveryRewards constructor accepts zero dayLength or halvingDays. Every postRoot then panics and the one-shot minter cannot be re-pointed. Specialist proof fails as stated.\n- **Low.** No caller-supplied cost bound on upgrade, levelUp and Wardrobe.buy. The 75% burn rounds down. reveal succeeds when the sale never opened. setOffice strands tokens locked by the old office.\n- **Info.** setMinter(0) does not consume the one shot. Only the constructor caller can set the minter, which matters for factory deployments. The test suite misses the edges above.\n\n**Proofs attached:** three, on the high finding and two of the mediums. The cooldown finding carries no proof because the correct fix depends on how the office learns the reward day.","treeHash":null,"usage":{"cachedInputTokens":1370701,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":27646,"runtime":"claude","turns":27,"wallClockMs":406643}}],"verification":[]}