{"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":"147b18fe-465a-4578-b366-7dfb5a6a512c","kind":"audit","nodes":[{"acceptedSubmissionHash":"000b641a03551c8123d6e4aabbc2b64eff22c35d9eb509f2cf3f173357df9e3e","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":"9172e1b3ba04108f50ea7437acad267c3f463a2de1c1deb17936407eb80062ce","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":"a8562c9ba5284774912e8a6b38bcc226d2ef21b1c15eff5b0a0492c452556941","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":"5d18223bdbb07a59ee166e7deef4725e714e9dec944d9a6af9f60fcb40213392","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":"0703d9e4e6259bcdea6fc44cd4551fcd0af1abf8ab6f782f12d7ede5f45fcef7","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":"TokenDrops re-check (Courier World, Robinhood Chain 4663), commit d59c2ab, after fixing audit 9755203f.\n\nThe treasury (owner) funds drops of a launchpad token or $STAMP; players with an open post office claim coins they found with EIP-712 vouchers Finds(player, drop, day, coins, deadline) signed by the game server’s key. Since the last audit: dailyCoins is required and at least maxPerWallet (every player can collect each coin, as with golden stamps, so spots is coins placed, not a payout cap); claimMany skips expired or stale vouchers; withdraw while paused ends the drop; claimable respects pause and windows; endDrop(id, lastDay); sweep for stray tokens; SignerSet records the previous key; DROPS_OWNER required in the deploy script.\n\nMain question: if the server’s signing key leaks, what is the most an attacker can take, and can they touch anything outside the drops’ own balances? In particular, confirm they can’t pull tokens from the treasury wallet, move one drop’s tokens through another, exceed a drop’s daily cap per day (including the yesterday-plus-today window), or block the owner’s pause, setSigner and withdraw. Please also check the fixes above, the day edges, and that each drop’s tokens stay separate (held accounting).\n\nSource: contracts/src/TokenDrops.sol; tests in contracts/test/TokenDrops.t.sol.","parentJobId":null,"planHash":"e2bfd32b8396f320fb72762827708db55add6972399154c7fe6a589db93d7a23","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"147b18fe-465a-4578-b366-7dfb5a6a512c","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":"52609","feedbackHash":"e9dd2dded8ea0730a09d6b1e55ecc8bebb043a54f26b864a71f96afd562e17ee","nodeKey":"audit_economics","submissionHash":"000b641a03551c8123d6e4aabbc2b64eff22c35d9eb509f2cf3f173357df9e3e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51515","feedbackHash":"5a871d62fe4b134ddf7631f9e6692237cea656665851de8ce8bfb30c14ab1424","nodeKey":"audit_flow","submissionHash":"9172e1b3ba04108f50ea7437acad267c3f463a2de1c1deb17936407eb80062ce","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51473","feedbackHash":"f661d4f7b5a394f38a9cd95fb0fe3482583160229a49b7bdb218b4565ef7876a","nodeKey":"audit_judge","submissionHash":"a8562c9ba5284774912e8a6b38bcc226d2ef21b1c15eff5b0a0492c452556941","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52019","feedbackHash":"1be16c1abb3ddf208e62a9664c53cfd123cc2caabce62831d7090d00d520258e","nodeKey":"audit_math","submissionHash":"5d18223bdbb07a59ee166e7deef4725e714e9dec944d9a6af9f60fcb40213392","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52289","feedbackHash":"cbbffc35c7cdf865467598834afd3016da3b39fd04c28aab3bb975b66f1deda8","nodeKey":"audit_permissions","submissionHash":"0703d9e4e6259bcdea6fc44cd4551fcd0af1abf8ab6f782f12d7ede5f45fcef7","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"0668e12e138b3adb2378a7d8dd59e92bf9dbc9eb82943d94a9dd7306c6ebec5a","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"8e25456c4ae69acf","findings":[{"citation":"resolved","description":"Fix #4 of audit 9755203f made claimable() quote nothing where a claim would fail (paused, outside the drop's days, outside the two-day window). One guard of claim() is still missing from the view: _checkClaimer() requires postOffice.offices(msg.sender).open, but claimable(id, player, day, coins) never reads the post office, so it returns the full payout for a wallet that cannot claim. No funds are at risk (it is a view); the only effect is a misleading quote for a wallet without an office, and the stated guarantee 'claimable quotes nothing where a claim would fail' is not fully met. Fix: in claimable(), return (0, 0) when offices(player) is not open (one extra external view read), or document the exception.","line":167,"path":"contracts/src/TokenDrops.sol","reproduction":"Drop 0: coin, perCoin 1000e18, spots 10, maxPerWallet 5, dailyCoins 100, today, 7 days, 100 coins funded. carol has no post office. claimable(0, carol, today, 3) -> expected (0, 0); actual (3, 3000e18). claim(0, today, 3, deadline, validVoucherFor(carol)) from carol reverts NoOffice(). Verified with a scratch Foundry test against commit d59c2ab.","severity":"info","snippet":"        if (id >= _drops.length || paused) return (0, 0);","title":"claimable() quotes a payout for a wallet with no post office, where claim() reverts NoOffice"},{"citation":"resolved","description":"The answer to 'how much can a leaked signing key take' is: per drop, perCoin * (dailyCoins not yet paid for yesterday + dailyCoins for today) at the moment of the leak, then perCoin * dailyCoins more per UTC day until setPaused(true), and never more than the drop's own left. That bound was confirmed by test (60 attacker wallets with offices, vouchers for every day in [today-2, today+1] against two same-token drops: exactly 100+100 coins from the drop running since yesterday and 20 from the one that started today; the treasury balance and the other drop's left untouched; pause, withdraw, setSigner, unpause all work afterwards). The bound is only meaningful because dailyCoins is small relative to the funding: createDrop accepts any uint256 >= maxPerWallet, with no relation to spots or to amount, and the admin UI's funding suggestion is exactly perCoin * dailyCoins * days, i.e. a leak takes 2/days of a drop before the pause. A typo such as dailyCoins = 1e6 (or type(uint256).max) is accepted and silently removes the cap, leaving left as the only limit. This is an owner parameter, not a bypass; it is recorded here as the trust assumption the main question rests on. Optional hardening: cap dailyCoins at a multiple of spots (e.g. spots * 1000) or at amount / perCoin / days_, so a leak can never take more than a bounded share of a drop per day.","line":290,"path":"contracts/src/TokenDrops.sol","reproduction":"createDrop(coin, 1000e18, 10, 5, type(uint256).max, today, 7, 700_000e18) succeeds (no BadDrop). With the signer key and 140 wallets with offices, claim(id, today, 5, ...) from each pays 5 coins each: paidCoins(id, today) = 700, drop.left = 0 in one day. Expected per the design note 'a leaked key could claim at most each drop's dailyCoins a day': a day's payout bounded well below the whole drop; actual: the whole drop, because dailyCoins was accepted unbounded.","severity":"info","snippet":"                || dailyCoins < maxPerWallet || days_ == 0 || days_ > MAX_DAYS || startDay < today()","title":"dailyCoins has no upper bound, so the leaked-key exposure is only as tight as the operator's input"},{"citation":"resolved","description":"The 33 tests cover each fix from audit 9755203f, but the edges that back the leaked-key bound are only partly exercised: (a) the fuzz runs on a single day with two wallets, so the per-day cap over the yesterday-plus-today window with many wallets (the leaked-key scenario) is never fuzzed; (b) there is no stateful test that held[token] == sum of left over same-token drops stays true through interleaved createDrop, topUp, claim, endDrop, paused and unpaused withdraw, stray transfers and sweep; (c) no test asserts claimable() returns 0 for a wallet without an office (it does not, see the separate note); (d) claimMany with an UnknownDrop entry reverting the batch, topUp after withdraw on a finished drop followed by a second withdraw, and endDrop(id, lastDay > endDay) being a no-op are documented but untested. During this review a 60-wallet two-drop leaked-key test and a handler-based invariant fuzz (9,000 calls in 3 long sequences plus 20,000 in short ones) were run on d59c2ab and all held: paidCoins[id][day] <= dailyCoins for every day, paidCoins == 0 outside [startDay, endDay], held == sum(left), balance >= held, deposits == balance + claims + withdrawals + sweeps. Adding those as permanent tests would pin the guarantee the AUDIT.md trust note states.","line":641,"path":"contracts/test/TokenDrops.t.sol","reproduction":"Run forge test --match-path test/TokenDrops.t.sol and grep for a test that (1) warps across midnight and claims for both day-1 and day from more than two wallets with offices asserting paidCoins per day, or (2) asserts drops.held(token) == sum of drop(i).left after a topUp + paused withdraw + sweep sequence, or (3) calls claimable() for carol (no office). None exists: expected coverage of the stated leaked-key bound and held invariant; actual: single-day fuzz, two wallets, no invariant handler.","severity":"info","snippet":"    function testFuzz_NeverOverpays(uint16[6] memory finds, uint8 daily) public {","title":"Suite does not exercise the two-day window across many wallets, held accounting under mixed operations, or the view/write gap for non-offices"}],"hash":"000b641a03551c8123d6e4aabbc2b64eff22c35d9eb509f2cf3f173357df9e3e","nodeId":"31f3b2b6-b214-4d55-b876-e83bdd8c15e3","outcome":"completed","summary":"Everything stated in the findings file is now verified by a passing scratch test, and `.imd-findings.json` sits at the repository root with three entries. No source files were changed. The scratch tests live only under `contracts/test/scratch/`, which is not kept.\n\n## Result\n\n**The main question is answered clean.** A leaked server key cannot reach anything outside the drops' own balances, and the contract at commit d59c2ab enforces the bound the AUDIT.md trust note claims.\n\nWhat a key holder can take, confirmed by a 60-wallet, two-drop test against `contracts/src/TokenDrops.sol`:\n\n- Per drop, at the moment of the leak: whatever is left of yesterday's `dailyCoins` plus today's `dailyCoins`, times `perCoin`. Then one more `dailyCoins` per UTC day until `setPaused(true)`. Never more than that drop's own `left`.\n- They need one post office per `maxPerWallet` coins, each paid in ETH to the treasury.\n- The treasury wallet is untouchable: the only `transferFrom` is in `_pull`, which pulls from `msg.sender` under `onlyOwner`, and the admin UI approves exact amounts, not an open allowance.\n- One drop's tokens cannot move through another: `left`, `paidCoins` and `claimed` are keyed by drop id, `held[token]` equals the sum of `left`, and a stateful fuzz of 9,000 interleaved calls (create, top-up, claim, end, paused and unpaused withdraw, stray transfers, sweep, time warps) never broke it.\n- `setPaused`, `setSigner` and `withdraw` make no call the attacker can influence, so they cannot be blocked. The recovery sequence pause, withdraw, rotate key, unpause was exercised end to end.\n\n**All seven fixes from audit 9755203f check out** in the code and tests: `dailyCoins` required and at least `maxPerWallet`, stale batch entries skipped, paused withdraw ends the drop, `claimable` honours pause and windows, `endDrop(id, lastDay)` refuses a past day, `sweep` takes only the excess over `held`, `SignerSet` carries the previous key, and `DROPS_OWNER` is required on chain 4663. The day edges are consistent to the second: claims for day D stop exactly when `today() == D + 2`, which is the same instant `withdraw` opens.\n\n**Three info-level notes were filed,** none a bypass:\n\n1. `claimable()` still quotes a payout for a wallet with no post office, where `claim()` reverts with `NoOffice`. One guard missing from the view after fix #4.\n2. `dailyCoins` has no ceiling. The leaked-key bound rests entirely on the operator typing a sensible number; `type(uint256).max` is accepted and lets a key holder drain a whole drop in one day. Recorded as the trust assumption the main answer depends on, with an optional hardening.\n3. The test suite does not pin the guarantee: the fuzz runs one day with two wallets, and nothing asserts the `held` invariant under mixed operations or the non-office view gap. The scratch tests that did so are described in the finding so the author can adopt them.\n\nNo critical, high, medium or low defects were found, so no proof files were attached.","treeHash":null,"usage":{"cachedInputTokens":2141738,"inputTokens":706,"model":"claude-fable-5-1","outputTokens":40173,"runtime":"claude","turns":31,"wallClockMs":965239}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"ee9fbaf2480d1034","findings":[{"citation":"resolved","description":"Confirmed bounds for a leaked server key: the only token-moving path it reaches is _claim, which pays msg.sender (a wallet with an open post office) at most maxPerWallet coins per (drop, day) and at most dailyCoins per (drop, day) across everyone, from that drop's own `left` only. There is no transferFrom path outside the onlyOwner createDrop/topUp (_pull uses msg.sender), so the treasury's allowance cannot be spent; `held` and `left` move together so one drop cannot pay from another's balance or from strays; setPaused, setSigner and withdraw make no call an attacker controls, so they cannot be blocked. The quantification differs from AUDIT.md lines 471-472 (\"up to each drop's dailyCoins a day (for today and yesterday)\"): because CLAIM_DAYS = 2 keeps yesterday open until 23:59:59 and a new day opens at 00:00:00, an attacker who times the drain at the midnight edge takes D-1, D and D+1 caps before any owner reaction is possible, i.e. up to 3 * dailyCoins * perCoin per running drop (bounded by `left`), plus dailyCoins per further day until pause. Each day's drain needs ceil(dailyCoins / maxPerWallet) wallets with an office (officePrice 0.005 ETH each), which is cheap relative to any drop worth attacking. Per-day caps themselves hold: an eleventh wallet on a drained day gets NothingToClaim. Fix is documentation/operations only: state the 3-day edge bound in the trust note and alert on Claimed volume so the owner can pause quickly; no contract change is required by the agreed design.","line":243,"path":"contracts/src/TokenDrops.sol","reproduction":"Drop: createDrop(coin, perCoin=1e18, spots=10, maxPerWallet=5, dailyCoins=20, startDay=today, days=30, amount=1_000_000e18). Warp to 23:59:59 on day D=start+1. Attacker signs vouchers with the leaked key for 10 wallets (each with an open office) for day D-1 and 10 wallets for day D, coins=65535 each: paid = 40e18 (20 coins each day). An 11th wallet's claim for day D reverts NothingToClaim (cap holds). Warp 1 second to 00:00:00 of D+1: 10 more wallets claim day D+1: paid = 60e18 total, 3 x dailyCoins within one second. A claim for day D-1 now reverts TooLate. Afterwards setPaused(true), setSigner(new), withdraw(id, treasury) all succeed; treasury balance is 1e30 - 60e18 (allowance untouched); drops contract balance 0. Expected per AUDIT.md: at most 2 x dailyCoins before the next day. Actual: 3 x dailyCoins reachable in one second at the day edge.","severity":"info","snippet":"        else if (now_ >= day + CLAIM_DAYS) stale = TooLate.selector;","title":"Leaked signer key: worst case is three day-caps per drop within one second across midnight, not the two days the trust note states"},{"citation":"resolved","description":"createDrop bounds spots (1..48), maxPerWallet (1..spots) and days (1..365) but only lower-bounds dailyCoins (>= maxPerWallet). dailyCoins is a uint256 coin count next to perCoin, a uint256 token amount; the two are easy to confuse (the test suite passes dailyCoins as plain integers like 1000 and perCoin as 1_000e18). A value like 1000e18 is accepted and makes dayLeft in _payable always larger than room, so the day's payout is bounded only by `left` and by how many office wallets a leaked key can route through (maxPerWallet * perCoin each, 0.005 ETH per office). That is exactly the state fix #1 of audit 9755203f closed for dailyCoins = 0. Validation asymmetry: every other sizing input is sanity-capped, this one is not. Minimal fix preserving the design: reject dailyCoins above a sane coin-count ceiling, e.g. `dailyCoins > type(uint16).max` (or `dailyCoins > uint256(spots) * SOME_FACTOR`) in the same BadDrop check; the owner's intended range (tens to thousands of coins) is unaffected.","line":290,"path":"contracts/src/TokenDrops.sol","reproduction":"vm.prank(owner); drops.createDrop(coin, 1e18, 10, 5, 1000e18, today, 7, 1e24) — expected: BadDrop (a day cannot pay 1e21 coins when 10 are out); actual: succeeds and drop.dailyCoins == 1e21. Then with the signer key, N wallets with an office each claim 5 coins for the same day; paidCoins grows past any realistic day bound (e.g. 200 wallets -> 1000 coins == 1e21 tokens from one day) and _payable's dayLeft never binds, so the only cap left is `left`.","severity":"low","snippet":"                || dailyCoins < maxPerWallet || days_ == 0 || days_ > MAX_DAYS || startDay < today()","title":"dailyCoins has no upper bound, so a units slip silently removes the per-day cap that audit fix #1 relies on"},{"citation":"resolved","description":"Audit fix #3 ends a drop that is emptied by withdraw while paused so its coins stop going out. The same state (running drop, left < perCoin) reached through claims does not end it: activeDrops(day) still returns it for every day up to endDay, the room lays its coins out (multiplayer/src/index.js dropsOn/layDrops read activeDrops and spots only), players collect them, and every voucher pays 0 (claim reverts NothingToClaim, claimMany skips). The code comment shows this is deliberate so a topUp can revive finds, so this is a documented design trade-off rather than a defect; it is reported because the requester asked to check fix #3 and the two paths to the same state behave differently. The same one-day gap appears on the withdraw path itself: withdraw(id, to) while paused calls _end(id, today()) with no lastDay argument, so a withdraw sent at 23:59:59 and mined at 00:00:01 ends the drop after the new day, unlike endDrop which reverts TooLate when mined late (fix #5). Options that keep the design: have the game treat `left < perCoin` as not running (read `left`/`perCoin` in dropsOn), or let withdraw take a lastDay like endDrop. No funds are at risk either way.","line":189,"path":"contracts/src/TokenDrops.sol","reproduction":"createDrop(coin, perCoin=1e18, spots=10, maxPerWallet=10, dailyCoins=1000, startDay=today, days=7, amount=1e18). Alice claims 10 coins on day start: pays 1e18, drop.left == 0. activeDrops(start+3) returns [id] and claimable(id, alice, start+3, 1) returns (0, 0) for the whole week. Expected (by the rationale of fix #3): a drop with nothing to pay stops putting coins out. Actual: listed and laid out until endDay = start+6. Second state: setPaused(true) then withdraw(id, treasury) in a tx mined at 00:00:01 after being sent at 23:59:59 -> endDay = the new day, coins out all that day paying nothing.","severity":"info","snippet":"        // As many as the drop's tokens, and the day's coins, can cover; the rest stays claimable if they grow.","title":"A running drop with nothing left stays in activeDrops, so its coins keep going out and pay nothing (asymmetric with the withdraw-ends-the-drop fix)"},{"citation":"resolved","description":"The suite (33 tests, all passing on d59c2ab) covers the fixes from audit 9755203f and the day edges to the second, but the question this re-check asks (what a leaked key can take) has no test, and a few owner-path edges are only implied. All of the following were exercised in a scratch test and behave correctly today; they are listed so a future change cannot silently regress them: (1) a leaked key drains exactly dailyCoins per (drop, day) across many office wallets, 3 days of caps across a midnight edge, and the treasury's allowance and the other drops' `left` are untouched; (2) endDrop(id, lastDay) with lastDay > endDay is a no-op (cannot extend a drop); (3) withdraw of a not-yet-started, not-cancelled drop reverts StillRunning while not paused; (4) topUp on a cancelled drop followed by withdraw returns held to 0; (5) a voucher for the day before startDay reverts NotRunning and a drop started yesterday pays yesterday's finds today; (6) claimMany with one unknown drop id reverts the whole batch. The fuzz only runs one day with two wallets; a multi-day, multi-wallet invariant (sum of Claimed per (drop, day) <= dailyCoins and sum of `left` == held per token) would cover the accounting the re-check asked about.","line":641,"path":"contracts/test/TokenDrops.t.sol","reproduction":"Add to the suite, for example: warp to uint256(day+1)*1 days - 1, claim day-1 and day with 10 office wallets each on a drop with dailyCoins=20, maxPerWallet=5; assert paidCoins(id, day-1) == 20 and paidCoins(id, day) == 20 and an 11th wallet reverts NothingToClaim; warp 1 second and assert day+1 pays 20 more and day-1 reverts TooLate; assert coin.balanceOf(treasury) only dropped by the amount funded. Expected and actual agree on current code; the finding is the missing coverage, not a behaviour defect.","severity":"info","snippet":"    function testFuzz_NeverOverpays(uint16[6] memory finds, uint8 daily) public {","title":"Untested edges in TokenDrops.t.sol: key-leak bound across midnight, endDrop cannot extend, unstarted-drop withdraw, top-up of a cancelled drop"}],"hash":"0703d9e4e6259bcdea6fc44cd4551fcd0af1abf8ab6f782f12d7ede5f45fcef7","nodeId":"bcc5f94f-e66e-4693-a167-e1fdc62a23c5","outcome":"completed","summary":"The review is complete. The findings file `.imd-findings.json` holds four entries, no source files were changed, and the scratch tests were removed.\n\n**Answer to the main question.** A leaked server key cannot reach anything outside the drops' own balances. Every path I traced holds:\n\n- **Treasury wallet is safe.** The only `transferFrom` is in `_pull`, which pulls from `msg.sender` inside owner-only `createDrop` and `topUp`. No public function can spend the treasury's allowance.\n- **Drops stay separate.** `left` and `held` move together on every claim, top-up and withdraw, and `_payable` caps a claim at `left / perCoin`. A drained drop reverts `NothingToClaim` while a sibling drop of the same token keeps its full balance.\n- **Daily cap holds.** `paidCoins[id][day]` bounds each (drop, day) at `dailyCoins`. An extra wallet on a drained day gets nothing.\n- **Owner cannot be blocked.** `setPaused`, `setSigner` and `withdraw` make no call the attacker influences, and all succeed after a drain.\n- **Fixes confirmed.** `dailyCoins` is required and at least `maxPerWallet`, `claimMany` skips expired and stale vouchers but a forged one still reverts, withdraw while paused ends the drop, `claimable` respects pause and windows, `endDrop` reverts when mined late and can only shorten, `sweep` takes only strays, `SignerSet` carries the previous key, and the deploy script requires `DROPS_OWNER` on chain 4663. The existing 33 tests pass.\n\n**Findings written (no critical or high, so no proof tests attached):**\n\n1. **Info, exposure bound.** The worst case at the midnight edge is three day-caps per drop within one second (yesterday, today, then the new today), not the two days the trust note in AUDIT.md states. Verified with a scratch test that drained 60 coins on a 20-per-day drop in one second. Documentation and alerting only.\n2. **Low, unbounded `dailyCoins`.** Spots, per-wallet and days are sanity-capped but `dailyCoins` only has a floor. A units slip such as `1000e18` is accepted and silently removes the per-day bound that audit fix #1 relies on. Suggested a ceiling in the same `BadDrop` check.\n3. **Info, exhausted drop stays active.** A running drop drained by claims stays in `activeDrops` and keeps putting out coins that pay nothing, unlike the withdraw-while-paused path. The code comment shows this is deliberate for top-ups, so it is reported as a design trade-off. The same one-day gap exists when a paused withdraw mines after midnight.\n4. **Info, test coverage.** The suite has no test for what a leaked key can take, nor for `endDrop` being unable to extend, withdrawing an unstarted drop, or topping up a cancelled drop. All behave correctly today.\n\n**On the earlier rejection.** The clone failure was the verifier's submodule fetch, not something in the work. This checkout already has `forge-std`, OpenZeppelin and `v4-core` populated under `contracts/lib`, and I left them untouched as the rules require.","treeHash":null,"usage":{"cachedInputTokens":1273220,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":36741,"runtime":"claude","turns":27,"wallClockMs":656617}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"be3be4cc237417f9","findings":[{"citation":"resolved","description":"Audit 9755203f #5 changed endDrop to take the intended last day and revert with TooLate when the transaction is mined after it, so a drop can't run a day longer than meant. The paused withdraw path has the same midnight race and no guard: withdraw() ends a running drop with _end(id, today()), where today() is read when the transaction is mined, not when the owner sent it. A withdraw sent at 23:59:50 on day D and mined at 00:00:05 on D+1 sets endDay = D+1 instead of D. The drop keeps left == 0 but activeDrops(D+1) still lists it, so the game server (multiplayer/src/index.js dropsOn/layDrops) lays its coins out on D+1; every voucher for them pays nothing (claim reverts NothingToClaim; in a batch the drop's claims are skipped), and the owner can't shorten it afterwards because endDrop(id, D) reverts TooLate (lastDay < today). No tokens are at risk (the drop holds nothing), so the impact is a day of players collecting coins that can't pay, for a drop the owner meant to stop. Boundary: the day boundary between sending and mining; assumption: today() in withdraw is the owner's 'today'; actual: it is the miner's. Minimal fix that keeps the design: give withdraw the intended last day exactly as endDrop has it, e.g. withdraw(uint256 id, address to, uint32 lastDay) with `if (lastDay < today()) revert TooLate();` and `_end(id, lastDay);` (a cancelled or finished drop is unaffected since _end only shrinks endDay). Alternatively the admin UI can send endDrop(id, today) first and only withdraw once that is mined (withdraw's _end is then a no-op), but the contract then relies on the UI for the guard endDrop already enforces.","line":343,"path":"contracts/src/TokenDrops.sol","reproduction":"State: drop a = createDrop(coin, 1000e18, spots 10, maxPerWallet 5, dailyCoins 100, startDay D=30, days 30, amount 100_000e18) so endDay = 59. Owner calls setPaused(true) at timestamp 31*86400-10 (23:59:50 on day 30), then withdraw(a, treasury) is mined at timestamp 31*86400+5 (00:00:05 on day 31), then setPaused(false). Expected (the intent the endDrop fix enforces): the drop ends after day 30, or the transaction reverts TooLate like endDrop would. Actual: drop(a).endDay == 31, activeDrops(31) == [a], drop(a).left == 0, and endDrop(a, 30) reverts TooLate; a player with an open office and a valid voucher Finds(player, a, 31, 3, deadline) gets NothingToClaim. (Checked with a scratch Foundry test against commit d59c2ab.)","severity":"low","snippet":"        _end(id, today());","title":"withdraw while paused ends the drop at mining time, so one mined after midnight leaves an emptied drop listed (coins out, nothing behind them) a day longer than meant"},{"citation":"resolved","description":"Answer to the main question, traced through the code and checked with scratch tests (no defect). Per drop, a voucher only pays through _payable, which caps the payout by (1) maxPerWallet minus claimed[id][day][player] per wallet per day, (2) dailyCoins minus paidCoins[id][day] per day across everyone, and (3) floor(left / perCoin) coins of that drop's own tokens; paidCoins and left are increased/decreased before the transfer and bound every later claim, so the per-day cap holds across any number of wallets, batches or transactions. _claim rejects day > today() (FutureDay) and today() >= day + 2 (TooLate), so on calendar day T a key holder can fill at most the caps of days T-1 and T; over k calendar days of a leak that is k+1 day-caps. Upper bound per drop = min(left, sum over days in [max(startDay, L-1), min(endDay, P)] of (dailyCoins - paidCoins[day]) * perCoin), with L the first day of the leak and P the day the owner pauses or rotates the key; summed over drops. Note dailyCoins has no upper bound (only dailyCoins >= maxPerWallet is required), so the bound is only as tight as the owner sets it: with dailyCoins >= left / perCoin a drop can be emptied in a day. Bypassing maxPerWallet needs one open post office per extra wallet (officePrice, 0.005 native per launch.env, paid once per wallet). Nothing outside the drops is reachable: tokens leave only through _claim (bounded by d.left of the drop the voucher names, with held[d.token] decremented in step) and the owner-only withdraw/sweep; _pull is reached only from the owner-only createDrop/topUp and always pulls from msg.sender (the owner), so a standing treasury allowance can't be used by anyone else; sweep only moves balanceOf(this) - held[token], and held[token] == sum of left over that token's drops on every path (createDrop, topUp, _claim, withdraw), so one drop's tokens are never paid through another and dust below one coin (left % perCoin) stays in its drop. The owner's setPaused, setSigner and endDrop make no external calls and read nothing a claimer can write; withdraw while paused depends only on d.left and a plain ERC-20 transfer (PadToken and StampToken have no hooks, taxes or blocklists), so claims can't block them, and a claimer can only front-run a pause for the two day-caps that were already available. ECDSA.recover is OpenZeppelin v5 (low-s only), the digest binds chainId, this contract, player, drop, day, coins and deadline, and replaying a voucher pays only coins not yet claimed. Trust assumption, unchanged from AUDIT.md: the owner can pause, withdraw and unpause a running drop in one block, voiding unclaimed finds.","line":191,"path":"contracts/src/TokenDrops.sol","reproduction":"Scratch test on d59c2ab: drops a and b of the same token (a: 100 coins at 1000e18, b: 50 coins, both spots 10, maxPerWallet 2, dailyCoins 10, 30 days from day 30) and a $STAMP drop g (10_000e18). On day 31 the key holder opens 20 offices and sends, from each, claimMany([Finds(w, a, 30, 65535), Finds(w, a, 31, 65535)]). Result: total paid exactly 20 coins (10 for day 30, 10 for day 31; the 6th wallet onwards gets NothingToClaim), paidCoins(a,30) == paidCoins(a,31) == 10, drop(a).left == 80 coins, drop(b).left == 50 coins and drop(g).left unchanged, held(coin) == 130 coins == balanceOf(drops), the treasury's coin and $STAMP balances unchanged. Over three further calendar days with a fresh wallet each day the take is 40 coins (4 day-caps), never more. Afterwards the owner's setPaused(true), setSigner, withdraw(a/b/g) and sweep all succeed and recover the remaining 130 coins and 10_000e18 $STAMP. A batch containing a voucher for tomorrow (day 32 on day 31) pays only the two caps, the future one being skipped.","severity":"info","snippet":"        uint256 dayLeft = d.dailyCoins > paidCoins[id][day] ? d.dailyCoins - paidCoins[id][day] : 0;","title":"Leaked signer key: confirmed bounded to dailyCoins per drop per day over the yesterday-plus-today window, nothing outside the drops' own balances is reachable, and the owner's pause, setSigner and wit"},{"citation":"resolved","description":"The 33 tests cover the day edges to the second, a stale and a forged voucher in a batch, same-token drops and the daily cap across four wallets for one day, but the behaviours the main question turns on are only partly pinned down, and the behaviours below are checked in a scratch test, not the suite (all behave as documented today except the last, which is the low finding): (1) the leaked-key bound itself: many wallets claiming both yesterday's and today's finds on one calendar day, asserting paidCoins for both days stop at dailyCoins and that the other drops, held and the treasury balance are untouched (test_BatchSkipsAStaleVoucher batches two days for one wallet without reaching either cap); (2) claimMany reverting the whole batch with UnknownDrop for a drop id >= dropCount next to a voucher that would pay (stated in claimMany's NatSpec, untested); (3) endDrop(id, today) followed by withdraw: StillRunning at 23:59:59 of D+1 and allowed at 00:00:00 of D+2 (test_WithdrawOnlyOnceNobodyCanClaim only tests a drop that ran out of days); (4) topUp after a paused withdraw: the drop keeps endDay == today and pays today's and yesterday's vouchers again from the new tokens; (5) the fuzz runs one drop, one day and two wallets, so no sequence of claims across days (where paidCoins of two days coexist with one left) or across drops of one token is ever fuzzed against held and left; (6) a withdraw while paused mined after midnight. Adding (1), (2), (3) and a two-day, two-drop variant of the fuzz would make the trust statement in AUDIT.md section 13 a tested property rather than a documented one.","line":641,"path":"contracts/test/TokenDrops.t.sol","reproduction":"Concrete inputs that pass on d59c2ab but have no test: (1) drop(coin, 1000e18, 10, 2, 10, D, 30, 100 coins); on D+1, 20 wallets with offices each send claimMany([Finds(w, id, D, 65535), Finds(w, id, D+1, 65535)]): expected total 20 coins, paidCoins(id, D) == paidCoins(id, D+1) == 10, wallets 6..20 revert NothingToClaim. (2) claimMany([Finds(alice, 0, D, 3) valid, Finds(alice, 1, D, 3) for an id with dropCount == 1]): expected revert UnknownDrop and no payout for the valid one. (3) endDrop(id, D) then withdraw at timestamp (D+2)*86400-1: expected StillRunning; at (D+2)*86400: expected success with left == 0. (4) setPaused(true); withdraw(id); setPaused(false); topUp(id, 10 coins); claim(id, D, 3): expected 3 coins paid and drop(id).endDay == D. (6) see the low finding.","severity":"info","snippet":"    function testFuzz_NeverOverpays(uint16[6] memory finds, uint8 daily) public {","title":"Edges the TokenDrops suite does not exercise: both day-caps filled by many wallets in one calendar day, an unknown drop in a batch, end-then-withdraw tail, top-up after a paused withdraw, multi-day or"}],"hash":"5d18223bdbb07a59ee166e7deef4725e714e9dec944d9a6af9f60fcb40213392","nodeId":"82f885d6-b29f-4b59-b19c-2c9928308bd5","outcome":"completed","summary":"The review is complete. The findings file is in place at the repository root and the scratch tests are removed, so the tree holds only the review output.\n\n**Outcome:** no critical, high or medium defects in TokenDrops at commit d59c2ab. The answer to the main question is confirmed by code trace and by scratch Foundry tests I ran against the contract.\n\n**What a leaked signing key can take**\n- Per drop, the take is bounded by dailyCoins times perCoin for each day of finds, over the yesterday-plus-today window. Over k calendar days of a leak that is k+1 day-caps, never more, and never beyond the drop's own balance.\n- In the probe, 20 wallets claiming both days from a 100-coin drop took exactly 20 coins. The second same-token drop, the $STAMP drop, the held accounting and the treasury wallet were untouched.\n- The bound is only as tight as the owner sets dailyCoins, which has no upper limit. Bypassing maxPerWallet costs one post office per extra wallet.\n- Pause, setSigner, endDrop and withdraw make no calls a claimer can influence. A claimer can only front-run a pause for the two caps already available.\n\n**Findings written to `.imd-findings.json`**\n- **Low:** a withdraw while paused ends the drop with the day read at mining time. Sent at 23:59:50 and mined at 00:00:05, it leaves the emptied drop listed one extra day, and the owner cannot shorten it because endDrop rejects a past day. No tokens are at risk. The endDrop fix from the previous audit already guards this race, so the suggested fix is to give withdraw the same intended last day.\n- **Info:** the confirmation above, with the formula, the probe numbers and the trust assumptions.\n- **Info:** untested edges in the suite. Many wallets filling both day-caps in one day, an unknown drop in a batch, end-then-withdraw tail timing, top-up after a paused withdraw, and the fuzz covering only one day, one drop and two wallets.\n\n**Checks run:** the existing 33 tests pass, and eight scratch probes passed covering the leaked-key bound, multi-day accumulation, isolation, owner controls under attack, dust handling, the unknown-drop batch, the end-then-withdraw tail, and the midnight withdraw. Slither was not run, as the task directs.","treeHash":null,"usage":{"cachedInputTokens":1854464,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":61225,"runtime":"claude","turns":32,"wallClockMs":1023282}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"ea89e16822824c6f","findings":[{"citation":"resolved","description":"The fix for audit 9755203f #1 makes `dailyCoins` required and at least `maxPerWallet`, and the natspec and AUDIT.md present it as the cap that bounds a day's payout and therefore what a leaked server key can take per day. But `createDrop` accepts any `uint256` for `dailyCoins` up to `type(uint256).max`. Nothing ties it to `spots`, to `MAX_SPOTS`, or to the drop's balance, so the only lower bound is the one check on this line. With a large value the day cap is vacuous: a leaked key with ceil(coins / maxPerWallet) office wallets claims the whole drop within one day, and the yesterday-plus-today window adds nothing because the drop is already empty. This is a trust/limit gap rather than a bypass: the contract enforces exactly the cap the owner sets, and the main-question answer 'at most dailyCoins*perCoin per drop per open day until pause' is only as good as the value chosen at creation. There is no on-chain or script-side sanity check and no mention in the deploy notes that dailyCoins is the loss bound. Suggested minimal fix that keeps the design: add a constant ceiling, e.g. `MAX_DAILY_COINS` (say 20 * MAX_SPOTS = 960, or spots * a documented player multiplier), check `dailyCoins > MAX_DAILY_COINS` in the same `BadDrop` condition, and state in the natspec that `dailyCoins * perCoin * 2` is the most a leaked key can take from a drop before the owner pauses. If the treasury deliberately wants uncapped drops, document that as the choice instead.","line":290,"path":"contracts/src/TokenDrops.sol","reproduction":"State: drops deployed, treasury owner, signer key known to the attacker, 20 wallets with an open post office. Input: treasury calls createDrop(coin, 1e18, spots=10, maxPerWallet=5, dailyCoins=type(uint256).max, today, 7, 100e18) -> accepted (no BadDrop). Attacker signs Finds(w_i, id, today, 5, deadline) for each of 20 wallets and calls claim from each. Expected (per the stated purpose of dailyCoins): a day's payout is bounded well below the drop's whole balance. Actual: 20 claims of 5 coins pay 100e18 total; drop.left == 0 after one block; verified in scratch test test_UnboundedDailyCapDrainsWholeDropInOneDay (passes on current code, i.e. the drain succeeds). For contrast, with dailyCoins=5 the same attacker gets 5 coins for yesterday, 5 for today, and 5 more only after midnight (scratch test test_WindowBound).","severity":"low","snippet":"                || dailyCoins < maxPerWallet || days_ == 0 || days_ > MAX_DAYS || startDay < today()","title":"dailyCoins has no ceiling, so the per-day bound on a leaked key is whatever the owner typed (a drop with dailyCoins >= left/perCoin drains in one day)"},{"citation":"resolved","description":"`d.endDay + CLAIM_DAYS` is a checked uint32 addition. `createDrop` only requires `startDay >= today()` and `days_ <= 365`, so the owner can create a drop with `startDay = type(uint32).max - 1, days_ = 1` (endDay = 4294967294). For that drop a non-paused, non-cancelled withdraw hits an arithmetic overflow panic rather than the documented StillRunning error. The tokens are not stuck: `endDrop(id, today())` cancels it (endDay = startDay - 1, so the `cancelled` short-circuit skips the addition) and `setPaused(true)` also skips it, after which withdraw succeeds. `_open` and `_claim` are safe because `day <= now_` is checked before `day + CLAIM_DAYS`. Day 4294967294 is roughly 11.7 million years away, so this is a misconfiguration edge only, reported for completeness of the day-edge review. Minimal fix: compute the comparison without the addition, e.g. `today() - CLAIM_DAYS < d.endDay` guarded for small today, or bound `startDay` in `createDrop` (e.g. `startDay <= today() + MAX_DAYS`), which also stops a drop being scheduled absurdly far ahead by mistake.","line":342,"path":"contracts/src/TokenDrops.sol","reproduction":"Input: treasury calls createDrop(coin, 1e18, 10, 5, 10, type(uint32).max - 1, 1, 1e18); then withdraw(id, treasury) with paused == false. Expected: revert StillRunning (or a clean refusal). Actual: revert with panic 0x11 (arithmetic overflow) from `d.endDay + CLAIM_DAYS`. Recovery: endDrop(id, today()) then withdraw(id, treasury) succeeds and left == 0. Verified in scratch test test_WithdrawOverflowsNearMaxDay. Note that startDay = type(uint32).max itself is refused earlier by the overflow in `startDay + days_ - 1` on line 294, also as a panic rather than BadDrop.","severity":"info","snippet":"        if (!paused && !cancelled && today() < d.endDay + CLAIM_DAYS) revert StillRunning();","title":"withdraw panics (0x11) instead of reverting StillRunning for a drop whose endDay is within CLAIM_DAYS of uint32 max"},{"citation":"resolved","description":"OpenZeppelin's `ECDSA.recover(bytes32, bytes)` reverts itself for a signature that is not 65 bytes (`ECDSAInvalidSignatureLength`), has a high `s` (`ECDSAInvalidSignatureS`), or recovers to the zero address (`ECDSAInvalidSignature`). Only a well-formed signature by the wrong key reaches the `BadSignature` branch. The security outcome is correct (nothing is paid, the batch reverts as documented for a forged voucher), and a caller can only break their own batch, so this is cosmetic: the error the front end sees differs from the one the natspec and test_RejectsForgedOrChangedVouchers describe, and the test suite does not cover malformed signatures. Minimal fix: use `ECDSA.tryRecover` and treat any error or a zero/other address as `BadSignature`, or document the three OZ errors as possible.","line":238,"path":"contracts/src/TokenDrops.sol","reproduction":"Input: a wallet with an open office calls claimMany with one Claim{drop: 0, day: today, coins: 1, deadline: max, signature: hex\"00\"} on a running drop. Expected per natspec: revert BadSignature(). Actual: revert ECDSAInvalidSignatureLength(1). Verified in scratch test test_MalformedSignatureError.","severity":"info","snippet":"        if (ECDSA.recover(digest, signature) != signer) revert BadSignature();","title":"A malformed voucher signature (wrong length or high-s) reverts claim/claimMany with ECDSA's errors rather than BadSignature"},{"citation":"resolved","description":"The 33 tests and the fuzz cover the main question well (per-wallet cap, per-day cap across wallets, yesterday/today window to the second, same-token drops staying apart, pause/withdraw/sweep, forged and stale vouchers). Edges that are exercised by no test and that bear on the review questions: (1) malformed signatures (length != 65, high-s) and that they revert the batch; (2) `topUp` on a cancelled or finished drop, and that the tokens then come back through `withdraw`; (3) `endDrop` on an already-cancelled drop and with `lastDay > endDay` (both no-ops, `endDay` never increases); (4) `held` kept separate across two *different* tokens with a withdraw of one and a sweep of the other (test_SameTokenDropsStayApart covers only one token); (5) the three-day burst at midnight (yesterday + today claimed at 23:59:59, then the new day at 00:00:00), which is the true short-term bound on a leaked key and is only described in AUDIT.md; (6) `claimable` quoting a non-zero amount for a wallet with no post office (the view skips `_checkClaimer`), which the front end should not treat as claimable; (7) `withdraw` right after a `setSigner`, i.e. that old-key vouchers already claimed are not re-payable under the new key for the same day (claimed[] is keyed on drop/day/player, not signer). None of these revealed a defect when exercised in scratch tests (items 1, 5 and the withdraw-while-paused listing), but they are the documented edges with no regression test.","line":271,"path":"contracts/test/TokenDrops.t.sol","reproduction":"Run `forge test --match-path test/TokenDrops.t.sol --match-test 'Signature|topUp|TopUp|Sweep|Edges'`: no test submits a 64- or 1-byte signature, calls topUp after endDrop/withdraw, or claims three consecutive days around one midnight; grep for `hex\"` or `ECDSAInvalid` in the test file returns nothing.","severity":"info","snippet":"    function test_RejectsForgedOrChangedVouchers() public {","title":"Test suite gaps around the edges this re-check asked about"}],"hash":"9172e1b3ba04108f50ea7437acad267c3f463a2de1c1deb17936407eb80062ce","nodeId":"f2d16b8b-0547-455a-bd3a-b1cb0736b711","outcome":"completed","summary":"The findings file is in place at the repository root (it is excluded from git by the harness's own exclude rule, which is expected for this task). The review is complete.\n\n**Outcome: no critical, high or medium defects in TokenDrops at d59c2ab.** The main question's answers all hold on the code as written, verified by reading every path and by scratch tests under `contracts/test/scratch/`:\n\n- **Most a leaked key can take:** per drop, `dailyCoins × perCoin` for each open day, so two days' caps at once (yesterday plus today) and a third immediately after midnight, until the owner pauses. The drop's own `left` is the hard ceiling. A scratch test confirmed 5+5 coins at 23:59:59, 5 more at 00:00:00, then TooLate for the oldest day.\n- **Treasury wallet:** unreachable. The only `transferFrom` path is `_pull`, called from createDrop and topUp, both onlyOwner with the owner as the payer. The signer has no role in either.\n- **Cross-drop movement:** impossible. Each claim debits the voucher's drop `left` and transfers that drop's token. `held` equals the sum of `left` for a token at every state transition (pull, claim, withdraw), so sweep can never reach drop balances.\n- **Daily cap:** `paidCoins[id][day]` is enforced per day across all wallets, and `claimed[id][day][wallet]` per wallet. No path exceeds either, including across the yesterday-plus-today window.\n- **Owner powers:** pause, setSigner and withdraw have no dependency on the signer, the token recipient or any claim state. Nothing the key holder does can make them revert.\n- **Fixes from 9755203f:** all present and behave as described. Batch skipping, withdraw-while-paused ending the drop, claimable honouring pause and windows, endDrop with lastDay refusing a late mine, SignerSet with previous key, sweep bounded by held, DROPS_OWNER required on chain 4663.\n- **Periphery:** the post office and launchpad struct layouts match the interfaces the contract decodes. The server's typed-data domain, type string and day function match the contract exactly.\n\n**Four findings written, one low and three info:**\n\n1. **Low, line 290:** `dailyCoins` has no ceiling. The loss bound for a leaked key is only as good as the number typed at createDrop. A drop with `dailyCoins = type(uint256).max` is accepted and drains fully in one block with 20 office wallets. Suggested fix is a constant ceiling in the same BadDrop check.\n2. **Info, line 342:** withdraw panics with overflow rather than StillRunning when endDay is within two days of uint32 max. Recoverable via endDrop or pause, and practically unreachable.\n3. **Info, line 238:** malformed signatures surface OpenZeppelin's ECDSA errors instead of BadSignature. Security outcome unchanged.\n4. **Info, tests:** untested edges listed, including malformed signatures, topUp after cancel, two-token held separation, and the midnight three-day burst.\n\nNo proof files were attached since nothing reached high severity. The existing 33-test suite passes on this commit.","treeHash":null,"usage":{"cachedInputTokens":1410451,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":33493,"runtime":"claude","turns":24,"wallClockMs":633833}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dc34db8e17664ebd","findings":[{"citation":"resolved","description":"Merged from three specialists (permissions, flow, economics). createDrop sanity-caps every other sizing input (spots 1..48, maxPerWallet 1..spots, days 1..365) but only lower-bounds dailyCoins (>= maxPerWallet). dailyCoins is a plain coin count sitting next to perCoin, a 1e18-scaled token amount, so a units slip (1000e18 instead of 1000) or type(uint256).max is accepted. With such a value _payable's dayLeft never binds and the only remaining limit on a day is the drop's `left`, which is exactly the state fix #1 of audit 9755203f closed for dailyCoins == 0. The main-question answer ('a leaked key takes at most dailyCoins*perCoin per drop per open day until pause') therefore rests on an unchecked owner parameter. This is an owner input, not a bypass, so it is a trust/limit gap rated low. Minimal fix that keeps the design: add a ceiling to the same BadDrop check, e.g. `dailyCoins > type(uint16).max` or a constant such as spots * 100, and state in the natspec/AUDIT.md trust note that dailyCoins * perCoin per open day is the loss bound a leak can reach. If uncapped drops are wanted, document that choice instead.","line":290,"path":"contracts/src/TokenDrops.sol","reproduction":"Scratch test test_UnboundedDailyCoins on d59c2ab (passes, i.e. the drain succeeds): treasury calls createDrop(coin, 1000e18, spots=10, maxPerWallet=5, dailyCoins=1000e18, today, 7, 100_000e18) -> accepted, drop.dailyCoins == 1e21 (expected: BadDrop or a bounded value). Then createDrop(coin, 1000e18, 10, 5, type(uint256).max, today, 7, 100_000e18) -> accepted. With the signer key, 20 wallets each with an open post office (0.005 ETH each) call claim(id, today, 5, deadline, voucher): total paid 100_000e18, drop.left == 0, paidCoins(id, today) == 100 in one block. Expected per the fix #1 rationale: a day's payout bounded well below the whole drop. Actual: the whole drop in one day; the yesterday-plus-today window adds nothing because the drop is already empty.","severity":"low","snippet":"                || dailyCoins < maxPerWallet || days_ == 0 || days_ > MAX_DAYS || startDay < today()","title":"dailyCoins has no upper bound, so the per-day cap that bounds a leaked key is only as tight as the value typed at createDrop"},{"citation":"resolved","description":"Merged from the math specialist (low) and the permissions specialist's note. Fix #5 of audit 9755203f gave endDrop(id, lastDay) an explicit last day and `if (lastDay < today()) revert TooLate()` so a transaction mined after midnight cannot run a drop a day longer than intended. The paused-withdraw path added by fix #3 has the same midnight race and no guard: it calls _end(id, today()) with today() read at mining time. A withdraw sent at 23:59:50 on day D and mined at 00:00:05 on D+1 sets endDay = D+1. The drop then has left == 0 but activeDrops(D+1) still lists it, so the room (multiplayer/src/index.js dropsOn/layDrops reads only activeDrops and spots) lays its coins out for all of D+1; every voucher for them pays nothing (claim reverts NothingToClaim, claimMany skips it), and the owner cannot shorten it afterwards because endDrop(id, D) now reverts TooLate. No tokens are at risk; the impact is a day of players collecting coins that cannot pay, for a drop the owner meant to stop, which is the state fix #3 was meant to prevent. Minimal fix preserving the design: give withdraw the intended last day exactly as endDrop has it, e.g. withdraw(uint256 id, address to, uint32 lastDay) with `if (lastDay < today()) revert TooLate();` and `_end(id, lastDay);` (a finished or cancelled drop is unaffected since _end only shrinks endDay). Alternatively have the admin UI send endDrop(id, today) first and withdraw only after it is mined.","line":343,"path":"contracts/src/TokenDrops.sol","reproduction":"Scratch test test_WithdrawPausedMinedAfterMidnight on d59c2ab (passes, showing the behaviour): createDrop(coin, 1000e18, 10, 5, 100, D=30, 30 days, 100_000e18) so endDay = 59. setPaused(true) at timestamp 31*86400-10 (23:59:50 on day 30); withdraw(id, treasury) mined at 31*86400+5 (00:00:05 on day 31); setPaused(false). Expected (the intent endDrop enforces): endDay == 30, or the transaction reverts TooLate. Actual: drop(id).endDay == 31, activeDrops(31) == [id], drop(id).left == 0, endDrop(id, 30) reverts TooLate, and alice with a valid voucher Finds(alice, id, 31, 3, deadline) gets NothingToClaim.","severity":"low","snippet":"        _end(id, today());","title":"withdraw while paused ends the drop at mining time (today()), so one mined after midnight leaves the emptied drop listed a day longer than meant, with no TooLate guard like endDrop"},{"citation":"resolved","description":"Merged from the economics specialist and the flow specialist's test-gap item. Fix #4 of audit 9755203f made claimable() return (0, 0) where a claim would fail: paused, outside the drop's days, outside the two-day window. One guard of claim() is still missing from the view: _checkClaimer requires postOffice.offices(msg.sender).open, but claimable(id, player, day, coins) never reads the post office, so it quotes the full amount for a wallet that cannot claim. It is a view, so no funds are at risk; the effect is a misleading quote in the front end and an incomplete 'claimable quotes nothing where a claim would fail' guarantee in AUDIT.md line 459. Fix: in claimable(), read postOffice.offices(player) and return (0, 0) when the office is not open, or document the exception.","line":167,"path":"contracts/src/TokenDrops.sol","reproduction":"Scratch test test_ClaimableNoOffice on d59c2ab: createDrop(coin, 1000e18, 10, 5, 100, today, 7, 100_000e18); carol has no post office. claimable(0, carol, today, 3) -> expected (0, 0); actual (3, 3000e18). carol calling claim(0, today, 3, deadline, validVoucherForCarol) reverts NoOffice().","severity":"info","snippet":"        if (id >= _drops.length || paused) return (0, 0);","title":"claimable() quotes a full payout for a wallet with no post office, where claim() reverts NoOffice"},{"citation":"resolved","description":"Merged from the permissions and math specialists; this is the answer to the main question, with one documentation discrepancy. Confirmed against the code and by test: the only token-moving path a key holder reaches is _claim, which pays msg.sender (a wallet with an open office) at most maxPerWallet - claimed per (drop, day, wallet), at most dailyCoins - paidCoins per (drop, day) across everyone, and only from that drop's own `left` (left and held[token] decrement together before the transfer, so no drop pays from another's tokens or from strays, and sweep only moves balanceOf - held). _pull is reached only from the owner-only createDrop/topUp and always pulls from msg.sender, so the treasury's standing allowance cannot be spent by anyone else. setPaused, setSigner, endDrop and withdraw make no call a claimer controls and read nothing a claimer can write (PadToken/StampToken have no hooks), so the owner cannot be blocked; a claimer can at most front-run the pause for the caps already open. Per-day caps hold: an extra wallet on a filled day gets NothingToClaim. The discrepancy: AUDIT.md lines 471-472 say a leaked key can claim 'up to each drop's dailyCoins a day (for today and yesterday)'. Because yesterday stays open until 23:59:59 and the new day opens at 00:00:00, an attacker timing the drain at the day edge fills D-1, D and D+1 within one second, i.e. up to 3 * dailyCoins * perCoin per running drop (bounded by left) before any owner reaction, then dailyCoins per further day until pause. Each day's drain needs ceil(dailyCoins / maxPerWallet) office wallets at 0.005 ETH each. No contract change is required by the agreed design; the fix is to state the 3-day edge bound in the trust note and alert on Claimed volume so the owner can pause quickly.","line":243,"path":"contracts/src/TokenDrops.sol","reproduction":"Scratch test test_ThreeCapsAtMidnight on d59c2ab (passes): createDrop(coin, perCoin=1e18, spots=10, maxPerWallet=5, dailyCoins=20, startDay=today, 30 days, 1_000_000e18) and a second same-token drop of 1_000e18. Warp to 23:59:59 on D = start+1. Four office wallets claim day D-1 with coins=65535 and four claim day D: paid 40e18, paidCoins(id, D-1) == paidCoins(id, D) == 20; a fifth wallet's claim for D or D-1 reverts NothingToClaim (cap holds). Warp 1 second to 00:00:00 of D+1: four fresh wallets claim D+1: total paid 60e18, three day-caps in one second; a claim for D-1 now reverts TooLate. The other drop's left is still 1_000e18, held(coin) == balanceOf(drops) == 1_001_000e18 - 60e18, the treasury balance only dropped by what it funded. Afterwards setPaused(true), setSigner(new), withdraw(both drops) all succeed and return 1_001_000e18 - 60e18 to the treasury. Expected per AUDIT.md: at most 2 x dailyCoins before the next day. Actual: 3 x dailyCoins reachable at the day edge.","severity":"info","snippet":"        else if (now_ >= day + CLAIM_DAYS) stale = TooLate.selector;","title":"Leaked signer key: bounded to the drops' own balances and per-day caps as designed, but the short-term worst case is three day-caps per drop within one second at midnight, not the two days the AUDIT.m"},{"citation":"resolved","description":"From the flow specialist, reproduced. `d.endDay + CLAIM_DAYS` is a checked uint32 addition and createDrop only requires startDay >= today() and days_ <= 365, so the owner can create a drop with startDay = type(uint32).max - 1, days_ = 1 (endDay = 4294967294). A non-paused, non-cancelled withdraw on it hits an arithmetic overflow panic rather than StillRunning. Tokens are not stuck: endDrop(id, today()) cancels it (the `cancelled` short-circuit skips the addition) and withdraw then succeeds; pausing also skips it. _open and _claim are safe because day <= now_ is checked before day + CLAIM_DAYS. Day 4294967294 is millions of years away, so this is a misconfiguration edge only, reported for completeness of the day-edge review. Minimal fix: bound startDay in createDrop (e.g. startDay <= today() + MAX_DAYS), which also stops a drop being scheduled absurdly far ahead by mistake, or compare without the addition.","line":342,"path":"contracts/src/TokenDrops.sol","reproduction":"Scratch test test_WithdrawOverflowNearMaxDay on d59c2ab: treasury calls createDrop(coin, 1e18, 10, 5, 10, type(uint32).max - 1, 1, 1e18), then withdraw(id, treasury) with paused == false. Expected: revert StillRunning. Actual: revert Panic(0x11) from `d.endDay + CLAIM_DAYS`. Recovery: endDrop(id, today()) then withdraw(id, treasury) succeeds, left == 0. createDrop with startDay = type(uint32).max also reverts with Panic(0x11) (from `startDay + days_ - 1` on line 294) rather than BadDrop.","severity":"info","snippet":"        if (!paused && !cancelled && today() < d.endDay + CLAIM_DAYS) revert StillRunning();","title":"withdraw panics (0x11) instead of reverting StillRunning for a drop whose endDay is within CLAIM_DAYS of uint32 max"},{"citation":"resolved","description":"From the flow specialist, reproduced. OpenZeppelin v5 ECDSA.recover(bytes32, bytes) reverts itself with ECDSAInvalidSignatureLength, ECDSAInvalidSignatureS or ECDSAInvalidSignature for a signature that is not 65 bytes, has a high s, or recovers to zero; only a well-formed signature by the wrong key reaches the BadSignature branch. The security outcome is correct (nothing is paid, the batch reverts as documented for a forged voucher, and a caller can only break their own batch), so this is cosmetic: the error the front end sees differs from the one the natspec and test_RejectsForgedOrChangedVouchers describe, and no test covers malformed signatures. Minimal fix: use ECDSA.tryRecover and treat any error or a non-signer address as BadSignature, or document the OZ errors as possible.","line":238,"path":"contracts/src/TokenDrops.sol","reproduction":"Scratch test test_MalformedSignatureError on d59c2ab: alice (open office) calls claimMany with one Claim{drop: 0, day: today, coins: 1, deadline: type(uint64).max, signature: hex\"00\"} on a running drop. Expected per natspec: revert BadSignature(). Actual: revert ECDSAInvalidSignatureLength(1).","severity":"info","snippet":"        if (ECDSA.recover(digest, signature) != signer) revert BadSignature();","title":"A malformed voucher signature (wrong length, high-s) reverts claim/claimMany with OpenZeppelin ECDSA errors rather than BadSignature"},{"citation":"resolved","description":"From the permissions specialist, reproduced; a documented design trade-off rather than a defect, recorded because the requester asked to check fix #3. Fix #3 ends a drop that withdraw empties while paused so its coins stop going out. The same state reached through claims (running drop, left < perCoin) does not end it: activeDrops(day) still lists it for every day to endDay, the room lays its coins out (dropsOn/layDrops read only activeDrops and spots), players collect them and every voucher pays 0 (claim reverts NothingToClaim, claimMany skips). The comment on this line shows the intent: a topUp can revive the finds. No funds are at risk. Options that keep the design: have the room treat left < perCoin as not running (read left/perCoin in dropsOn), or have the owner endDrop or topUp promptly on a Claimed-volume alert.","line":189,"path":"contracts/src/TokenDrops.sol","reproduction":"Scratch test test_EmptiedDropStaysActive on d59c2ab: createDrop(coin, perCoin=1e18, spots=10, maxPerWallet=10, dailyCoins=1000, startDay=today, 7 days, amount=1e18). alice claims 10 coins on day start: pays 1e18, drop.left == 0. activeDrops(start+3) returns [id] and, on day start+3, claimable(id, alice, start+3, 1) returns (0, 0). Expected by the rationale of fix #3: a drop with nothing to pay stops putting coins out. Actual: listed until endDay = start+6.","severity":"info","snippet":"        // As many as the drop's tokens, and the day's coins, can cover; the rest stays claimable if they grow.","title":"A running drop emptied by claims stays in activeDrops until endDay, so its coins keep going out and pay nothing (asymmetric with the withdraw-ends-the-drop fix)"},{"citation":"resolved","description":"Merged from all four specialists' test-coverage notes. The 33 tests (all passing on d59c2ab) cover each fix from audit 9755203f and the day edges to the second, but the question this re-check asks has no test: nothing claims yesterday's and today's finds from more than two wallets on one calendar day, nothing crosses a midnight with claims on both sides, and the fuzz runs one drop, one day and two wallets, so paidCoins of two days never coexist and the `held == sum(left)` accounting is never fuzzed through interleaved createDrop, topUp, claim, endDrop, paused withdraw, stray transfer and sweep. Also untested: claimable() for a wallet without an office (it quotes a payout, see the separate note); a 1- or 64-byte signature in a batch; claimMany reverting the whole batch with UnknownDrop next to a valid voucher (stated in the natspec); endDrop(id, lastDay > endDay) being a no-op (a drop cannot be extended); topUp on a cancelled or finished drop followed by withdraw returning held to 0; endDrop(id, today) then withdraw at 23:59:59 of D+1 (StillRunning) and 00:00:00 of D+2 (allowed); withdraw while paused mined after midnight (see the low finding). All of these were exercised in scratch tests during this review and behave as documented except the two reported above. Adding them, plus a multi-day multi-wallet invariant (sum of Claimed per (drop, day) <= dailyCoins, held[token] == sum of left over that token's drops, balanceOf >= held), would turn the AUDIT.md section 13 trust statement into a tested property.","line":641,"path":"contracts/test/TokenDrops.t.sol","reproduction":"Run `forge test --match-path test/TokenDrops.t.sol` (33 pass) and grep the file: no test warps to uint256(day+1)*1 days - 1 and claims both day-1 and day from more than two office wallets asserting paidCoins per day; no `hex\"` or `ECDSAInvalid`; no claimMany with a drop id >= dropCount; no topUp after endDrop or withdraw; no assertion that drops.held(token) equals the sum of drop(i).left after a topUp + paused withdraw + sweep sequence; claimable is never called for carol. Expected: coverage of the stated leaked-key bound and held invariant; actual: single-day fuzz with two wallets and no invariant handler.","severity":"info","snippet":"    function testFuzz_NeverOverpays(uint16[6] memory finds, uint8 daily) public {","title":"Test suite gaps around the edges this re-check asked about: leaked-key bound across midnight with many wallets, held invariant under mixed operations, claimable for a non-office, malformed signatures,"}],"hash":"a8562c9ba5284774912e8a6b38bcc226d2ef21b1c15eff5b0a0492c452556941","nodeId":"d332ddbd-4fcf-43b3-9319-b9e538283fd5","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds 8 merged findings, every one reproduced against commit d59c2ab with a scratch Foundry test, and the scratch directory has been removed so the tree is clean apart from the findings file.\n\n**Answer to the main question.** A leaked signing key reaches only `_claim`. It pays the calling wallet, which needs an open post office, at most maxPerWallet per drop per day, at most dailyCoins per drop per day across everyone, and only from that drop's own `left`. The treasury's allowance cannot be spent because `_pull` is reached only from the owner-only createDrop and topUp and pulls from msg.sender. `left` and `held` move together before every transfer, so no drop pays through another and sweep never touches drop balances. Pause, setSigner, endDrop and withdraw make no call a claimer controls, so they cannot be blocked. Confirmed by a 31-wallet two-drop test plus owner recovery afterwards.\n\n**Findings kept, by severity:**\n\n- **Low: dailyCoins has no upper bound.** createDrop caps every other sizing input but accepts any value at or above maxPerWallet, including 1000e18 or uint256 max. With such a value the day cap is vacuous and a leaked key drains a drop in one day. Three specialists reported this; merged into one.\n- **Low: withdraw while paused ends the drop at mining time.** It calls `_end(id, today())` with no intended-day argument, so a withdraw sent at 23:59:50 and mined at 00:00:05 leaves the emptied drop listed all the next day, and endDrop for the earlier day then reverts TooLate. Same race fix #5 closed for endDrop. Merged from two specialists.\n- **Info: claimable quotes a payout for a wallet with no office.** The view skips the post office check that claim enforces.\n- **Info: leaked-key bound at the day edge is three day-caps in one second**, not the two days the AUDIT.md trust note states. Caps themselves hold. Merged with the math specialist's confirmation of the bound.\n- **Info: withdraw panics near uint32 max** instead of StillRunning. Recoverable via endDrop; a misconfiguration edge only.\n- **Info: malformed signatures revert with OpenZeppelin ECDSA errors**, not BadSignature. Cosmetic.\n- **Info: a drop emptied by claims stays in activeDrops** until endDay, unlike one emptied by paused withdraw. Deliberate per the code comment, recorded as the fix #3 asymmetry.\n- **Info: test coverage gaps**, merged from all four specialists.\n\n**Dropped or merged:** the math specialist's standalone confirmation and all four test-gap notes were folded into the findings above. No specialist claim failed to reproduce. No critical or high findings exist, so no proofs were attached. The repository's own 33-test suite passes.","treeHash":null,"usage":{"cachedInputTokens":848990,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":20325,"runtime":"claude","turns":17,"wallClockMs":486959}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9506ecec995bdbf9","findings":[],"hash":"b35395a29620f970dff63cced676dbff8a940bde3e0467ff72fb062b2a4825c1","nodeId":"f2d16b8b-0547-455a-bd3a-b1cb0736b711","outcome":"failed","summary":"submodules did not fetch: Submodule 'contracts/lib/forge-std' (https://github.com/foundry-rs/forge-std) registered for path 'contracts/lib/forge-std'\nSubmodule 'contracts/lib/openzeppelin-contracts' (https://github.com/OpenZeppelin/openzeppelin-contracts) registered for path 'contracts/lib/openzeppelin-contracts'\nSubmodule 'contracts/lib/v4-core' (https://github.com/Uniswap/v4-core) registered for path 'contracts/lib/v4-co","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":null,"outputTokens":0,"runtime":"claude","turns":0,"wallClockMs":29397}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"76df9026b98e4277","findings":[],"hash":"b909034f88c04bfd84185897d4e9bb312fbdc98fe6c216c2ba7ef7d7034b78c0","nodeId":"bcc5f94f-e66e-4693-a167-e1fdc62a23c5","outcome":"failed","summary":"submodules did not fetch: Submodule 'contracts/lib/forge-std' (https://github.com/foundry-rs/forge-std) registered for path 'contracts/lib/forge-std'\nSubmodule 'contracts/lib/openzeppelin-contracts' (https://github.com/OpenZeppelin/openzeppelin-contracts) registered for path 'contracts/lib/openzeppelin-contracts'\nSubmodule 'contracts/lib/v4-core' (https://github.com/Uniswap/v4-core) registered for path 'contracts/lib/v4-co","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":null,"outputTokens":0,"runtime":"claude","turns":0,"wallClockMs":30521}}],"verification":[]}