{"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":"66b2e980-5b17-4b7f-bdf7-5a74b69fc3c5","kind":"audit","nodes":[{"acceptedSubmissionHash":"57fb36b9c838b91559e9115b9604512c927cd905bce8d4cabd014c0f39886a6e","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":"2ab2c371c03ad87a5799ca4556fb522495ff7333ac28eda953d14dc349d253d5","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":"24d0fa9c9fcea668a1f4d4c1dcd51475f1c25b94c5b2e8b6e02b79fb26e8403d","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":"0cc96be2f8003e07cc9150913efc18c68c07477645909a811ecca2ba22b175b7","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":"1278fb0f9d028b476d539df2176347aa860a8a518c498d57788b2d289aa13b53","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 0xtenang/Runner as they are.","parentJobId":null,"planHash":"43a5670e06ebbc701686bd6ce62f8e02998ece57ddad8c20edb482741dc380dc","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"66b2e980-5b17-4b7f-bdf7-5a74b69fc3c5","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":"51040","feedbackHash":"7ad620b00e9d792c7db13006d2ff0e22671666d3b7eceb08f4e860ca0a23d948","nodeKey":"audit_economics","submissionHash":"57fb36b9c838b91559e9115b9604512c927cd905bce8d4cabd014c0f39886a6e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52552","feedbackHash":"f42e7cf6653db49ffec9b7e585e98332001e5e13d4da64533af591a5a783d85d","nodeKey":"audit_flow","submissionHash":"2ab2c371c03ad87a5799ca4556fb522495ff7333ac28eda953d14dc349d253d5","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51203","feedbackHash":"242beab195177ecd9821ee69df78e5bf45c57e422f1f102f4a157a9addc15aed","nodeKey":"audit_judge","submissionHash":"24d0fa9c9fcea668a1f4d4c1dcd51475f1c25b94c5b2e8b6e02b79fb26e8403d","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51468","feedbackHash":"58834b66a95fa965c22280f6f3f71539630cb4abee263457ee40cd7af2efb7f0","nodeKey":"audit_math","submissionHash":"0cc96be2f8003e07cc9150913efc18c68c07477645909a811ecca2ba22b175b7","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52173","feedbackHash":"5dd72cc23b15b89d58878f42d5bbee6638fc17e308aed49199844115f40e56b4","nodeKey":"audit_permissions","submissionHash":"1278fb0f9d028b476d539df2176347aa860a8a518c498d57788b2d289aa13b53","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"17ae636a65064f208b8c10038de4069b6b94682d798b44067615c1e091bc8269","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ea62f3bebece9304","findings":[{"citation":"resolved","description":"_exclude (called by addPair) subtracts the account's eligible weight from the share total of each of the last 7 days, so the share no one can claim isn't left stranded. It does this even for days where the account has already claimed its share (holderClaimedDay[account][d] == true). That share has already been paid out of day d's rewards, but after the denominator shrinks, the other holders' remaining claims add up to the whole of day d's rewards again. So day d pays out rewards + the share already claimed. The code comment (and AUDIT.md item 2) says 'claims already paid can never exceed a day's tax'. That holds only when the excluded account had not yet claimed. Result: (a) if holderRewardsHeld is smaller than the overpayment, claimHolderRewards reverts with an underflow at line 237 and legitimate holders can't claim; (b) if other days' unclaimed tax covers it, the overpayment comes out of other days' holders, and day d's paidOut > rewards. After that, sweepExpired reverts at line 250 (uint256(day.rewards) - day.paidOut underflows) whenever it reaches day d, and because nextSweepDay only moves forward one day at a time, every later expired day can never be swept either. Precondition: the owner registers (addPair) a contract that exposes token0()/token1() == RUNNER, held >= 300,000 RUNNER for a full day in the last 7 days, and has already called claimHolderRewards (any pool/ALM/vault contract that can make arbitrary calls, or a contract built to do this). Fix: skip days the account has already claimed, i.e. `if (!past.started || holderClaimedDay[account][d]) continue;` (its share is already paid, so nothing is stranded). With this change the attached test passes.","line":597,"path":"contracts/RunnerToken.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.24;\n\nimport \"forge-std/Test.sol\";\nimport {RunnerToken} from \"contracts/RunnerToken.sol\";\n\ncontract MockPool {\n    address public token0;\n    constructor(address t) { token0 = t; }\n    function claim() external returns (uint256) { return RunnerToken(token0).claimHolderRewards(); }\n}\n\ncontract ExcludeClaimedTest is Test {\n    RunnerToken token;\n    address L = address(0x1111);\n    address T = address(0x2222);\n\n    function test_addPairAfterPoolClaimedOverpaysDay() public {\n        vm.warp(100 days + 1 hours);\n        token = new RunnerToken(address(this), L, T);\n        MockPool q = new MockPool(address(token));\n        MockPool p = new MockPool(address(token));\n        token.addPair(address(q));\n\n        // day 100: P receives 100M (eligible from day 101), L sells 10M through Q\n        vm.prank(T); token.transfer(address(p), 100_000_000 ether);\n        vm.prank(L); token.transfer(address(q), 10_000_000 ether);\n\n        // day 101: shares = L 90M + P 100M; L sells 1M -> 20,000 holder tax, shares 189M\n        vm.warp(101 days + 1 hours);\n        vm.prank(L); token.transfer(address(q), 1_000_000 ether);\n        (uint256 shares101, uint256 rewards101,) = token.dayInfo(101);\n        assertEq(shares101, 189_000_000 ether);\n        assertEq(rewards101, 20_000 ether);\n\n        // day 102: P (a contract exposing token0 = RUNNER) claims its day-101 share, then is registered\n        vm.warp(102 days + 1 hours);\n        p.claim();\n        token.addPair(address(p));\n\n        // L's legitimate claim must still be payable and day 101 must not pay out more than it collected\n        vm.prank(L); token.claimHolderRewards();\n        (,, uint256 paid101) = token.dayInfo(101);\n        assertLe(paid101, rewards101, \"day 101 paid out more than its holder tax\");\n    }\n}","reproduction":"Deploy at day 100 (owner = test, liquidity L = 100M, team T = 100M). Register mock pool Q. Day 100: T sends 100M to contract P (token0() returns RUNNER, can call claimHolderRewards); L sends 10M to Q. Day 101: L sends 1M to Q -> day 101 rewards = 20,000 RUNNER, shares = 189M (L 89M + P 100M). Day 102: P claims 20,000*100/189 = 10,582 RUNNER, then the owner calls addPair(P) -> day 101 shares become 89M. L calls claimHolderRewards: expected L gets 200,000 (day 100) + 20,000*89/189 = 9,417 (day 101), and day 101 pays out <= 20,000 in total. Actual: L's day-101 share is computed as 20,000*89/89 = 20,000, so day 101 would pay 30,582 > 20,000. holderRewardsHeld (209,418) < 220,000, so the call reverts with panic 0x11 and L can't claim at all. Foundry test below: fails on the current code, passes with the one-line fix.","severity":"medium","snippet":"            if (!past.started) continue;\n            uint256 w = eligibleBalanceOn(account, d);\n            past.shares -= w < past.shares ? w : past.shares;","title":"addPair/_exclude removes an already-claimed holder weight from past days' share totals, so a day pays out more than its holder tax"}],"hash":"0cc96be2f8003e07cc9150913efc18c68c07477645909a811ecca2ba22b175b7","nodeId":"090b0278-2b57-4d48-9542-353bec6e7b54","outcome":"completed","summary":"I found one defect, rated **medium**. It's written to `.imd-findings.json` with a Foundry proof that fails on the current code and passes once the one-line fix is applied.\n\n**Finding: registering a pool can make a day's holder rewards pay out more than was collected** (`contracts/RunnerToken.sol:597`, in `_exclude`)\n- **What goes wrong:** when the owner calls `addPair`, `_exclude` takes the pool's weight out of the share totals for the last 7 days. It also does this for days where that contract has already claimed its share. That share is already paid, but with the smaller total the other holders can again claim the whole day's rewards. So the day pays out its rewards plus the share already claimed. That breaks the guarantee the code comment and `AUDIT.md` item 2 make: \"claims already paid can never exceed a day's tax\".\n- **Example:**\n  1. On day 101 the holder tax is 20,000 RUNNER, split across 189M of eligible balance (L holds 89M, a contract P holds 100M).\n  2. On day 102, P claims 10,582 RUNNER. Then the owner calls `addPair(P)`, which lowers day 101's share total to 89M.\n  3. L's day-101 share now comes out as the full 20,000 instead of 9,417, so day 101 would pay 30,582 in total.\n  4. In the test, the contract doesn't hold enough, so L's `claimHolderRewards` reverts with an underflow and L can't claim anything.\n- **If the contract holds other days' unclaimed tax:** the overpayment comes out of other holders' money instead. After that, `sweepExpired` reverts whenever it reaches that day (line 250), and because the sweep moves forward one day at a time, no later expired day can be swept either.\n- **When it can happen:** the owner registers a contract that reports RUNNER as `token0()`/`token1()`, held at least 300,000 RUNNER for a full day in the last 7 days, and has already claimed holder rewards. A standard V2 pair can't claim, so the risk is pool or vault contracts that can make arbitrary calls, or a contract built for this.\n- **Fix:** skip days the contract has already claimed: `if (!past.started || holderClaimedDay[account][d]) continue;`\n\n**What else I checked in the math area, with no defect found:**\n- Holder rewards: the daily minimum and end-of-day balances, the 300,000 threshold, the share totals going down during a day, the lookup of which balance counts on a given day, rounding in the payouts, and the 7-day claim window against the sweep timing.\n- Run-reward emission: halving, the floor, promoting a pending schedule, and the daily cap.\n- Instant and early-release burns and their rounding, and the lock ordering and count limit.\n- Tax rounding, and whether any of the narrowed number types (`uint112`, `uint128`, `uint32`, `uint64`) can overflow. None can at this supply.\n- RunnerGenesis: the rarity formula matches `js/collection.js`, and over ids 1–9999 it gives 7097 common, 1904 uncommon, 795 rare and 203 epic, close to the stated 70/20/8/2%. I also checked royalty math and the owned-token list bookkeeping.\n\n**Limits:**\n- The project's own Node test suites were not run.\n- No Foundry setup or forge-std is in the repo, so I fetched forge-std into `/tmp` and passed it as a remapping on the command line.\n- The scratch test is deleted; its full source is in the findings file. Nothing in the repository was changed.","treeHash":null,"usage":{"cachedInputTokens":734525,"inputTokens":26,"model":"claude-opus-5-5","outputTokens":17629,"runtime":"claude","turns":13,"wallClockMs":201464}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c2e167266d13dcf4","findings":[{"citation":"resolved","description":"Trust gap (access x asymmetry). `_exclude`, reached only through the owner's `addPair`, subtracts the newly registered pool's weight from `_days[d].shares` for every still-claimable day d (today-7 .. today-1). `_holderShareOf` divides a day's fixed `rewards` by the day's *current* `shares`, so a holder who claimed day d before `addPair` was paid `rewards * bal / S_old`, while a holder with the same eligible balance who claims after it is paid `rewards * bal / S_new` with S_new < S_old. The total can never exceed `rewards` (the removed slice was unclaimable), but the removed slice is handed entirely to late claimers and the sweep instead of being shared pro rata, so the owner's transaction creates two user classes for the same day. Any holder who knows a pool has been receiving RUNNER without being registered (the README itself tells the owner to add liquidity first and register afterwards) is better off delaying `claimHolderRewards`; a holder who claims promptly is strictly worse off. Preconditions: a contract that answers token0()/token1() with RUNNER holds >= 300,000 RUNNER through at least one full UTC day inside the 7-day window before the owner registers it, and holder tax was collected on that day. Impact is bounded by the pool's fraction of eligible supply times that day's holder tax (in the reproduction 2.7% of each early claimer's payout). Fix that keeps the IMD #2 intent (no stranded slice): in the `_exclude` loop, skip days that already have `paidOut > 0` (freeze a day's denominator once anyone has been paid from it; the pool's slice of such days then goes to the protocol via `sweepExpired` as before), or alternatively document that `addPair` must be called in the same UTC day the pool first receives RUNNER. Verified with a Foundry scratch test (test/scratch/Leads.t.sol, test_excludeAfterPartialClaims_sweepStillWorks); the sweep and paidOut <= rewards invariant were also confirmed to hold.","line":599,"path":"contracts/RunnerToken.sol","reproduction":"Deploy day D (RunnerToken(owner, liq, team)). liq transfers 1,000,000 RUNNER each to alice and bob and 5,000,000 to a MockPair P (token0 = RUNNER) that is NOT registered. Day D+1: liq sends 10,000,000 to pool Q, owner calls addPair(Q), Q transfers 10,000,000 to a wallet: 200,000 RUNNER holder tax lands on day D+1, whose shares total is 190,000,000 (liq 83M min, team 100M, alice 1M, bob 1M, P 5M). Day D+2: alice calls claimHolderRewards() -> receives 1,052.63 RUNNER (200,000 * 1M / 190M). Owner then calls addPair(P): dayInfo(D+1).shares drops to 185,000,000. bob calls claimHolderRewards() -> receives 1,081.08 RUNNER (200,000 * 1M / 185M). Expected: alice and bob, who both held exactly 1,000,000 RUNNER for all of day D+1, receive the same amount. Actual: bob receives 2.7% more purely because he claimed after the owner's addPair; alice has no way to claim the difference (holderClaimedDay[alice][D+1] is already true).","severity":"low","snippet":"            past.shares -= w < past.shares ? w : past.shares;","title":"addPair re-weights past days' holder rewards, so identical holders are paid different amounts depending on whether they claimed before or after the owner's call"},{"citation":"resolved","description":"Access control. `transferOwnership` is the only writer of `pendingOwner` other than `acceptOwnership`, and it rejects address(0). There is therefore no way for the owner to withdraw a proposal: once `transferOwnership(X)` has been sent, X can call `acceptOwnership()` at any later time until the owner overwrites the proposal with another non-zero address. The two-step design (IMD #7) protects against a typo'd address that nobody controls, but not against proposing an address that someone else controls (wrong multisig, a former team member, a compromised key noticed after the proposal). The owner can work around it only by knowing to call `transferOwnership(owner)` to overwrite the slot, which is undocumented. The same code is in RunnerGenesis.transferOwnership (contracts/RunnerGenesis.sol:302). Minimal fix: allow `transferOwnership(address(0))` to clear `pendingOwner` (emit OwnershipTransferStarted(owner, address(0))), or add an explicit `cancelOwnershipTransfer()`; `acceptOwnership` already rejects msg.sender == address(0) implicitly because address(0) cannot send transactions. Verified with a Foundry scratch test (test_pendingOwnerCannotBeCleared).","line":482,"path":"contracts/RunnerToken.sol","reproduction":"owner calls transferOwnership(0xA1) by mistake. owner calls transferOwnership(address(0)) to cancel -> reverts ZeroAddress(); pendingOwner() still returns 0xA1. One year later (vm.warp +365 days) 0xA1 calls acceptOwnership() -> succeeds, owner() == 0xA1. Expected: the current owner can withdraw a pending proposal; actual: the proposal stays live until it is overwritten or accepted.","severity":"low","snippet":"        if (newOwner == address(0)) revert ZeroAddress();","title":"A proposed ownership transfer cannot be cancelled; a mistaken pendingOwner keeps the right to take over indefinitely"},{"citation":"resolved","description":"Documented owner power, reported separately as required: this is not a permission bypass. `setSigner` has no delay and no zero/EOA check, `setEmissionSchedule` takes effect the next UTC day, and `claim` accepts any signature from the current signer up to `emissionOn(today)`. A single compromised owner key therefore converts into pool drain at MAX_DAILY_EMISSION per day (2.5% of the 800M pool per day; the whole pool in 40 days) with no on-chain brake other than another owner action. The same key can `setProtocolWallet` (immediate redirect of the 2% protocol tax and of every future `sweepExpired`), `setClaimsPaused(true)` (blocks `claim`, including the matured-lock release inside it; `release()` remains available) and `addPair` (irreversible 4% tax plus loss of holder rewards for any contract that reports RUNNER as token0/token1). AUDIT.md #8 and the contract header already state this and recommend a multisig; at the pinned commit `owner` and `protocolWallet` are both the dev EOA from the constructor (line 166-167). What fixing it would take if the requester wants an on-chain bound rather than key custody: a timelock on `setSigner` (e.g. new signer becomes valid after 1 day, so a stolen key gives holders time to react), or capping `setEmissionSchedule` increases relative to the current schedule.","line":444,"path":"contracts/RunnerToken.sol","reproduction":"State: owner key compromised, emission started. Attacker (as owner) calls setEmissionSchedule(20_000_000e18, 7, 20_000_000e18) and setSigner(attackerSigner) on day D. On day D+1 attacker signs Claim(attacker, 20_000_000e18, deadline, false) and calls claim(...) -> totalLocked == 20,000,000e18, 20M leaves the pool; repeated each day. Verified in test/scratch/Leads.t.sol test_ownerKeyCanDrain20MPerDay. Expected per the design: at most the day's emission; actual matches the design. Reported as a trust assumption, not a defect.","severity":"info","snippet":"    function setSigner(address newSigner) external onlyOwner {","title":"Trust assumption: the token owner key can route up to 20,000,000 RUNNER per UTC day of the run-reward pool to itself from the next day (setSigner + setEmissionSchedule), and can pause claims and redir"},{"citation":"resolved","description":"Documented owner powers, reported separately. `setTransferValidator` has no code-size check, so pointing it at an EOA or a reverting contract makes every `transferFrom`/`safeTransferFrom` revert until the owner changes it (the validator runs before any ownership check, which is the correct order for the ERC-721C re-entrancy concern fixed in the internal audit). `setTreasury` has only a zero check; `mint` (line 144-145) forwards msg.value with `treasury.call` and reverts the mint on failure, so a treasury contract that rejects ETH halts the sale. `withdraw` likewise. Neither is a bypass; both are the intended owner surface and are listed in AUDIT.md. They matter because at the pinned commit owner, treasury and royaltyReceiver are all the same dev EOA. The two-step ownership code shares the un-cancellable proposal issue reported for the token (line 302).","line":233,"path":"contracts/RunnerGenesis.sol","reproduction":"Owner calls setTransferValidator(0x000000000000000000000000000000000000dEaD) (no code). Any holder calls transferFrom(holder, friend, 7) -> the high-level call to an address without code reverts, so every transfer and marketplace sale fails until the owner calls setTransferValidator(address(0)). Owner calls setTreasury(<contract without receive/fallback>); buyer calls mint(7) with 0.005 ETH -> PaymentFailed(), minting halted. Expected: owner powers behave as documented; actual matches. Reported as trust assumptions.","severity":"info","snippet":"        if (validator != address(0)) ITransferValidator(validator).validateTransfer(msg.sender, from, to, tokenId);","title":"Trust assumption: the NFT owner can freeze every transfer with the validator hook and halt minting through the treasury address"}],"hash":"1278fb0f9d028b476d539df2176347aa860a8a518c498d57788b2d289aa13b53","nodeId":"5e6a1b17-ac78-4557-84ba-416c5299fdc9","outcome":"completed","summary":"Both files are on disk; they are ignored by the harness's git exclude list, which is expected. The review is complete.\n\n## Recap\n\nI audited both contracts in `contracts/` against the Access Control, Trust Gap and Asymmetry guides, mapping every state-changing entry point, its guard, and its paired or mirrored function. The findings file `.imd-findings.json` holds four entries, all verified against the source lines and, where behavioural, reproduced with a Foundry scratch test in `test/scratch/Leads.t.sol` (run from a scratch project outside the repo, since the tree has no `foundry.toml`).\n\n**No critical or high defects.** Signature handling (EIP-712 domain, half-order and v checks, zero-recovered check, cumulative `totalEarned` as replay guard), the daily emission cap, the lock accounting, the holder-reward checkpoint model, and the tax branches all held under the counterexamples I tried. The pair-exclusion path was checked explicitly: paid-out never exceeds a day's tax and `sweepExpired` never reverts afterwards.\n\n**Reported:**\n\n- **Low, RunnerToken line 599.** The owner's `addPair` retroactively shrinks past days' share totals, so two holders with identical balances on the same day are paid differently depending on whether they claimed before or after the owner's call. In the reproduction the late claimer received 2.7% more. Suggested fix keeps the intent of the earlier internal fix: freeze a day's denominator once anyone has been paid from it.\n- **Low, RunnerToken line 482 and RunnerGenesis line 302.** A proposed ownership transfer cannot be cancelled. A mistaken `pendingOwner` can accept at any later time unless the owner knows to overwrite the slot with their own address.\n- **Info, trust assumptions** for the token owner (signer rotation plus 20M/day schedule drains the pool at 2.5% a day, protocol-wallet redirect, pause, irreversible pair registration) and the NFT owner (validator can freeze all transfers, treasury can halt minting). Both are documented in the repo's own audit notes and are reported as powers, not bypasses.\n\n**Not covered:** the off-chain server and web app were read only where they touch the signing path, which matches the contract's typed-data layout. The remaining pashov specialty passes outside the three assigned guides (math precision, economics, periphery) were applied only as a sanity check, not in depth.","treeHash":null,"usage":{"cachedInputTokens":1535053,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":44328,"runtime":"claude","turns":25,"wallClockMs":714600}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"896d1238054266ca","findings":[{"citation":"resolved","description":"Merged from audit_math, audit_economics and audit_flow (same root cause, same line) and audit_permissions' payout-asymmetry report (same mechanism, secondary consequence). _exclude, reached only from addPair, subtracts the newly registered account's eligible weight from _days[d].shares for each still-claimable day d (today-7..today-1). It does not check holderClaimedDay[account][d]. If the account already claimed day d, its slice was paid at the old denominator and _days[d].paidOut already contains it, but the remaining holders are now priced at a smaller denominator, so day d's payouts sum to rewards_d * (1 + w/shares_new) > rewards_d. The comment above _exclude and AUDIT.md item 2 ('claims already paid can never exceed a day's tax') hold only when the excluded account never claimed. Consequences, all reproduced with Foundry: (a) when no other day's unclaimed tax sits in the bucket, the last honest claimer's `holderRewardsHeld -= total` (line 237) underflows and claimHolderRewards reverts with panic 0x11; because the loop always visits day d while it is in the 7-day window, that holder cannot claim ANY holder rewards until day d expires, after which the unpaid rest is swept to the protocol wallet; (b) when another day's unclaimed tax covers the excess, the overpayment is silently taken from that day's holders and _days[d].paidOut ends above _days[d].rewards; from then on sweepExpired reverts at line 250 (`uint256(day.rewards) - day.paidOut` underflows) as soon as nextSweepDay reaches d, and since nextSweepDay only advances through d, no later expired day can ever be swept: every future unclaimed holder tax is stuck in the contract forever; (c) even when the registered pool never claimed, late claimers of day d are paid rewards*bal/S_new while earlier claimers of the same day with the same balance were paid rewards*bal/S_old, so identical holders are paid different amounts depending on whether they claimed before or after the owner's addPair (2.7% difference in the reproduction). Preconditions: the owner registers (addPair) a contract that answers token0()/token1() with RUNNER, that held >= 300,000 RUNNER through a full UTC day inside the 7-day window on which holder tax was collected; for (a)/(b) that contract must also have called claimHolderRewards before registration (a plain Uniswap V2 pair cannot, but any pool/vault/wrapper contract with an arbitrary-call or harvest path can, and the README tells the owner to add liquidity first and register afterwards). The owner acts within intended operation; the unprivileged amplifier is the pool's own claim. Fix (minimal, keeps the IMD #2 behaviour for the normal case): in the _exclude loop skip days the account already claimed: `if (!past.started || holderClaimedDay[account][d]) continue;`. Verified: all three specialist proofs and my scratch tests (claim revert, paidOut > rewards, sweep brick) pass with this one-line change. It does not remove consequence (c); if the requester also wants that, freeze a day's denominator once anyone has been paid from it (`if (!past.started || past.paidOut > 0) continue;`), accepting that the pool's slice of such days goes to the protocol through sweepExpired instead of to holders, or document that addPair must be called the same UTC day a pool first receives RUNNER.","line":597,"path":"contracts/RunnerToken.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.24;\n\nimport \"forge-std/Test.sol\";\nimport \"contracts/RunnerToken.sol\";\n\n/// A contract that passes addPair's pool check (token0() == RUNNER) and can also call\n/// claimHolderRewards before it is registered.\ncontract ClaimingPool {\n    address public token0;\n    address public token1;\n    constructor(address t) { token0 = t; token1 = address(0xdead); }\n    function claim(RunnerToken t) external { t.claimHolderRewards(); }\n}\n\n/// A plain pool used only to generate taxed trades.\ncontract TaxPool {\n    address public token0;\n    address public token1;\n    constructor(address t) { token0 = t; token1 = address(0xdead); }\n}\n\n/// Fails on the current code: registering a pool that already claimed a day's holder reward\n/// removes its weight from that day's share total, so the remaining holders' slices add up to\n/// more than the day's tax and the last honest claimer reverts with an arithmetic underflow.\n/// Passes once _exclude leaves the share total of days the account already claimed untouched.\ncontract ExcludeAfterClaimTest is Test {\n    RunnerToken t;\n    ClaimingPool P;\n    TaxPool X;\n    address owner = address(0xA);\n    address liq = address(0xB);\n    address team = address(0xC);\n    address H = address(0x111);\n\n    function setUp() public {\n        vm.warp(1_800_000_000 - (1_800_000_000 % 1 days)); // 00:00 UTC of day D0\n        t = new RunnerToken(owner, liq, team);\n        X = new TaxPool(address(t));\n        P = new ClaimingPool(address(t));\n        vm.startPrank(liq);\n        t.transfer(H, 1_000_000 ether);\n        t.transfer(address(P), 1_000_000 ether);\n        t.transfer(address(X), t.balanceOf(liq)); // the rest of the liquidity allocation into the pool\n        vm.stopPrank();\n        uint256 tb = t.balanceOf(team);\n        vm.prank(team);\n        t.transfer(address(X), tb);               // team wallet out of the picture as well\n        vm.prank(owner);\n        t.addPair(address(X));\n    }\n\n    function test_holderCanStillClaimAfterPoolRegisteredLate() public {\n        uint256 d0 = block.timestamp / 1 days;\n        uint256 d1 = d0 + 1;\n        vm.warp(d1 * 1 days + 1);                 // day D1: H and P each held 1,000,000 all day\n        vm.prank(address(X));\n        t.transfer(address(0x999), 5_000_000 ether); // one 5M buy -> 100,000 holder tax on D1\n        (uint256 sharesBefore, uint256 rewards,) = t.dayInfo(d1);\n        assertEq(sharesBefore, 2_000_000 ether);\n        assertEq(rewards, 100_000 ether);\n\n        vm.warp((d0 + 2) * 1 days + 1);           // day D2: D1 is claimable\n        uint256 pBefore = t.balanceOf(address(P));\n        P.claim(t);                                // P takes 50,000 (1M / 2M of 100,000)\n        uint256 pGot = t.balanceOf(address(P)) - pBefore;\n        assertEq(pGot, 50_000 ether);\n\n        vm.prank(owner);\n        t.addPair(address(P));                    // owner registers P after it already claimed D1\n\n        // Expected: H's slice of D1 stays 50,000 and is payable.\n        // Actual (current code): D1 shares drop to 1,000,000, H is owed 100,000, and the claim\n        // reverts because holderRewardsHeld (50,000) underflows.\n        uint256 hBefore = t.balanceOf(H);\n        vm.prank(H);\n        t.claimHolderRewards();\n        uint256 hGot = t.balanceOf(H) - hBefore;\n        assertLe(pGot + hGot, rewards, \"D1 paid out more than D1 collected\");\n        assertEq(hGot, 50_000 ether);\n    }\n}","reproduction":"Foundry (forge 1.8.3, solc 0.8.24), tests under test/scratch and the attached proof. Case (a), attached proof: deploy at 00:00 UTC of day D0 with owner 0xA, liquidity 0xB, team 0xC. 0xB sends 1,000,000 to H and 1,000,000 to P (a contract whose token0() is the token and which can call claimHolderRewards), the rest of liquidity and all of team to pool X; owner addPair(X). Day D1: X transfers 5,000,000 to a buyer -> dayInfo(D1) = shares 2,000,000e18, rewards 100,000e18. Day D2: P.claim() receives 50,000e18. Owner addPair(P) -> dayInfo(D1).shares becomes 1,000,000e18. H calls claimHolderRewards: expected 50,000e18 paid and D1 total <= 100,000e18; actual revert panic 0x11 (holderRewardsHeld is 50,000e18, code subtracts 100,000e18). Test output: `[FAIL: panic: arithmetic underflow or overflow (0x11)] test_holderCanStillClaimAfterPoolRegisteredLate()`; with the one-line fix: PASS. Case (b), test_sweepBricksWhenDayOverpaid: deploy day 20000 (L, T 100M each, owner = test); addPair(Q); L->P 50M, T->H1 90M, L->Q 10M (day 20000 tax 200,000, shares L 40M + T 10M). Day 20001: L->Q 1M (tax 20,000, shares 189M). Day 20002: P.harvest() takes 20,000*50/189 = 5,291; owner addPair(P) -> day 20001 shares 139M; L claims (day 20000 160,000 + day 20001 20,000*39/139 = 5,611), H1 claims 20,000*90/139 = 12,949 (succeeds because T's unclaimed day-20000 slice covers it). dayInfo(20001) = shares 139,000,000e18, rewards 20,000e18, paidOut 23,852.156e18 > rewards. Day 20010: sweepExpired(20) -> `[Revert] panic: arithmetic underflow or overflow (0x11)` at RunnerToken.sweepExpired; nextSweepDay stays at 20001 forever. Expected: sweep passes day 20001 and later days are swept. With the fix: PASS. Case (c), test_asymmetricPayout: alice and bob each hold 1,000,000 all of day 20001 (200,000 tax, shares 190M incl. unregistered pool P 5M). Day 20002: alice claims 1,052.631e18; owner addPair(P); bob claims 1,081.081e18. Expected equal; actual bob gets 2.7% more.","severity":"medium","snippet":"            if (!past.started) continue;","title":"addPair/_exclude shrinks past days' share totals after payouts: a pool that already claimed makes other holders' claims revert and permanently bricks sweepExpired"},{"citation":"resolved","description":"Merged from audit_economics (info) and audit_flow (low). The view is documented as 'How much account can claim with a server signature for totalEarned' but returns the raw delta totalEarned - claimed[account]. claim() with the same inputs additionally requires !claimsPaused, emissionStartDay != 0, a non-zero matching signer, claimedOnDay[today] + amount <= emissionOn(today) and rewardsRemaining() >= amount. Every other view (holderClaimable, emissionToday, locksOf, rewardsRemaining) matches its write path; this one does not, so a front end or integrator that gates submission on it sends transactions that revert with EmissionNotStarted, ClaimsPaused, DailyCapReached or PoolEmpty. No funds at risk. Fix: return 0 when emission has not started, claims are paused or signer is zero, and cap the delta by emissionOn(today) - claimedOnDay[today] and by rewardsRemaining(); or rename/document it as the raw unclaimed difference.","line":300,"path":"contracts/RunnerToken.sol","reproduction":"Foundry test test_claimableIgnoresChecks (test/scratch/Leads.t.sol) passes on current code, i.e. the divergence is real: right after deployment (emissionStartDay == 0) claimable(alice, 100e18) returns 100e18 while claim() for alice reverts EmissionNotStarted before the signature check. After setSigner, startEmission and setClaimsPaused(true): claimable(alice, 1e18) returns 1e18 while claim reverts ClaimsPaused. With emission started on the launch schedule (10,000,000e18/day), claimable(alice, 30_000_000e18) returns 30,000,000e18 while a correctly signed claim reverts DailyCapReached. Expected: 0 or the amount payable now; actual: the full delta.","severity":"low","snippet":"        return totalEarned > done ? totalEarned - done : 0;","title":"claimable() ignores emission start, pause, daily cap, signer and pool balance, so the view promises amounts claim() rejects"},{"citation":"resolved","description":"From audit_permissions, reproduced. transferOwnership is the only writer of pendingOwner besides acceptOwnership and it rejects address(0), so there is no way to withdraw a proposal. Once transferOwnership(X) is sent, X can call acceptOwnership at any later time until the owner overwrites the slot with another non-zero address. The two-step design protects against an address nobody controls but not against proposing an address someone else controls (wrong multisig, former team member, a key found compromised after the proposal). The only workaround, transferOwnership(owner) to overwrite the slot, is undocumented. The identical code is in RunnerGenesis.transferOwnership (contracts/RunnerGenesis.sol line 302). Fix: allow transferOwnership(address(0)) to clear pendingOwner (emit OwnershipTransferStarted(owner, address(0))) or add cancelOwnershipTransfer(); acceptOwnership already cannot be called by address(0).","line":482,"path":"contracts/RunnerToken.sol","reproduction":"Foundry test test_pendingOwnerCannotBeCleared (test/scratch/Leads.t.sol) passes on current code, confirming the behaviour: owner calls transferOwnership(0xA1); owner calls transferOwnership(address(0)) -> reverts ZeroAddress(); pendingOwner() still returns 0xA1; vm.warp(+365 days); 0xA1 calls acceptOwnership() -> succeeds, owner() == 0xA1. Expected: the current owner can withdraw a pending proposal; actual: the proposal stays live until accepted or overwritten.","severity":"low","snippet":"        if (newOwner == address(0)) revert ZeroAddress();","title":"A proposed ownership transfer cannot be cancelled; a mistaken pendingOwner keeps the right to take over indefinitely (token and NFT)"},{"citation":"resolved","description":"From audit_flow, reproduced. Only address(0) is rejected. With protocolWallet == address(this): (1) in _transfer, _move(from, protocolWallet, protocolCut) credits the 2% protocol cut to the token contract; balanceOf[address(this)] grows while holderRewardsHeld and totalLocked do not, so rewardsRemaining() grows and the protocol's income becomes claimable through signer-signed claim(); (2) in sweepExpired, _move(address(this), protocolWallet, total) is a self-transfer that returns early after emitting Transfer, while holderRewardsHeld -= total (line 260) and day.paidOut = day.rewards (line 252) still run, so expired holder rewards are marked paid and released into the run-reward pool instead of reaching a wallet. Owner misconfiguration, but silent and irreversible for the swept amounts. Fix: `if (wallet == address(0) || wallet == address(this)) revert ZeroAddress();` (or a dedicated error).","line":438,"path":"contracts/RunnerToken.sol","reproduction":"Foundry test test_protocolWalletSelf (test/scratch/Leads.t.sol) passes on current code, confirming: deploy day 20000, register pool Q, owner setProtocolWallet(address(token)). L transfers 10,000e18 to Q: rewardsRemaining() rises by exactly 200e18 (the protocol cut) and holderRewardsHeld is 200e18. Warp to day 20009 and call sweepExpired(10): holderRewardsHeld becomes 0, token.balanceOf(address(token)) is unchanged, rewardsRemaining() is now 400e18 above its starting value. Expected: 200e18 protocol cut and 200e18 expired holder tax reach the protocol wallet and the contract balance falls with holderRewardsHeld; actual: nothing leaves and both amounts become run rewards.","severity":"low","snippet":"        if (wallet == address(0)) revert ZeroAddress();","title":"setProtocolWallet accepts the token's own address, turning the protocol tax and expired holder rewards into run rewards without any tokens leaving"},{"citation":"resolved","description":"From audit_flow, reproduced. Only address(0) is rejected. RunnerGenesis has no receive or fallback function, so with treasury == address(this) the `treasury.call{value: msg.value}(\"\")` in mint (line 144) fails and every mint reverts PaymentFailed after _mint and _checkReceiver have run; withdraw() fails the same way, so stray ETH stays stuck until the owner sets another treasury. setRoyaltyReceiver has the same gap: a royalty receiver equal to the contract cannot accept royalty payments from marketplaces that send ETH. Owner misconfiguration, recoverable by the owner, but it halts the sale. Fix: reject newTreasury == address(this) in setTreasury and newReceiver == address(this) in setRoyaltyReceiver.","line":272,"path":"contracts/RunnerGenesis.sol","reproduction":"Foundry test test_genesisTreasurySelf (test/scratch/Leads.t.sol) passes on current code, confirming: deploy RunnerGenesis(owner = test, treasury = test, royaltyReceiver = test, 'u/', 'c'); owner calls setTreasury(payable(address(genesis))); alice (funded 1 ether) calls mint{value: priceOf(1)}(1) -> reverts PaymentFailed, no token minted. After setTreasury back to a payable EOA the same mint succeeds. Expected: a configured treasury receives the price and the token is minted; actual: all mints revert while this state holds.","severity":"low","snippet":"        if (newTreasury == address(0)) revert ZeroAddress();","title":"setTreasury (and setRoyaltyReceiver) accept the NFT contract's own address; treasury == address(this) bricks every mint and withdraw"},{"citation":"resolved","description":"From audit_permissions; documented owner power, reported separately as the methodology requires, not a permission bypass. setSigner has no delay and no zero/EOA check, setEmissionSchedule takes effect the next UTC day up to MAX_DAILY_EMISSION, and claim accepts any signature from the current signer up to emissionOn(today). A compromised owner key therefore drains the pool at 20M/day (2.5% of 800M per day, the whole pool in 40 days) with no on-chain brake other than another owner action. The same key can setProtocolWallet (immediate redirect of the 2% protocol tax and of every future sweepExpired), setClaimsPaused(true) (blocks claim(), including the matured-lock release inside it; release() remains available) and addPair (irreversible 4% tax and loss of holder rewards for any contract that reports RUNNER as token0/token1). The contract header and AUDIT.md item 8 state this and recommend a multisig; at the pinned commit the constructor makes owner and protocolWallet the same address (lines 166-167). If the requester wants an on-chain bound rather than key custody: a one-day delay on setSigner taking effect, or capping setEmissionSchedule increases relative to the current schedule.","line":444,"path":"contracts/RunnerToken.sol","reproduction":"Code reading of lines 444-447, 466-477, 308-345: owner (or holder of a stolen owner key) calls setEmissionSchedule(20_000_000e18, 7, 20_000_000e18) and setSigner(attackerSigner) on day D; emissionOn(D+1) == 20_000_000e18. On day D+1 the attacker signs Claim(attacker, 20_000_000e18, deadline, false) with attackerSigner and calls claim(): all checks pass, totalLocked becomes 20_000_000e18, and 20M RUNNER are paid out 7 days later; repeated daily. Expected per the design: at most the day's emission leaves the pool; actual matches the design. Reported as a trust assumption.","severity":"info","snippet":"    function setSigner(address newSigner) external onlyOwner {","title":"Trust assumption: the token owner key can redirect the run-reward pool to itself at up to 20,000,000 RUNNER per UTC day from the next day, pause claims, redirect protocol income, and tax any pool-shap"},{"citation":"resolved","description":"From audit_permissions; documented owner powers, reported separately. setTransferValidator has no code-size check: pointing it at an EOA or a reverting contract makes every transferFrom/safeTransferFrom revert until the owner changes it (the validator runs before the ownership check, which is the correct order for the ERC-721C re-entrancy concern). setTreasury has only a zero check, so a treasury that rejects ETH halts the sale (see the separate low finding for the self-address case). Neither is a bypass; both are the intended owner surface listed in AUDIT.md. They matter because at the pinned commit owner, treasury and royaltyReceiver are all the same wallet. The un-cancellable ownership proposal reported for the token applies here too (line 302).","line":233,"path":"contracts/RunnerGenesis.sol","reproduction":"Foundry test test_genesisTreasurySelf (test/scratch/Leads.t.sol) passes on current code, confirming the validator part: alice mints token 1; owner calls setTransferValidator(0x000000000000000000000000000000000000dEaD) (no code); alice calls transferFrom(alice, bob, 1) -> reverts (high-level call to an address without code fails extcodesize check), so every transfer and marketplace sale fails until the owner calls setTransferValidator(address(0)). Expected: owner powers behave as documented; actual matches. Reported as a trust assumption.","severity":"info","snippet":"        if (validator != address(0)) ITransferValidator(validator).validateTransfer(msg.sender, from, to, tokenId);","title":"Trust assumption: the NFT owner can freeze every transfer by pointing the validator at an address without code, and can halt minting through the treasury address"}],"hash":"24d0fa9c9fcea668a1f4d4c1dcd51475f1c25b94c5b2e8b6e02b79fb26e8403d","nodeId":"b3485a91-0c49-411e-ab3f-7464a1a2d8af","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds the judged result. Working tree shows no tracked changes; only the findings file and `test/scratch/Leads.t.sol` were written, with the Foundry harness kept outside the repository.\n\n**Outcome.** The specialists' 11 reports collapsed to 7 findings. Every one was reproduced against the code with Foundry 1.8.3 and solc 0.8.24. Nothing was dropped as non-reproducing.\n\n- **Medium, merged from four reports** at `contracts/RunnerToken.sol:597`. The exclusion loop in `addPair` shrinks past days' share totals without checking whether the registered account already claimed those days. All three attached proofs failed on the current code with the stated underflow and passed on a copy with the one-line fix. My own scratch test added a consequence the specialists only asserted: once an overpayment is covered by another day's tax, a day's paidOut exceeds its rewards and `sweepExpired` reverts permanently, so no later expired day can ever be swept. The payout asymmetry reported by the permissions specialist is the same mechanism and is folded in as a secondary consequence, with a note that the one-line fix does not remove it. One proof is attached to this finding.\n- **Low, merged from two reports.** The `claimable` view returns the raw delta while `claim` enforces start, pause, cap, signer and pool checks.\n- **Low.** A pending ownership proposal cannot be cancelled in either contract.\n- **Low.** `setProtocolWallet` accepts the token's own address, which silently converts protocol tax and swept holder rewards into run rewards.\n- **Low.** `setTreasury` accepts the NFT contract's own address, which bricks every mint and `withdraw`.\n- **Info, two items.** Owner powers on the token and the NFT, kept as trust assumptions rather than defects, as the methodology requires.\n\n**Limits.** No fork, fuzzing or static analysis ran. My own pass over the two contracts beyond the specialist leads found no additional defects, which is not proof that none exist.","treeHash":null,"usage":{"cachedInputTokens":1405401,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":26854,"runtime":"claude","turns":20,"wallClockMs":522816}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"809ae05b6282abad","findings":[{"citation":"resolved","description":"`_exclude` (only called from `addPair`) rewrites each of the last seven days' share totals by subtracting the excluded account's weight, without checking whether that account already claimed that day. If it did, `_days[d].paidOut` already contains its payout while the denominator `_days[d].shares` still shrinks. The remaining holders' recomputed shares then sum to more than `rewards - paidOut`: the first claimant can take an inflated amount, and the next claimant's `holderRewardsHeld -= total` (line 237) underflows, reverting the whole claim. Because `claimHolderRewards` always iterates that day while it is inside the seven-day window, the affected holder cannot claim any holder rewards until the day expires, and the unpaid rest is later swept to the protocol wallet. Precondition: the owner registers through `addPair` a contract that reports RUNNER as `token0()` or `token1()` and that has already called `claimHolderRewards()` itself. A plain Uniswap V2 pair cannot call it; a pool-like contract that exposes a harvest path can. Fix: skip days where `holderClaimedDay[account][d]` is already true, or refuse to register an account that has claimed.","line":599,"path":"contracts/RunnerToken.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.24;\n\nimport \"forge-std/Test.sol\";\nimport \"contracts/RunnerToken.sol\";\n\n/// A contract that looks like a RUNNER pool (token0/token1) and can call\n/// claimHolderRewards() for itself. A plain Uniswap V2 pair cannot claim, but a\n/// pool-like contract that exposes a harvest path can.\ncontract PoolLike {\n    RunnerToken public immutable tok;\n    address public immutable other;\n    constructor(RunnerToken t, address o) { tok = t; other = o; }\n    function token0() external view returns (address) { return address(tok); }\n    function token1() external view returns (address) { return other; }\n    function harvest() external { tok.claimHolderRewards(); }\n}\n\ncontract OverclaimTest is Test {\n    RunnerToken tok;\n    PoolLike p1;\n    PoolLike p2;\n    address constant L = address(0x1000000000000000000000000000000000000001);\n    address constant T = address(0x2000000000000000000000000000000000000002);\n\n    function setUp() public {\n        vm.warp(20_000 * 1 days + 1 hours);\n        tok = new RunnerToken(address(this), L, T);\n        p1 = new PoolLike(tok, address(0xdead));\n        p2 = new PoolLike(tok, address(0xbeef));\n        vm.prank(L); tok.transfer(address(p2), 50_000_000 ether);\n        vm.prank(T); tok.transfer(address(p2), 50_000_000 ether);\n        tok.addPair(address(p1));\n    }\n\n    function testRegisteringAClaimingPoolMustNotBreakOtherHolders() public {\n        // Day 20,001: a taxed sell through the registered pool funds holder rewards.\n        vm.warp(20_001 * 1 days + 1 hours);\n        vm.prank(L);\n        tok.transfer(address(p1), 10_000_000 ether);\n        (, uint256 rewards,) = tok.dayInfo(20_001);\n        assertEq(rewards, 200_000 ether, \"day reward\");\n\n        // Day 20,002: the pool-like contract claims its day-20,001 share first.\n        vm.warp(20_002 * 1 days + 1 hours);\n        p2.harvest();\n\n        // The owner registers it as a pool.\n        (bool registered,) = address(tok).call(abi.encodeWithSelector(RunnerToken.addPair.selector, address(p2)));\n        if (!registered) return; // refusing to register a claimed pool is an acceptable fix\n\n        // Every remaining holder must still be able to claim day 20,001.\n        vm.prank(L);\n        try tok.claimHolderRewards() {} catch { revert(\"L could not claim after the pool was registered\"); }\n        vm.prank(T);\n        try tok.claimHolderRewards() {} catch { revert(\"T could not claim after the pool was registered\"); }\n\n        (,, uint256 paid) = tok.dayInfo(20_001);\n        assertLe(paid, rewards, \"claims exceed the day's holder tax\");\n    }\n}","reproduction":"The Foundry test below. Deploy RunnerToken with L and T holding 100M each (the constructor allocations); move 50M from each to a pool-like contract p2; register a taxable pair p1; on day 20,001 sell 10M through p1, which sets day 20,001 rewards to 200,000 RUNNER and its shares to 190M (L 40M, T 50M, p2 100M); on day 20,002 call p2.harvest(), which claims 105,263.157... RUNNER for day 20,001; the owner calls addPair(p2) and `_exclude` rewrites day 20,001 shares to 90M. L then claims 88,888.888... (leaving 5,847.95... in the bucket), and T's recomputed 111,111.111... exceeds what is left, so `holderRewardsHeld -= total` panics (0x11). Expected: both remaining holders can claim and total paid <= 200,000 RUNNER; actual: T's claimHolderRewards always reverts while day 20,001 is in the window (the test fails with 'T could not claim after the pool was registered').","severity":"medium","snippet":"            past.shares -= w < past.shares ? w : past.shares;","title":"Registering an account that already claimed holder rewards makes other holders' claims revert"},{"citation":"resolved","description":"The public view is documented as how much `account` can claim with a server signature for `totalEarned`, but it returns the raw delta `totalEarned - claimed[account]` without any of the checks `claim()` performs: `emissionStartDay != 0`, `!claimsPaused`, `amount <= emissionOn(today) - claimedOnDay[today]`, and `rewardsRemaining() >= amount`. An integrator that trusts it submits a transaction that reverts. It is the only view whose result diverges from its write path: `holderClaimable`, `emissionToday`, `locksOf` and `rewardsRemaining` all match their execution functions. Fix: return 0 while emission has not started or claims are paused, and cap by today's remaining emission and by `rewardsRemaining()`.","line":300,"path":"contracts/RunnerToken.sol","reproduction":"Immediately after deployment (`emissionStartDay == 0`), `claimable(A, 100e18)` returns 100e18 (a Foundry assertEq against 0 fails with `100000000000000000000 != 0`), while `claim(A, 100e18, deadline, 27, 0, 0, false)` reverts `EmissionNotStarted` before the signature check. After the day's cap is consumed it still returns the delta, but `claim` reverts `DailyCapReached`; with `setClaimsPaused(true)` it returns the delta while `claim` reverts `ClaimsPaused`; when `rewardsRemaining() < amount` it returns the delta while `claim` reverts `PoolEmpty`. Expected: 0 (or the amount that can actually be paid now); actual: the full unclaimed delta.","severity":"low","snippet":"        return totalEarned > done ? totalEarned - done : 0;","title":"claimable() returns an amount that claim() rejects (not started, paused, capped or pool empty)"},{"citation":"resolved","description":"Only address(0) is rejected. With `protocolWallet = address(this)`: (1) in `_transfer`, `_move(from, protocolWallet, protocolCut)` credits the 2% protocol cut to the token contract itself; `balanceOf[address(this)]` grows but `holderRewardsHeld` and `totalLocked` do not, so `rewardsRemaining()` grows and the run-reward signer can claim the protocol's income; (2) `sweepExpired` calls `_move(address(this), protocolWallet, total)`, a self-transfer that returns early after emitting Transfer, while `holderRewardsHeld -= total` (line 260) and `_days[d].paidOut = rewards` (line 252) still execute, so expired holder rewards are marked paid and released into the run-reward pool instead of being paid to a wallet. Any unprivileged trader or sweeper triggers these effects once the owner has set the state. Expected: the protocol tax and expired holder rewards are paid to the protocol wallet; actual: they stay in the contract and become claimable by the signer. Fix: reject `wallet == address(this)`.","line":438,"path":"contracts/RunnerToken.sol","reproduction":"Set protocolWallet = address(token). A 10,000 RUNNER taxed trade through a registered pair sends 200 RUNNER to the token contract instead of the protocol wallet and raises `rewardsRemaining()` by 200 RUNNER. After the day's holder tax expires (8 days), `sweepExpired(10)` lowers `holderRewardsHeld` by the expired amount and sets the day's `paidOut` to its rewards, while `token.balanceOf(address(token))` is unchanged and `rewardsRemaining()` rises by exactly that amount; a signer-signed `claim` can then pay it out. Expected: `holderRewardsHeld` and the contract balance fall together as the tokens reach the protocol wallet; actual: the accounting is released but no tokens leave.","severity":"low","snippet":"        if (wallet == address(0)) revert ZeroAddress();","title":"setProtocolWallet accepts the token's own address, turning the protocol tax and expired holder rewards into run rewards"},{"citation":"resolved","description":"Only address(0) is rejected. If the owner sets `treasury = address(this)` (the NFT contract), `mint` finishes `_mint` and `_checkReceiver` and then calls `treasury.call{value: msg.value}('')`, which targets RunnerGenesis itself. The contract has no receive/fallback function, so the call fails and `mint` reverts `PaymentFailed` for every buyer; `withdraw` fails the same way, so stray ETH is also stuck until the owner calls `setTreasury` again. The same missing check exists in `setRoyaltyReceiver`: a royalty receiver that is the contract itself cannot accept royalty payments. Expected: minting continues and payments reach a treasury; actual: all mints revert after this state is set. Fix: reject `newTreasury == address(this)` (and the same for the royalty receiver).","line":272,"path":"contracts/RunnerGenesis.sol","reproduction":"Deploy RunnerGenesis with owner = the test contract; the owner calls `setTreasury(payable(address(genesis)))`; a buyer calls `mint(1)` with exactly `priceOf(1)` (0.005 ETH for a common id); the treasury call to the NFT contract fails, so the transaction reverts with `PaymentFailed` and no token is minted. Expected: the token is minted and the price is forwarded; actual: revert. The same state makes `withdraw()` revert.","severity":"low","snippet":"        if (newTreasury == address(0)) revert ZeroAddress();","title":"setTreasury accepts the NFT contract's own address, bricking every mint"}],"hash":"2ab2c371c03ad87a5799ca4556fb522495ff7333ac28eda953d14dc349d253d5","nodeId":"ba3a5aa0-a70e-49c2-b3b9-a12911b2218e","outcome":"completed","summary":"Review complete. The deliverable is `.imd-findings.json` in the repository root, holding four substantiated findings and one embedded Foundry proof. Nothing is pending on my side.\n\n- **Medium, `contracts/RunnerToken.sol:599`**: registering a pool that already claimed holder rewards still shrinks the last seven days' share totals. The first remaining holder takes an inflated share, the next holder's `holderRewardsHeld -= total` underflows and reverts, and the unpaid rewards are later swept to the protocol wallet. The proof at `test/scratch/Overclaim.t.sol` fails on the current code with \"T could not claim after the pool was registered\".\n- **Low, `contracts/RunnerToken.sol:300`**: `claimable()` returns the full unclaimed delta even when `claim()` would revert because emission has not started, claims are paused, the daily cap is used up, or the pool is empty.\n- **Low, `contracts/RunnerToken.sol:438`**: `setProtocolWallet(address(this))` is accepted. The 2% protocol tax then credits the token contract, and expired holder rewards are marked paid by a self-transfer, both becoming run rewards the signer can claim.\n- **Low, `contracts/RunnerGenesis.sol:272`**: `setTreasury(address(this))` is accepted. `mint` then calls the NFT contract itself, which has no receive function, so every mint reverts with `PaymentFailed`.\n\nChecks run: both contracts compile with solc 0.8.24, optimizer 200, EVM paris; both configured creation-code fingerprints match the browser build, confirmed independently with `cast keccak`; the repo's own suites pass, 150 token checks and 52 NFT checks; the public upstream repository matches this checkout byte for byte; the holder-reward and run-reward invariants held under a full manual trace of every write path, with the claimed-pool case above as the exception. Every finding's path, line and snippet was verified as an exact match against the tree, and the JSON parses.\n\nNo tracked file was modified. The build artifacts `out/` and `cache/` were removed, and `test/scratch/` contains only the proof file.","treeHash":null,"usage":{"cachedInputTokens":5412480,"inputTokens":395870,"model":"claude-fable-5-1","outputTokens":287823,"runtime":"claude","turns":66,"wallClockMs":1603568}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"75052237a39b6e12","findings":[],"hash":"330093d0b1c0b84f4f264877ebbfef69161ba3f01afe0f62390fa87e94f89fc9","nodeId":"5e6a1b17-ac78-4557-84ba-416c5299fdc9","outcome":"failed","summary":"Failed to authenticate: OAuth session expired and could not be refreshed","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":null,"outputTokens":0,"runtime":"claude","turns":1,"wallClockMs":3162}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"df74f6c887684f20","findings":[{"citation":"resolved","description":"Invariant: for every claimable day d, sum over holders of rewards_d * weight_i / shares_d must be <= rewards_d, which is what keeps holderRewardsHeld solvent. _exclude (called from addPair) subtracts the newly registered account's weight from the share totals of the last 7 days without checking whether that account already claimed any of them (holderClaimedDay[account][d]). If it did, its slice was paid at the old denominator and the remaining holders are now priced at a smaller denominator, so the day's payouts sum to rewards_d * (1 + w_pool/shares_d). The excess has no backing: claimHolderRewards does holderRewardsHeld -= total, which underflows and reverts for the last claimer when no other day's tax is sitting in the bucket, or silently takes other days' holder tax when it is. The AUDIT.md note for IMD #2 ('claims already paid can never exceed a day's tax') assumes the excluded account never claimed, which is true of a Uniswap V2 pair but not of every contract that passes the token0()/token1() check (custom pools with an owner/execute function, pool wrappers). Preconditions: a contract that reports RUNNER as token0 or token1, held >= 300,000 for a full day, claimed that day's reward, and is then registered by the owner within 7 days. The owner is acting within intended operation (registering a pool); the unprivileged amplifier is the pool's own claim before registration. Fix (preserves the IMD #2 behaviour for the normal case): in the _exclude loop, skip days the account already claimed, e.g. `if (!past.started || holderClaimedDay[account][d]) continue;`. With the old denominator kept for such days the sum of all slices is exactly rewards_d, so conservation holds.","line":599,"path":"contracts/RunnerToken.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.24;\n\nimport \"forge-std/Test.sol\";\nimport \"contracts/RunnerToken.sol\";\n\n/// A contract that passes addPair's pool check (token0() == RUNNER) and can also call\n/// claimHolderRewards before it is registered.\ncontract ClaimingPool {\n    address public token0;\n    address public token1;\n    constructor(address t) { token0 = t; token1 = address(0xdead); }\n    function claim(RunnerToken t) external { t.claimHolderRewards(); }\n}\n\n/// A plain pool used only to generate taxed trades.\ncontract TaxPool {\n    address public token0;\n    address public token1;\n    constructor(address t) { token0 = t; token1 = address(0xdead); }\n}\n\n/// Fails on the current code: registering a pool that already claimed a day's holder reward\n/// removes its weight from that day's share total, so the remaining holders' slices add up to\n/// more than the day's tax and the last honest claimer reverts with an arithmetic underflow.\n/// Passes once _exclude leaves the share total of days the account already claimed untouched.\ncontract ExcludeAfterClaimTest is Test {\n    RunnerToken t;\n    ClaimingPool P;\n    TaxPool X;\n    address owner = address(0xA);\n    address liq = address(0xB);\n    address team = address(0xC);\n    address H = address(0x111);\n\n    function setUp() public {\n        vm.warp(1_800_000_000 - (1_800_000_000 % 1 days)); // 00:00 UTC of day D0\n        t = new RunnerToken(owner, liq, team);\n        X = new TaxPool(address(t));\n        P = new ClaimingPool(address(t));\n        vm.startPrank(liq);\n        t.transfer(H, 1_000_000 ether);\n        t.transfer(address(P), 1_000_000 ether);\n        t.transfer(address(X), t.balanceOf(liq)); // the rest of the liquidity allocation into the pool\n        vm.stopPrank();\n        uint256 tb = t.balanceOf(team);\n        vm.prank(team);\n        t.transfer(address(X), tb);               // team wallet out of the picture as well\n        vm.prank(owner);\n        t.addPair(address(X));\n    }\n\n    function test_holderCanStillClaimAfterPoolRegisteredLate() public {\n        uint256 d0 = block.timestamp / 1 days;\n        uint256 d1 = d0 + 1;\n        vm.warp(d1 * 1 days + 1);                 // day D1: H and P each held 1,000,000 all day\n        vm.prank(address(X));\n        t.transfer(address(0x999), 5_000_000 ether); // one 5M buy -> 100,000 holder tax on D1\n        (uint256 sharesBefore, uint256 rewards,) = t.dayInfo(d1);\n        assertEq(sharesBefore, 2_000_000 ether);\n        assertEq(rewards, 100_000 ether);\n\n        vm.warp((d0 + 2) * 1 days + 1);           // day D2: D1 is claimable\n        uint256 pBefore = t.balanceOf(address(P));\n        P.claim(t);                                // P takes 50,000 (1M / 2M of 100,000)\n        uint256 pGot = t.balanceOf(address(P)) - pBefore;\n        assertEq(pGot, 50_000 ether);\n\n        vm.prank(owner);\n        t.addPair(address(P));                    // owner registers P after it already claimed D1\n\n        // Expected: H's slice of D1 stays 50,000 and is payable.\n        // Actual (current code): D1 shares drop to 1,000,000, H is owed 100,000, and the claim\n        // reverts because holderRewardsHeld (50,000) underflows.\n        uint256 hBefore = t.balanceOf(H);\n        vm.prank(H);\n        t.claimHolderRewards();\n        uint256 hGot = t.balanceOf(H) - hBefore;\n        assertLe(pGot + hGot, rewards, \"D1 paid out more than D1 collected\");\n        assertEq(hGot, 50_000 ether);\n    }\n}","reproduction":"Deploy at 00:00 UTC of day D0 with owner 0xA, liquidity 0xB, team 0xC. 0xB sends 1,000,000 to H, 1,000,000 to P (a contract whose token0() returns the token and which can call claimHolderRewards), and the rest to pool X; 0xC sends all 100M to X; owner addPair(X). Day D1: X sends 5,000,000 to a buyer -> 100,000 holder tax on D1; dayInfo(D1) = shares 2,000,000, rewards 100,000. Day D2: P calls claimHolderRewards -> receives 50,000. Owner calls addPair(P) -> dayInfo(D1).shares becomes 1,000,000. holderClaimable(H) now returns 100,000 (expected 50,000). H calls claimHolderRewards: expected 50,000 paid; actual revert with panic 0x11 because holderRewardsHeld is 50,000 and the code does holderRewardsHeld -= 100,000. H can never collect D1; after 7 days sweepExpired sends the 50,000 to the protocol wallet. If another day's tax were in the bucket, H would instead receive 100,000 and that other day's holders would be short by 50,000.","severity":"low","snippet":"            if (!past.started) continue;\n            uint256 w = eligibleBalanceOn(account, d);\n            past.shares -= w < past.shares ? w : past.shares;","title":"addPair on a pool that already claimed a day's holder reward shrinks that day's share total after payout, so the day pays out more than it collected and the last honest claimer reverts"},{"citation":"resolved","description":"Interface guarantee gap (view diverges from write). claimable(account, totalEarned) returns totalEarned - claimed[account] unconditionally, while claim() with the same inputs also requires !claimsPaused, emissionStartDay != 0, a non-zero matching signer, claimedOnDay[today] + amount <= emissionOn(today), and rewardsRemaining() >= amount. Any front end or integrator that trusts the view to decide whether to submit will send transactions that revert. No funds are at risk; the server already computes the real limit itself (api/rewards.js claim()). Fix: have claimable() return 0 when claims are paused, emission has not started or signer is zero, and cap the result by emissionOn(today) - claimedOnDay[today] and by rewardsRemaining(); or rename/document it as the raw difference.","line":300,"path":"contracts/RunnerToken.sol","reproduction":"Deploy, owner calls setSigner(S) and startEmission() on day D (launch schedule: emissionOn(D) = 10,000,000e18). claimable(A, 30_000_000e18) returns 30,000,000e18. A valid signature for (A, 30_000_000e18, deadline, false) submitted to claim() reverts with DailyCapReached because 0 + 30,000,000e18 > 10,000,000e18. Likewise after owner setClaimsPaused(true): claimable(A, 1e18) still returns 1e18 while claim() reverts ClaimsPaused.","severity":"info","snippet":"        uint256 done = claimed[account];\n        return totalEarned > done ? totalEarned - done : 0;","title":"claimable() ignores the daily emission cap, the pool balance, the pause flag and the signer, so the view promises amounts claim() will refuse"}],"hash":"57fb36b9c838b91559e9115b9604512c927cd905bce8d4cabd014c0f39886a6e","nodeId":"bbafd2ce-6428-4a92-9655-c44e0446dc8d","outcome":"completed","summary":"Both output files are in place. The review is complete.\n\n**Result:** the economics of `RunnerToken` and `RunnerGenesis` hold up well. I found one substantiated low and one informational issue, both written to `.imd-findings.json`.\n\n**What I checked**\n- Economic Security, Invariant and Flow Gap passes over both contracts, reading the server's claim-signing path in `api/rewards.js` as the periphery for the claim flow.\n- A Foundry invariant fuzz (10,240 calls across 128 runs) of the holder-reward accounting with trades, wallet transfers, self-transfers, burns, holder claims, sweeps, day jumps and a late `addPair`. Sum of weights equals the frozen day share total on every started day, the contract balance always covers `holderRewardsHeld + totalLocked`, and total supply is conserved.\n- Run-reward paths: daily cap, pool reservation for locks, instant burn, early release, schedule promotion, and signature scope. No defect found there.\n- NFT sale: exact pricing, receiver check, treasury forwarding, validator ordering. No defect found.\n\n**Findings**\n- **Low, `contracts/RunnerToken.sol:599`.** `_exclude` removes a newly registered pool's weight from the last 7 days' share totals even for days the pool already claimed. A contract that passes the `token0()/token1()` check and can call `claimHolderRewards` takes its slice at the old denominator, then after `addPair` the remaining holders are priced at the smaller one. In the reproduction the day collected 100,000, the pool took 50,000, the honest holder is then owed 100,000, and its claim reverts with an arithmetic underflow because the bucket only holds 50,000. The leftover is swept to the protocol after 7 days. Precondition is an owner registering a pool that can itself claim, which a real Uniswap V2 pair cannot do, hence low. The proof test in `test/scratch/ExcludeAfterClaim.t.sol` fails on the current code and passes with a one-line fix: skip days where `holderClaimedDay[account][d]` is set.\n- **Info, `contracts/RunnerToken.sol:300`.** `claimable()` returns the raw difference and ignores the daily cap, pool balance, pause flag and signer, so it reports 30M claimable while `claim()` reverts with `DailyCapReached` at 10M.\n\n**Not reported, by design or already documented:** owner powers bounded by the 20M/day ceiling, per-address threshold and tax, untaxed swaps routed to the token contract itself (a pure donation to the pool), the server-side race where a signed claim can hit a filled daily cap, and the historical inaccuracy of `emissionOn` for past days after a schedule change.\n\nNothing in the repository was modified beyond the findings file and the scratch test.","treeHash":null,"usage":{"cachedInputTokens":2478576,"inputTokens":802,"model":"claude-fable-5-1","outputTokens":46681,"runtime":"claude","turns":32,"wallClockMs":725838}}],"verification":[]}