{"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":"28c0a5e2-0f75-404e-a9d9-37a528840d88","kind":"audit","nodes":[{"acceptedSubmissionHash":"fdac93eff8bf465bbf75cc5f3762c4adcb91180d078e54decb1a8786f1eb4780","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":"f93663a201517807f5bf52cb642a01646ea607c7b7fcb948894bc229aacc23ae","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":"937960866e42cc84c35513286d1aaaa240b038bdfc0b656eb5d02599515c4407","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":"70e3ad597d83fca0fc25dab8f8e418a51212855f2d753c4b12f88ca428dbef94","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":"8ce74fbe0542516c9ee3d9c9c75f830b844a4c8190d72473051b9b35bd135238","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":"28c0a5e2-0f75-404e-a9d9-37a528840d88","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":"52190","feedbackHash":"588ce4fa8d8e1dced9d202544b945f3adfd07a46f3ddfcb5d0aca0f3087ffc3b","nodeKey":"audit_economics","submissionHash":"fdac93eff8bf465bbf75cc5f3762c4adcb91180d078e54decb1a8786f1eb4780","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51081","feedbackHash":"80f44e8df17190205f870eea134383a492aaa31ff281fbddf6e2c50a94e6841a","nodeKey":"audit_flow","submissionHash":"f93663a201517807f5bf52cb642a01646ea607c7b7fcb948894bc229aacc23ae","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51494","feedbackHash":"0dea6728dd9d3b3ca17bf6349cf092c21ad4761cae32d3cd42ad7301eb9ba36d","nodeKey":"audit_judge","submissionHash":"937960866e42cc84c35513286d1aaaa240b038bdfc0b656eb5d02599515c4407","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52396","feedbackHash":"2768ebf501cae68ed07e8c0dd7122b4e3dc919a54843db0f0f6a46d9e8aeb606","nodeKey":"audit_math","submissionHash":"70e3ad597d83fca0fc25dab8f8e418a51212855f2d753c4b12f88ca428dbef94","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52600","feedbackHash":"95c937d63dd06e648e80f925528f3e0ec326233bf80abe2dc5c558527dd42d59","nodeKey":"audit_permissions","submissionHash":"8ce74fbe0542516c9ee3d9c9c75f830b844a4c8190d72473051b9b35bd135238","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"65215bb4f49cebd4988751f4010cb16723f748e13892f7790743315a6bf54478","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a11137177fc72d8b","findings":[{"citation":"resolved","description":"_move() debits `from` and runs _track() on that leg before it credits `to`. When from == to (transfer(self, v), or transferFrom(holder, holder, v) by any approved spender) the first _track() call sees the balance fall from B to B - v and records B - v as the day's minBalance, subtracting the drop from today's share total. The second leg restores the balance, but a checkpoint's minBalance only ever goes down, so the dip is permanent for that day even though no tokens left the wallet. Holder rewards are paid in proportion to the day's lowest balance, so a wallet holding exactly MIN_HOLDING (300,000 RUNNER) that self-transfers 1 wei gets 0 for the day, and any eligible wallet loses v of weight. transferFrom(holder, holder, v) only needs an allowance of v, so a spender with a 1 wei allowance can zero a 300,000 RUNNER holder's share for the day without moving a token. eligibleSupply is unaffected (the two legs cancel), so only today's `shares` and the victim's checkpoint are wrong; the portion of that day's tax the wallet should have received is never claimable and is later swept to the protocol wallet. The existing test ('self-transfer keeps balance', tests/token.test.mjs:85) only asserts the balance, not the checkpoint, so it does not catch this. Fix: in _move(), after the InsufficientBalance check, short-circuit `if (from == to) { emit Transfer(from, to, value); return; }` so checkpoints only see real balance changes (no economic rule changes).","line":535,"path":"contracts/RunnerToken.sol","reproduction":"Day 0: deploy, register a mock pair, liquidity wallet sends alice and bob exactly 300,000e18 each and the pair 10M. Day 1: pair transfers 1,000,000e18 to a buyer so day 1 collects 20,000e18 holder tax; eligibleBalanceOn(alice, day1) == 300,000e18 and dayInfo(day1).shares == 190,000,000e18. alice calls transfer(alice, 1). Expected: balanceOf(alice) unchanged (it is), eligibleBalanceOn(alice, day1) still 300,000e18, shares unchanged. Actual: eligibleBalanceOn(alice, day1) == 0 and shares == 189,700,000e18; on day 2 holderClaimable(alice) == 0 while bob, an identical 300,000e18 holder, can claim 31.6e18. Same outcome with alice.approve(bob, 1) then bob calling transferFrom(alice, alice, 1). Foundry reproduction: test/scratch/SelfTransfer.t.sol fails on the current code.","severity":"low","snippet":"        _track(from, fromBal, fromBal - value, today);","title":"Self-transfer records a phantom balance dip and erases the wallet's holder-reward eligibility for the day"},{"citation":"resolved","description":"_exclude() (called from addPair) removes the pool from today's `shares` and from eligibleSupply, but days before today keep the pool's balance inside `_days[d].shares`. eligibleBalanceOn() then returns 0 for the excluded address on those same past days, so the sum of claimable shares is smaller than the frozen denominator: every real holder is diluted by the pool's balance for those days, and the pool's slice of the day's 2% holder tax can never be claimed by anyone and is swept to the protocol wallet after 7 days. The precondition is a pool that already held >= 300,000 RUNNER at the start of a day on which another registered pool produced holder tax, and that is only registered on a later day (a second pool, or a first pool whose registration slips a day while an earlier pool trades). The documented launch sequence (admin.html: add liquidity first, then register) makes this reachable for any pool added after launch. Fix options that keep the design: register the pool before funding it (createPair -> addPair -> addLiquidity, accepting the taxed liquidity add the AUDIT already documents), or have the admin tooling refuse/warn when the pool's balance is already >= MIN_HOLDING and its last checkpoint is from an earlier day. There is no on-chain way to repair past days once rewards were recorded.","line":266,"path":"contracts/RunnerToken.sol","reproduction":"Day 0: deploy, register pair1, liquidity wallet sends pair1 20,000,000e18 (taxed: 400,000e18 to protocol wallet, 400,000e18 to the holder bucket) and sends an unregistered pair2 10,000,000e18. Day 1: pair1 sells 1,000,000e18 to a buyer, adding 20,000e18 to day 1's holder rewards; dayInfo(day1).shares == 180,400,000e18, which includes pair2's 10,000,000e18. Day 2: owner calls addPair(pair2). Expected: day 1 rewards split over the 170,400,000e18 held by real holders (team 100M -> 11,737e18, liquidity 70M -> 8,216e18). Actual: eligibleBalanceOn(pair2, day1) == 0 while shares stays 180,400,000e18, so team can claim 11,086.47e18 and liquidity 7,760.53e18; the remaining 1,108.6e18 of day 1's holder tax is unclaimable and goes to the protocol wallet on sweepExpired. Foundry probe: test_latePairDilutesAndStrandsShare in test/scratch/Probe.t.sol logs these values.","severity":"low","snippet":"        if (excludedFromHolderRewards[holder]) return 0;","title":"Excluding a pool applies retroactively to past days whose share total already counted it, stranding that slice of holder tax"},{"citation":"resolved","description":"mint() is the only way to obtain a token and it writes ownership to msg.sender without calling onERC721Received when msg.sender has code, unlike safeTransferFrom() which does enforce it. A contract that cannot handle ERC-721 tokens (no onERC721Received, no transfer logic) pays the full price, receives the NFT, and can never move or use it; the ETH is forwarded to the treasury and the id is permanently taken out of the 9,999 supply. This is outside the math scope and is a convention gap (OpenZeppelin's _safeMint), not an arithmetic error. Fix: after _mint, if msg.sender.code.length > 0 require onERC721Received to return its selector (same code path as safeTransferFrom), which keeps EOA and smart-wallet (Safe) minting unchanged.","line":140,"path":"contracts/RunnerGenesis.sol","reproduction":"Deploy RunnerGenesis. A contract with only `function buy(g, id) external payable { g.mint{value: msg.value}(id); }` and no onERC721Received calls buy for id 1 with exactly priceOf(1) == 0.005 ether. Expected (safe-mint semantics): revert UnsafeRecipient and no payment taken. Actual: ownerOf(1) == the contract, the 0.005 ETH is sent to the treasury, and token 1 can never be transferred because that contract has no code path to call transferFrom/approve. Foundry probe: test_genesisMintToNonReceiverAndRoyaltyDust in test/scratch/Probe.t.sol shows ownerOf(1) == the non-receiver contract.","severity":"low","snippet":"        _mint(msg.sender, tokenId);","title":"mint() delivers tokens to contracts without the ERC-721 receiver check, so a non-receiver contract's purchase is stuck"}],"hash":"70e3ad597d83fca0fc25dab8f8e418a51212855f2d753c4b12f88ca428dbef94","nodeId":"d959ae92-6b69-4ac6-8284-3e19263a6079","outcome":"completed","summary":"The review is complete. The findings file is in place and all three snippets verify against the tree.\n\n**Outcome:** no critical or high defects in the math. The holder-reward accounting (frozen daily share totals, per-wallet min-balance checkpoints, the 7-day claim window versus sweep boundary, the uint112/uint128 packing, the halving schedule with promotion, and the daily claim cap) all held up under hand tracing and a randomized Foundry invariant run of 7,680 calls across transfers, burns, donations to the contract, holder claims, sweeps and time jumps. The promised NFT rarity counts match the id formula exactly, and the JavaScript mirrors of rarity and emission agree with the Solidity.\n\n**Findings written to `.imd-findings.json`, all low:**\n\n- **Self-transfer phantom dip.** `_move` tracks the debit leg before the credit leg, so `transfer(self, 1 wei)` or `transferFrom(holder, holder, 1 wei)` by any approved spender permanently lowers that day's minimum balance. A wallet holding exactly 300,000 RUNNER loses its whole share for the day. Confirmed by a failing Foundry test.\n- **Retroactive pool exclusion.** A pool that held tokens on earlier days and is registered later is zeroed for those days while the day's share denominator still counts it. Holders are diluted and that slice of the tax is swept to the protocol wallet. Reachable for any second pool or a registration that slips a day.\n- **NFT mint without receiver check.** Outside the math area. `mint` writes ownership to a contract caller without calling `onERC721Received`, so a non-receiver contract's purchase is stuck for good.\n\n**Boundaries checked and found correct or by design:** claim cap at exact equality, 4 wei instant claims burning 0, sub-50 wei pool trades paying no tax, instant/lock front-running (equivalent outcome via `releaseEarly`), signature low-s and v checks, royalty rounding on tiny sale prices, and the schedule replace-then-promote sequence.\n\n**Limits:** Slither was not run. Reproductions ran in a scratch Foundry project outside the repository, since the repository has no Foundry configuration. The repository tree was not modified beyond the findings file.","treeHash":null,"usage":{"cachedInputTokens":1217506,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":44253,"runtime":"claude","turns":31,"wallClockMs":651278}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fa4b91aed45c94fa","findings":[{"citation":"resolved","description":"Asymmetry between the writer and the reader of the exclusion flag. _exclude() (lines 568-577) removes the new pool's weight only from today's _days[today].shares and from eligibleSupply, but eligibleBalanceOn() (line 266) returns 0 for the excluded wallet on EVERY day, including the up-to-7 still-claimable days before registration. For those days the frozen share total still contains the pool's balance, so the sum of claimable weights is strictly below _days[d].shares and the pool's slice of that day's 2% holder tax can be claimed by nobody; sweepExpired() later sends it to protocolWallet. Holders of those days are diluted by pool.balance / shares (26% in the reproduction). Preconditions are normal operation: a pool that held >= 300,000 RUNNER for a whole UTC day before the owner registered it while another registered pool generated tax (second DEX pool, or liquidity added the day before registration). The owner action is the documented addPair flow; the unprivileged effect is the retroactive rewrite of pending day weights. Fix (keeps the design 'pools get no share'): in _exclude(), for each still-open day d in [today-7, today-1] subtract eligibleBalanceOn(account, d) from _days[d].shares before setting the flag, so weights that can be claimed again add up to the share total. Alternative (changes economics): store an excludedFromDay and have eligibleBalanceOn() return 0 only for day >= excludedFromDay, which makes the view consistent but still leaves the pool's past slice to the sweep.","line":266,"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/// Minimal pool: reports RUNNER as token0 so addPair accepts it, and can send tokens out.\ncontract MockPool {\n    address public token0;\n    address public token1 = address(1);\n    constructor(address t) { token0 = t; }\n    function send(address t, address to, uint256 a) external { RunnerToken(t).transfer(to, a); }\n}\n\n/// addPair() excludes the new pool from holder rewards retroactively (eligibleBalanceOn reads the\n/// current flag for every day), but the share totals of past days still contain the pool's\n/// balance. Holders of those days are diluted and the remainder is swept to the protocol wallet.\ncontract RetroactivePairExclusionTest is Test {\n    RunnerToken token;\n    address owner = address(0xA11CE);\n    address liq = address(0xB0B);\n    address team = address(0xCAFE);\n    address holder = address(0xD00D);\n    MockPool poolA;\n    MockPool poolB;\n\n    uint256 constant DAY = 1 days;\n\n    function setUp() public {\n        vm.warp(20_500 * DAY + 3600);\n        token = new RunnerToken(owner, liq, team);\n        poolA = new MockPool(address(token));\n        poolB = new MockPool(address(token));\n\n        // Day 0: pool A gets liquidity and is registered right away (the documented launch flow).\n        vm.prank(liq);\n        token.transfer(address(poolA), 10_000_000 ether);\n        vm.prank(owner);\n        token.addPair(address(poolA));\n        // The only eligible holder besides liq/team: 1,000,000 RUNNER.\n        vm.prank(team);\n        token.transfer(holder, 1_000_000 ether);\n        // Pool B receives 50M liquidity on day 0 but is NOT registered yet.\n        vm.prank(liq);\n        token.transfer(address(poolB), 50_000_000 ether);\n    }\n\n    function test_addPairDilutesPastDays() public {\n        uint256 day1 = block.timestamp / DAY + 1;\n        vm.warp(day1 * DAY + 10);\n        // Day 1: a trade through pool A creates holder tax (2% of 1,000,000 = 20,000).\n        poolA.send(address(token), address(0xEEEE), 1_000_000 ether);\n        (uint256 sharesBefore, uint256 rewards,) = token.dayInfo(day1);\n        assertEq(rewards, 20_000 ether);\n\n        // Day 2: holder's share of day 1 as the contract computes it before anything else happens.\n        vm.warp((day1 + 1) * DAY + 10);\n        uint256 claimableBefore = token.holderClaimable(holder);\n        // Expected: rewards * 1M / sharesBefore, where sharesBefore includes pool B's 50M.\n        assertEq(claimableBefore, rewards * 1_000_000 ether / sharesBefore);\n\n        // Owner registers pool B on day 2 (normal operation: a second DEX pool).\n        vm.prank(owner);\n        token.addPair(address(poolB));\n\n        // Pool B now reports 0 eligible balance for day 1 ...\n        assertEq(token.eligibleBalanceOn(address(poolB), day1), 0);\n        // ... but day 1's share total was never reduced by its 50M.\n        (uint256 sharesAfter,,) = token.dayInfo(day1);\n        assertEq(sharesAfter, sharesBefore);\n\n        // Sum of what every wallet can claim for day 1 is now strictly below the day's rewards by\n        // (at least) pool B's weight: the part of the day-1 tax that was attributed to pool B is\n        // claimable by no holder and will be swept to the protocol wallet instead.\n        uint256 sumWeights = token.eligibleBalanceOn(holder, day1) + token.eligibleBalanceOn(liq, day1)\n            + token.eligibleBalanceOn(team, day1) + token.eligibleBalanceOn(address(poolA), day1)\n            + token.eligibleBalanceOn(address(poolB), day1);\n        // Expected after the fix: weights that can be claimed add up to the day's share total, so the\n        // holder's share is computed against a denominator that only contains claimable wallets.\n        assertEq(sumWeights, sharesAfter, \"day-1 share total still counts the excluded pool\");\n    }\n}","reproduction":"Deploy RunnerToken(owner, liq, team) at day D. Day D: liq transfers 10M to poolA, owner addPair(poolA); team transfers 1,000,000 to holder; liq transfers 50M to poolB (not registered). Day D+1: poolA sends 1,000,000 to a buyer (taxed; dayInfo(D+1).rewards = 20,000, shares = 190M incl. poolB's 50M). Day D+2: holderClaimable(holder) = 20,000 * 1M / 190M. Owner calls addPair(poolB). Expected: either poolB keeps its weight for D+1 or D+1's shares drop to 140M so holder's share becomes 20,000 * 1M / 140M. Actual: eligibleBalanceOn(poolB, D+1) = 0 while dayInfo(D+1).shares stays 190M; the 50M/190M slice (5,263 RUNNER) of day D+1's tax is unclaimable by anyone and is swept to protocolWallet after day D+8. test/scratch/RetroactivePairExclusion.t.sol fails on the current code with '140000000000000000000000000 != 190000000000000000000000000'.","severity":"low","snippet":"        if (excludedFromHolderRewards[holder]) return 0;","title":"addPair() excludes a pool from holder rewards retroactively but leaves its weight in past days' share totals"},{"citation":"resolved","description":"_move() has no from == to short-circuit: it debits `from`, calls _track() for the lowered balance (which lowers the wallet's checkpoint minBalance for today, reduces eligibleSupply and subtracts the drop from _days[today].shares), then credits `to` and tracks the raise. The balance ends unchanged (the existing test 'self-transfer keeps balance' only checks that), but the checkpoint now says the wallet's lowest balance today was balance - value, so eligibleBalanceOn() for today returns 0 when value >= balance - 300,000 and the wallet gets no share of today's holder tax. Any approved spender (transferFrom(holder, holder, allowance)) can inflict this on a holder at zero token cost, and wallets/UIs that 'send to self' trigger it by accident. Expected per ERC-20 and the stated rule ('lowest balance it held that day'): a transfer to self does not change the held balance and must not change the day's minimum. Fix: in _transfer()/_move(), if from == to, emit Transfer and return without calling _track(), or compute the day's minimum from the post-transfer balance when from == to.","line":535,"path":"contracts/RunnerToken.sol","reproduction":"Day D: team transfers 1,000,000 RUNNER to holder. Day D+1: eligibleBalanceOn(holder, D+1) == 1,000,000. holder calls transfer(holder, 1,000,000) (or holder approves spender and spender calls transferFrom(holder, holder, 1,000,000)). Expected: balanceOf(holder) == 1,000,000 and eligibleBalanceOn(holder, D+1) == 1,000,000. Actual: balanceOf(holder) == 1,000,000 but eligibleBalanceOn(holder, D+1) == 0, so holder is excluded from day D+1's holder rewards. test/scratch/SelfTransferMin.t.sol (both tests) fails on the current code with '0 != 1000000000000000000000000'.","severity":"low","snippet":"        _track(from, fromBal, fromBal - value, today);","title":"Self-transfer (transfer or transferFrom with from == to) records a false daily minimum and removes the wallet from that day's holder rewards"},{"citation":"resolved","description":"Access x asymmetry seam. The instant branch is runner-only but the locked branch of claim() is open to any msg.sender holding the server signature, and the two branches are the only consumers of a given (account, totalEarned) signature: once claimed[account] == totalEarned the runner's own call reverts with NothingToClaim. A third party who sees the pending instant claim (mempool, or any party that obtains the signed payload) submits it with instant=false and the runner's choice is overridden. The runner then has no per-lock early release: releaseEarly() pays out ALL unfinished locks at 80% and burns 20% of each, so a runner holding earlier locks they wanted at 100% pays 20% of those too, or waits 7 days. No funds are stolen; the attacker's cost is gas. Fix: have the signature carry the payout choice (add `bool instant` to the Claim typed data so the server signs what the runner asked for), or let only `account` submit a non-instant claim as well, or add a per-lock early release so a forced lock can be undone without touching other locks.","line":302,"path":"contracts/RunnerToken.sol","reproduction":"Owner setSigner(S), startEmission(). Runner A claims total=100,000 with instant=false (lock L1, 100%). Server signs (A, 101,000, deadline). Attacker B calls claim(A, 101000e18, deadline, v, r, s, false) before A. A calls claim(..., true): expected the 1,000 difference paid as 800 to A and 200 burned, L1 untouched. Actual: A's call reverts NothingToClaim; A now holds 101,000 locked. To get any of it before 7 days A must call releaseEarly(): paid = 80,800, totalSupply drops by 20,200 (20% of L1's 100,000 is burned although A never chose instant for it). test/scratch/ForcedLock.t.sol demonstrates the sequence (it passes by asserting the actual outcome).","severity":"low","snippet":"        if (instant && msg.sender != account) revert OnlyRunnerCanClaimInstantly();","title":"Anyone holding a runner's claim signature can force the 7-day locked branch, and the only early exit also burns 20% of every other unfinished lock"},{"citation":"resolved","description":"Ownership moves in one call with only a zero-address check; nothing verifies the new owner can act. AUDIT.md's own 'Before mainnet' step 2 is to move ownership to a multisig, which is exactly the call where a wrong or not-yet-deployed address is most likely. After a bad transfer no one can rotate the server signer, pause claims, change the emission schedule, register a new pool or move the protocol wallet, for the lifetime of the token. Fix: two-step handover (pendingOwner set by the owner, acceptOwnership() by the pending owner), as in OpenZeppelin Ownable2Step.","line":471,"path":"contracts/RunnerToken.sol","reproduction":"Owner calls transferOwnership(0x0f397E46e4e810313dBC782CDb0236347157f1B5) intending the dev wallet 0x...f1B4 (one hex digit off) or a Safe address that was never deployed on this chain. Expected: the mistake is recoverable until the new owner accepts. Actual: owner == 0x...f1B5 immediately; every subsequent setSigner/setClaimsPaused/setEmissionSchedule/addPair/setProtocolWallet/transferOwnership from the dev wallet reverts NotOwner() with no recovery path.","severity":"low","snippet":"    function transferOwnership(address newOwner) external onlyOwner {\n        if (newOwner == address(0)) revert ZeroAddress();\n        emit OwnershipTransferred(owner, newOwner);\n        owner = newOwner;","title":"RunnerToken.transferOwnership is single-step: a mistyped address permanently loses setSigner/setClaimsPaused/setEmissionSchedule/addPair"},{"citation":"resolved","description":"Same defect as in RunnerToken: ownership moves in one call with only a zero-address check. The NFT owner is also the OpenSea collection manager (owner()), and controls the royalty receiver, the treasury that receives all mint ETH, pausing and the ERC-721C validator. A bad handover freezes all of these, and future mint payments keep going to whatever treasury was last set. Fix: two-step handover (pendingOwner + acceptOwnership()).","line":291,"path":"contracts/RunnerGenesis.sol","reproduction":"Owner calls transferOwnership(X) where X is a typo of the intended Safe address or a Safe not yet deployed on Robinhood Chain. Expected: recoverable until X accepts. Actual: owner == X at once; setTreasury/setRoyaltyReceiver/setPaused/setBaseURI/setContractURI/setTransferValidator/withdraw/transferOwnership from the dev wallet all revert NotOwner() forever.","severity":"low","snippet":"    function transferOwnership(address newOwner) external onlyOwner {\n        if (newOwner == address(0)) revert ZeroAddress();\n        emit OwnershipTransferred(owner, newOwner);\n        owner = newOwner;","title":"RunnerGenesis.transferOwnership is single-step: a mistyped address permanently loses setTreasury/setRoyaltyReceiver/setPaused/setTransferValidator"}],"hash":"8ce74fbe0542516c9ee3d9c9c75f830b844a4c8190d72473051b9b35bd135238","nodeId":"ddb8aa10-83a4-4057-9425-93df1ac4541b","outcome":"completed","summary":"The review is complete. The findings are in `.imd-findings.json` at the repository root, no tracked file was changed, and the scratch tests live under `test/scratch/`.\n\n**Scope and method.** I inventoried every state-changing entry point of `RunnerToken` and `RunnerGenesis` (ten public and seven owner functions on the token, six public and eight owner functions on the NFT, no receive or fallback), then applied the Access Control, Trust Gap and Asymmetry passes, with the other specialties used only where they touched those seams. The core holder-reward accounting, the EIP-712 claim path, the emission schedule, the lock bookkeeping, the NFT transfer and validator ordering, and reentrancy on the mint payment all held up against concrete counterexamples. No critical or high defect was found. Documented owner powers (signer rotation, pause, schedule, pool registration, validator, treasury) are recorded as trust assumptions, not findings.\n\n**Findings, all low severity:**\n\n- **Retroactive pool exclusion** (`RunnerToken.sol:266`). After `addPair`, the pool's weight is zero for every past day, but those days' share totals still contain its balance. That slice of the day's holder tax is unclaimable and is swept to the protocol wallet. The Foundry proof fails on current code with weights summing to 140M against a frozen share total of 190M.\n- **Self-transfer records a false daily minimum** (`RunnerToken.sol:535`). A transfer to self, or a `transferFrom` to self by any approved spender, sets the wallet's lowest balance for the day to zero and drops it from that day's rewards. Both scratch tests fail on current code.\n- **Forced locked branch** (`RunnerToken.sol:302`). Anyone holding a runner's signature can submit it with `instant=false`, and the only early exit burns 20% of every other unfinished lock. Demonstrated by a passing scenario test, not attached as a proof since it is low.\n- **Single-step ownership transfer** in both contracts (`RunnerToken.sol:471`, `RunnerGenesis.sol:291`). A mistyped address loses all admin powers permanently, which matters because moving to a multisig is a documented pre-launch step.\n\n**Limits.** The rewards server was not reviewed beyond its claim-signature shape, since the task scope is the contracts. No fuzzing or static analysis ran, only targeted Foundry tests. A clean result on the other passes is evidence, not proof, that no further defects exist.","treeHash":null,"usage":{"cachedInputTokens":1950078,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":37683,"runtime":"claude","turns":32,"wallClockMs":545236}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3a40eaafbd83a6bc","findings":[{"citation":"resolved","description":"_move() has no from == to short-circuit. It debits `from`, calls _track() with newBal = fromBal - value (which lowers today's checkpoint minBalance, lowers eligibleSupply and subtracts the drop from _days[today].shares), and only then credits `to` and tracks the raise. The balance ends unchanged, but a checkpoint's minBalance can only fall, so the wallet's lowest balance for the day is now recorded as balance - value. The documented rule (contract header, README) is 'the lowest balance it held that day'; the wallet never held less. eligibleBalanceOn(wallet, today) therefore returns 0 when value >= balance - 300,000 (and balance - value otherwise), and the wallet loses that share of today's 2% holder tax; the unclaimed slice is later swept to the protocol wallet. Trigger paths: a wallet's own 'send to self' (some wallets and UIs do this as a test), or any approved spender calling transferFrom(holder, holder, v), which needs only an allowance of v: a spender with a 1 wei allowance zeroes a 300,000 RUNNER holder's weight for the day without moving a token. The existing test 'self-transfer keeps balance' (tests/token.test.mjs:85) asserts only the balance, so the suite does not catch it. Reported by three specialists (flow, math, permissions); merged here. Fix: in _move(), after the InsufficientBalance check, `if (from == to) { emit Transfer(from, to, value); return; }` so checkpoints only see real balance changes (no economic rule changes), or compute the net balance change per address before calling _track().","line":535,"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 transfer with from == to leaves the balance unchanged, so the wallet's lowest balance for\n/// the day must not change either. On the current code _move() debits and checkpoints the sender\n/// before crediting it back, so the checkpoint records a dip that never happened and the wallet\n/// loses its holder-reward weight for the day.\ncontract SelfTransferMinTest is Test {\n    RunnerToken token;\n    address owner = address(0xA11CE);\n    address liq = address(0xB0B);\n    address team = address(0xCAFE);\n    address alice = address(0xA1);\n    address spender = address(0xB2);\n    uint256 constant DAY = 1 days;\n    uint256 day0;\n\n    function setUp() public {\n        vm.warp(20_500 * DAY + 3600);\n        day0 = block.timestamp / DAY;\n        token = new RunnerToken(owner, liq, team);\n        vm.prank(team);\n        token.transfer(alice, 1_000_000 ether);\n    }\n\n    function test_selfTransferKeepsDayMinimum() public {\n        uint256 d1 = day0 + 1;\n        vm.warp(d1 * DAY + 10);\n        assertEq(token.eligibleBalanceOn(alice, d1), 1_000_000 ether);\n\n        vm.prank(alice);\n        token.transfer(alice, 1_000_000 ether);\n\n        assertEq(token.balanceOf(alice), 1_000_000 ether);\n        assertEq(token.eligibleBalanceOn(alice, d1), 1_000_000 ether, \"self-transfer must not lower the day minimum\");\n    }\n\n    function test_transferFromSelfKeepsDayMinimum() public {\n        uint256 d1 = day0 + 1;\n        vm.warp(d1 * DAY + 10);\n        vm.prank(alice);\n        token.approve(spender, 1);\n\n        vm.prank(spender);\n        token.transferFrom(alice, alice, 1);\n\n        assertEq(token.balanceOf(alice), 1_000_000 ether);\n        assertEq(token.eligibleBalanceOn(alice, d1), 1_000_000 ether, \"transferFrom(self, self) must not lower the day minimum\");\n    }\n}","reproduction":"Day D: team transfers 1,000,000 RUNNER to alice. Day D+1 (any time): eligibleBalanceOn(alice, D+1) == 1,000,000e18. alice calls transfer(alice, 1_000_000e18). Expected: balanceOf(alice) == 1,000,000e18 and eligibleBalanceOn(alice, D+1) still 1,000,000e18. Actual: balanceOf(alice) == 1,000,000e18 but eligibleBalanceOn(alice, D+1) == 0, and dayInfo(D+1).shares drops by 1,000,000e18. Variant: alice.approve(spender, 1); spender calls transferFrom(alice, alice, 1): eligibleBalanceOn(alice, D+1) becomes 999,999.999999999999999999e18 (a 300,000e18 holder would drop to 0). Foundry: test/scratch/SelfTransferMin.t.sol, both tests fail on the current code ('0 != 1000000000000000000000000' and '999999999999999999999999 != 1000000000000000000000000') and pass with the from == to short-circuit.","severity":"low","snippet":"        _track(from, fromBal, fromBal - value, today);","title":"Self-transfer (transfer or transferFrom with from == to) records a false daily minimum and strips the wallet's holder-reward weight for the day"},{"citation":"resolved","description":"_exclude() (lines 568-577, called from addPair) removes the newly registered pool's balance only from today's _days[today].shares and from eligibleSupply. eligibleBalanceOn() however reads the current exclusion flag for every day, so for the up-to-7 still-claimable days before registration it returns 0 for the pool while those days' frozen share totals still contain the pool's balance. The sum of claimable weights is then strictly below _days[d].shares: every real holder's share for those days is divided by a denominator that includes the pool, and the pool's slice of that day's holder tax can be claimed by nobody; sweepExpired() sends it to protocolWallet after the window. Preconditions are normal operation: a contract that already held >= 300,000 RUNNER at the start of a day on which a registered pool produced holder tax, and that is registered on a later day (a second DEX pool; or liquidity added to a pool the day before it is registered, which the admin flow 'add liquidity, then register' makes easy). Reported by the math and permissions specialists; merged here. Fix that keeps the design: in _exclude(), for each still-claimable day d in [today-7, today-1] subtract eligibleBalanceOn(account, d) from _days[d].shares before setting the flag (bounded loop of 7). Alternative: store an excludedFromDay and have eligibleBalanceOn() return 0 only for day >= excludedFromDay, which keeps the pool's past weight claimable by the pool itself. There is no on-chain way to repair past days once rewards were recorded, so the admin tooling should also refuse or warn when the pool's balance is already >= MIN_HOLDING on an earlier day.","line":266,"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/// Minimal pool: reports RUNNER as token0 so addPair accepts it, and can send tokens out.\ncontract MockPool {\n    address public token0;\n    address public token1 = address(1);\n    constructor(address t) { token0 = t; }\n    function send(address t, address to, uint256 a) external { RunnerToken(t).transfer(to, a); }\n}\n\n/// addPair() excludes the new pool from holder rewards for every day (eligibleBalanceOn reads the\n/// current flag), but the share totals of still-claimable past days keep the pool's balance.\n/// Invariant that must hold for every claimable day: the sum of eligibleBalanceOn over all\n/// wallets that held tokens equals the day's share total, otherwise part of the day's holder tax\n/// can be claimed by nobody and every other holder is diluted.\ncontract RetroactivePairExclusionTest is Test {\n    RunnerToken token;\n    address owner = address(0xA11CE);\n    address liq = address(0xB0B);\n    address team = address(0xCAFE);\n    address holder = address(0xD00D);\n    address buyer = address(0xEEEE);\n    MockPool poolA;\n    MockPool poolB;\n\n    uint256 constant DAY = 1 days;\n\n    function setUp() public {\n        vm.warp(20_500 * DAY + 3600);\n        token = new RunnerToken(owner, liq, team);\n        poolA = new MockPool(address(token));\n        poolB = new MockPool(address(token));\n\n        // Day 0: pool A gets liquidity and is registered right away.\n        vm.prank(liq);\n        token.transfer(address(poolA), 10_000_000 ether);\n        vm.prank(owner);\n        token.addPair(address(poolA));\n        vm.prank(team);\n        token.transfer(holder, 1_000_000 ether);\n        // Pool B receives 50M liquidity on day 0 but is NOT registered yet.\n        vm.prank(liq);\n        token.transfer(address(poolB), 50_000_000 ether);\n    }\n\n    function _sumWeights(uint256 day) internal view returns (uint256) {\n        return token.eligibleBalanceOn(holder, day) + token.eligibleBalanceOn(liq, day)\n            + token.eligibleBalanceOn(team, day) + token.eligibleBalanceOn(address(poolA), day)\n            + token.eligibleBalanceOn(address(poolB), day) + token.eligibleBalanceOn(buyer, day)\n            + token.eligibleBalanceOn(owner, day) + token.eligibleBalanceOn(address(token), day);\n    }\n\n    function test_addPairKeepsPastDaysConsistent() public {\n        uint256 day1 = block.timestamp / DAY + 1;\n        vm.warp(day1 * DAY + 10);\n        // Day 1: a trade through pool A creates holder tax (2% of 1,000,000 = 20,000).\n        poolA.send(address(token), buyer, 1_000_000 ether);\n        (uint256 sharesBefore, uint256 rewards,) = token.dayInfo(day1);\n        assertEq(rewards, 20_000 ether);\n        assertEq(_sumWeights(day1), sharesBefore, \"consistent before registration\");\n\n        // Day 2: owner registers pool B (normal operation: a second DEX pool).\n        vm.warp((day1 + 1) * DAY + 10);\n        vm.prank(owner);\n        token.addPair(address(poolB));\n\n        // Weights that can still be claimed for day 1 must add up to day 1's share total.\n        (uint256 sharesAfter,,) = token.dayInfo(day1);\n        assertEq(_sumWeights(day1), sharesAfter, \"day-1 claimable weights no longer add up to its share total\");\n    }\n}","reproduction":"Deploy RunnerToken(owner, liq, team) on day D at 01:00 UTC. Day D: liq transfers 10M to poolA, owner addPair(poolA); team transfers 1,000,000 to holder; liq transfers 50M to poolB (a pool contract that is not yet registered). Day D+1: poolA sends 1,000,000 to a buyer (taxed): dayInfo(D+1) == (shares 190M including poolB's 50M, rewards 20,000e18). Day D+2: holderClaimable(holder) == 20,000e18 * 1M / 190M. Owner calls addPair(poolB). Expected: either poolB keeps its weight for D+1, or D+1's shares drop to 140M so claimable weights add up to the share total. Actual: eligibleBalanceOn(poolB, D+1) == 0 while dayInfo(D+1).shares stays 190M; the sum of eligibleBalanceOn over all wallets for D+1 is 140M; the 50M/190M slice (5,263e18) of day D+1's tax is unclaimable and goes to protocolWallet on sweepExpired after day D+8. Foundry: test/scratch/RetroactivePairExclusion.t.sol fails on the current code with 'day-1 claimable weights no longer add up to its share total: 140000000000000000000000000 != 190000000000000000000000000' and passes once _exclude() subtracts the pool's past-day weights (checked against a patched copy). The permissions specialist's proof was run and fails for the stated reason, but it also asserts that the share total stays unchanged while the pool's weight is 0, which no fix can satisfy, so it is replaced.","severity":"low","snippet":"        if (excludedFromHolderRewards[holder]) return 0;","title":"addPair() excludes a pool from holder rewards retroactively, but past days' share totals keep its weight: holders of those days are diluted and the pool's slice is stranded for the protocol sweep"},{"citation":"resolved","description":"The EIP-712 struct is Claim(account,totalEarned,deadline); the payout choice `instant` is a plain calldata flag outside the signature. Line 302 only stops a third party from choosing instant=true. The locked branch is open to any msg.sender, and both branches consume the same (account, totalEarned) because claimed[account] is set to totalEarned on the first successful submission. Whoever lands first decides the payout mode: a third party who sees the pending instant claim (public mempool, a relayer, or anyone the signed payload was shared with) submits it with instant=false and the runner's own transaction reverts with NothingToClaim. The runner then has no per-lock early exit: releaseEarly() pays out ALL unfinished locks at 80% and burns 20% of each, so a runner holding earlier voluntary locks they wanted at 100% must either wait up to 7 days or burn 20% of those too. The forced lock also occupies one of the 30 active lock slots. No funds are stolen and the griefer gains nothing but gas, so this is griefing (low). Reported by the flow, permissions and economics specialists; merged here. Fix: include the payout choice in the signed struct (Claim(address account,uint256 totalEarned,uint256 deadline,bool instant)) so the server signs what the runner asked for, keeping the msg.sender == account rule for instant; or require msg.sender == account on both paths; or add a per-lock early release. The server (api/_lib/eth.js claimDigest) and app need the same struct change in the first option.","line":302,"path":"contracts/RunnerToken.sol","reproduction":"Owner setSigner(S), startEmission(). Server signs Claim(alice, 1000e18, deadline=now+1h) -> (v,r,s). Bob calls claim(alice, 1000e18, deadline, v, r, s, false) before alice. Alice calls claim(alice, 1000e18, deadline, v, r, s, true). Expected: alice receives 800e18 now and 200e18 is burned (her explicit choice). Actual: bob's call succeeds; alice's call reverts NothingToClaim(); balanceOf(alice) == 0; locksOf(alice) == (locked 1000e18, unlocked 0, nextUnlockAt now+7d, activeLocks 1). Second scenario: alice holds a voluntary 10,000e18 lock from day 0; on day 6 bob forces her new 1,000e18 claim (total 11,000e18) into a lock; alice calls releaseEarly() and receives 8,800e18 while 2,200e18 is burned (20% of the 10,000e18 lock she never chose instant for), versus the 800e18-now-plus-untouched-lock she attempted. Foundry: test/scratch/Verify.t.sol test_frontRunForcesLock and test_forcedLockThenReleaseEarlyBurnsOtherLocks (both assert the actual outcome and pass on the current code).","severity":"low","snippet":"        if (instant && msg.sender != account) revert OnlyRunnerCanClaimInstantly();","title":"claim(): the signature does not bind `instant`, so anyone holding a runner's signed claim can force it into a 7-day lock and the runner's instant claim reverts"},{"citation":"resolved","description":"Tax applies only when `from` or `to` is in isPair, and holder-reward eligibility is any non-excluded address whose lowest daily balance is >= MIN_HOLDING. Both are evaluated per address, so a plain contract holding RUNNER on behalf of others defeats the stated rules: (a) a vault that accepts deposits from many sub-300k wallets reaches 300k itself, is not excluded, calls claimHolderRewards() and forwards the holder tax to depositors who individually do not qualify; (b) a wrapper token (wRUNNER) traded on any pool looks like a wallet-to-wallet transfer to RunnerToken and pays no tax, so volume can migrate to an untaxed venue; (c) any DEX whose pool is a singleton without per-pool token0()/token1() (Uniswap V4 PoolManager, CoW/UniswapX settlers, aggregators with internal balances) cannot be registered because addPair() requires token0()/token1() to return this token, so trades through them are permanently untaxed. The owner cannot exclude a wrapper either, because addPair is the only exclusion path and has the same gate. The README/NatSpec promise ('tax on every buy/sell through exchange pools; rewards only for wallets holding >= 300,000') therefore does not hold economically. AUDIT.md #10 already documents the V3/V4 incompatibility for a different reason (fee-on-transfer), so part of this is a known limitation. This is a design limitation rather than a code bug, which is why it is kept at low rather than the economics specialist's medium. Fixing it is a scope decision: accept and document 'registered V2-style pools only' and that the threshold is per address, or add an owner-gated exclusion/tax list for arbitrary contracts (with its own centralisation trade-off), or drop the per-address threshold.","line":421,"path":"contracts/RunnerToken.sol","reproduction":"Foundry (test/scratch/Verify.t.sol test_wrapperAggregation, passes on current code asserting the actual outcome): deploy RunnerToken, register a MockPool exposing token0()==RUNNER and fund it with 10M. Deploy Wrapper (deposit() does transferFrom into itself; harvest() calls claimHolderRewards()). Ten wallets each receive 30,000 RUNNER (below MIN_HOLDING) and deposit it. Next day the pool sends 1,000,000 RUNNER to a buyer, collecting 20,000 RUNNER holder tax. Expected per the stated rule: none of the ten wallets qualifies, so their 300k earns nothing. Actual: eligibleBalanceOn(wrapper, day) == 300,000e18 and wrapper.harvest() returns 31.578947368421052631e18 (300k/190M of the 20k), which the wrapper can forward. Separately: owner calls addPair(address of a contract with no token0()/token1()) -> reverts NotAPool(), so a V4-style singleton can never be taxed.","severity":"low","snippet":"        if (_poolToken(pair, 0x0dfe1681) != address(this) && _poolToken(pair, 0xd21220a7) != address(this)) revert NotAPool(); // token0(), token1()","title":"The 4% tax and the 300k holder threshold are per-address rules that pooled intermediaries sidestep, and singleton DEXes (Uniswap V4 PoolManager, intent settlers) can never be registered"},{"citation":"resolved","description":"The constructor calls _startDay(today) before the three mints, so _days[deployDay].shares is frozen at 0 while the liquidity and team wallets hold 200M RUNNER from the first block (eligibleSupply becomes 200M right after, but the day's share total is never re-read). Every 2% holder cut collected that day has no eligible claimant: _holderShareOf() returns 0 when shares == 0, holderClaimable() is 0 for every wallet, and after 7 days sweepExpired() moves the whole amount to protocolWallet (the owner by default). Per the launch sequence 'deploy -> add liquidity -> register the pool right away', the deploy day is launch day and typically the heaviest trading day, and the first liquidity transfer into the registered pool is itself taxed. This is consistent with the strict 'held for the whole day' rule (nobody held tokens at 00:00 UTC of the deploy day), so it may be intended, but the NatSpec says the 2% goes 'to holders' and the owner 'can never take tokens out of the holder-reward bucket', and on this day the owner-controlled wallet receives 100% of the holder cut instead of 50%. If intended, document it; otherwise mint before calling _startDay() in the constructor so deploy-day shares equal the initial eligible supply, and/or roll a zero-share day's rewards into the next day's bucket instead of sweeping them.","line":171,"path":"contracts/RunnerToken.sol","reproduction":"Foundry (test/scratch/Verify.t.sol test_deployDaySharesZero; the test's final assertion expected 20,000e18 and the actual sweep was 220,000e18 because the 10M liquidity transfer into the registered pool in setUp was also taxed, which confirms the behaviour): deploy RunnerToken(owner, liq, team) at 01:00 UTC on day D; dayInfo(D).shares == 0 although liq and team hold 100M each. Register a MockPool, fund it with 10M from liq (200,000e18 holder cut), and have it send 1,000,000 RUNNER to a buyer the same day (20,000e18 holder cut): dayInfo(D).rewards == 220,000e18. Day D+1: holderClaimable(liq) == 0 and holderClaimable(team) == 0. Day D+9: sweepExpired(10) returns 220,000e18 and balanceOf(owner) rises by 220,000e18. Expected per README: holders share the 2%; actual: the protocol wallet receives all of it.","severity":"low","snippet":"        _startDay(today);","title":"The deployment day's share total is frozen at 0 before the initial mints, so 100% of launch-day holder tax goes to the protocol wallet"},{"citation":"resolved","description":"mint() is the only way to obtain a token and it writes ownership to msg.sender without calling onERC721Received when msg.sender has code, unlike safeTransferFrom() which enforces it. A contract without onERC721Received and without any transfer/approve logic (a minimal multisig, a contract that forwards a mint call) pays the full price, receives the NFT, and can never move or use it; the ETH has already gone to the treasury and the id is permanently taken out of the 9,999 supply. Self-harm only, no effect on other holders; reported because the collection is sold through a wallet-connected web page where contract wallets are common. Reported by the flow and math specialists; merged here. Fix: after _mint, if msg.sender.code.length > 0 require onERC721Received to return its selector (the same code path as safeTransferFrom), which keeps EOA and Safe minting unchanged.","line":140,"path":"contracts/RunnerGenesis.sol","reproduction":"Deploy RunnerGenesis. Deploy a contract C with only `function buy(g, id) external payable { g.mint{value: msg.value}(id); }` and no onERC721Received; fund it with 1 ETH. C.buy(g, 1) with msg.value == priceOf(1) == 0.005 ether. Expected (safe-mint semantics): revert UnsafeRecipient and no payment taken. Actual: ownerOf(1) == C, the treasury balance rises by 0.005 ETH, and C has no code path to call transferFrom/approve, so token 1 is stuck. Foundry: test/scratch/Verify.t.sol test_genesisMintNonReceiver (asserts the actual outcome; passes on current code).","severity":"low","snippet":"        _mint(msg.sender, tokenId);","title":"RunnerGenesis.mint() delivers the token to msg.sender without the ERC-721 receiver check, so a contract that cannot handle NFTs pays and gets a token it can never move"},{"citation":"resolved","description":"Ownership moves in one call with only a zero-address check and nothing verifies the new owner can act. The same code is at contracts/RunnerGenesis.sol:291. AUDIT.md's own 'before mainnet' step is to move ownership to a multisig, which is exactly the call where a wrong or not-yet-deployed address is most likely. After a bad transfer on the token nobody can rotate the server signer, pause claims, change the emission schedule, register a new pool or move the protocol wallet, for the lifetime of the token; on the NFT nobody can change the treasury that receives all mint ETH, the royalty receiver, pausing, URIs or the ERC-721C validator, and the OpenSea collection manager role is lost. Reported twice by the permissions specialist (one per contract) and by the flow specialist; merged here. Fix: two-step handover (pendingOwner set by the owner, acceptOwnership() by the pending owner, as in OpenZeppelin Ownable2Step) in both contracts. This keeps the owner role and its powers unchanged.","line":471,"path":"contracts/RunnerToken.sol","reproduction":"Owner calls token.transferOwnership(0x0000000000000000000000000000000000000001) (any typo of the intended multisig, or a Safe not yet deployed on this chain). Expected: recoverable until the new owner accepts. Actual: owner() == 0x...0001 immediately and the former owner's next setSigner() reverts NotOwner(); no function can recover ownership. Same on RunnerGenesis: setTreasury/setRoyaltyReceiver/setPaused/setBaseURI/setContractURI/setTransferValidator/withdraw all revert NotOwner() for the former owner. Foundry: test/scratch/Verify.t.sol test_singleStepOwnership (asserts the actual outcome; passes on current code).","severity":"low","snippet":"    function transferOwnership(address newOwner) external onlyOwner {","title":"transferOwnership is single-step in both contracts: a mistyped or undeployed address permanently loses every owner function"},{"citation":"resolved","description":"Not a permission bypass: this is the privileged-power inventory the review relies on, recorded so the trust assumptions are explicit (AUDIT.md #1, #4, #16 acknowledge most of it). The owner (an EOA, the dev wallet, per README) can: (1) setSigner() to a key it controls and, after setEmissionSchedule(20_000_000e18, 7, 20_000_000e18) takes effect the next UTC day, claim up to 20M RUNNER/day (2.5% of the 800M pool) through claim() with instant=false until the key is rotated, bounded only by MAX_DAILY_EMISSION and rewardsRemaining(); (2) setProtocolWallet() to redirect the 2% protocol cut and every swept holder-reward day; (3) addPair() on any contract that returns this token from token0()/token1(), which taxes every transfer touching it and excludes it from holder rewards (a wallet cannot be targeted, a user-deployed vault that happens to expose token0() can); (4) setClaimsPaused(true) to stop run-reward payouts while locks keep maturing. On RunnerGenesis the owner can setTransferValidator() to a contract that reverts, freezing all NFT transfers, and setTreasury() to a contract that reverts, halting mint(). The NatSpec sentence 'The owner can never take tokens out of the run-reward pool' is true only up to the daily emission cap. Reported by the flow and economics specialists; merged here. Recommended, consistent with AUDIT.md: multisig owner on both contracts and the two-step handover from the previous finding.","line":436,"path":"contracts/RunnerToken.sol","reproduction":"State: owner O holds the key on day D. O calls setEmissionSchedule(20_000_000e18, 7, 20_000_000e18) and setSigner(K). Day D+1: emissionOn(D+1) == 20_000_000e18; K signs Claim(O, 20_000_000e18, deadline) and O calls claim(O, 20_000_000e18, deadline, v, r, s, false): succeeds, locksOf(O).locked == 20_000_000e18, released at 100% after 7 days; repeatable every day. No contract check stops this; only key custody does. Foundry: test/scratch/Verify.t.sol test_ownerDrains20MPerDay (asserts the actual outcome; passes on current code).","severity":"info","snippet":"    function setSigner(address newSigner) external onlyOwner {","title":"Trust assumptions: the owner key can route up to 20M RUNNER/day from the reward pool to itself, redirect the protocol share and swept rewards, pause claims, and freeze NFT transfers"}],"hash":"937960866e42cc84c35513286d1aaaa240b038bdfc0b656eb5d02599515c4407","nodeId":"13fa6b96-300b-4fd0-9488-489650a9617d","outcome":"completed","summary":"The review is complete. The findings file holds 8 verified findings, all reproduced in Foundry against the pinned code.\n\n**What I did**\n- Reproduced all 16 specialist claims in Foundry (test/scratch/Verify.t.sol plus two proof files), using forge-std mounted from outside the repository. Every claim held; none was dropped as false.\n- Merged duplicates across the four specialists into 8 findings: the self-transfer checkpoint dip (3 reports), the retroactive pool exclusion (2), the forced 7-day lock (3), the non-receiver NFT mint (2), single-step ownership in both contracts (3), and the owner trust inventory (2).\n- Ran the permissions specialist's attached proof. It fails on the current code for the stated reason, but it also asserts the share total stays unchanged while the pool's weight is zero, which no fix can satisfy. I replaced it with a fix-independent invariant test and confirmed it passes against a patched copy of the contract outside the repo. The self-transfer proof was also confirmed to pass on a patched copy.\n\n**Severity decisions**\n- Nothing reached high or critical. No funds can be stolen; the defects are mis-attributed holder rewards, griefing, and operational risk.\n- The economics specialist's medium (wrapper vaults and V4 singletons sidestep the per-address tax and threshold) is kept at low. It reproduces, but it is a design limitation already partly covered by the repository's own audit note, and fixing it is a scope decision.\n- The zero-share deploy day is kept at low. It is consistent with the strict whole-day rule, but it sends 100% of launch-day holder tax to the owner wallet, which contradicts the README.\n\n**Findings written, in order**\n1. low: self-transfer records a false daily minimum and strips the wallet's reward weight (proof attached).\n2. low: addPair excludes a pool retroactively but past days' share totals keep its weight (proof attached).\n3. low: claim signature does not bind `instant`, so anyone can force a runner's claim into a lock.\n4. low: per-address tax and threshold bypassed by pooled intermediaries; singleton DEXes cannot be registered.\n5. low: deploy-day share total frozen at 0, launch-day holder tax goes entirely to the protocol wallet.\n6. low: RunnerGenesis.mint skips the ERC-721 receiver check.\n7. low: single-step transferOwnership in both contracts.\n8. info: owner trust assumptions, including 20M RUNNER per day routable to the owner via setSigner plus the schedule cap.\n\nOnly .imd-findings.json and test/scratch/ were written. Build outputs and forge-std live under /tmp.","treeHash":null,"usage":{"cachedInputTokens":1057331,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":28061,"runtime":"claude","turns":20,"wallClockMs":374754}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2c968e88904ec22b","findings":[{"citation":"resolved","description":"The EIP-712 struct is Claim(account,totalEarned,deadline). The payout choice `instant` is a plain calldata flag outside the signature. Line 302 only stops a third party from choosing instant=true; it does not stop a third party from choosing instant=false with the runner's signature. Because claimed[account] is set to totalEarned on the first successful submission, whoever lands first decides the payout mode and the runner's own transaction reverts with NothingToClaim. The runner's 80% can still be recovered through releaseEarly(), but releaseEarly() burns 20% of every unfinished lock the runner holds, not only the forced one, so a runner who also has older locks waiting for 100% must either wait up to 7 days or burn 20% of those too. The forced lock also occupies one of the 30 active lock slots. Execution-trace class: parameter divergence (two inputs, signature and instant, with no enforced relationship) and operation interleaving between the server signing and the runner submitting. Fix: include `instant` in the signed struct (Claim(address account,uint256 totalEarned,uint256 deadline,bool instant)) and keep the msg.sender==account rule for instant, or let only `account` submit claims at all and keep release()/releaseEarly() for third-party and self payouts. Note that the server (api/_lib/eth.js claimDigest) and the app would need the same struct change.","line":307,"path":"contracts/RunnerToken.sol","reproduction":"State: owner has called setSigner(S) and startEmission(); runner R has claimed[R]==0. Server signs (R, totalEarned=1000e18, deadline=now+1h) -> (v,r,s). R broadcasts claim(R, 1000e18, deadline, v, r, s, true). Griefer G sends claim(R, 1000e18, deadline, v, r, s, false) first. Expected: R receives 800e18 now and 200e18 is burned (R's explicit choice). Actual: G's call succeeds (no revert, since instant==false), locksOf(R) = (locked 1000e18, unlocked 0, nextUnlockAt now+7d, activeLocks 1), balanceOf(R)==0, and R's own call reverts with NothingToClaim(). Verified in Foundry (test/scratch/Verify.t.sol, testFrontRunForcesLock): griefer's call succeeds, runner's instant call reverts NothingToClaim, locksOf(runner) == (1000e18, 0, _, 1), balanceOf(runner) == 0.","severity":"low","snippet":"            \"\\x19\\x01\", domainSeparator(), keccak256(abi.encode(CLAIM_TYPEHASH, account, totalEarned, deadline))","title":"claim(): the signed message does not bind `instant`, so anyone can front-run a runner's instant claim and force the reward into a 7-day lock"},{"citation":"resolved","description":"_move debits `from`, checkpoints it with newBal = fromBal - value, then credits `to` and checkpoints again. When from == to the wallet's balance is unchanged at the end of the call, but the first _track call has already written minBalance = fromBal - value into today's checkpoint and subtracted the corresponding eligible amount from _days[today].shares and eligibleSupply (the second _track adds eligibleSupply back but a day minimum can only fall). The documented rule (header comment, README) is 'lowest balance it held that day'; the wallet held its full balance the whole day. The loss is the wallet's own (its share for the day goes to the other holders), so this is not an attack vector, but it is a correctness defect in the checkpoint logic that wallets and tooling can trigger by accident: a self-transfer of the whole balance (some wallets' 'send to self' test, ERC-20 approvals exercised via transferFrom(self, self, x) by routers) forfeits that day's holder rewards. tests/token.test.mjs line 85 ('self-transfer keeps balance') asserts only the balance, so the suite does not catch it. Fix: in _move, return early (after _startDay) when from == to, or compute the net balance change per address before calling _track.","line":535,"path":"contracts/RunnerToken.sol","reproduction":"State: day D+1 where D is deployment day; team wallet T has held 100,000,000e18 since D so eligibleBalanceOn(T, D+1) == 100,000,000e18. Input: T calls transfer(T, 100_000_000e18). Expected: balanceOf(T) unchanged and eligibleBalanceOn(T, D+1) still 100,000,000e18 (T never held less). Actual: balanceOf(T) == 100,000,000e18 but eligibleBalanceOn(T, D+1) == 0 and dayInfo(D+1).shares drops by 100,000,000e18, so T is excluded from day D+1's holder tax. Verified in Foundry (test/scratch/Verify.t.sol, testSelfTransferDropsDailyMin): after the self-transfer eligibleBalanceOn(team, day) == 0 while balanceOf(team) == 100,000,000e18 and dayInfo(day).shares == 100,000,000e18 (only the liquidity wallet remains).","severity":"low","snippet":"        _track(from, fromBal, fromBal - value, today);","title":"_move(): a self-transfer records the sender's balance as if it had dropped, zeroing the wallet's holder-reward weight for the day although its balance never changed"},{"citation":"resolved","description":"safeTransferFrom checks onERC721Received, but the only mint path does not. A contract without onERC721Received (a minimal multisig, a contract wallet with no fallback handler, or a contract that forwards a mint call) can pay the price and receive a token it can never move; the ETH has already gone to the treasury. Self-harm only, no effect on other holders; reported because the collection is sold through a wallet-connected web page where contract wallets are common. Fix: after _mint, if msg.sender.code.length > 0 require onERC721Received to return its selector (same code as safeTransferFrom), or expose mint(address to) with the check.","line":140,"path":"contracts/RunnerGenesis.sol","reproduction":"State: contract C with no onERC721Received function, holding 0.005 ETH. Input: C calls mint(146) with msg.value == priceOf(146) == 0.005 ether. Expected (safe-mint semantics): revert UnsafeRecipient and the ETH stays with C. Actual: ownerOf(146) == C, treasury receives 0.005 ETH, and C has no way to call transferFrom/approve, so token 146 is permanently stuck.","severity":"info","snippet":"        _mint(msg.sender, tokenId);","title":"RunnerGenesis.mint() mints to msg.sender without the ERC-721 receiver check, so a contract caller that cannot handle NFTs locks the token and its ETH payment"},{"citation":"resolved","description":"Not a defect, documented here as the privileged-power inventory the review relies on (AUDIT.md #1, #4, #16 already acknowledge most of it). The owner (an EOA, the dev wallet, per README) can: setSigner(any address) and sign its own claims; setEmissionSchedule(20_000_000e18, 7, x) so that from the next UTC day 20M/day (2.5% of the 800M pool) can be claimed by that signer; setProtocolWallet to receive the 2% protocol tax and every swept holder-reward day; addPair any contract that returns address(this) from token0()/token1(), which taxes transfers through it (a self-deployed 'pool' could tax a chosen counterparty only if that counterparty trades with it, so wallets cannot be targeted directly). transferOwnership is one-step with only a zero-address guard, so a typo hands both contracts to an unrecoverable address. The NFT owner can additionally set a validator that reverts every transferFrom, freezing the collection. None of these are bypasses; they are the design. Recommended before mainnet, consistent with AUDIT.md: multisig owner, two-step ownership handover (pendingOwner + acceptOwnership) on both contracts.","line":471,"path":"contracts/RunnerToken.sol","reproduction":"State: owner O holds the key. Sequence: O.setSigner(O); O.setEmissionSchedule(20_000_000e18, 7, 0); next UTC day O signs Claim(O, 20_000_000e18, deadline) and calls claim(O, 20_000_000e18, deadline, v, r, s, false); release(O) after 7 days. Expected per README ('can never take tokens from the reward pool'): revert. Actual: succeeds, 20M/day leaves the pool until the key is rotated. For the handover: O.transferOwnership(0x0000...0001) succeeds and no function can recover ownership.","severity":"info","snippet":"    function transferOwnership(address newOwner) external onlyOwner {","title":"Trust assumptions: owner key can redirect up to 20M RUNNER/day from the pool and ownership transfer is single-step"}],"hash":"f93663a201517807f5bf52cb642a01646ea607c7b7fcb948894bc229aacc23ae","nodeId":"a13acc4c-1bf7-49db-8161-4d1a2a74c112","outcome":"completed","summary":"The findings file is in place and git-ignored by the harness as expected. Nothing else in the tree was touched.\n\n**Review complete.** The findings are in `.imd-findings.json` at the repository root: two low and two info, no critical or high. Both contracts held up under the Execution Trace, Periphery and First Principles passes, and the holder-reward checkpoint accounting, the emission schedule, the lock queue and the signature handling all traced cleanly with concrete values.\n\nWhat I found, each verified with a Foundry test run against the contracts as they are:\n\n- **Low, RunnerToken.claim():** the signed message covers account, totalEarned and deadline but not `instant`. A third party can submit the runner's signature first with instant=false, so the runner's own instant claim reverts with NothingToClaim and the reward sits in a 7-day lock. Getting the 80% out through releaseEarly also burns 20% of every other unfinished lock the runner holds. Fix is to put `instant` in the signed struct, with the matching change in the server's digest builder.\n- **Low, RunnerToken._move():** a self-transfer debits and checkpoints the sender before crediting it back, so transfer(self, fullBalance) writes a day minimum of zero. The wallet's balance never changed, yet it is dropped from that day's holder tax. The existing JS test for self-transfers checks only the balance, so the suite misses it. Fix is an early return in `_move` when from equals to.\n- **Info, RunnerGenesis.mint():** mints to msg.sender with no onERC721Received check, so a contract that cannot handle NFTs pays and receives a permanently stuck token. Self-harm only.\n- **Info, trust assumptions:** owner powers as designed, recorded separately as the adapter asks. The notable ones are setSigner plus setEmissionSchedule allowing up to 20M per day from the pool, and single-step ownership transfer on both contracts.\n\n**Scope and limits.** I reviewed both Solidity files in full, the server's claim-signing path and schedule decoder as the periphery that feeds `claim()`, and the token and NFT JS test suites. I did not run the repository's own JS suites or Slither. Neither low finding carries a proof file, since neither is high enough to warrant one and a fix for the first would change the signature format in a way a pre-written test cannot anticipate. The scratch tests that confirm both behaviors are in `test/scratch/Verify.t.sol`, run through a Foundry project outside the repository that points at the contracts directory.","treeHash":null,"usage":{"cachedInputTokens":1726356,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":38963,"runtime":"claude","turns":31,"wallClockMs":579857}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3beb94a990617d46","findings":[{"citation":"resolved","description":"The tax applies only when `from` or `to` is in `isPair`, and holder-reward eligibility is any non-excluded address with a lowest daily balance >= MIN_HOLDING. Both rules are evaluated per address, so a plain contract that holds RUNNER on behalf of others defeats them: (a) a wrapper/vault that accepts deposits from many sub-300k wallets reaches 300k itself, is not excluded, calls claimHolderRewards() and forwards the 2% holder tax to depositors who individually do not qualify; (b) a wrapper token (wRUNNER) traded on any pool is a wallet-to-wallet transfer from RunnerToken's point of view and pays no tax, so volume migrates to the untaxed venue and the holder-reward bucket and protocol cut both shrink; (c) any DEX whose pool is a singleton without token0()/token1() per pool (Uniswap V4 PoolManager, intent settlement contracts such as CoW/UniswapX fillers, aggregators settling via an internal balance) cannot be registered at all because addPair() requires token0()/token1() to return this token, so every trade through them is permanently untaxed. The owner cannot exclude a wrapper either, because addPair is the only exclusion path and it has the same token0()/token1() gate. The README/NatSpec promise (tax on every buy/sell through exchange pools; rewards only for wallets holding >= 300,000) therefore does not hold economically. Fixing it means a scope decision: either accept that the tax is 'registered V2/V3 pools only' and document it, or add an owner-gated exclusion/tax list for arbitrary contracts (with its own centralisation trade-off), or drop the per-address threshold.","line":421,"path":"contracts/RunnerToken.sol","reproduction":"Foundry (test/scratch/Probe.t.sol, test_wrapperPoolsEligibility and test_v4SingletonCannotBeRegistered): deploy RunnerToken, register a MockPool exposing token0()==RUNNER, deploy Wrapper (a contract with deposit() doing transferFrom and harvest() calling claimHolderRewards). Ten wallets each deposit 30,000 RUNNER (each below MIN_HOLDING). Next day a 1,000,000 RUNNER 'buy' from the pool collects 20,000 RUNNER holder tax. Expected per the stated rule: none of the ten wallets qualifies, so their 300k earns nothing. Actual: eligibleBalanceOn(wrapper, day)==300,000e18 and wrapper.harvest() returns 31.578 RUNNER (300k/190M of the day's 20k) which the wrapper can forward to the ten depositors. Separately: owner calls addPair(<any contract without token0()/token1()>, e.g. a V4 PoolManager) -> reverts NotAPool(), so trades through it are untaxed forever.","severity":"medium","snippet":"        if (_poolToken(pair, 0x0dfe1681) != address(this) && _poolToken(pair, 0xd21220a7) != address(this)) revert NotAPool(); // token0(), token1()","title":"4% trading tax and 300k holder threshold are bypassable through unregistered intermediaries; Uniswap V4-style singletons can never be registered"},{"citation":"resolved","description":"claim() accepts the server signature from any msg.sender as long as `instant` is false. The signature is public once the runner broadcasts an instant claim (or shares it with any relayer), so a third party can submit the same (account,totalEarned,deadline,v,r,s) with instant=false first. `claimed[account]` is then advanced to totalEarned and the runner's own instant transaction reverts with NothingToClaim(). The runner's only ways out are to wait 7 days or call releaseEarly(), which burns 20% of every unfinished lock the runner holds, not just the forced one. The attacker pays gas and gains nothing, so this is griefing, and on a sequencer-ordered L2 with no public mempool the window is a relayer or a shared signature rather than front-running; impact is delay plus a worse choice set for the runner. A minimal fix that keeps 'anyone may submit a lock for the runner' is to let the signed message carry the runner's chosen mode (add `bool instant` to the Claim typehash so the signature binds the payout path), or require msg.sender == account for both paths.","line":302,"path":"contracts/RunnerToken.sol","reproduction":"Foundry (test/scratch/Probe.t.sol, test_forcedLock and test_forcedLockThenReleaseEarly): signer signs Claim(alice, 1000e18, deadline). Bob calls claim(alice, 1000e18, deadline, v, r, s, false). Alice then calls claim(..., true). Expected: Alice receives 800e18 now (instant path she chose). Actual: Alice's call reverts NothingToClaim(), balanceOf(alice)==0, locksOf(alice).locked==1000e18. Second scenario: Alice has a voluntary 10,000e18 lock from day 0; on day 6 Bob forces her new 1,000e18 claim into a lock; Alice calls releaseEarly() and receives 8,800e18 with 2,200e18 burned, whereas the instant claim she attempted would have paid 800e18 now and kept the 10,000e18 lock for its last day at 100%.","severity":"low","snippet":"        if (instant && msg.sender != account) revert OnlyRunnerCanClaimInstantly();","title":"Anyone can convert a runner's pending instant claim into a 7-day lock (griefing via the open `instant=false` path)"},{"citation":"resolved","description":"The constructor calls _startDay(today) before the three mints, so _days[deployDay].shares is frozen at 0 while the liquidity and team wallets hold 200M RUNNER from the first block. Every 2% holder cut collected that day (which, per the launch sequence 'deploy -> add liquidity -> register the pool right away', is launch day and typically the heaviest trading day) has no eligible claimant: _holderShareOf returns 0 when shares==0, holderClaimable() is 0 for every wallet, and after 7 days sweepExpired() moves the whole amount to protocolWallet (the owner by default). The same happens on any later day where no non-excluded wallet was >= 300k at the day start. The NatSpec says the 2% goes 'to holders' and the owner 'can never take tokens out of the holder-reward bucket', but on such days the full holder cut ends up with the owner-controlled protocol wallet. If that is intended for day 0, document it; otherwise mint before calling _startDay in the constructor so deploy-day shares equal the initial eligible supply, and/or roll a zero-share day's rewards into the next day's bucket instead of sweeping them.","line":171,"path":"contracts/RunnerToken.sol","reproduction":"Foundry (test/scratch/Probe.t.sol, test_deployDayTaxUnclaimable): deploy RunnerToken(owner, liq, team) at 01:00 UTC; dayInfo(currentDay()).shares == 0 although liq and team hold 100M each. Register a MockPool funded with 10M and have it send 1,000,000 RUNNER to a buyer the same day: dayInfo(day).rewards == 20,000e18. Next day holderClaimable(liq)==0 and holderClaimable(team)==0. On day+9 sweepExpired(10) returns 20,000e18 and balanceOf(owner) rises by 20,000e18 (plus the 20,000e18 protocol cut), i.e. the protocol wallet receives 100% of launch-day tax instead of 50%.","severity":"low","snippet":"        _startDay(today);","title":"Holder tax collected on a day whose share total is zero (the deployment/launch day, by construction) is unclaimable and flows entirely to the protocol wallet"},{"citation":"resolved","description":"Not permission bypasses; the intended owner role can: (1) setSigner() to a key it controls and, after setEmissionSchedule(20_000_000e18, 7, 20_000_000e18) takes effect the next UTC day, claim up to 20M RUNNER/day (2.5% of the pool) via claim() with instant=false until the key is rotated; (2) setProtocolWallet() to redirect the 2% protocol cut and all swept expired holder rewards; (3) addPair() on any contract that returns this token from token0() or token1(), which excludes that contract from holder rewards and taxes every transfer touching it (a user-deployed vault that happens to expose token0() can be registered, a multisig cannot); (4) setClaimsPaused(true) to stop run-reward payouts while locks keep maturing. On RunnerGenesis the owner can setTransferValidator() to a contract that reverts, freezing all NFT transfers, and setTreasury() to a contract that reverts, halting mint(). The repository's AUDIT.md already recommends a multisig; this entry records the concrete actions and preconditions so the trust assumptions are explicit.","line":436,"path":"contracts/RunnerToken.sol","reproduction":"State: owner key compromised on day D. Calls: setEmissionSchedule(20e24, 7, 20e24) on D; setSigner(attackerKey) on D. On D+1 emissionOn(D+1)==20e24, attacker signs Claim(attacker, 20e24, deadline) and calls claim(attacker, 20e24, deadline, v, r, s, false); on D+8 release(attacker) pays 20M RUNNER. Repeats daily; bounded by MAX_DAILY_EMISSION and rewardsRemaining(). No contract check stops this; only key custody does.","severity":"info","snippet":"    function setSigner(address newSigner) external onlyOwner {","title":"Owner powers that move value (trust assumptions, documented separately from defects)"}],"hash":"fdac93eff8bf465bbf75cc5f3762c4adcb91180d078e54decb1a8786f1eb4780","nodeId":"6070bf8b-416f-44db-bd60-ada3903ba4c0","outcome":"completed","summary":"The review is complete. Findings are in `.imd-findings.json` at the repository root, and the working tree is otherwise untouched.\n\n**Scope reviewed.** Both contracts in full: the ERC-20 with tax, daily holder-reward checkpoints, EIP-712 run-reward claims, locks and emission schedule, and the ERC-721 with ERC-2981 and the ERC-721C hook. Passes applied: entry-point inventory and access control, economic security, invariants and value-flow gaps, signatures, reentrancy, arithmetic and time handling. The off-chain emission mirror in `js/rules.js` was checked for drift against `emissionOn` and matches.\n\n**What I verified holds.** A Foundry invariant fuzz I wrote confirmed the core accounting under random sequences: the sum of per-wallet daily weights equals each day's frozen share total, the holder-reward bucket equals the sum of unpaid daily rewards, the contract balance always covers the bucket plus locked rewards, and balances sum to total supply. The claim signature path, daily cap, lock prefix logic, and the validator-first ordering in the NFT transfer were also exercised and behave as documented.\n\n**Findings recorded, each with a reproduced failing state:**\n\n- **Medium.** The 4% tax and the 300k holder threshold are per-address, so an unregistered wrapper or vault pools small holders into one eligible address and claims the 2% holder tax, and wrapped-token trading pays no tax. Singleton DEXs with no per-pool `token0()` can never be registered, so trades there are permanently untaxed. Reproduced: ten 30k depositors collect holder rewards through a wrapper, and `addPair` reverts for a contract without `token0()`.\n- **Low.** Anyone holding a runner's server signature can submit it with the locked option, advancing the claimed total and reverting the runner's own instant claim. The runner then either waits 7 days or calls `releaseEarly`, which burns 20% of all unfinished locks, not only the forced one.\n- **Low.** The constructor freezes the deploy day's shares at zero before minting, so all holder tax from launch day is unclaimable and goes to the protocol wallet after seven days. Reproduced with a 20,000 RUNNER tax day sweeping entirely to the owner.\n- **Info.** Owner trust assumptions with concrete preconditions: signer rotation plus a 20M/day schedule drains 2.5% of the pool daily, protocol wallet redirection, pair registration of any contract exposing `token0()`, and NFT transfer or mint halts via validator or treasury choice.\n\n**Not found.** No permission bypass, reentrancy, replay, overflow, or accounting defect that loses or double-pays funds. No proof files were attached because nothing reached high or critical severity.\n\n**Limits.** Slither was not run. The rewards server and app code were out of the assigned contract scope beyond the emission mirror check. Scratch tests were removed as the task specifies, and the reproduction steps are written out in each finding.","treeHash":null,"usage":{"cachedInputTokens":1417503,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":45479,"runtime":"claude","turns":22,"wallClockMs":679821}}],"verification":[]}