{"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":"f1d5def3-c15d-4e7f-99cb-e0b0ebfdd584","kind":"audit","nodes":[{"acceptedSubmissionHash":"37a97bce33e587953aebb484eb334fbc71128d0551b99314dc85af8a88c9ea48","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":"b49cb2326f02f9699dd5dc0dac0059616c312b7e31df5b6f35487ed6c3c19a06","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":"60b43c7582e74763252818ed3bd0457e34f61bd214598d6510432001d5c10cd8","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"4d1b544723107b2d9b1f5369791ec81f52dcdcd7e39fa93b6588bcb09e1f8fd2","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":"6763d399008cc8bc6836fb8a60ef86454a7424805490148c7e0f6bafd7392777","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":"Re-check of IMD Swarm audit 78c00339 (which audited commit 9fe5e93) for The Zero Person Billion Dollar Company ($COMPANY) on Robinhood Chain (4663). AUDIT.md section 4 maps each finding to its fix and its test.\n\nWhat the contracts are for: CompanyToken is a fixed 1,000,000,000 supply ERC-20; its ownership is renounced in the constructor. CompanyHook owns the token's only Uniswap v4 pool, paired with IMD, with liquidity locked forever, and takes 4% of every swap: 1% to the protocol, 3% to holders. CompanyRouter and CompanyEthRouter buy and sell with IMD or ETH. Holder fees are split 50% IMD and 10% each to NVDA, GOOGL, AAPL, AMC and MSTR stock tokens, bought IMD -> USDG -> stock at the start of every claim(). Only wallets holding at least 100,000 earn, and unclaimed rewards expire after 7 days.\n\nFixes to verify:\n1 (high) JIT liquidity inflating maxConvert(): fixed per-round ceiling MAX_ROUND_IMD = 20 IMD. Test: test_audit1_jitLiquidityCannotInflateRound replays your attack.\n2 (low) USDG -> stock hop: per-stock limit stockRoundLimit(asset) = half the stock pool's fee x its virtual USDG depth, priced in IMD. Test: test_audit2_thinStockPoolLimitsItsRound.\n3 (low) partial fills: CompanyHook.afterSwap reverts PartialFill unless an IMD-specified swap filled completely; the Trade event amount is clamped. Test: test_audit3_partialFillIsRejected_fullFillWorks.\n4 (low) free timer reset: receipts from the PoolManager no longer count as activity. Test: test_audit4_poolManagerPingDoesNotResetTimer.\n5 (low) releaseStuckReserve was removed: a failed stock purchase now credits that round's IMD to holders at once (_fallBackToImd), inside a self-call with a fixed gas budget (CONVERT_GAS); a claim with too little gas reverts with NotEnoughGas. Please review this new path hardest: can anyone force the fallback?\n6 (low) and 7 (info): accepted and documented in the CompanyToken notice and README.\n8 (info): refused expired rewards stay in recycledHeld until sendRecycled. Test: test_audit8_expiredStockHeldWhenFeeRecipientBlocked.\n9 (info): the Trade event decodes the user for both routers. Test: test_audit9_ethRouterTradeLogsRealBuyer.\n\nPlease confirm each fix and check that none of them broke the solvency of the six reward assets, the flash-borrow guard, the 100,000 minimum, expiry, or the scanner-relevant properties (no honeypot, hidden owner, owner-can-change-balance or suspicious function). Report anything new.\n\nTests: cd contracts; git submodule update --init --recursive; forge test. Fork test: FORK_RPC=https://robinhood.drpc.org forge test --mc CompanyForkTest.","parentJobId":null,"planHash":"d5498d5277d6b57e3fb242b79f1065f4ab5e855c9afe91ba1ae723547a007575","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"f1d5def3-c15d-4e7f-99cb-e0b0ebfdd584","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":"51025","feedbackHash":"8a135fadd5572aa2cfe97032642477234b1702afcc74a84255183a7b63281927","nodeKey":"audit_economics","submissionHash":"37a97bce33e587953aebb484eb334fbc71128d0551b99314dc85af8a88c9ea48","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51742","feedbackHash":"fa80109cf5c309f1eb1373422bf165b8ad8b429d700a7cec44ab07559053912e","nodeKey":"audit_flow","submissionHash":"b49cb2326f02f9699dd5dc0dac0059616c312b7e31df5b6f35487ed6c3c19a06","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51481","feedbackHash":"c95da47d4e7d543230c39585ba030be39d7ad4d0fbea442257b5bb7a34f51ab8","nodeKey":"audit_judge","submissionHash":"60b43c7582e74763252818ed3bd0457e34f61bd214598d6510432001d5c10cd8","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51285","feedbackHash":"940dba6be38d594be903b7cf63f1cfa17730050f7e3ba0fb3dfe4e9c55b90897","nodeKey":"audit_math","submissionHash":"4d1b544723107b2d9b1f5369791ec81f52dcdcd7e39fa93b6588bcb09e1f8fd2","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51020","feedbackHash":"af237b04b23e839f8cc03bb1833a4c0cd04321a2a327d77b3968552ae229907b","nodeKey":"audit_permissions","submissionHash":"6763d399008cc8bc6836fb8a60ef86454a7424805490148c7e0f6bafd7392777","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"d7b8acb0936985f60c6af93d3d35678f30493fb1299b857b6fb12f9c3fc17f39","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b273d407784470b4","findings":[{"citation":"resolved","description":"Fix 2 sizes each stock round by stockRoundLimit(asset) = pool fee / 2 x the stock pool's virtual USDG depth, where the depth is L / sqrtP read from getLiquidity() at the current tick, in the same transaction as the round. That is exactly the depth read that finding 1 (high) showed to be inflatable with a just-in-time position; fix 1 added a fixed ceiling for the IMD hop, but the stock hop got only this read, with no ceiling in the stock pool's own fee terms. Two gaps follow. (a) Anyone can mint a narrow position around the current (or pushed) tick for one transaction: getLiquidity() then returns their liquidity, the limit jumps from its honest value to thousands of IMD, and the round sells the full 4 IMD share into that position at the attacker's price. (b) _convertAll line 555 (`if (imdIn > poolLimit) imdIn = poolLimit == 0 ? imdIn : poolLimit;`) treats poolLimit == 0 as 'empty pool, the swap will fail and fall back to IMD', but getLiquidity() == 0 only means no liquidity at the current tick; if the price sits in a gap (for example after a push past the edge of the real range) the swap does not fail, it fills in the next initialized range, which can be the attacker's, with no limit at all. The design intent stated in the fix (a sandwich of that hop costs more in the pool's fee than it can move the price) therefore does not hold once the real liquidity near the price is thin relative to the 4 IMD share, which is the very scenario test_audit2_thinStockPoolLimitsItsRound models: its limit of 0.015 IMD is restored to a 4 IMD round by the JIT position. The attack's cost is the pool fee on pushing the price through the real liquidity in the path; the gain is up to the whole 4 IMD share per stock per round, and the attacker can trigger a round every minute with convert(). On the mock scenario of the proof (the project's own thin-NVDA-pool setup, 0.3% fee): honest limit 0.015 IMD; with the JIT position 9,474 IMD; the round sells 4 IMD and receives 0.396 NVDA instead of about 3.95; attacker profit 3.46 USDG-equivalent per round. I also replayed the sequence on a fork of Robinhood Chain (block 82349819) against the real pools: the limit was lifted in every run (NVDA read 119 IMD after a 51-tick push, GOOGL 610 IMD, AAPL 734 IMD, AMC 101 IMD, MSTR 794 IMD, all far above the 4 IMD share), but the sandwich lost money in every run because each real pool currently holds hundreds of thousands of USDG of real liquidity in the path (pushing NVDA 51 ticks cost 267,335 USDG, i.e. about 53 USDG of 0.01% fees round trip, more than the 39 USDG share). So the exposure today is bounded by the market makers' liquidity, not by this limit, and it opens whenever that liquidity thins; the market-maker positions are 1 to 60 ticks wide and are withdrawn and re-placed continuously. Note also that for concentrated positions the virtual depth hugely overstates real depth (the NVDA pool reads 119M USDG virtual against about 6,000 USDG of real liquidity per tick), so even without JIT the limit (about 600 IMD for NVDA) never binds and gives no protection at the 4 IMD scale. Suggested fix, preserving the design: record each stock pool's sqrtPrice at the last successful round and skip (not fall back) a round whose current price deviates more than a few percent from it, refreshing the reference only after the pool has been stable across rounds or after a timeout, so a same-transaction push can never be the execution price; treat poolLimit == 0 as a skip, not a pass-through; and, if the liquidity read is kept, give each stock a fixed ceiling in its own pool-fee terms (round <= fee x an assumed minimum real depth), the way MAX_ROUND_IMD does for the IMD hop.","line":654,"path":"contracts/src/CompanyToken.sol","reproduction":"State: pool opened; alice and bob each bought 1,000 IMD worth so 6 IMD waits per stock; the NVDA pool's LP removed 999,990e18 of its 1,000,000e18 liquidity (the project's test_audit2 setup); one minute elapsed. Attacker (100,000 USDG, 100,000 NVDA): (1) swap USDG -> NVDA in the NVDA pool with a price limit 23,000 ticks away (10x); (2) modifyLiquidity +2,000,000e18 over 11 tick spacings around the new tick; stockRoundLimit(1) now reads 9,474 IMD (honest: 0.015 IMD); (3) token.convert(): pendingConvert(1) drops by 4e18 and the contract receives 0.396 NVDA (about 3.95 at the honest price); (4) remove the position; (5) sell the NVDA the push bought back into the pool. Expected (AUDIT.md guarantee 4 and the stockRoundLimit comment): a thin pool's round is held to its limit and sandwiching it costs more than it moves the price. Actual: the round sells 4 IMD at the attacker's price and the attacker ends 3.46e18 USDG+NVDA richer. Run: cd contracts; forge test --match-path test/scratch/JitStockLimit.t.sol","severity":"medium","snippet":"        uint256 liquidity = IPoolManager(poolManager).getLiquidity(sk.toId());","title":"stockRoundLimit (fix for finding 2) is inflated by just-in-time liquidity and bypassed at zero active liquidity, so a thin stock pool's round is sandwichable"},{"citation":"resolved","description":"After fix 4, CompanyToken._transfer (lines 338-341) records activity for the recipient only when the recipient initiated the transfer (msg.sender == to) or on its first receipt (lastActive[to] == 0). Tokens delivered by the PoolManager, which is how both routers deliver a buy (poolManager.take(cOut, d.user, out)), no longer touch lastActive, and test_expiry_giftsAndBuysDontResetTimer_claimDoes asserts that a buy is not activity. Section 5 of AUDIT.md still lists the pre-fix behaviour as a known and accepted way to reset another wallet's timer at a 4% cost. A reader of the brief would expect a buy to keep a wallet's rewards alive; it does not, and rewards older than 7 days expire to feeRecipient unless the wallet claims or sends. The contract notice and README already say 'buying alone does not count: claim at least weekly', so only the brief is wrong.","line":57,"path":"AUDIT.md","reproduction":"alice buys 1,000 IMD worth and bob buys; warp 8 days; run the TimerPinger from test_audit4 to deliver 1 wei to alice from the PoolManager, or have alice buy again through CompanyRouter. Expected per AUDIT.md line 57: alice's lastActive is reset. Actual: lastActive(alice) is unchanged and expiredRewardsOf(alice, 0) stays > 0, which is what test_audit4_poolManagerPingDoesNotResetTimer and test_expiry_giftsAndBuysDontResetTimer_claimDoes assert. Fix: delete or reword the bullet.","severity":"info","snippet":"- A buy delivered by a router to another address resets that address's expiry timer (costs the buyer 4%).","title":"AUDIT.md still states that a router buy delivered to another address resets its expiry timer, which fix 4 made false"}],"hash":"37a97bce33e587953aebb484eb334fbc71128d0551b99314dc85af8a88c9ea48","nodeId":"9ffa628e-704a-4194-9257-b5d75b7a9db1","outcome":"completed","summary":"Review complete. The findings are in `.imd-findings.json` at the repository root (two entries, one with a Foundry proof), and nothing else was left in the tree.\n\n**What I checked.** The full suite passes (38 tests), including the nine audit-fix tests. I traced each fix against the code and confirmed fixes 1, 3, 4, 5, 8 and 9 do what the brief says. The fee accounting in the hook, the full-fill check, the flash-borrow guard, the 100,000 minimum, expiry, and the six-asset solvency all held under the fuzz suite and my reading. The scanner-relevant properties are unchanged: no owner function on the token, standard allowances, no transfer restrictions.\n\n**Fix 5, the fallback path.** I could not find a way to force it cheaply. The sub-call budget has about 3.4x headroom (real conversions used 242k to 295k gas each on a Robinhood Chain fork). Making a purchase fail needs a real push through the stock pool's liquidity or a dense-tick gas grief after such a push, both of which cost more than the round and only turn stock into IMD for holders, which the brief already accepts.\n\n**Finding 1 (medium).** The fix for finding 2 reuses the pattern finding 1 condemned. The per-stock limit reads active liquidity at the current tick in the same transaction, so a just-in-time position inflates it, and a zero read is passed through as \"the swap will fail\" although the swap fills in the next range instead. In the project's own thin-pool scenario the limit went from 0.015 IMD to 9,474 IMD, the round sold its full 4 IMD share for a tenth of the stock, and the attacker netted about 3.5 USDG per round, repeatable every minute through `convert()`. On today's real pools the same sequence loses money because each pool holds hundreds of thousands of USDG of real liquidity near the price, so the exposure opens only when market-maker liquidity thins. The proof test fails now and passes with a reference-price skip or any fix that keeps a manipulated round from executing.\n\n**Finding 2 (info).** AUDIT.md section 5 still says a router buy resets the recipient's expiry timer. Fix 4 made that false, and the suite asserts the opposite.\n\n**Limits of this review.** The fork numbers are a snapshot of one block, the Robinhood stock tokens' sequencer-level filtering was not modelled, and I did not run static analysers since none were provided.","treeHash":null,"usage":{"cachedInputTokens":4875704,"inputTokens":706,"model":"claude-fable-5-1","outputTokens":102266,"runtime":"claude","turns":60,"wallClockMs":2151979}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"7e929507773df661","findings":[{"citation":"resolved","description":"Fix 2 bounds each stock's round by stockRoundLimit(asset) = fee/2 x the USDG/stock pool's virtual USDG depth, priced in IMD. The depth is getLiquidity() of that pool, read in the same transaction as the round, exactly the value that finding 1 showed can be inflated with a just-in-time position (the fix for finding 1 added the fixed MAX_ROUND_IMD ceiling for the IMD/USDG hop; nothing equivalent was added for the stock hop). An attacker who (1) pushes the stock pool's price, (2) adds a narrow position around the pushed tick, (3) calls convert() (or lets any claim run), (4) removes the position and (5) swaps back, makes stockRoundLimit read far above the 4 IMD share, so _convertAll sells the full min(pending, MAX_ROUND_IMD/5) = 4 IMD into the manipulated pool with no minimum output. The protocol buys stock at the pushed price from the attacker's own position; the only bound left is the 4 IMD share, which is calibrated for the 0.9% IMD/USDG pool and not for a 0.01% (NVDA), 0.1% (AMC), 0.25% (MSTR) or 0.3% (GOOGL, AAPL) pool. The sandwich is profitable whenever the stock pool's real in-range depth is below (4 IMD worth of USDG) / fee: about $400k of virtual USDG depth for NVDA, $40k for AMC, $16k for MSTR, $13k for GOOGL and AAPL at the live price of ~$10 per IMD. On the fork at block 82,346,484 the live pools are far deeper (NVDA $118M, GOOGL $9.8M, AAPL $10.7M, AMC $13.2M, MSTR $8.3M of virtual USDG depth; stockRoundLimit 595 to 1,619 IMD), so this is not exploitable today; it means the protection fix 2 was meant to give against a thin stock pool does not hold when the thinness is what makes the attack cheap. Loss per event is bounded by 4 IMD (~$40) per stock per CONVERT_INTERVAL, and about 70% of it is extractable (see reproduction), repeatable every minute while that stock's reserve has IMD. The accepted-risk list in AUDIT.md section 5 covers an LP pulling liquidity to force the IMD fallback, not this: here the round succeeds and holders receive stock worth a quarter of the IMD spent. Minimal fix that preserves the design: do not trust a same-transaction liquidity read for the stock hop. For example, snapshot each stock pool's (liquidity, sqrtPrice) at construction and after every round and size the round from min(stored, current) liquidity, or additionally refuse the round (skip, not fall back) when the current price is more than a few percent from the snapshot, so a manipulation has to persist across a full CONVERT_INTERVAL and be exposed to arbitrage; or give each stock a fixed ceiling scaled to its pool fee (share x fee_stock / 9000) on top of MAX_ROUND_IMD.","line":654,"path":"contracts/src/CompanyToken.sol","reproduction":"State: IMD/USDG pool 1:1 with 10,000 of depth (as in the unit tests); NVDA pool with the mainnet parameters fee 100 (0.01%), tick spacing 1, but thin: full-range liquidity 1e21 (1,000 USDG of virtual depth) at 1:1. alice and bob each buy 1,000 IMD through CompanyRouter so pendingConvert(1) = 6 IMD; warp 1 minute. Expected (what fix 2 promises): stockRoundLimit(1) = 0.0001/2 x 1,000 = 0.05 IMD and the NVDA round sells at most 0.05 IMD. Actual: attacker swaps 1,000 USDG for NVDA in the NVDA pool (NVDA now ~4 USDG), adds liquidity 2e24 in ticks [tick-1, tick+2], and stockRoundLimit(1) reads 200.09 IMD; token.convert() sells 4,000,000,000,000,000,000 wei = 4 IMD (80x the honest limit) and receives 990,606,340,017,848,806 wei = 0.99 NVDA for ~3.96 USDG (fair value 3.96 NVDA). Attacker removes the position and sells the pushed NVDA back: USDG+NVDA balance goes from 2,000,000e18 to 2,000,002.8208e18, a profit of 2,820,840,488,266,486,780 wei (~2.82 USDG) out of a 4 IMD round. Run: forge test --match-path test/scratch/StockLimitJit.t.sol -vv (fails on this code at 'NVDA round sold more than the stock pool's honest limit: 4000000000000000000 > 50000000000000000'; the second assertion, attacker not profitable, also fails). Live calibration (fork of Robinhood Chain at block 82,346,484): IMD/USDG depth 19,823 IMD / 197,377 USDG; stockRoundLimit NVDA 595 IMD, GOOGL 1,475, AAPL 1,619, AMC 660, MSTR 1,039; so the attack is unprofitable against today's pools and becomes profitable only if a stock pool's in-range depth falls below the thresholds in the description.","severity":"low","snippet":"        uint256 liquidity = IPoolManager(poolManager).getLiquidity(sk.toId());","title":"stockRoundLimit (fix for finding 2) is inflated by just-in-time liquidity, so a thin stock pool's round still sells the full 4 IMD share and the stock hop can be sandwiched at a profit"},{"citation":"resolved","description":"After fix 4, CompanyToken._transfer only records activity for the recipient when msg.sender == to or when lastActive[to] == 0 (first receipt); tokens arriving from the PoolManager, which is how both routers deliver a buy (poolManager.take(cOut, d.user, out)), never reset an existing holder's timer. The contract notice, README and website say so ('Buying alone doesn't reset the timer'), but the 'Known and accepted' list in AUDIT.md section 5 still describes the pre-fix behaviour as an accepted risk, which can mislead the next reviewer or a holder reading the brief. Fix: delete or reword the bullet (a buy delivered to a wallet that has never held $COMPANY sets its first lastActive; a buy to an existing holder changes nothing).","line":57,"path":"AUDIT.md","reproduction":"In contracts/test/Company.t.sol, test_expiry_giftsAndBuysDontResetTimer_claimDoes: alice holds and is 8 days inactive, _buy(alice, 1e18) through CompanyRouter delivers tokens from the PoolManager to alice; expiredRewardsOf(alice, 0) stays > 0 and lastActive(alice) is unchanged, i.e. the buy did not reset the timer. Expected per AUDIT.md line 57: the timer is reset (at a 4% cost). Actual: no reset; only a wallet with lastActive == 0 gets a timestamp on receipt.","severity":"info","snippet":"- A buy delivered by a router to another address resets that address's expiry timer (costs the buyer 4%).","title":"AUDIT.md section 5 still says a router buy resets the recipient's expiry timer, which fix 4 made false"}],"hash":"4d1b544723107b2d9b1f5369791ec81f52dcdcd7e39fa93b6588bcb09e1f8fd2","nodeId":"e44ddd16-fe03-4039-97b9-163469469446","outcome":"completed","summary":"Review complete. The findings file is written and validated, and the one proof test fails on the current code as required.\n\n**Outcome.** The nine fixes hold as described, and I found no new arithmetic defect in the fee, split, per-share, expiry, cap or limit formulas. Two findings are recorded in `.imd-findings.json`, one low and one informational.\n\n**What I verified**\n\n- All 37 unit tests plus the fork test pass against live Robinhood Chain state at block 82,346,484.\n- Fee math in the hook is exact for all four swap shapes, and the partial-fill check compares the raw pool delta as the vendored v4 library passes it.\n- Per-share accounting, the 100,000 minimum, eligible-supply bookkeeping, checkpoints and the strict expiry boundaries are consistent, with every rounding direction favouring solvency or the holder.\n- Solvency of all six assets holds through the new fallback path, since it only moves IMD from the pending reserve to owed.\n- The fallback cannot be forced by a caller: the self-call gets a fixed 1,000,000 gas, a real conversion costs about 360k on the live pools, and the per-round caps keep tick crossings to at most one. Stock transfers and conversions also succeed on a Sunday block, so there is no weekend fallback.\n- Live calibration: IMD is about $10, the IMD/USDG pool holds about 19,800 IMD of depth, and USDG has 6 decimals. The code handles that correctly.\n\n**Finding 1, low.** The fix for finding 2 reads the stock pool's in-range liquidity in the same transaction, so the same just-in-time trick that inflated `maxConvert` inflates `stockRoundLimit`. In a thin NVDA pool with the mainnet 0.01% fee, the honest limit is 0.05 IMD but the round sells 4 IMD and the attacker nets about 2.82 USDG per round. Today's pools are 20 to 300 times too deep for this to pay, so it is a robustness gap rather than a live exploit. The proof is in `contracts/test/scratch/StockLimitJit.t.sol`.\n\n**Finding 2, info.** AUDIT.md section 5 still lists a router buy resetting the recipient's timer as an accepted risk. Fix 4 made that false, and the contract notice, README and website already say so.\n\n**Not covered.** No Slither or long fuzz runs were available. The weekend check used a single Sunday block.","treeHash":null,"usage":{"cachedInputTokens":2425304,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":85019,"runtime":"claude","turns":41,"wallClockMs":1444510}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3f91b58cf7cd2d45","findings":[{"citation":"resolved","description":"Merged from audit_permissions (low), audit_economics (medium, part a) and audit_math (low); all three reproduce. stockRoundLimit(asset) sizes a stock's round as fee/2 x the USDG/stock pool's virtual USDG depth, where the depth is getLiquidity() at the current tick read in the same transaction as the round. That is exactly the read finding 1 (high) showed to be inflatable: fix 1 added the fixed MAX_ROUND_IMD ceiling for the IMD/USDG hop, but the stock hop got only this manipulable read and no ceiling in the stock pool's own fee terms. An attacker who (1) pushes the stock pool's price in the direction the round moves it, (2) mints a narrow, large position around the pushed tick, (3) calls convert() (or lets any claim() run the round), (4) removes the position and (5) swaps back, makes stockRoundLimit read far above the 4 IMD share, so _convertAll sells the full min(pending, MAX_ROUND_IMD/5) = 4 IMD into the attacker's position with no minimum output (_swapExactIn sets none; convertStock only rejects stockOut == 0). The guarantee the fix claims (contract notice: 'A sandwich of that hop then costs more in the pool's fee than it can move the price'; AUDIT.md section 3 item 4) does not hold in the thin-pool scenario the fix was written for, which is the scenario test_audit2_thinStockPoolLimitsItsRound models. Loss is bounded by the 4 IMD share per stock per CONVERT_INTERVAL, about 85% of it extractable, and repeatable every minute while that stock's reserve has IMD; the attack transaction can check the pool and revert when it is deep, so trying it every minute costs only gas. Not exploitable on today's mainnet state: on the fork (block of 2026-10-07) stockRoundLimit reads 680-1,667 IMD, far above the 4 IMD share, and pushing through the market makers' hundreds of thousands of USDG of in-range liquidity costs more in pool fees than a 4 IMD round is worth. It becomes profitable whenever a stock pool's real in-range depth falls below roughly (4 IMD in USDG) / pool fee: about $400k of depth for NVDA (0.01%), $40k for AMC (0.1%), $16k for MSTR (0.25%), $13k for GOOGL and AAPL (0.3%) at $10 per IMD, including the moments when market makers withdraw and re-place their positions. AUDIT.md section 5 covers an LP forcing the IMD fallback, not this: here the round succeeds and holders receive stock worth a small fraction of the IMD spent. Fix that preserves the design: do not price a round from state the same transaction can set. Record each stock pool's sqrtPriceX96 at deployment and after every successful round, and skip (do not fall back) a round whose current price deviates from that reference by more than a few percent, so a same-transaction push can never be the execution price (a manipulation then has to persist across a full CONVERT_INTERVAL, exposed to arbitrage); or enforce a minimum output per round derived from that reference price. This patch, together with the one for the zero-liquidity case, was applied locally: the attached proof passes and the project's 37 tests still pass. A fixed per-stock ceiling scaled to the pool fee alone (share x fee/9000) only bounds the loss; in the thin pool it stays profitable.","line":654,"path":"contracts/src/CompanyToken.sol","reproduction":"Local suite setup (IMD/USDG 1:1 with 10,000 depth; USDG/NVDA fee 0.3%, spacing 60). alice and bob each buy for 1,000 IMD, so 6 IMD waits per stock. The NVDA pool's LP removes 999,990e18 of its 1,000,000e18 liquidity, exactly as test_audit2_thinStockPoolLimitsItsRound does: stockRoundLimit(1) = 0.015 IMD. Warp 1 minute. Attacker (100,000 USDG, 100,000 NVDA): (1) swaps 30 USDG for NVDA through PoolSwapTest (the price moves to roughly 0.1 NVDA per USDG); (2) mints L = 50,000e18 over 180 ticks around the new tick: stockRoundLimit(1) now reads 299.38 IMD; (3) token.convert(): pendingConvert(1) drops by 4e18 and the contract receives 0.248 NVDA (about 3.95 at the honest price); (4) removes the position; (5) sells the NVDA from the push back. Expected (fix 2): the NVDA round spends at most 0.015 IMD and the attacker's USDG+NVDA (valued 1:1) does not grow. Actual: the round spends 4 IMD and the attacker ends 3.6019 USDG+NVDA richer (200,003.6019e18 vs 200,000e18). Run: cd contracts; forge test --match-path test/scratch/JitStockLimit.t.sol -vv. Fails on this code with 'sandwich of the stock hop is profitable: 200003601874124907240677 > 200000000000000000000000'; passes with a reference-price check as described.","severity":"medium","snippet":"        uint256 liquidity = IPoolManager(poolManager).getLiquidity(sk.toId());","title":"Fix 2 bypass: stockRoundLimit reads same-transaction liquidity, so just-in-time liquidity lifts a thin stock pool's limit back to the full 4 IMD share and the stock hop can be sandwiched at a profit"},{"citation":"resolved","description":"Merged from audit_permissions (medium) and audit_economics (medium, part b); reproduces. _convertAll treats stockRoundLimit(a) == 0, which stockRoundLimit returns when getLiquidity() is 0 at the stock pool's current tick, as 'empty pool, the swap will fail and the round falls back to IMD', and keeps imdIn at the full per-stock cap (4 IMD). That is not how a Uniswap v4 swap behaves: Pool.swap steps across a zero-liquidity range to the next initialized tick, crosses it and fills there (lib/v4-core/src/libraries/Pool.sol swap loop; only a pool with no position anywhere leaves the input unfilled, which _swapExactIn then rejects with Slippage). With no minimum output anywhere on the path, whoever owns the next initialized position sells the whole capped round at the price that position sets, with no capital at risk beyond resting an order, and can collect up to 4 IMD of that stock's reserve every CONVERT_INTERVAL from every claim() or convert() until the reserve is gone. The limit added for finding 2 is removed in exactly the state the pool is most exposed, and the code comment, the contract notice ('Zero for a pool with no liquidity (the round then falls back to IMD)') and AUDIT.md section 5 ('holders still receive its full value in IMD') all describe a fallback that does not happen. maxConvert() handles the same reading the other way (zero depth gives a cap of 0 and no round runs). Reachability today is limited: every live stock pool holds a dust full-range position, so getLiquidity() at the current tick is small but not zero (then the limit is tiny and binds), and an attacker cannot empty a full-range position alone; the state arises when those dust LPs withdraw and the market makers' concentrated positions are out of range or being re-placed. Fix: never widen a round on a zero reading: `if (imdIn > poolLimit) imdIn = poolLimit;` so a zero limit skips the stock this round (or call _fallBackToImd directly without swapping, if the IMD fallback is preferred). With that one-line change the attached proof passes and the project's 37 tests still pass.","line":555,"path":"contracts/src/CompanyToken.sol","reproduction":"Local suite setup. alice and bob each buy for 1,000 IMD, so 6 IMD waits per stock. (1) The NVDA pool's LP removes all 1,000,000e18 of its liquidity: pm.getLiquidity(poolId) == 0 and token.stockRoundLimit(1) == 0. Warp 1 minute. (2) The attacker mints one position of L = 2,000e18 at ticks [-46080, -46020] when USDG is currency0 (mirrored, [46020, 46080], otherwise), about 100x the fair NVDA price on the side the purchase moves toward; it holds about 0.6 NVDA and no USDG; stockRoundLimit(1) still reads 0. (3) Anyone calls token.convert(). Expected (code comment, contract notice, AUDIT.md section 5): the round is skipped or its 4 IMD is credited to holders as IMD. Actual: pendingConvert(1) drops by 4e18, no ConversionFailed, owed(0) unchanged, and the contract receives 0.0396 NVDA for 4 IMD (fair about 3.95). (4) The attacker removes the position and ends 3.92 USDG+NVDA richer (2,003.9228e18 vs 2,000e18). Run: cd contracts; forge test --match-path test/scratch/ZeroLiquidityGap.t.sol -vv. Fails on this code with 'resting position sold the whole round at its own price: 2003922797156961323124 > 2000000000000000000000'; passes with `imdIn = poolLimit`.","severity":"low","snippet":"            if (imdIn > poolLimit) imdIn = poolLimit == 0 ? imdIn : poolLimit; // an empty pool fails below -> IMD","title":"Fix 2/5: a stockRoundLimit of zero (no liquidity at the current tick) removes the per-stock limit instead of skipping; the v4 swap crosses the gap and the whole 4 IMD round fills at the next resting p"},{"citation":"resolved","description":"From audit_flow (low); reproduces. The new path is sound in what it prevents: the self-call always receives exactly CONVERT_GAS, so a caller cannot starve a purchase into _fallBackToImd, and a short claim reverts without state change (test_lowGasClaim_isRefused_notTurnedIntoImd). Its side effect is that the gas a claim needs is a step function of state the caller does not control. For every stock with pendingConvert > 0 whose minute has elapsed, the loop demands gasleft() >= 1,065,873 at that point, although the purchase then consumes 100k-300k. A claim with nothing due uses about 479k gas; the same claim one minute later uses about 975k but needs a limit well above 1.07M at the first due stock, and a further 1M-plus headroom at each later due stock whose predecessor used little. Wallets set the gas limit from eth_estimateGas plus a 10-50% margin, so any claim estimated while the last round was less than a minute old and included after the minute boundary reverts with NotEnoughGas, and the holder pays for a failed transaction; with reserves waiting, rounds are due every minute, so this window recurs every minute. The same state can be created by anyone: when every reserve is fully converted (estimation sees no conversion at all), a dust swap through any router (1e15 wei of IMD, fee 4e13 wei) leaves holder fees in the hook; the victim's claim flushes them, every pendingConvert becomes nonzero, and the claim reverts for the same reason. A purchase that fails by exhausting its budget (a swap made to cross many initialized ticks) also consumes the full 1M, so the gas needed grows by that much more than the estimate. No funds are at risk; the effect is failed claims and wasted gas. Fix that keeps the starvation protection: when gasleft() is below the headroom, `continue` instead of reverting. A skipped stock is neither bought nor fallen back, so a low-gas caller still cannot turn stock into IMD, and anyone can run convert() later. Alternatively keep the revert and document that claim() must be sent with a fixed limit (about 5 x 1.1M plus payouts) so front-ends do not size it from an estimate.","line":559,"path":"contracts/src/CompanyToken.sol","reproduction":"Scratch test (contracts/test/scratch/GasEstimate.t.sol, run with forge test --match-path, removed after the review) on the repo's own setup. Scenario 1: alice buys 1,000 IMD, bob buys 10,000 IMD (the reserve stays > 0 after a round), warp 1 minute, convert(). alice's claim() with nothing due uses 479,448 gas and succeeds. Warp 1 minute; the same claim sent with 1.5 x 479,448 = 719,172 gas: expected (from the estimate) success; actual: revert with selector NotEnoughGas. Sent with 5M gas it succeeds and uses 975,114. Scenario 2: alice buys 1,000 IMD, bob buys 100 IMD, convert() empties all five reserves (pendingConvert == 0); a claim now uses 550,691 gas; the attacker swaps 1e15 wei of IMD through PoolSwapTest; alice's claim sent with 2 x 550,691 = 1,101,382 gas reverts with NotEnoughGas. Expected: a claim whose estimate was valid seconds earlier succeeds; actual: it reverts whenever a round became due or a dust fee arrived in between.","severity":"low","snippet":"            if (gasleft() < (CONVERT_GAS * 64) / 63 + 50_000) revert NotEnoughGas();","title":"Fix 5 side effect: the gas a claim() needs jumps by about 1.07M per due stock while the gas it uses does not, so a claim sent with the limit from an estimate made before a round became due reverts wit"},{"citation":"resolved","description":"From audit_flow (info); confirmed. Finding 5's resolution removed releaseStuckReserve and the 30-day release entirely; the contract now falls back to IMD one capped round at a time (_fallBackToImd), as AUDIT.md section 4 and the contract notice say. README.md line 127 ('the 30-day release applies') and line 82 ('blocked holders and blocked stocks, including the 30-day release') still describe the old mechanism, and line 73 says 'forge test # 31 unit, attack and fuzz tests' while the suite has 37. A reader of the public README, the document scanners and holders are pointed to, is told that a reserve stuck by a drained pool is released after 30 days; in the shipped code it is paid out as IMD, at most 4 IMD per stock per minute, as soon as a purchase fails. Fix: replace both 30-day sentences with the IMD-fallback description already used in the 'IMD fallback' bullet, and update the count.","line":127,"path":"README.md","reproduction":"grep -n '30-day' README.md returns lines 82 and 127; grep -rn 'releaseStuck\\|30 days' contracts/src returns nothing (the only time constants in CompanyToken.sol are INACTIVITY_PERIOD = 7 days and CONVERT_INTERVAL = 1 minutes). cd contracts; forge test reports 37 tests in Company.t.sol against the README's 31 on line 73.","severity":"info","snippet":"- **Fixed routes.** The conversion pools can't be changed after deployment. If liquidity leaves them, conversion slows or stops, and the 30-day release applies.","title":"README still describes the removed 30-day release (finding 5) and an outdated test count"},{"citation":"resolved","description":"Merged from audit_economics (info) and audit_math (info); confirmed. After fix 4, CompanyToken._transfer (lines 338-341) records activity for the recipient only when the recipient initiated the transfer (msg.sender == to) or on its first receipt (lastActive[to] == 0). Tokens delivered by the PoolManager, which is how both routers deliver a buy (poolManager.take(cOut, d.user, out)), no longer touch an existing holder's lastActive; test_expiry_giftsAndBuysDontResetTimer_claimDoes asserts that a buy is not activity, and the contract notice and README say 'buying alone does not count: claim at least weekly'. Section 5 of the brief still describes the pre-fix behaviour as a known and accepted way to reset another wallet's timer at a 4% cost, so a reviewer or holder reading the brief expects a buy to keep a wallet's rewards alive when it does not. Fix: delete the bullet or reword it (a buy delivered to a wallet that has never held $COMPANY sets its first lastActive; a buy to an existing holder changes nothing).","line":57,"path":"AUDIT.md","reproduction":"contracts/test/Company.t.sol, test_expiry_giftsAndBuysDontResetTimer_claimDoes: alice holds and is 8 days inactive; _buy(alice, 1e18) through CompanyRouter delivers tokens from the PoolManager to alice; expiredRewardsOf(alice, 0) stays > 0 and lastActive(alice) is unchanged. test_audit4_poolManagerPingDoesNotResetTimer shows the same for a direct PoolManager delivery. Expected per AUDIT.md line 57: the timer is reset. Actual: no reset.","severity":"info","snippet":"- A buy delivered by a router to another address resets that address's expiry timer (costs the buyer 4%).","title":"AUDIT.md section 5 still lists 'a router buy resets the recipient's expiry timer' as accepted behaviour, which fix 4 made false"}],"hash":"60b43c7582e74763252818ed3bd0457e34f61bd214598d6510432001d5c10cd8","nodeId":"51b51dd4-8015-4f9f-ab4e-b6ef5f4f0d0b","outcome":"completed","summary":"The review is complete. The findings file holds five findings, all reproduced against the code, and the source tree is unchanged apart from the scratch tests.\n\n**What I did**\n\n- Ran the project suite (37 unit tests pass) and the live Robinhood Chain fork test (passes; live stock round limits read 680 to 1,667 IMD).\n- Reproduced every specialist claim with scratch Foundry tests, merged the duplicates, and verified the proposed fixes locally by patching the token temporarily: both proofs pass with the patches and the 37 project tests still pass. The patch was reverted.\n- Reviewed fixes 1 through 9 and the fallback path independently. The fallback can only be forced by making a purchase fail (an LP pulling in-range liquidity or a pushed swap crossing many ticks), which is the accepted risk in AUDIT.md and yields no value to the attacker. Solvency, the flash-borrow guard, the minimum holding, expiry and the scanner properties are intact.\n\n**Findings kept** (in `.imd-findings.json`)\n\n- **Medium, with proof.** Fix 2's `stockRoundLimit` reads the stock pool's liquidity in the same transaction, so just-in-time liquidity lifts a thin pool's limit from 0.015 IMD to 299 IMD. The round then sells the full 4 IMD share into the attacker's position and the attacker nets 3.60. Not profitable on today's mainnet depth. Merged from three specialists.\n- **Low, with proof.** A zero limit (no liquidity at the current tick) passes the full 4 IMD through instead of skipping. The v4 swap crosses the gap and fills at the next resting position's price, with no fallback to IMD. Merged from two specialists. One-line fix.\n- **Low.** The gas a claim needs jumps by about 1.07M per due stock while the gas it uses does not. A claim sent with 1.5 times an idle estimate reverts with `NotEnoughGas` one minute later, and a dust swap can arm the same state.\n- **Info.** README still references the removed 30-day release and a test count of 31 instead of 37.\n- **Info.** AUDIT.md section 5 still says a router buy resets the recipient's expiry timer, which fix 4 made false. Merged from two specialists.\n\n**Nothing new** beyond the specialists' set was substantiated. The gas finding carries no proof since it is a usability defect whose fix is a design choice.","treeHash":null,"usage":{"cachedInputTokens":1536023,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":57532,"runtime":"claude","turns":25,"wallClockMs":1067374}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a31e321b410aaa02","findings":[{"citation":"resolved","description":"_convertAll treats stockRoundLimit(a) == 0 (no liquidity at the stock pool's current tick) as 'empty pool, the swap will fail and the round falls back to IMD', and so it keeps imdIn at the full per-stock cap (4 IMD). A Uniswap v4 swap does not fail when the current tick has no liquidity. It crosses the gap to the next initialized position and fills there at whatever price that position sets. _swapExactIn sets no minimum output, and convertStock only rejects stockOut == 0. Whoever owns the next position therefore sells the whole capped round at a price of their choosing. The limit added for finding 2 is skipped in exactly the state where the pool is most exposed. This also contradicts AUDIT.md section 5, which says an LP who empties a stock pool can only turn the round into IMD and that 'holders still receive its full value in IMD'. maxConvert() handles the same reading the other way: zero liquidity in the IMD/USDG pool gives a cap of 0, so no round runs. Once the state exists, the position owner can leave its order in place and collect up to 4 IMD of that stock's reserve every CONVERT_INTERVAL (every claim() or convert() triggers a round), with no capital at risk, until the reserve (10% of holder fees per stock) is gone. On today's mainnet state every stock pool still holds dust full-range liquidity (fork reading: NVDA L=3,025,167, AMC 79,761,554,533), so the current tick never reads exactly 0 there. It reads 0 as soon as those dust positions are withdrawn, and the routes can't be changed after deployment.","line":555,"path":"contracts/src/CompanyToken.sol","reproduction":"Local suite setup (IMD/USDG 1:1 with 10,000 depth; USDG/NVDA fee 0.3%, spacing 60). alice and bob each buy for 1,000 IMD, so 6 IMD waits per stock. Warp 1 minute. (1) The NVDA pool's LP removes all its liquidity: stockRoundLimit(1) == 0. (2) Any address adds one position of L=2,000e18 at ticks [-46080,-46020] (USDG is currency0; mirrored if not), i.e. ~100x the fair NVDA price, on the side the purchase moves toward. (3) Anyone calls token.convert(). Expected: the empty pool fails over to IMD (or the round is skipped). Actual: the NVDA round spends 4 IMD -> 3.963 USDG and receives 0.0396 NVDA (fair ~3.95), and the position owner removes its position 3.962 USDG richer. Fix: never widen a round on a zero reading: `if (imdIn > poolLimit) imdIn = poolLimit;` (0 skips the stock this round, or call _fallBackToImd directly without swapping). With that one-line change the proof test passes. Consider also a minimum output per hop derived from the pre-swap price.","severity":"medium","snippet":"            if (imdIn > poolLimit) imdIn = poolLimit == 0 ? imdIn : poolLimit; // an empty pool fails below -> IMD","title":"Fix 2/5: a stock pool with zero in-range liquidity removes the per-stock limit, so the round fills at any price instead of falling back to IMD"},{"citation":"resolved","description":"stockRoundLimit(asset) sizes a stock's round from the USDG/stock pool's in-range liquidity and price, read in the same transaction as the purchase. That is the property that made finding 1 a high: anyone can add a narrow, large-L position just before convert()/claim() and remove it right after. A thin stock pool's limit (the case finding 2 was raised for) can therefore be raised back to the per-stock cap of maxConvert()/5 = 4 IMD. The round then buys stock from the attacker's narrow position at a price the attacker pushed beforehand. Fix 1's fixed 20 IMD ceiling bounds the damage to 4 IMD per stock per minute, but inside that bound the finding-2 guarantee ('a sandwich of that hop then costs more in the pool's fee than it can move the price') does not hold. Not exploitable on today's mainnet: the fork shows stock limits of 660-1,970 IMD, far above the 4 IMD round, and the ask sides hold $145k-$510k of liquidity, so pushing costs more than a round. It becomes reachable when a stock pool thins out, which is the scenario the fix was written for.","line":654,"path":"contracts/src/CompanyToken.sol","reproduction":"Local suite setup, then remove 999,990e18 of the 1,000,000e18 NVDA liquidity, as test_audit2_thinStockPoolLimitsItsRound does: stockRoundLimit(1) = 0.015 IMD and 6 IMD waits. Warp 1 minute. Attacker: (1) buys NVDA with USDG up to tick -23040 (~10x price; mirrored if USDG is currency1), (2) adds L=50,000e18 on a 180-tick range around the new tick, (3) calls token.convert(), (4) removes the position, (5) sells the NVDA back to tick 0. Expected (fix 2): the NVDA round spends <= 0.015 IMD and the sandwich doesn't pay. Actual: the round spends 4 IMD, and the attacker ends +3.48 (USDG + NVDA valued 1:1) from a 4 IMD round. Fix: don't size a round from state the caller can set in the same transaction, e.g. use a fixed per-stock ceiling set at deployment, or enforce a minimum output against a price that can't be moved in the same transaction (a TWAP or the price recorded at the previous round).","severity":"low","snippet":"        uint256 liquidity = IPoolManager(poolManager).getLiquidity(sk.toId());","title":"Fix 2: stockRoundLimit reads same-transaction liquidity, so just-in-time liquidity lifts a thin stock pool's limit back to the full round and the sandwich pays"}],"hash":"6763d399008cc8bc6836fb8a60ef86454a7424805490148c7e0f6bafd7392777","nodeId":"233e0b6e-184d-419e-95c6-c2d9aa682eac","outcome":"completed","summary":"I found two new problems, both in the stock-purchase limit added for fix 2 (one also affects fix 5's fallback). Fixes 1, 3, 4, 8 and 9 hold, and fixes 6 and 7 are documented as stated. Both findings, with failing Foundry tests, are in `.imd-findings.json`; no source files were changed.\n\n**Finding 1 (medium): an empty stock pool doesn't fall back to IMD; the purchase fills at any price.** `contracts/src/CompanyToken.sol:555` assumes that when a stock pool has no liquidity at its current price, the swap will fail and that round's IMD goes to holders as IMD. A Uniswap v4 swap doesn't fail there: it skips past the empty range and fills from the next position at whatever price that position sets. Nothing checks the output beyond \"more than zero\".\n- **Reproduction:** after the NVDA pool's liquidity is removed, someone places one position at 100× the NVDA price. `convert()` then spends the full 4 IMD on NVDA and gets 0.0396 NVDA instead of about 3.95. The position's owner collects about 3.96 USDG with no capital at risk, and can repeat this every minute until that stock's reserve is gone.\n- **What it contradicts:** AUDIT.md §5 says emptying a stock pool only turns the round into IMD \"of full value\". The IMD/USDG cap (`maxConvert`) handles the same zero reading the opposite way: no round runs.\n- **Mainnet today:** not reachable yet. Every stock pool still has dust full-range liquidity, so the reading is never exactly zero. It becomes reachable once those dust positions are withdrawn, and the routes can't be changed after deployment.\n- **Fix:** `if (imdIn > poolLimit) imdIn = poolLimit;` so a zero reading skips the stock. With that one line the proof test passes; I applied it temporarily, ran it and reverted.\n\n**Finding 2 (low): the per-stock limit can be inflated the same way finding 1 of the original audit was.** `stockRoundLimit` reads the stock pool's liquidity in the same transaction as the purchase. In the thin-pool state from `test_audit2`, the limit is 0.015 IMD. An attacker who pushes the price, adds a narrow position, converts, then unwinds lifts it to the full 4 IMD round and ends +3.48 (USDG plus NVDA counted 1:1) per round. On today's mainnet this doesn't pay: fork limits are 660–1,970 IMD and the pools hold $145k–$510k of liquidity on the side a purchase draws from.\n\n**Fix 5, can anyone force the fallback?** Only by making the pool itself unable to fill: pulling liquidity or emptying it, which you already accept (and, per finding 1, can be worse than a fallback). The 1,000,000 gas budget is safe. On the fork each stock purchase uses 242k–295k gas. The low-gas test shows a starved claim reverts with `NotEnoughGas` and moves nothing. I found no affordable way to make a purchase run out of gas.\n\n**Fix 1:** the fixed 20 IMD ceiling holds on current mainnet liquidity. The IMD/USDG pool has no full-range liquidity, but its bid side holds about 37.5k USDG. Clearing it to sandwich a round costs about 108 IMD in fees, against 20 IMD at stake.\n\n**Other properties you asked about:**\n- **Solvency of the six assets:** intact. The fallback moves IMD from the stock reserve to holders without changing the total tracked, and the solvency fuzz test passes.\n- **Flash-borrow guard:** intact; nothing distributes or converts while another caller has the PoolManager unlocked.\n- **100,000 minimum and expiry:** intact; fix 4 doesn't weaken the expiry estimate's assumption.\n- **Scanner properties:** no owner on the token, and the partial-fill revert only triggers on partial fills, so it doesn't make the token look like a honeypot.\n\n**Access control:** only the hook has an owner. It can open the pool once, change `feeRecipient` (which also redirects pending protocol fees and held expired rewards) and hand over ownership in two steps. Every other entry point is open to anyone or limited to the PoolManager or the contract itself. I found no way around these checks.\n\n**Tests run:** all 38 local tests pass and the mainnet fork test passes. The two proo","treeHash":null,"usage":{"cachedInputTokens":2365564,"inputTokens":44,"model":"claude-opus-5-5","outputTokens":59417,"runtime":"claude","turns":26,"wallClockMs":1009241}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ce319efac2b76da0","findings":[{"citation":"resolved","description":"The fix for finding 5 is sound in what it prevents: the self-call always receives exactly CONVERT_GAS, so a caller cannot starve a purchase into _fallBackToImd, and a short claim reverts without state change. Its side effect is that the gas a claim *needs* is no longer close to the gas it *uses*. For every stock with pendingConvert > 0 whose minute has elapsed, the loop demands gasleft() >= 1,065,873 at that point, although the purchase itself then consumes ~150k-300k. A claim with nothing due uses ~470k gas; the same claim one minute later has a minimum gas limit above 1.07M (and ~1.07M more per additional due stock whose predecessor used little). Wallets size the gas limit from eth_estimateGas plus a margin of 10-50%, so a claim estimated while the last round was less than a minute old, and included after the minute boundary, reverts with NotEnoughGas and the holder pays for a failed transaction. The state that arms it can also be created by anyone: when all five reserves are fully converted (pendingConvert == 0, so estimation sees no conversion at all), a dust swap through any router (1e15 wei of IMD, fee 4e13 wei) puts holder fees in the hook; the victim's claim flushes them, every pendingConvert becomes nonzero, and the claim reverts for the same reason. The attacker's cost is the 4% of the dust. No funds are at risk; the effect is failed claims and wasted gas, repeatable while the attacker keeps re-arming it. Fix (keeps the starvation protection): when gasleft() is below the headroom, skip that stock with `continue` instead of reverting. A skipped stock is neither bought nor fallen back, so a low-gas caller still cannot turn stock into IMD, and anyone can run convert() later; alternatively keep the revert but document that claim() must be sent with at least 5 x 1.1M gas plus payouts so front-ends set the limit explicitly rather than from an estimate.","line":559,"path":"contracts/src/CompanyToken.sol","reproduction":"Scratch test (contracts/test/scratch, run with forge test --match-path, removed after the review) on the repo's own setup: alice buys 1,000 IMD, bob buys 10,000 IMD (reserve stays > 0 after a round), warp 1 minute, convert(). (1) snapshot; alice's claim() now uses 470,872 gas (nothing due). (2) warp +1 minute; the same claim uses 1,189,208 gas and needs a limit > 1,065,873 at the first due stock. (3) warp +1 minute and send alice's claim with gas = 470,872 x 1.5 = 706,308: expected (from the estimate) success; actual: revert with selector NotEnoughGas. Second scenario: alice buys 1,000 IMD, bob buys 100 IMD, convert() empties all five reserves (pendingConvert == 0); a claim now uses 551,119 gas; bob swaps 1e15 wei of IMD through PoolSwapTest (third-party router); alice's claim sent with 2 x 551,119 = 1,102,238 gas reverts with NotEnoughGas. Expected: a claim whose estimate was valid seconds earlier succeeds; actual: it reverts whenever a round became due or a dust fee arrived in between.","severity":"low","snippet":"            if (gasleft() < (CONVERT_GAS * 64) / 63 + 50_000) revert NotEnoughGas();","title":"claim() minimum gas limit jumps by ~1.07M per stock the moment a round becomes due, so claims estimated a minute earlier (or before anyone's dust swap) revert with NotEnoughGas"},{"citation":"resolved","description":"Finding 5's resolution removed releaseStuckReserve and the 30-day release entirely; the contract now falls back to IMD one capped round at a time (_fallBackToImd), as AUDIT.md section 4 and the contract notice say. README.md line 127 ('the 30-day release applies') and line 82 ('blocked holders and blocked stocks, including the 30-day release') still describe the old mechanism, and line 73 says 'forge test # 31 unit, attack and fuzz tests' while the suite has 37. A reader of the public README (the document scanners and holders are pointed to) is told a reserve stuck by a drained pool is released after 30 days; in the shipped code it is instead paid out as IMD, 4 IMD per stock per minute, as soon as a purchase fails. Fix: replace both 30-day sentences with the IMD-fallback description already used in the 'IMD fallback' bullet, and update the count.","line":127,"path":"README.md","reproduction":"grep -n '30-day' README.md returns lines 82 and 127; grep -rn 'releaseStuck\\|30 days' contracts/src returns nothing (the only time constants in CompanyToken.sol are INACTIVITY_PERIOD = 7 days and CONVERT_INTERVAL = 1 minutes). forge test reports 37 tests in Company.t.sol against the README's 31.","severity":"info","snippet":"- **Fixed routes.** The conversion pools can't be changed after deployment. If liquidity leaves them, conversion slows or stops, and the 30-day release applies.","title":"README still describes the removed 30-day release (finding 5) and an outdated test count"}],"hash":"b49cb2326f02f9699dd5dc0dac0059616c312b7e31df5b6f35487ed6c3c19a06","nodeId":"3df58282-4b19-496e-86be-22fd771fc9e2","outcome":"completed","summary":"Review complete. The findings file is at `.imd-findings.json` in the repository root, the scratch test is removed, and the working tree is otherwise unchanged.\n\n**All nine fixes are confirmed** against the code at this commit, and the full suite passes (37 unit tests plus the fork test on live Robinhood Chain, where every stock pool's round limit sits between 599 and 1,619 IMD, far above the 4 IMD per-stock share).\n\n- **1 (JIT liquidity):** `maxConvert` is clamped to 20 IMD after the depth read, so JIT liquidity can only shrink the round, never grow it. The replay test holds.\n- **2 (stock hop):** `stockRoundLimit` prices half the fee times virtual USDG depth into IMD correctly for both currency orderings. The fixed 20 IMD ceiling bounds it anyway, so inflating the stock pool's limit gains nothing.\n- **3 (partial fills):** I traced the v4 delta math for all four branches. With a zero LP fee the specified delta equals the hook-adjusted amount exactly, so the check is tight and stays correct if a v4 protocol fee is ever set. The event clamp cannot underflow.\n- **4 (timer reset):** receipts count only for first receipt or when the recipient is `msg.sender`. Neither can be triggered for someone else.\n- **5 (fallback):** the self-call always receives exactly 1,000,000 gas, so its failure is independent of the caller's gas. An outsider can still force the fallback only by pushing a stock pool's price out of real liquidity or spamming initialized ticks behind it, which is the documented accepted case and only converts the round to IMD at the attacker's expense. The EIP-150 headroom arithmetic is right with about 48k to spare.\n- **8 and 9:** both work as described, and `recycledHeld` is included in every solvency tracker.\n\n**Solvency, the flash-borrow guard, the 100,000 minimum, expiry and scanner properties were not broken.** I specifically checked the ETH router's sell path, where the user's `receive()` runs inside the router's unlock. Every reachable entry there (`distribute`, `distributeStock`, `convert`, `claim`, `flush`) is still blocked or harmless.\n\n**Two new items reported:**\n\n- **Low:** the NotEnoughGas guard makes the required gas limit of `claim()` jump by about 1.07M per due stock while the gas actually used stays small. A claim estimated while nothing was due uses ~471k gas, and sending it with a 50% margin one minute later reverts. Anyone can arm this against a fully converted reserve with a dust swap costing 4e13 wei of IMD. No funds at risk. The suggested fix is to skip the stock when gas is short instead of reverting, which preserves the starvation protection since a skip never falls back.\n- **Info:** README lines 82 and 127 still describe the removed 30-day release, and line 73 gives an old test count.\n\nOne observation I did not report because I could not substantiate it against realistic liquidity: if the IMD/USDG pool's price were held in a dust-liquidity gap across minutes, `maxConvert` would shrink to dust and the reserve would stall rather than fall back. This requires a gap in the real LP positions and sustained price control, and it costs the attacker fees with no gain.","treeHash":null,"usage":{"cachedInputTokens":1602195,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":75413,"runtime":"claude","turns":35,"wallClockMs":1156262}}],"verification":[]}