{"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":"8d4586f5-6af3-4ebb-a492-75f941aa0e4f","kind":"audit","nodes":[{"acceptedSubmissionHash":"e99f77d9a2bdb858730978f76e3907216b6e2c9712222b12e18b783b52c0d3ce","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":"6af4b8c44f31f346a2fe5ea183e7728429be9160b054d02f15aea8cb34962af1","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":"b6cdf21065f81c36d7e0d0902f44529c35e1d1130aa89aaf41f7a164f0654cd6","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":"555f912691d91aaf47a951a5927a13e75134427c70f97cbc1e22376aa1bf5558","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":"8b831a2eff97b0f0472b073b9d47377703d7fd69ba4174be74e0cacb247aae72","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":"8d4586f5-6af3-4ebb-a492-75f941aa0e4f","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":"51881","feedbackHash":"f67286713ef76d903ad0dcc84209257e18c61d3dad978869d3ab2b8d1212b023","nodeKey":"audit_economics","submissionHash":"e99f77d9a2bdb858730978f76e3907216b6e2c9712222b12e18b783b52c0d3ce","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51883","feedbackHash":"49c9c20af048b4bab0fe1585914cb6d75be9bffb2f3407c85305f9e9d865afed","nodeKey":"audit_flow","submissionHash":"6af4b8c44f31f346a2fe5ea183e7728429be9160b054d02f15aea8cb34962af1","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51352","feedbackHash":"a368bded37ed545844fcff3bebb119509377a114baaccdf810752423c55f1af1","nodeKey":"audit_judge","submissionHash":"b6cdf21065f81c36d7e0d0902f44529c35e1d1130aa89aaf41f7a164f0654cd6","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52256","feedbackHash":"016c969bc6810550d71c4c668724a04a9463e623adf3a62ea1d1fcd5610d323e","nodeKey":"audit_math","submissionHash":"555f912691d91aaf47a951a5927a13e75134427c70f97cbc1e22376aa1bf5558","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52019","feedbackHash":"d1e02c0a3078b9566415c2c21d8a91a95dfdf269159e11b4744e8b12e16c700e","nodeKey":"audit_permissions","submissionHash":"8b831a2eff97b0f0472b073b9d47377703d7fd69ba4174be74e0cacb247aae72","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"f5f9509d442b51a79b1464f6268f8076fcdb97856d32a152aab83f6b1a4d50f5","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"72f49cf84b9ab056","findings":[{"citation":"resolved","description":"claim() pays the full difference totalEarned - claimed[account] or nothing. The difference is compared with the whole day's emission, and the emission is not carried over between days. Once the schedule has halved down to the floor (default 100,000 RUNNER/day after seven halvings, i.e. ~210 days), any runner whose unclaimed backlog exceeds the day's emission is refused on every day, forever, even when nobody else has claimed that day. claimable() correctly returns 0 for the same input, so the app shows nothing to claim. The only way out is off-chain: the server must issue staged signatures for smaller intermediate totals (which the contract allows but the design documents nowhere). Boundary x invariant seam: the invariant 'a valid signed total is eventually payable' breaks exactly where amount > emissionOn(day), and the condition is persistent because the cap never accumulates. The owner can raise the schedule (max 20M/day), but that is a protocol-wide change to work around one account.","line":379,"path":"contracts/RunnerToken.sol","reproduction":"State: emission started; 210 days later emissionOn(day) == 100_000e18 (10M >> 7 = 78,125 < floor). Input: a valid signer signature for (runner, totalEarned = 100_000e18 + 1, deadline, instant=false), claimed[runner] == 0, claimedOnDay[day] == 0. Expected (from the runner's view): the earned 100,000.000000000000000001 RUNNER is claimable over time. Actual: claim() reverts DailyCapReached on that day and on every following day (verified in a scratch Foundry test: the same signature re-issued the next day reverts identically), claimable() returns 0; a signature for exactly 100_000e18 succeeds. Minimal fix options: let claim() pay min(amount, emission left today) and record claimed[account] += paid (the signature then stays reusable until exhausted, which is safe because claimed[] only grows), or document that the server must sign staged totals <= today's remaining emission.","severity":"low","snippet":"        if (claimedOnDay[day] + amount > emissionOn(day)) revert DailyCapReached();","title":"Run-reward backlog larger than the day's emission can never be claimed with its true total (all-or-nothing cap, no carry-over)"},{"citation":"resolved","description":"The end bound is computed as nextSweepDay + maxDays in checked arithmetic. A caller passing type(uint256).max (the idiomatic 'no limit') reverts with an arithmetic panic rather than sweeping everything. No funds are affected; every smaller value works and the sweep is permissionless and resumable, so this is a usability edge only.","line":275,"path":"contracts/RunnerToken.sol","reproduction":"State: nextSweepDay = 20510 (any value > 0). Input: sweepExpired(type(uint256).max). Expected: sweep all expired days. Actual: Panic(0x11) overflow at `d + maxDays`; sweepExpired(type(uint256).max - nextSweepDay) succeeds (verified in a scratch Foundry test). Fix: `uint256 end = maxDays > type(uint256).max - d ? type(uint256).max : d + maxDays;` or loop on a counter.","severity":"info","snippet":"        uint256 end = d + maxDays;","title":"sweepExpired(type(uint256).max) reverts on the end-bound addition instead of meaning 'all'"},{"citation":"resolved","description":"Fees round down. For an exact-in buy, amount * 400 / 10000 == 0 for every amount <= 24 wei of IMD; for an exact-out sell, amount * 400 / 9600 == 0 for amount <= 23 wei. The same holds for the mirrored formulas in afterSwap (line 329) on the unspecified side. Such trades pay no fee at all. With 18-decimal IMD (confirmed on the live token: decimals() == 18) the fee-free amount is 2.4e-17 IMD per swap and gas dwarfs it by many orders of magnitude, so there is no economic leak; recorded so the 'exactly 4%' statements in the comments and docs are read as 'floor of 4%'.","line":292,"path":"contracts/RunnerHook.sol","reproduction":"Input: exact-in buy with amountSpecified = -24 (IMD specified). Expected per docs: fee = 4% of 24 wei. Actual: fee = 24 * 400 / 10000 = 0, _chargeFee returns early, the pool swaps the full 24 wei and emits Trade with fee 0. Exact-out sell amountSpecified = 23: fee = 23 * 400 / 9600 = 0. Fix if wanted: round the fee up (`(amount * FEE_BPS + BPS - 1) / BPS`), which keeps the PartialFill equality checks valid since they reuse the same `fee` value.","severity":"info","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"Hook fee truncates to zero for IMD legs below 25 wei (exact-in) / 24 wei (exact-out)"},{"citation":"resolved","description":"RunnerToken has no mint but does burn: every instant claim burns 20% of the amount (INSTANT_BURN_BPS), releaseEarly burns 20% of unfinished locks, and holders can burn(). totalSupply therefore only falls, while marketCap() (and getTokenInfo().marketCap, which tools read) multiplies the hard-coded TOTAL_SUPPLY constant by the pool price. The reported figure drifts above the real fully-diluted cap by the burned fraction. View-only; no on-chain logic depends on it. The hook already has IRunnerToken.totalSupply() in its interface.","line":456,"path":"contracts/RunnerHook.sol","reproduction":"State: pool at any price P (IMD per RUNNER); 100,000,000 RUNNER burned via instant claims so totalSupply() == 900,000,000e18. Expected: marketCap == 900,000,000e18 * P. Actual: marketCap == 1,000,000,000e18 * P, 11.1% high. Fix: use IRunnerToken(t).totalSupply() in place of TOTAL_SUPPLY (keep the constant for openPool's checks).","severity":"info","snippet":"            ? FullMath.mulDiv(FullMath.mulDiv(TOTAL_SUPPLY, Q96, sqrtP), Q96, sqrtP)","title":"marketCap() prices the fixed 1,000,000,000 constant, not the live totalSupply, so it overstates the cap after burns"}],"hash":"555f912691d91aaf47a951a5927a13e75134427c70f97cbc1e22376aa1bf5558","nodeId":"f5d99015-0641-40fd-9134-058cf522f897","outcome":"completed","summary":"Review complete. The findings file is written and validated, and the project's own suite passes on this tree.\n\n**Outcome**\n\nThe math in the RUNNER contracts is sound. I found no arithmetic defect that moves or loses value. Four findings are recorded in `.imd-findings.json`, one low and three informational, each with a concrete failing input.\n\n**What I checked, and how**\n\n- **Holder-reward share math** in RunnerToken (checkpoint min-balance tracking, frozen daily share totals, the drop adjustment, exclusion of pools inside the 7-day window, claim and sweep windows). I wrote a scratch fuzz harness with random transfers, amounts straddling the 300,000 threshold, day rollovers, interleaved claims and a mid-window pool exclusion. Over 600 runs the frozen share total always equalled the sum of eligible balances, payouts never exceeded a day's rewards, and the IMD held always matched the bookkeeping.\n- **Hook fee math** in all four swap directions, the partial-fill equality checks, the fee split, the single-sided launch liquidity formula against Uniswap v4's round-up amount deltas, the rounding buffer, and the constructor's max-liquidity-per-tick guard. The arithmetic is consistent and bounded.\n- **Downcasts and overflow intermediates**: uint112 checkpoints, uint128 locks and day totals, int128 hook deltas, the mulDiv chains in marketCap and the deploy helper. All are within range for a 1e27 supply and 18-decimal IMD. I confirmed the live IMD token on Robinhood Chain reports 18 decimals.\n- **Emission schedule** halving, floor, lazy promotion, and daily-cap boundaries. RunnerGenesis rarity and royalty arithmetic is pure and bounded.\n\n**Findings**\n\n- **Low:** a runner whose unclaimed backlog exceeds the day's emission can never claim with the true signed total. At the 100,000 RUNNER floor, a signature for 100,000e18 + 1 reverts with the daily-cap error on every day, and the cap never carries over. Only staged server signatures unblock it. Verified with a scratch test.\n- **Info:** `sweepExpired` with the maximum uint reverts on the end-bound addition. Verified.\n- **Info:** the hook fee rounds to zero below 25 wei of IMD, so tiny trades are fee-free. No economic impact at 18 decimals.\n- **Info:** `marketCap` prices the fixed 1,000,000,000 constant, so it overstates the cap after instant-claim burns.\n\n**Not covered**\n\nThe fork test was not run. The live IMD token was only checked for symbol and decimals, not for transfer semantics. Scratch tests live under `test/scratch/` and were run with the test directory overridden via environment variable, since foundry.toml points tests at `contracts/test`. No repository file was modified.","treeHash":null,"usage":{"cachedInputTokens":1413945,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":42052,"runtime":"claude","turns":33,"wallClockMs":750586}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"707624586655cf74","findings":[{"citation":"resolved","description":"The launch position is single-sided: with RUNNER as currency1 its range is [minUsableTick, startTick] and the pool opens exactly at startTick (contracts/RunnerHook.sol:250-260 and 242-244). Above startTick there is no liquidity at all. A sell (oneForZero in that ordering) whose RUNNER input is worth more than the IMD the pool currently holds first drains the reserve back to startTick and then keeps stepping through empty ticks until the caller's sqrtPriceLimitX96. Both routers use the extreme limits (RunnerRouter.sol:99, RunnerEthRouter.sol:162) and so does any generic v4 router. Nothing is swapped in that region: the hook correctly charges 4% only of the IMD actually paid and the routers pull only the RUNNER actually consumed, so no funds are lost. But slot0 now reports the price at the limit: marketCap() (lines 453-458) returns 0, getTokenInfo().sqrtPriceX96/marketCap are nonsense, and the Trade event for that swap (lines 337-338) carries sqrtPriceX96 = MAX_SQRT_PRICE-1 (or MIN when RUNNER is currency0). The pool self-heals on the next buy, which crosses startTick downward and re-activates the position, so this is an integrity problem for the app, indexers and the PepesFamily-style tooling that reads this interface, not a fund-safety one. The trigger is free and permissionless whenever the reserve is small: right after launch the reserve is 0 IMD, so a 1 RUNNER exact-in sell through any external v4 router moves the tick to the limit while taking 0 RUNNER from the seller (RunnerRouter refuses that case with Slippage because out == 0, but PoolSwapTest-style routers do not). Fix (minimal, preserves design): in marketCap()/getTokenInfo clamp sqrtP to the launch price when the current tick is outside the position (tick > startTick when quoteIsCurrency0, tick < -startTick otherwise) and emit the clamped price in Trade; optionally have RunnerRouter/RunnerEthRouter use TickMath.getSqrtPriceAtTick(launch tick) as the sell-side price limit so official-router sells stop exactly at the reserve boundary.","line":454,"path":"contracts/RunnerHook.sol","reproduction":"Setup as in contracts/test/RunnerHook.t.sol (pool opened at startTick 142600, IMD is currency0 in that run). Case A, zero reserve: team transfers 1,000 RUNNER to alice; alice calls PoolSwapTest.swap(key, SwapParams(zeroForOne=false, amountSpecified=-1e18, sqrtPriceLimitX96=MAX_SQRT_PRICE-1)). Expected: either a revert or the price staying at the reserve boundary (tick 142600, marketCap ~641.6 IMD). Actual (forge run): swap succeeds, alice's RUNNER balance is unchanged (0 spent), getSlot0 tick goes 142600 -> 887271, hook.marketCap(runner) == 0. Case B, with a reserve: alice buys with 1,000 IMD via RunnerRouter.buy (pool holds 960 IMD, alice has ~93.7M RUNNER), team sends alice 5,000,000 more, alice calls router.sell(runner, 98,735,273.2e18, 0, deadline). Actual: she receives 921.6 IMD (960 gross minus 4%), only 93,735,273.2e18 RUNNER is pulled, and afterwards sqrtPriceX96 = 1461446703485210103287273052203988822378723970341, tick = 887271, marketCap == 0 (was 163,480e18 after her buy). A following router.buy(1,000 IMD) by bob returns exactly alice's original 93,735,273.2e18 RUNNER and marketCap returns to 163,480e18, showing the pool itself was never damaged. Scratch tests: contracts/test/scratch/Probe.t.sol (test_sellBeyondReserve_pushesPriceToLimit_marketCapZero, test_zeroReserve_officialRouterReverts_externalRouterMovesPrice).","severity":"low","snippet":"        (uint160 sqrtP,,,) = poolManager.getSlot0(poolKey(t).toId());","title":"Any sell beyond the pool's IMD reserve parks the pool price at the tick limit for free, so marketCap(), getTokenInfo() and the Trade event report a zero/extreme price until the next buy"},{"citation":"resolved","description":"RunnerHook.setFeeRecipient rejects both address(0) and the hook itself (RunnerHook.sol:413) because plain ERC-20 IMD that lands in the hook can never leave: collectProtocolFees/_collect only move ERC-6909 claims via poolManager.burn/take (RunnerHook.sol:381-388) and there is no sweep for ERC-20 balances. RunnerToken.setProtocolWallet only rejects address(0) and the token itself. The token knows the hook address (immutable `hook`), so the symmetric check is cheap. Owner-only misconfiguration with no unprivileged trigger: recorded as an asymmetry to close, not a vulnerability. Fix: add `if (wallet == hook) revert BadAddress();` to setProtocolWallet.","line":492,"path":"contracts/RunnerToken.sol","reproduction":"Input: owner calls RunnerToken.setProtocolWallet(hookAddress) (hookAddress has code and is neither address(0) nor the token, so no revert). After 8+ days anyone calls sweepExpired(30) for a day with unclaimed holder IMD. Expected: swept IMD reaches a wallet the protocol can spend from. Actual: _sendReward transfers the IMD to the RunnerHook's ERC-20 balance; pendingProtocolFees is unchanged, collectProtocolFees moves only ERC-6909 claims, and no function in RunnerHook can transfer ERC-20 IMD out, so it is stuck permanently.","severity":"info","snippet":"        if (wallet == address(this)) revert BadAddress(); // swept IMD would stay here and be re-booked","title":"setProtocolWallet accepts the RunnerHook address, unlike the hook's own setFeeRecipient; IMD swept there has no exit"}],"hash":"6af4b8c44f31f346a2fe5ea183e7728429be9160b054d02f15aea8cb34962af1","nodeId":"690a86fc-b244-4c20-a97a-f814d96264bc","outcome":"completed","summary":"The findings file is valid, both quoted snippets match the tree exactly, and the only untracked path is my scratch test directory, which is not kept. The review is complete.\n\n## Review outcome\n\nI audited all six in-scope contracts at commit 9382eb2 (RunnerToken, RunnerHook, RunnerRouter, RunnerEthRouter, RunnerGenesis, SafeTransfer, plus RunnerDeployLib) through the Execution Trace, Periphery and First Principles lenses, with the access-control inventory, x-ray threat profiles and the eth-security checklist as supporting passes. The existing 24 Foundry tests pass against the pinned v4-core, forge-std and solmate libraries. The fork tests were skipped because I did not use a live RPC.\n\n**Findings written to `.imd-findings.json`:**\n\n- **Low. Free price parking at the tick limit.** The launch position is single-sided with the pool opened exactly at its upper tick, so any sell that exceeds the IMD reserve walks through empty ticks to the caller's price limit. At zero reserve (right after launch), a 1 RUNNER sell through an external v4 router moves the tick from 142600 to 887271 while taking 0 RUNNER from the seller. `marketCap()` then returns 0, `getTokenInfo()` and the `Trade` event report the extreme price, until the next buy self-heals the pool. No funds are at risk and fees stay exactly 4% of what traded. Proposed fix: clamp the reported price to the launch price when the tick is outside the position, and optionally have the official routers use the launch tick as the sell-side price limit.\n- **Info. Asymmetric self-address guard.** `setProtocolWallet` rejects the token but not the hook, while the hook's `setFeeRecipient` rejects both. IMD swept to the hook as plain ERC-20 has no exit path. Owner-only misconfiguration, so recorded as an asymmetry rather than a vulnerability.\n\n**What held up under testing.** I wrote scratch invariant tests for the holder-reward accounting: on every started day, `dayInfo(d).shares` equals the sum of `eligibleBalanceOn` across all holders, including after a mid-window pool exclusion with a partially paid day and a same-day checkpoint on the excluded pool. Every IMD booked is either claimed or swept, and `holderRewardsHeld` matches the IMD balance throughout. The hook's four swap directions, ERC-6909 claim settlement, PartialFill guard, transient fee slot, launch liquidity math for both pair orderings, router flash-accounting flows, and the NFT's mint/transfer/validator ordering all traced cleanly with no counterexample.\n\n**Limitations.** IMD's real on-chain behaviour (blacklist, pause, fee) was not verified; the official routers call `flush` and `syncHolderRewards` inside their unlock, so an IMD transfer failure to the token would block router trades while external routers keep working. That remains a lead, not a finding. The `imdEthHooks` pool configuration and the deployer-side steps (verification, multisig handover) are deployment items outside this code review.","treeHash":null,"usage":{"cachedInputTokens":2489967,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":65793,"runtime":"claude","turns":40,"wallClockMs":2150545}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"be3be4cc237417f9","findings":[{"citation":"resolved","description":"Asymmetry across the admin setters that redirect in-flight IMD. setProtocolWallet refuses only address(0) and the token itself; RunnerHook.setFeeRecipient refuses the hook. Neither refuses the two other protocol addresses the token already knows about (the immutable `poolManager` and `hook`), although IMD sent to either is lost to the protocol. sweepExpired() is permissionless and pays protocolWallet with a plain IMD transfer. If protocolWallet is the Uniswap v4 PoolManager, the IMD enters the PoolManager outside of any settle accounting, and in v4 whoever has called sync(IMD) before such a transfer and settle() after it is credited the difference: an unprivileged caller wraps sweepExpired() in their own unlock and takes the whole swept amount. If protocolWallet is the hook, the IMD sits as a raw ERC-20 balance in the hook, which only ever moves ERC-6909 claims (collectProtocolFees burns claims, it never transfers raw IMD), so it is unrecoverable. This is an owner misconfiguration, not a bypass, but it is the same guard-gap class as the second audit's finding 4 (protocol wallet could be the token), which the team chose to guard against, and the PoolManager case has an unprivileged amplifier. Reproduced on this commit with a Foundry probe (test/scratch/Probe.t.sol, tests test_protocolWalletPoolManager_anyoneSkimsSweep and test_protocolWalletHook_sweptImdStuck). Fix: also revert when `wallet == poolManager || wallet == hook` (both are immutables on this contract), mirroring the existing address(this) check; optionally mirror the same in RunnerHook.setFeeRecipient.","line":492,"path":"contracts/RunnerToken.sol","reproduction":"State: pool open, alice buys 10,000 IMD of RUNNER via RunnerRouter (200 IMD booked as today's holder rewards). Owner calls setProtocolWallet(address(poolManager)) — expected: revert like the address(this) case; actual: accepted. Warp 9 days so the day has expired unclaimed. Attacker contract calls poolManager.unlock(); inside unlockCallback it calls poolManager.sync(IMD), then RunnerToken.sweepExpired(30) (which transfers 200 IMD from the token to protocolWallet = PoolManager), then poolManager.settle() — credited 200e18 — then poolManager.take(IMD, attacker, 200e18). Expected: the swept 200 IMD reach the protocol; actual: the attacker holds 200 IMD and the protocol receives nothing. Variant: setProtocolWallet(address(hook)) is also accepted; after sweepExpired(30), imd.balanceOf(hook) == 200e18 and collectProtocolFees(IMD) leaves it unchanged — no function in RunnerHook can move it.","severity":"low","snippet":"        if (wallet == address(this)) revert BadAddress(); // swept IMD would stay here and be re-booked","title":"setProtocolWallet accepts the PoolManager and the hook: swept holder IMD is then skimmable by anyone (PoolManager) or stuck (hook)"},{"citation":"resolved","description":"Mirror of the token-side guard gap. The hook rejects itself as fee recipient because IMD would be stuck, but accepts its own immutable `poolManager`. collectProtocolFees() is permissionless; _collect burns the hook's ERC-6909 IMD claims and calls poolManager.take(IMD, feeRecipient, amount). With feeRecipient == poolManager, take() debits the hook's delta and performs an IMD transfer from the PoolManager to itself, so the IMD never leaves the PoolManager and no account is credited: the claims are gone, pendingProtocolFees is zeroed, and the fee becomes surplus inside the PoolManager that no one (including the protocol) can withdraw. Owner misconfiguration, but the burn is triggered by anyone the moment the setter lands, and the cost is the whole pending protocol fee plus every fee collected until the recipient is corrected. Reproduced on this commit (test/scratch/Probe.t.sol, test_feeRecipientPoolManager_anyoneSkimsProtocolFees). Fix: `if (feeRecipient_ == address(0) || feeRecipient_ == address(this) || feeRecipient_ == address(poolManager)) revert ZeroAddress();`.","line":413,"path":"contracts/RunnerHook.sol","reproduction":"State: pool open, alice buys 10,000 IMD of RUNNER (pendingProtocolFees[IMD] == 200e18, hook holds 200e18 IMD claims). Owner calls setFeeRecipient(address(poolManager)) — expected: revert like the address(this) case; actual: accepted. Anyone calls collectProtocolFees(IMD). Expected: 200 IMD delivered to a recipient; actual: pendingProtocolFees[IMD] == 0, the hook's IMD claim balance dropped by 200e18, and imd.balanceOf(poolManager) is unchanged — the 200 IMD are now unaccounted inside the PoolManager and no address can take them.","severity":"low","snippet":"        if (feeRecipient_ == address(0) || feeRecipient_ == address(this)) revert ZeroAddress(); // IMD sent here would be stuck","title":"setFeeRecipient accepts the PoolManager: protocol fees are then burned into unaccounted PoolManager surplus"},{"citation":"resolved","description":"The third audit's finding 5 removed the forged mint event from transferFrom(address(0), to, 0) by adding a zero-address check in _transfer. burnFrom and burn do not go through _transfer: _spendAllowance(address(0), 0) passes (allowance 0 is not less than value 0), _burn(address(0), 0) passes its balance check, and `emit Transfer(from, address(0), value)` fires with from == address(0). No balance or supply changes, so the impact is limited to indexers and explorers that treat Transfer-from-zero as a mint. Reproduced on this commit (test/scratch/Probe.t.sol, test_burnFromZero_emitsFakeEvent). Fix: revert ZeroAddress in _burn when `from == address(0)` (or in burnFrom), matching _transfer.","line":236,"path":"contracts/RunnerToken.sol","reproduction":"Any caller invokes RunnerToken.burnFrom(address(0), 0). Expected: revert ZeroAddress, as transferFrom(address(0), to, 0) now does. Actual: the call succeeds and emits Transfer(address(0), address(0), 0).","severity":"info","snippet":"    function burnFrom(address from, uint256 value) external {\n        _spendAllowance(from, value);\n        _burn(from, value);\n    }","title":"burnFrom(address(0), 0) emits a Transfer(0x0, 0x0, 0) event (same class as third audit #5)"}],"hash":"8b831a2eff97b0f0472b073b9d47377703d7fd69ba4174be74e0cacb247aae72","nodeId":"d7fd5355-1567-4103-89c1-edc697163a43","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds three substantiated findings, all low or informational. No critical or high defect was found in the permission model, so no failing proof file was needed.\n\n**Scope covered.** All five in-scope contracts plus the two utility libraries, with the pinned Access Control, Trust Gap and Asymmetry guides as the checklist. I built the full state-changing entry-point inventory (token, hook, both routers, NFT, including hook callbacks and `unlockCallback`), traced every guard, and ran the existing 24-test Foundry suite (all pass) plus five scratch probes of my own under `test/scratch/`. The claim server was read only as far as it signs: it signs solely for the session's own wallet, so no third party can obtain a lock signature for a victim.\n\n**Findings recorded**\n\n- **Low, RunnerToken.setProtocolWallet.** Rejects the token itself but accepts the immutable PoolManager and hook. With the PoolManager as wallet, anyone can wrap the permissionless `sweepExpired` in their own v4 unlock (sync, sweep, settle, take) and pocket the swept holder IMD. Reproduced: attacker receives the full 200 IMD. With the hook as wallet the IMD is permanently stuck, since the hook only moves ERC-6909 claims.\n- **Low, RunnerHook.setFeeRecipient.** Rejects the hook but accepts the PoolManager. `collectProtocolFees` then burns the claims and does a PoolManager-to-PoolManager transfer, so the fee becomes unaccounted surplus nobody can withdraw. Reproduced: pending zeroed, claims gone, PoolManager balance unchanged.\n- **Info, RunnerToken.burnFrom.** `burnFrom(address(0), 0)` still emits `Transfer(0x0, 0x0, 0)`. The third audit's fix for the same event forgery only covered `_transfer`.\n\nBoth low items are owner misconfigurations rather than bypasses. I reported them because they are the same guard-gap class the team already chose to close (second audit finding 4), and the PoolManager case has an unprivileged amplifier.\n\n**Verified clean.** Hook callbacks are reachable only through the PoolManager, which only calls back the unlock initiator, so the routers cannot be made to spend user approvals on another caller's behalf. Only the hook can initialize its pool or add liquidity, and there is no removal path. The four swap directions charge exactly 4 percent with the partial-fill guard intact. Holder-reward share accounting stays consistent across exclusion, claims and sweeps. Two-step ownership, signer rotation and the EIP-712 claim binding (chain id, contract, deadline, `instant` flag, monotonic total) hold. The flush-timing choice for fees from external routers remains a design acceptance, as the requester stated.\n\nNothing was modified outside `.imd-findings.json` and `test/scratch/`; the fetched `lib/` directory is gitignored.","treeHash":null,"usage":{"cachedInputTokens":1967265,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":49806,"runtime":"claude","turns":41,"wallClockMs":1029013}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"8f08088e7a7f557f","findings":[{"citation":"resolved","description":"Merged from audit_economics (RunnerHook.sol:329) and audit_flow (RunnerHook.sol:454); both describe the same mechanism. The launch position is single-sided, [minUsableTick, startTick] with RUNNER as currency1 (mirrored otherwise), so there is no liquidity above the start price. A sell whose RUNNER input is worth more than the IMD the pool holds drains the reserve back to startTick and then keeps stepping through empty ticks up to the caller's sqrtPriceLimitX96; both official routers (RunnerRouter.sol:99, RunnerEthRouter.sol:162) and any generic v4 router pass the extreme limit. v4 swaps nothing in that region, the hook charges 4% only of the IMD actually paid and the routers pull only the RUNNER actually consumed, so no funds are lost. But afterSwap accepts a swap that moved nothing (the exact-in sell direction has IMD as the unspecified side, so it goes to the else branch, computes fee = 0 and _chargeFee returns early; nothing requires poolQuote > 0 or tokenAmount > 0), and the views read slot0 raw: marketCap() returns 0, getTokenInfo().sqrtPriceX96/marketCap are at the limit, and the Trade event for that swap carries fee 0, amounts 0 and sqrtPriceX96 = MAX_SQRT_PRICE-1 (or MIN when RUNNER is currency0). Right after openPool the reserve is 0 IMD, so an account holding no RUNNER and no IMD can do this with a 1-wei exact-in sell through PoolSwapTest or the universal router (RunnerRouter refuses only because out == 0 trips its own Slippage check). It can be repeated after every time sellers return all the IMD. The next genuine buy steps down ~745,000 empty ticks before reaching liquidity (about 83k extra gas measured) and fills at the launch price, so the pool itself is never damaged. Integrity problem for the app, indexers and PepesFamily-style tooling that reads this interface, not a fund-safety one. Fix (preserves the design): in afterSwap revert when the swap moved nothing (`if (poolQuote == 0 || tokenAmount == 0) revert PartialFill();` before the fee branch), and in marketCap()/getTokenInfo() clamp sqrtP to the launch price when the current tick is outside the position (tick > startTick when quoteIsCurrency0, tick < -startTick otherwise), emitting the clamped price in Trade; optionally have the routers use TickMath.getSqrtPriceAtTick(launch tick) as the sell-side price limit so official-router sells stop exactly at the reserve boundary.","line":454,"path":"contracts/RunnerHook.sol","reproduction":"Reproduced on this commit with contracts/test/scratch/Judge.t.sol (setup identical to contracts/test/RunnerHook.t.sol: pool opened at startTick 142600, IMD is currency0). Case A, zero reserve: griefer with 0 RUNNER and 0 IMD calls PoolSwapTest.swap(key, SwapParams(zeroForOne = !imdIs0, amountSpecified = -1, sqrtPriceLimitX96 = MAX_SQRT_PRICE-1)). Expected: refused, or price stays at the reserve boundary (tick 142600, marketCap 641.6e18). Actual: swap succeeds, slot0 tick 142600 -> 887271, hook.marketCap(runner) 641608811066259581626 -> 0, griefer's RUNNER balance still 0. A following router.buy(100 IMD) still fills and marketCap returns above 600e18, at 512,984 gas versus 429,745 for the same buy without the push. Case B, official router: alice buys with 1,000 IMD (pool holds 960 IMD), team sends her 5,000,000 RUNNER more, alice calls router.sell(runner, got + 5,000,000e18, 0, deadline). Actual: she receives 921.6 IMD, only 93,735,273.2e18 RUNNER is pulled (not the full amount), marketCap == 0 afterwards; bob's router.buy(1,000 IMD) restores marketCap to exactly its post-alice-buy value, showing the pool was not damaged.","severity":"low","snippet":"        (uint160 sqrtP,,,) = poolManager.getSlot0(poolKey(t).toId());","title":"Any sell beyond the pool's IMD reserve (or a zero-value sell when the reserve is empty) parks the pool price at the tick limit for free: marketCap()/getTokenInfo() read 0 and Trade reports an extreme "},{"citation":"resolved","description":"Merged from audit_permissions (PoolManager and hook cases) and audit_flow (hook case), same line and same guard gap. setProtocolWallet refuses only address(0) and the token itself, although the token already holds the two other protocol addresses as immutables (`poolManager`, `hook`) and IMD sent to either is lost to the protocol. sweepExpired() is permissionless and pays protocolWallet with a plain IMD transfer. If protocolWallet is the Uniswap v4 PoolManager, the IMD enters the PoolManager outside settle accounting; in v4 whoever called sync(IMD) before such a transfer and settle() after it is credited the difference, so an unprivileged caller wraps sweepExpired() in their own unlock and takes the whole swept amount. If protocolWallet is the hook, the IMD sits as a raw ERC-20 balance in RunnerHook, which only ever moves ERC-6909 claims (collectProtocolFees burns claims and takes; nothing transfers raw IMD), so it is unrecoverable. Owner misconfiguration rather than a bypass, but it is the same guard class the team already closed for the token address itself (second audit #4), and the PoolManager case has an unprivileged amplifier. Fix: also revert when `wallet == poolManager || wallet == hook` (both immutables on this contract), mirroring the existing address(this) check.","line":492,"path":"contracts/RunnerToken.sol","reproduction":"Reproduced on this commit (contracts/test/scratch/Judge.t.sol, test_G_protocolWalletPoolManager_anyoneSkimsSweep and test_G_protocolWalletHook_sweptImdStuck). State: pool open, alice buys 10,000 IMD of RUNNER via RunnerRouter (200 IMD booked as today's holder rewards). Owner calls setProtocolWallet(address(poolManager)). Expected: revert like the address(this) case. Actual: accepted. Warp 9 days. An attacker contract calls poolManager.unlock(); in unlockCallback it calls poolManager.sync(IMD), RunnerToken.sweepExpired(30) (transfers 200 IMD from the token to protocolWallet = PoolManager), poolManager.settle() (credited 200e18), poolManager.take(IMD, attacker, 200e18). Result: attacker holds 200 IMD, imd.balanceOf(poolManager) unchanged, protocol receives nothing. Variant: setProtocolWallet(address(hook)) is accepted; after sweepExpired(30), imd.balanceOf(hook) == 200e18 and collectProtocolFees(IMD) leaves it at 200e18.","severity":"low","snippet":"        if (wallet == address(this)) revert BadAddress(); // swept IMD would stay here and be re-booked","title":"setProtocolWallet accepts the PoolManager and the hook: swept holder IMD is then skimmable by anyone (PoolManager) or stuck forever (hook)"},{"citation":"resolved","description":"Mirror of the token-side guard gap. The hook rejects itself as fee recipient because IMD would be stuck, but accepts its own immutable `poolManager`. collectProtocolFees() is permissionless; _collect burns the hook's ERC-6909 IMD claims and calls poolManager.take(IMD, feeRecipient, amount). With feeRecipient == poolManager, take() debits the hook's delta and performs an IMD transfer from the PoolManager to itself, so the IMD never leaves the PoolManager and no account is credited: the claims are gone, pendingProtocolFees is zeroed, and the fee becomes surplus inside the PoolManager that nobody (including the protocol) can withdraw. Owner misconfiguration, but the burn is triggered by anyone the moment the setter lands, and it costs the whole pending protocol fee plus every fee collected until the recipient is corrected. Fix: `if (feeRecipient_ == address(0) || feeRecipient_ == address(this) || feeRecipient_ == address(poolManager)) revert ZeroAddress();`.","line":413,"path":"contracts/RunnerHook.sol","reproduction":"Reproduced on this commit (contracts/test/scratch/Judge.t.sol, test_H_feeRecipientPoolManager_burnsProtocolFees). State: pool open, alice buys 10,000 IMD of RUNNER (pendingProtocolFees[IMD] == 200e18, hook holds 200e18 IMD claims). Owner calls setFeeRecipient(address(poolManager)). Expected: revert like the address(this) case. Actual: accepted. Anyone calls collectProtocolFees(IMD). Expected: 200 IMD delivered to a recipient. Actual: pendingProtocolFees[IMD] == 0, the hook's ERC-6909 IMD claim balance dropped by exactly 200e18, imd.balanceOf(poolManager) unchanged; the 200 IMD are unaccounted inside the PoolManager.","severity":"low","snippet":"        if (feeRecipient_ == address(0) || feeRecipient_ == address(this)) revert ZeroAddress(); // IMD sent here would be stuck","title":"setFeeRecipient accepts the PoolManager: protocol fees are then burned into unaccounted PoolManager surplus by anyone calling collectProtocolFees"},{"citation":"resolved","description":"claim() pays the full difference totalEarned - claimed[account] or nothing, compares it with the whole day's emission, and the emission is not carried over between days. Once the schedule has halved down to the floor (100,000 RUNNER/day after seven halvings, ~210 days with the default schedule), any runner whose unclaimed backlog exceeds the day's emission is refused on every day, forever, even when nobody else claimed that day. claimable() correctly returns 0 for the same input, so the app shows nothing to claim. The only way out is off-chain: the server must sign staged intermediate totals (the contract allows it, but nothing documents it), or the owner raises the schedule protocol-wide. Invariant 'a valid signed total is eventually payable' breaks exactly where amount > emissionOn(day), persistently. Fix options: let claim() pay min(amount, emission left today) and record claimed[account] += paid (the signature stays reusable until exhausted, safe because claimed[] only grows), or document that the server must sign staged totals <= today's remaining emission.","line":379,"path":"contracts/RunnerToken.sol","reproduction":"Reproduced on this commit (contracts/test/scratch/Judge.t.sol, test_C_backlogAboveDailyEmission_neverClaimable). State: owner sets a signer and calls startEmission(); warp 210 days so emissionOn(today) == 100_000e18 (10M >> 7 = 78,125 < floor). Input: a valid signer signature for (alice, totalEarned = 100_000e18 + 1, deadline, instant = false), claimed[alice] == 0, claimedOnDay[today] == 0. Expected: the earned amount is claimable over time. Actual: claim() reverts DailyCapReached; the same signature one day later reverts DailyCapReached again; claimable(alice, total) returns 0. A signature for exactly 100_000e18 succeeds and sets claimed[alice] = 100_000e18.","severity":"low","snippet":"        if (claimedOnDay[day] + amount > emissionOn(day)) revert DailyCapReached();","title":"Run-reward backlog larger than the day's emission can never be claimed with its true total (all-or-nothing cap, no carry-over)"},{"citation":"resolved","description":"Not a code defect in Runner; recorded as a counterparty trust assumption the launch documentation should state. Both routers call pad.flush on every trade; because the same swap just charged the 4% fee, pendingHolderFees[token] > 0 at that point, so _flush runs unconditionally: poolManager.burn + poolManager.take(IMD, RunnerToken, amount) (an IMD.transfer to the token contract) followed by RunnerToken.syncHolderRewards(). The live IMD on Robinhood Chain is an owner-controlled token exposing blocked(address) and a transfer switch (per the economics specialist's probe; its exact blocklist semantics are unverified because the explorer source could not be fetched). If the IMD issuer blocks the RunnerToken address (or the PoolManager, or disables transfers), every RunnerRouter.buy/sell and RunnerEthRouter.buyWithEth/sellForEth reverts inside _flush, while swaps through any other v4 router keep working and keep accumulating ERC-6909 fee claims in the hook that flush() can no longer deliver; claimHolderRewards() and sweepExpired() revert in _sendReward, freezing all holder IMD inside RunnerToken; collectProtocolFees reverts if feeRecipient is blocked (owner-fixable via setFeeRecipient). If the team wants trading to survive a reward-token freeze, the routers could flush in a try/catch (leaving the holder share pending) so only reward delivery, not trading, depends on IMD's policy; that is a design decision.","line":114,"path":"contracts/RunnerRouter.sol","reproduction":"Reproduced on this commit against a mock with the same blocklist shape (contracts/test/scratch/Judge.t.sol, JudgeTrustTest.test_B_rewardTokenBlocklist_freezesOfficialRoutersAndClaims): deploy the system with a reward token whose transfer() reverts when sender or recipient is blocked. alice's router.buy(1,000 IMD) succeeds. Mark RunnerToken as blocked. Expected per the design text ('every trade pays 4%, 2% to holders'): trades keep working. Actual: router.buy(runner, 1,000e18, 0, deadline) reverts; PoolSwapTest.swap of 1,000 IMD on the same pool succeeds and raises pendingHolderFees by 20e18; hook.flush(runner) reverts; after 9 days sweepExpired(30) reverts TransferFailed. Not reproducible against the live IMD without a fork; the live token's selectors are what the specialist verified.","severity":"info","snippet":"        pad.flush(d.token);","title":"Trust assumption: every official-router trade and every holder claim depends on the IMD token's issuer-controlled blocklist and transfer switch"},{"citation":"resolved","description":"RunnerToken has no mint but does burn: every instant claim burns 20% of the amount (INSTANT_BURN_BPS), releaseEarly burns 20% of unfinished locks, and holders can burn(). totalSupply therefore only falls, while marketCap() (and getTokenInfo().marketCap, which tools read) multiplies the hard-coded TOTAL_SUPPLY constant by the pool price, so the reported figure drifts above the real fully-diluted cap by the burned fraction. View-only; no on-chain logic depends on it. The hook already has IRunnerToken.totalSupply() in its interface. Fix: use IRunnerToken(t).totalSupply() in place of TOTAL_SUPPLY in marketCap (keep the constant for openPool's checks).","line":456,"path":"contracts/RunnerHook.sol","reproduction":"Reproduced on this commit (contracts/test/scratch/Judge.t.sol, test_F_marketCapIgnoresBurns). State: pool open at the launch price, marketCap == 641608811066259581626. Team burns 50,000,000 RUNNER; totalSupply() == 950_000_000e18. Expected: marketCap falls 5% to ~609.5e18. Actual: marketCap unchanged at 641608811066259581626.","severity":"info","snippet":"            ? FullMath.mulDiv(FullMath.mulDiv(TOTAL_SUPPLY, Q96, sqrtP), Q96, sqrtP)","title":"marketCap() prices the fixed 1,000,000,000 constant, not the live totalSupply, so it overstates the cap after burns"},{"citation":"resolved","description":"Fees round down. For an exact-in buy, amount * 400 / 10000 == 0 for every amount <= 24 wei of IMD; for an exact-out sell, amount * 400 / 9600 == 0 for amount <= 23 wei; the mirrored formulas in afterSwap (line 329) behave the same on the unspecified side. Such trades pay no fee at all. With 18-decimal IMD the fee-free amount is 2.4e-17 IMD per swap and gas dwarfs it by many orders of magnitude, so there is no economic leak; recorded so the 'exactly 4%' statements in comments and docs are read as 'floor of 4%'. Fix if wanted: round the fee up (`(amount * FEE_BPS + BPS - 1) / BPS`), which keeps the PartialFill equality checks valid since they reuse the same `fee` value.","line":292,"path":"contracts/RunnerHook.sol","reproduction":"Reproduced on this commit (contracts/test/scratch/Judge.t.sol, test_E_tinyBuyPaysNoFee). Input: PoolSwapTest exact-in buy with amountSpecified = -24 (IMD specified). Expected per docs: fee = 4% of 24 wei. Actual: pendingProtocolFees + pendingHolderFees unchanged (fee = 24 * 400 / 10000 = 0, _chargeFee returned early, the swap went through). The same swap with amountSpecified = -25 raises the pending total by exactly 1 wei.","severity":"info","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"Hook fee truncates to zero for IMD legs below 25 wei (exact-in) / 24 wei (exact-out)"},{"citation":"resolved","description":"The end bound is computed as nextSweepDay + maxDays in checked arithmetic. A caller passing type(uint256).max (the idiomatic 'no limit') reverts with an arithmetic panic rather than sweeping everything. No funds are affected; every smaller value works and the sweep is permissionless and resumable, so this is a usability edge only. Fix: `uint256 end = maxDays > type(uint256).max - d ? type(uint256).max : d + maxDays;` or loop on a counter.","line":275,"path":"contracts/RunnerToken.sol","reproduction":"Reproduced on this commit (contracts/test/scratch/Judge.t.sol, test_D_sweepExpiredMax_overflows). State: nextSweepDay == 20500 (the deploy day). Input: sweepExpired(type(uint256).max). Expected: sweep all expired days. Actual: Panic(0x11) arithmetic overflow at `d + maxDays`; sweepExpired(type(uint256).max - nextSweepDay) succeeds.","severity":"info","snippet":"        uint256 end = d + maxDays;","title":"sweepExpired(type(uint256).max) reverts on the end-bound addition instead of meaning 'all'"},{"citation":"resolved","description":"The third audit's finding 5 removed the forged mint event from transferFrom(address(0), to, 0) by adding a zero-address check in _transfer. burnFrom and burn do not go through _transfer: _spendAllowance(address(0), 0) passes (allowance 0 is not less than value 0), _burn(address(0), 0) passes its balance check, and `emit Transfer(from, address(0), value)` fires with from == address(0). No balance or supply changes, so the impact is limited to indexers and explorers that treat Transfer-from-zero as a mint. Fix: revert ZeroAddress in _burn when `from == address(0)` (or in burnFrom), matching _transfer.","line":236,"path":"contracts/RunnerToken.sol","reproduction":"Reproduced on this commit (contracts/test/scratch/Judge.t.sol, test_I_burnFromZero_emitsEvent). Any caller invokes RunnerToken.burnFrom(address(0), 0). Expected: revert ZeroAddress, as transferFrom(address(0), to, 0) now does. Actual: the call succeeds and emits Transfer(address(0), address(0), 0) (checked with vm.expectEmit on the token address).","severity":"info","snippet":"    function burnFrom(address from, uint256 value) external {","title":"burnFrom(address(0), 0) emits a Transfer(0x0, 0x0, 0) event (same class as third audit #5)"}],"hash":"b6cdf21065f81c36d7e0d0902f44529c35e1d1130aa89aaf41f7a164f0654cd6","nodeId":"9fb59ede-991d-4f7e-893e-dad37410a5ac","outcome":"completed","summary":"The findings file is in place at the repository root and excluded from git by the harness's own exclude list, which is expected. Review complete.\n\n**Outcome:** all eleven specialist findings reproduced on this commit. None were dropped. Two pairs were duplicates and are merged, so `.imd-findings.json` holds 9 findings: 4 low, 5 info. No critical or high, so no proof files are attached.\n\n**What I verified** (scratch suite `contracts/test/scratch/Judge.t.sol`, 23 tests, all passing against the current code):\n\n- **Price push to the tick limit** (low, merged economics + flow). A 1-wei exact-in RUNNER sell by an account holding nothing moved the tick from 142600 to 887271 and `marketCap()` from ~641.6 IMD to 0. The official router's sell beyond the reserve does the same while pulling only part of the RUNNER. The next buy heals the pool at the launch price and costs ~83k more gas.\n- **setProtocolWallet accepts PoolManager/hook** (low, merged permissions + flow). With the PoolManager as wallet, an attacker's `sync`/`sweepExpired`/`settle`/`take` sequence took the full 200 IMD swept. With the hook as wallet, the 200 IMD are stuck as a raw balance.\n- **setFeeRecipient accepts PoolManager** (low). `collectProtocolFees` burned 200e18 of claims with no account credited.\n- **Daily cap backlog** (low). A signed total of the floor emission plus 1 wei reverts `DailyCapReached` on consecutive days; exactly the floor succeeds.\n- **IMD blocklist trust assumption** (info). Reproduced with a mock reward token that blocks the RunnerToken: official router trades, `flush` and `sweepExpired` revert while a generic v4 router keeps trading.\n- **marketCap uses the constant supply, fee truncation under 25 wei, sweepExpired(max) overflow, burnFrom(0,0) event** (info). Each confirmed with the exact inputs the specialists gave.\n\n**Independent pass:** I re-checked the hook's four swap directions and ERC-6909 settlement, the ETH router's IMD netting, holder-day accounting and exclusion, claim signature handling, and RunnerGenesis. Nothing further was substantiated.\n\nPaths, lines and snippets in the findings file were checked programmatically against the tree and all match. The project's own test suite (24 non-fork tests) passes unchanged.","treeHash":null,"usage":{"cachedInputTokens":1364557,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":31102,"runtime":"claude","turns":25,"wallClockMs":585779}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1a7ecd03bd365366","findings":[{"citation":"resolved","description":"The launch position is single-sided: liquidity exists only in [minUsableTick, startTick] (RUNNER as currency1; mirrored when RUNNER is currency0). Above startTick there is no liquidity at all, and the pool holds exactly 0 IMD whenever the price sits at startTick (right after openPool, and again every time sellers return all the IMD). In that state an exact-in sell of RUNNER (oneForZero when IMD is currency0) with the extreme price limit walks through the empty ticks up to MAX_SQRT_PRICE-1: Uniswap v4 consumes 0 input and pays 0 output but still moves slot0. afterSwap accepts this swap: the sell direction has IMD as the unspecified currency, so it goes to the else-branch above, computes fee = 0, and _chargeFee returns early; nothing requires tokenAmount > 0 or poolQuote > 0. RunnerRouter.sell refuses it only because of its own `out == 0` Slippage check; any plain v4 router (PoolSwapTest, the universal router) gets through. Consequences: (1) marketCap(t) (and getTokenInfo(t).marketCap / sqrtPriceX96, the PepesFamily-compatible interface the request says tools read) returns 0 instead of ~642 IMD; (2) the Trade event is emitted with quoteAmount 0, tokenAmount 0 and sqrtPriceX96 = MAX-1, so indexers see a 'trade' that crashed the price to zero; (3) the next genuine buy has to step down ~745,000 empty ticks (about 15 bitmap words) before reaching liquidity, costing roughly 83k more gas than a buy from the start tick (529,820 vs 446,618 measured). No funds are lost and the buy still fills from the launch price. The attacker needs no RUNNER and no IMD, only gas, and can repeat it after every time the reserve is drained (including the window between openPool and the first buy). Suggested minimal fix (keeps the design): in afterSwap revert when the swap moved nothing, e.g. `if (poolQuote == 0 || tokenAmount == 0) revert PartialFill();` (or a dedicated error) before the fee branch, so a swap that only moves the price is refused the same way partial fills are.","line":329,"path":"contracts/RunnerHook.sol","reproduction":"State: pool just opened (owner called openPool; no buy yet), so the pool holds 0 IMD and slot0 tick == 142600 (startTick, RUNNER as currency1 in the test ordering). Input: an account with 0 RUNNER and 0 IMD calls PoolSwapTest.swap(key, SwapParams(zeroForOne = !imdIs0, amountSpecified = -1, sqrtPriceLimitX96 = MAX_SQRT_PRICE-1), settings, \"\"). Expected: a sell into a pool with no IMD is refused, or at least cannot change the price. Actual (test/scratch/Probe.t.sol::Probe2::test_freePriceToMax, run with FOUNDRY_TEST=test forge test --match-path test/scratch/Probe.t.sol): the swap succeeds, slot0 tick goes 142600 -> 887271, sqrtPriceX96 == MAX_SQRT_PRICE-1, hook.marketCap(runner) goes 641e18 -> 0, the griefer's RUNNER balance stays 0, a Trade event with fee 0 / amounts 0 is emitted; a following router.buy of 100 IMD still returns 59,939,730 RUNNER (same as from a clean start) at 529,820 gas vs 446,618 for the same buy without the push (Probe3::test_normalBuyGas).","severity":"low","snippet":"            fee = exactIn ? (poolQuote * FEE_BPS) / BPS : (poolQuote * FEE_BPS) / (BPS - FEE_BPS);","title":"Anyone can push the pool price to the maximum tick for free whenever the IMD reserve is empty; marketCap()/getTokenInfo() then read 0 and the Trade event reports a zero price"},{"citation":"resolved","description":"Both routers call pad.flush on every trade. Because the same swap just charged the 4% fee, pendingHolderFees[token] is always > 0 at that point, so _flush runs unconditionally: poolManager.burn + poolManager.take(IMD, RunnerToken, amount) (an IMD.transfer to the token contract) followed by RunnerToken.syncHolderRewards(). The live IMD at 0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127 on Robinhood Chain (probed with cast on 2026-10-25) is an owner-controlled LayerZero OFT: it exposes owner() = 0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7, blocked(address) (a blocklist), transfersEnabled()/enableTransfers() (currently true) and setV4Config(address,address,bool) (poolManager() currently 0x0). Its verified source could not be fetched (the explorer API is behind a bot wall), so the exact blocklist semantics (sender-only or sender-and-recipient) are unverified. If the IMD owner ever sets blocked(RunnerToken) (or blocked(PoolManager), or disables transfers, or a future setV4Config restricts PoolManager flows), then: every RunnerRouter.buy/sell and RunnerEthRouter.buyWithEth/sellForEth reverts inside _flush, while swaps through any other v4 router keep working and keep minting fee claims in the hook (flush() from outside an unlock also reverts, so holder IMD accumulates as ERC-6909 claims that cannot be delivered); claimHolderRewards() and sweepExpired() revert in _sendReward, freezing all holder IMD inside RunnerToken; collectProtocolFees reverts if feeRecipient is blocked (owner-fixable via setFeeRecipient). None of this is a code defect in Runner: it is the counterparty risk of paying rewards in a token whose issuer can block addresses, and it is reported as a trust assumption the launch documentation should state. If the team wants the trading path to survive a reward-token freeze, the routers could flush in a try/catch (leaving the holder share pending) so that only reward delivery, not trading, depends on IMD's policy; whether that tradeoff is wanted is a design decision.","line":114,"path":"contracts/RunnerRouter.sol","reproduction":"State: deployed system with the real IMD; pendingHolderFees[RUNNER] > 0 (true immediately after any swap). Trigger (privileged, external party): the IMD owner blocks the RunnerToken address (or calls the transfer-disable switch). Expected per the design text: 'every trade pays 4%, 2% to holders'. Actual: RunnerRouter.buy(RUNNER, 1000e18, 0, deadline) reverts (IMD.transfer inside poolManager.take reverts, bubbling through _flush), RunnerEthRouter.buyWithEth reverts the same way, claimHolderRewards() reverts TransferFailed for every holder, sweepExpired() reverts, while PoolSwapTest.swap on the same pool still succeeds. Not reproducible without a fork of Robinhood Chain; the IMD selectors and current state above are what was verified.","severity":"info","snippet":"        pad.flush(d.token);","title":"Trust assumption: every router trade and every holder claim depends on the IMD token's owner-controlled blocklist and transfer switch"}],"hash":"e99f77d9a2bdb858730978f76e3907216b6e2c9712222b12e18b783b52c0d3ce","nodeId":"ad18d6b9-fe9c-4eaa-af5b-3a5f399c7246","outcome":"completed","summary":"Review complete. The findings file is written and validated.\n\n**Outcome.** The economics of the Runner contracts hold up. I found no critical, high or medium defect. Two items are recorded in `.imd-findings.json`: one low and one trust assumption.\n\n**What I verified, with concrete scenarios run against a real v4 PoolManager:**\n- Holder-reward conservation. Across buys, transfers, a mid-window `excludePool`, claims before and after exclusion, and the sweep, every day paid out at most its rewards, the share total equalled the sum of eligible balances, and the IMD ledger matched the token's balance to zero after sweeping.\n- Fee math in all four swap directions, including the reverse currency ordering (RUNNER as currency0), which the project's own suite never reaches because its addresses are deterministic. Fees were exactly 4%, the hook's ERC-6909 claims always covered pending fees, and the opening market cap matched.\n- Run-reward emission, daily cap, lock and burn accounting, signature handling, and the server's claim sizing. The server already signs partial totals that fit the day's cap, so the all-or-nothing claim path is not a liveness gap.\n- Genesis sale economics. The rarity hash yields 203, 795, 1904 and 7097 tokens per tier, matching the advertised 2, 8, 20 and 70 percent split.\n\n**Findings:**\n- **Low.** When the pool's IMD reserve is empty, which is the case right after `openPool` and every time sellers drain it, anyone holding no RUNNER can sell 1 wei through a plain v4 router. The swap moves nothing but pushes the price to the maximum tick. `marketCap()` and `getTokenInfo()` then read zero, a zero-amount Trade event is emitted, and the next real buy costs about 83k more gas. No funds move. The fix is a one-line refusal of zero-movement swaps in `afterSwap`.\n- **Info.** The live IMD token is an owner-controlled LayerZero OFT with a blocklist and a transfer switch. Every router trade flushes holder IMD to RunnerToken unconditionally, so a block on RunnerToken would halt both routers and freeze holder claims while external routers keep trading. This is counterparty risk to document, not a code defect. I could not fetch IMD's verified source because the explorer is behind a bot wall, and I note that in the finding.\n\n**Limits.** No fork tests were run, so the IMD dependency is described from on-chain selector probes rather than reproduced. Proof tests were not attached because nothing reached high severity. Probe tests under `test/scratch/` need `FOUNDRY_TEST=test` because the project's test directory is `contracts/test`.","treeHash":null,"usage":{"cachedInputTokens":3184220,"inputTokens":802,"model":"claude-fable-5-1","outputTokens":69670,"runtime":"claude","turns":49,"wallClockMs":1358693}}],"verification":[]}