{"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":"4d037a63-766b-42d8-810a-2ddc7bad2299","kind":"audit","nodes":[{"acceptedSubmissionHash":"223c56a689b2888e66c7d55f786340faa1a4d37acc97662dad66a48061818253","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":"9a118880f8b581ca26971c389cf133652b293b26a53d38cee4cfac708709db75","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":"30e1f10a4c65789541a5d0255158337c3d8bd9f43f69174859a35285af05444c","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":"66dc7db90542e7fa832b2c7b0e58f680c5882d5fc566b140b1c8ed62e08d00ad","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":"bea58c8f2e38f35c37f46a1642f8483bce50614776a2315b450e1a374b891fa0","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":"Final check 5 for The Zero Person Billion Dollar Company ($COMPANY) on Robinhood Chain (4663), after IMD Swarm audit 78c00339, re-check f1d5def3 and final checks 363ab052, 882666b4, 986abba2 and dddb75ec. AUDIT.md sections 4 to 9 map every 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 in that pool: 1% to the protocol, 3% to holders. Holder fees are split 50% IMD and 10% each to NVDA, GOOGL, AAPL, GME and MSTR Robinhood stock tokens, bought IMD -> USDG -> stock at the start of every claim(), each step checked against a reference price. Only wallets holding at least 100,000 earn. If a wallet goes more than 7 days without claiming, buying, selling or sending, its unclaimed rewards older than 7 days expire.\n\nChanged since dddb75ec; review these hardest:\n1. Fallback size: every IMD fallback now pays min(pendingConvert, MAX_ROUND_IMD / 5), never a swap-clipped amount; a successful purchase below a tenth of a round doesn't reset the stuck clocks.\n2. Stuck rule: failingSince[stock] is set on the first skipped or failed attempt since the last real purchase (stale feed, IMD/USDG pool unusable, PriceOff, dust purchase) and cleared by a real purchase; _skipOrFallBack pays as IMD only when waitingSince is more than DEAD_AFTER (30 days) old AND failingSince is at least a day old. Can a healthy stock still be paid as IMD early, or a broken one be kept from ever falling back?\n3. IMD/USDG pool usability: usable only with maxConvert() >= 1% of a round AND its spot within IMD_TOLERANCE_BPS (10%) of IMD's reference (IMD/ETH pool + Chainlink ETH/USD and USDG/USD); stockRoundLimit prices USDG->IMD at that reference; an IMD/USDG-side fill failure reverts PriceOff (skip), so only stock-side failures fall back at once.\n4. _swapFee: minUsdOut and minStockOut subtract the LP fee plus Uniswap's protocol fee for the swap's direction (0.1% is on for IMD/USDG and GME/USDG on Robinhood Chain).\n\nPlease confirm these, check that nothing broke the solvency of the six reward assets, the flash-borrow guards, the 100,000 minimum, expiry or the scanner-relevant properties (no external calls in transfers), and report anything new.\n\nTests: cd contracts; git submodule update --init --recursive; forge test. Fork test (live Chainlink feeds, pools and protocol fees): FORK_RPC=https://robinhood.drpc.org forge test --mc CompanyForkTest.","parentJobId":null,"planHash":"28d0e4da87e2cda5f14e20e43176fd50654a64abca3a0db710dc5e4733d7515d","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"4d037a63-766b-42d8-810a-2ddc7bad2299","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":"51162","feedbackHash":"743c64a600e77427609ff30680a69068f13a38c9ce1510b577583a01e323cacb","nodeKey":"audit_economics","submissionHash":"223c56a689b2888e66c7d55f786340faa1a4d37acc97662dad66a48061818253","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51487","feedbackHash":"27c37bb4c59e5c928b56f3657c281da526c367e153dcac7c540a7152cfefa43d","nodeKey":"audit_flow","submissionHash":"9a118880f8b581ca26971c389cf133652b293b26a53d38cee4cfac708709db75","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51473","feedbackHash":"16a47b85167ce3aef1f471752e62eb7cf9ca0047fa981433d2d1bc65b8014940","nodeKey":"audit_judge","submissionHash":"30e1f10a4c65789541a5d0255158337c3d8bd9f43f69174859a35285af05444c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50995","feedbackHash":"b497d0b2ad2e3246dec01bb4699a9f48b6f1e716759ccf33da4ebaac7667df48","nodeKey":"audit_math","submissionHash":"66dc7db90542e7fa832b2c7b0e58f680c5882d5fc566b140b1c8ed62e08d00ad","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51375","feedbackHash":"ef662458512d8594e7fa6bc6352d5972a3d1fffcbb463fbd4db42c2ae22b8196","nodeKey":"audit_permissions","submissionHash":"bea58c8f2e38f35c37f46a1642f8483bce50614776a2315b450e1a374b891fa0","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"9881c8634a0fbc3d4ebd3288de220ebacc15606b78919ab8b7420dcc25ae694a","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"771f83f312eace21","findings":[{"citation":"resolved","description":"The dddb75ec finding-1 fix says a purchase below a tenth of a round (0.4 IMD) \"doesn't count as the stock working\": convertStock sets failingSince and leaves waitingSince untouched (lines 807-814). But the only place that pays a stuck stock as IMD is _skipOrFallBack, which runs solely on skip paths (IMD/USDG pool unusable, stale feed, PriceOff). The success branch of the try at line 752 does nothing with the clocks. So a stock pool whose round limit sits between the 'empty' threshold (cap/100 = 0.04 IMD with a full cap) and the 'real purchase' threshold (0.4 IMD) answers every minute with a dust fill, failingSince stays armed, waitingSince ages past DEAD_AFTER, and the fallback never happens: the clocks are set but never read. The pool-empty case (limit < 0.04 IMD) pays a full 4 IMD round at once, while a pool only slightly less empty throttles the reserve by 10x-100x forever, the very throttle dddb75ec finding 1 rated Medium. On the live chain GME's round limit is 3.17 IMD (fork run today), so a partial withdrawal of its 1% pool (about 90%) puts it in this band; an attacker can also keep an otherwise-empty stock pool in this band with a single dust position, turning the immediate fallback into an indefinite throttle. Holders' stock share is not lost but is delayed without bound while fee inflow exceeds the dust fill rate. Fix: on a successful purchase that is dust (imdIn below MAX_ROUND_IMD/5/10) and with waitingSince older than DEAD_AFTER and failingSince at least a day old, pay fullRound as IMD (call _skipOrFallBack(a, fullRound) after the dust purchase, or treat the dust band like the empty band once the clocks are stale).","line":752,"path":"contracts/src/CompanyToken.sol","reproduction":"Unit setup as in test/Company.t.sol. Two buys of 1,000 IMD (6 IMD waiting per stock). Reduce the NVDA/USDG pool to 70e18 full-range liquidity: stockRoundLimit(1) is about 0.105 IMD (> 0.04 IMD so not 'empty', < 0.4 IMD so every purchase is dust). Warp 1 minute, convert(): NVDA buys 0.105 IMD, failingSince(1) = now. Warp 31 days, refresh feeds, convert(): another 0.105 IMD dust fill; waitingSince(1) unchanged, block.timestamp > waitingSince + DEAD_AFTER and > failingSince + 1 day. Warp 1 more day, convert(). Expected (AUDIT.md section 3.7 / dddb75ec finding 1): a full round min(pending, 4 IMD) is paid to holders as IMD. Actual: owed(0) grows by 0, pendingConvert(1) falls by only 0.105 IMD; repeating any number of times never pays a round as IMD.","severity":"medium","snippet":"            try this.convertStock{gas: CONVERT_GAS}(a, imdIn) returns (uint256 out) {\n                stockOut[a] = out;","title":"Stuck fallback never fires while a dust-liquidity stock pool keeps filling: successful dust purchases arm failingSince but no path checks the clocks"},{"citation":"resolved","description":"dddb75ec finding 2 required a stock to be 'failing for at least a day' before a quiet month can end in an IMD payout, to stop one transient skip from paying a healthy stock as IMD. The guard only compares the first recorded skip to now. failingSince is cleared solely by a real purchase, by the reserve emptying, or by a refill from empty; a quiet month clears nothing. So one skip at day 0 (a weekend-stale feed, a 5% feed/pool gap, or a sandwich that pushes the IMD/USDG spot more than 10% from the reference around someone's convert(), cost about 2 x 0.9% of the push), followed by 30 days without a claim, makes the next transient skip pay 4 IMD as IMD on the very first attempt, and every minute after while the condition lasts, although the stock was healthy for the whole month. The requester asked whether a healthy stock can still be paid early: yes, through a stale failingSince. Impact is limited (holders receive equal value in IMD, one round per minute, only after a quiet month), so Low. Fix: record the last skip as well and require continuity, e.g. restart failingSince when the previous skip is older than a day (as imdPoolLastSeenEmpty did in 882666b4 finding 6), or clear failingSince whenever convert ran successfully for the stock or whenever more than a day passed since the last skip.","line":770,"path":"contracts/src/CompanyToken.sol","reproduction":"Unit setup as in test/Company.t.sol. Two buys of 1,000 IMD. Warp 1 minute; set the NVDA feed to 0.95e8 (5% below the pool) and convert(): NVDA reverts PriceOff, is skipped, failingSince(1) = T0, pendingConvert(1) = 6 IMD. Restore the feed. Warp 31 days with no claim or convert (quiet month), refresh all feeds, then make the NVDA feed 4 days + 1 s old (a long weekend). convert(). Expected (AUDIT.md section 3.7, dddb75ec finding 2 and test_final4_2a): the first transient skip after a quiet month only arms the clock, nothing is paid as IMD, pendingConvert(1) stays 6 IMD. Actual: failingSince(1) is still T0, so _skipOrFallBack pays 4 IMD as IMD at once: owed(0) goes from 30 to 34 IMD and pendingConvert(1) drops to 2 IMD.","severity":"low","snippet":"        if (failingSince[asset] == 0) failingSince[asset] = block.timestamp;","title":"failingSince keeps the date of a single earlier skip across a quiet month, so the first transient skip after 30 days pays a healthy stock as IMD at once"}],"hash":"223c56a689b2888e66c7d55f786340faa1a4d37acc97662dad66a48061818253","nodeId":"e46db705-fddb-40e7-bc46-7e0d929d5752","outcome":"completed","summary":"Review complete. The findings are in `.imd-findings.json` at the repository root, with one Foundry proof per finding under `contracts/test/scratch/`. No contract, config or submodule file was changed.\n\n**Verdict on the four changed areas**\n\n- **Fallback size (1): confirmed.** Every fallback path receives `fullRound = min(pending, 4 IMD)`, computed before any swap clipping, and a dust purchase below 0.4 IMD leaves `waitingSince` alone. Solvency holds because the fallback only moves value from `pendingConvert` to `owed[0]`.\n- **Stuck rule (2): two gaps found**, both reproduced with failing tests. See below.\n- **IMD/USDG usability (3): confirmed.** The usable check needs a live reference, at least 0.2 IMD of round capacity, and a spot within 10% of the reference. A pushed-to-extreme empty pool is caught by the tolerance check before v4 could revert in a way that would look like a stock failure. First-hop partial fills and the first-hop price check both surface as `PriceOff`, so only the stock hop falls back at once.\n- **_swapFee (4): confirmed** against v4 source. The direction bits, the `pf + lp - pf*lp/1e6` formula, and fee-off-the-input all match `ProtocolFeeLibrary` and `SwapMath`. The fork run against the live chain passed with protocol fees on.\n\n**Findings**\n\n1. **Medium. Dust purchases never trigger the stuck fallback.** A stock pool whose round limit sits between 0.04 IMD and 0.4 IMD fills a dust amount every minute. That arms `failingSince` and freezes `waitingSince`, but only `_skipOrFallBack` reads those clocks, and it runs only on skip paths. The success branch at `contracts/src/CompanyToken.sol:752` never checks them, so the reserve is throttled 10x to 100x forever while a fully empty pool would pay a full round at once. GME's live round limit is 3.17 IMD, so a 90% LP withdrawal, or one attacker dust position in an emptied pool, lands in this band. Proof test fails with 0 IMD paid after 32 days.\n2. **Low. A stale `failingSince` pays a healthy stock early.** One skip, then a quiet month, then one transient skip pays 4 IMD as IMD on the first attempt, because `failingSince` at line 770 keeps the old date and nothing in between clears it. The dddb75ec finding-2 guard is bypassed by any single earlier skip. Proof test shows owed IMD jumping from 30 to 34.\n\n**Checked and found intact**: six-asset solvency across distribute, convert, fallback, claim, forfeit and recycle; the flash-borrow guards on claim, recycle, distribute, distributeStock and convert; the 100,000 minimum and `eligibleSupply` as the sum of weights; expiry boundaries and the from-pool tag; and transfers still make no external calls. All 66 existing unit tests and the fork test pass.","treeHash":null,"usage":{"cachedInputTokens":2366562,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":56886,"runtime":"claude","turns":37,"wallClockMs":975345}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dc34db8e17664ebd","findings":[{"citation":"resolved","description":"convertStock (lines 807-814) classifies a purchase below a tenth of a round (imdIn < MAX_ROUND_IMD/5/10 = 0.4 IMD) as 'not a working stock': it arms failingSince and leaves waitingSince untouched, exactly as the dddb75ec finding 1 fix intends and as the failingSince NatSpec ('only dust bought') and the README describe. But the only code that pays a stuck stock as IMD is _skipOrFallBack, which _convertAll reaches on three skip paths only (IMD/USDG pool unusable, stale feed, PriceOff). A round whose stock pool allows between cap/100 (0.04 IMD with a full cap) and 0.4 IMD takes the try branch at line 752, succeeds with dust, and never reaches the stuck rule. The clocks are written but never read: waitingSince ages past DEAD_AFTER, failingSince stays armed, and no round is ever paid as IMD while the dust fills keep succeeding. A pool slightly emptier (limit < 0.04 IMD) pays a full 4 IMD round at once; a pool in the band above it throttles the reserve 10x-100x indefinitely, the exact throttle dddb75ec finding 1 rated Medium. The band is reachable on the live chain: for NVDA's 0.01% pool it is 800-8,000 USDG of in-range virtual depth, for GME's 1% pool 8-80 USDG (GME's live limit is 3.11 IMD on today's fork). Anyone can also keep an otherwise-empty stock pool in the band with one small position near Chainlink's price, turning the immediate fallback into an indefinite throttle. Holders lose nothing (IMD stays backed, solvency holds) but their stock share is delayed without bound while fee inflow exceeds the dust drain. The same root cause applies when the IMD/USDG pool is shallow (maxConvert between 0.2 and 2 IMD): all five stocks fill dust forever (see the info finding on the two thresholds). Merged from audit_flow and audit_economics (same mechanism, same fix). Minimal fix, verified: in _convertAll after clipping imdIn to poolLimit, `if (imdIn < MAX_ROUND_IMD/(ASSETS-1)/10 && pending >= MAX_ROUND_IMD/(ASSETS-1)/10) { _skipOrFallBack(a, fullRound); if (lastConvert[a] == block.timestamp) continue; }` then go on to buy the dust. With this patch the attached proof passes and all 65 existing tests still pass (checked).","line":752,"path":"contracts/src/CompanyToken.sol","reproduction":"Setup as test/Company.t.sol but the NVDA/USDG pool has 70e18 of full-range liquidity (stockRoundLimit(1) = 0.105 IMD: above cap/100 = 0.04, below a tenth of a round = 0.4). alice and bob each buy 1,000 IMD: pendingConvert(1) = 6 IMD, waitingSince(1) = T0. For 29 days: warp 1 day, refresh all feeds, arbitrage the NVDA pool back to tick 0, call convert(): out[1] > 0 every day (about 0.103 NVDA), failingSince(1) = T0 + 1 day and never cleared, waitingSince(1) still T0, pendingConvert(1) = 2.95 IMD. Day 31: warp 2 days, refresh feeds, call convert(). Expected (README, failingSince NatSpec, AUDIT.md 3.7): waited more than DEAD_AFTER without a real purchase and failing for 30 days, so min(pending, 4 IMD) = 2.95 IMD is credited to holders as IMD and ConversionFailed is emitted. Actual: another 0.105 IMD dust fill, owed(0) unchanged (30 IMD), no ConversionFailed; the same on every later day. Proof test/scratch/DustStockPoolNeverFallsBack.t.sol fails on this code with \"a stuck stock's round is paid to holders as IMD: 0 <= 2000000000000000000\" and passes with the fix above.","severity":"medium","snippet":"            try this.convertStock{gas: CONVERT_GAS}(a, imdIn) returns (uint256 out) {\n                stockOut[a] = out;","title":"Dust-only stock purchases arm failingSince but the stuck rule is never evaluated on the success path, so a stock pool in the 0.04-0.4 IMD band throttles its reserve forever instead of falling back aft"},{"citation":"resolved","description":"The rule is documented as 'waited DEAD_AFTER AND been failing for at least a day, so a quiet month alone never pays a healthy stock as IMD' (failingSince NatSpec lines 222-225, README, AUDIT.md section 9 finding 2). failingSince is armed by the first skip and cleared only by a real purchase (convertStock), by the reserve emptying, or by distribute() refilling an empty reserve. The end of a transient condition clears nothing, because a quiet period produces no purchase. So the clock measures 'time since the first skip with no real purchase since', not 'continuously failing for a day'. One ordinary transient skip (a weekend-stale equity feed, a 5% gap between a stock's pool and Chainlink, the IMD/ETH vs IMD/USDG gap exceeding 10% after a large ETH-route buy, which arms all five stocks in one read) followed by 30 quiet days makes the next transient skip satisfy both conditions immediately: 4 IMD are paid as IMD at the first attempt, and every later due round pays another 4 IMD while the transient lasts. This answers request question 2: a healthy stock can still be paid as IMD early. Griefing variant: anyone can arm a stock's clock in the same transaction as a convert() by pushing its thin pool 3% off Chainlink (about $2 of fees on the live GME pool) or all five by pushing IMD/USDG 10% off its reference, but the 30 quiet days are not attacker-controllable, so this stays griefing. Impact is value-neutral for holders (IMD of equal value), hence low, matching the requester's rating of the parent finding. Merged from audit_permissions and audit_economics. Trade-off to decide (verified): the obvious fix, recording lastSkip[asset] and restarting failingSince when the previous skip is older than a window (for example 7 days), makes the attached reproduction pass but breaks five existing tests that pin the opposite expectation for a genuinely dead feed or empty pool seen once at day 5 and once at day 31 with no observation in between (test_final3_deadFeedFallsBackToImdAfter30Days, test_final3_emptyImdPoolFallsBackToImdAfter30Days, test_final2_4_unusableFeedHoldsUntilDeadAfter, test_final4_1a, test_final4_1b): without a daily keeper the contract cannot tell 'failed once, fine in between, failed again' from 'failing all month'. Either require the failing day to be observed by two skips within a window (and accept that a dead stock then needs two convert()/claim() calls a day apart after the 30 days), or keep the code and correct the NatSpec, README and AUDIT.md to the weaker semantics ('a stock skipped at least a day before and not bought since'). No proof is attached because the two legitimate resolutions lead to different outcomes.","line":770,"path":"contracts/src/CompanyToken.sol","reproduction":"Unit harness of test/Company.t.sol (all pools 1:1, feeds $1). 1) _buy(alice,1000e18); _buy(bob,1000e18): pendingConvert(1) = 6 IMD, failingSince(1) = 0. 2) warp +1 minute; feeds[0].set(0.95e8, now) (Chainlink says NVDA is 5% cheaper than its pool); convert(): NVDA reverts PriceOff and is skipped, pendingConvert(1) = 6e18, failingSince(1) = now (= T1). 3) feeds[0].set(1e8, now): the condition is over. Nobody claims or converts for 31 days. 4) warp +31 days; refresh all feeds; feeds[0].set(1e8, now - 4 days - 1) (a long weekend); convert(). Expected per the documented rule and test_final4_2a (which asserts exactly this without step 2): NVDA skipped, pendingConvert(1) == 6e18, owed(0) unchanged. Actual: _skipOrFallBack sees waitingSince T0 (> 30 days) and failingSince T1 (>= 1 day) and calls _fallBackToImd: pendingConvert(1) drops 6e18 -> 2e18 and owed(0) rises by 4e18; a convert() a minute later with the feed still stale pays the remaining 2e18. Reproduced in test/scratch/StaleFailingClock.t.sol: fails with 'healthy NVDA waits for its feed; nothing paid as IMD: 2000000000000000000 != 6000000000000000000' on this code; passes with a 7-day continuity window on failingSince (which in turn fails the five existing tests listed above).","severity":"low","snippet":"        if (failingSince[asset] == 0) failingSince[asset] = block.timestamp;","title":"failingSince never decays, so one isolated skip before a quiet month makes the first transient skip after it pay a healthy stock as IMD at once"},{"citation":"resolved","description":"_stockRoundLimit reads getLiquidity(id), the liquidity active at the pool's exact current tick, and returns 0 when it is zero. In a concentrated pool this is the state whenever the price sits in a gap between positions, which an ordinary sell produces: the swap exhausts the active position and the price lands at the seller's price limit just outside it. The token's own purchase direction (USDG -> stock) re-enters that position at once at a price below Chainlink's, so the round could be bought and would pass minStockOut by a wide margin. _convertAll instead classifies poolLimit < cap/100 as 'no liquidity at the price' and calls _fallBackToImd(a, fullRound): 4 IMD are credited to holders as IMD without a swap, and every following due round (one per minute, on every claim) does the same until some other trade moves the price back inside a position. The zero-limit shortcut predates the Chainlink check (it was added for f1d5def3 finding 2, a far resting position); with minStockOut now guarding the price, a far position is already rejected as PriceOff, so the shortcut only converts a transient, fillable state into an immediate IMD payout, contradicting AUDIT.md guarantee 7's intent that pool-state problems wait for the 30-day rule. It costs nothing to cause (one sell with a price limit, or the natural result of a trade that exhausts a range) and holders receive IMD of equal value, hence low. On the live chain the five stock pools and IMD/USDG all have several positions near their current ticks today (fork probe), so the state needs a trade that crosses the whole active range; GME's thin 1% pool (3.11 IMD round limit today) is the most exposed. Fix options, both of which change behaviour the requester's tests currently pin (test_recheck2_zeroLiquidityRoundPaidAsImd_noSwap and test_final4_dustPositionCountsAsEmpty fail with either): route poolLimit < cap/100 through _skipOrFallBack (a truly empty pool then falls back through the 30-day rule, which guarantee 7 promises for pool-state problems), or attempt the purchase and let minStockOut and Slippage decide. The requester should decide which rule they want and update AUDIT.md section 1 ('a stock pool with no liquidity at its price has the round credited as IMD without a swap') to match. No proof attached for that reason.","line":739,"path":"contracts/src/CompanyToken.sol","reproduction":"Setup as test/Company.t.sol but the NVDA/USDG pool has one position [-600, 600] of liquidity 1e24 instead of full range; alice and bob each buy 1,000 IMD (6 IMD pending per stock). Sell 200,000 NVDA into the pool with a price limit at tick 660 (-660 when NVDA is currency0): the price lands at the limit, pm.getLiquidity(pool) == 0, token.stockRoundLimit(1) == 0. In a snapshot, a direct 3.964 USDG -> NVDA swap at this moment returns more than token.minStockOut(1, 3.964e18) (the pool fills from the position at a price better than Chainlink's). Warp 1 minute, call convert(). Expected: NVDA bought, or the round skipped and retried (30-day rule). Actual: owed(0) rises from 30e18 to 34e18 and pendingConvert(1) drops from 6e18 to 2e18, the NVDA round is paid as IMD immediately, and each later minute pays another round while the price stays outside the position. Reproduced in test/scratch/OutOfRangePoolPaysImdAtOnce.t.sol: fails with 'a fillable pool must not be paid as IMD at once: 34000000000000000000 != 30000000000000000000' on this code and passes when the zero-limit branch calls _skipOrFallBack instead.","severity":"low","snippet":"            if (poolLimit < cap / 100) {\n                _fallBackToImd(a, fullRound);","title":"Zero in-range liquidity at the stock pool's current tick is treated as an empty pool and pays a full round as IMD at once, although the purchase would fill within the Chainlink tolerance"},{"citation":"resolved","description":"_stockRoundLimit sizes the round from the virtual depth at the current tick (half the fee times depth), which says nothing about how far that depth extends in the purchase direction. When the price sits inside a position but within the round's price move of its edge, with no liquidity beyond, the v4 swap runs out of liquidity, fills only part of the input, _swapExactIn reverts Slippage (second hop), and the catch branch at line 761 calls _fallBackToImd(a, fullRound) at once: a healthy pool that agrees with Chainlink and would fill a smaller purchase is paid as IMD, 4 IMD per minute, until the price moves away from the edge. The token's own purchases walk the price toward that edge. This contradicts AUDIT.md guarantee 7 (pool-state problems wait 30 days) and the request's framing that only stock-side refusals fall back at once; here nothing refused. Holders receive IMD of equal value and the state is not attacker-profitable, hence low. On the live GME/USDG pool (1% fee, tick spacing 200, round clipped to 3.11 IMD today, so a round moves its price about 1%) the next liquidity edge in the purchase direction is 643 ticks above the current tick on today's fork, so the state is not present now but one trade or a few rounds can produce it; the other four stock pools have deeper neighbouring positions. Minimal fix, verified: treat a Slippage revert from the stock hop like PriceOff, i.e. `if (reason.length >= 4 && (bytes4(reason) == PriceOff.selector || bytes4(reason) == Slippage.selector)) { _skipOrFallBack(a, fullRound); continue; }` so a pool that cannot fill the round is retried and only falls back through the 30-day rule (a token that refuses the contract still reverts with its own error and falls back at once). With this patch the attached proof passes and all 65 existing tests still pass (checked). Alternatively size imdIn to the depth available up to the next initialized tick before giving up on the round.","line":761,"path":"contracts/src/CompanyToken.sol","reproduction":"Setup as test/Company.t.sol but the NVDA/USDG pool has one position [-600, 600] with liquidity 3,000e18 (about 3,000 USDG of virtual depth; stockRoundLimit(1) = 4.5 IMD, above the 4 IMD round). alice and bob each buy 1,000 IMD. Buy NVDA with USDG up to tick -590 (590 when NVDA is currency0), 10 ticks (0.1%) before the position's edge in the purchase direction, and set the NVDA feed to the pool price (1.0608e8). In a snapshot, a direct 1 USDG -> NVDA swap returns more than token.minStockOut(1, 1e18), so a quarter-size purchase is fine. Warp 1 minute, call convert(). Expected: stock bought (smaller size) or the round skipped and retried. Actual: the 3.964 USDG swap needs a 0.26% move but only 0.1% of range remains, _swapExactIn reverts Slippage, and owed(0) rises from 30e18 to 34e18 with pendingConvert(1) = 2e18: the full round is paid as IMD at once. Proof test/scratch/NearEdgePartialFillPaysImdAtOnce.t.sol fails on this code with 'a healthy pool must not be paid as IMD at once: 34000000000000000000 != 30000000000000000000' and passes once a Slippage revert from the stock hop is skipped instead of falling back.","severity":"low","snippet":"                // The stock side failed (its token refuses this contract, its pool can't fill): pay as IMD.\n                _fallBackToImd(a, fullRound);","title":"A stock-hop swap that cannot fill the whole round because the price is near the edge of a thin position is treated as a stock failure and pays the full round as IMD at once"},{"citation":"resolved","description":"_convertAll declares the IMD/USDG pool usable when maxConvert() >= MAX_ROUND_IMD/100 (0.2 IMD) and then spends cap = maxConvert()/5 per stock, while convertStock counts a purchase as real (resetting waitingSince and failingSince) only when imdIn >= MAX_ROUND_IMD/5/10 = 0.4 IMD, a fixed number that does not scale with the round actually allowed. Whenever maxConvert() is between 0.2 and 2 IMD (IMD/USDG virtual in-range depth between 80 and 800 IMD; the live pool holds about 8,550), every round is both executed and classified as dust: failingSince is set by the first purchase, waitingSince stays at the time the reserve filled, and because the reserve refills from trading it never empties, so the clocks never clear. From then on the first transient skip after 30 days (a weekend feed gap, an IMD/ETH drift, a momentary PriceOff) pays a full 4 IMD round as IMD, then 4 IMD per due round while the skip lasts, for stocks whose own pools are fine; and (per the medium finding above) with no skip the stuck rule never fires at all, so the dust state persists. Whether that is a defect depends on the rule the requester wants: the task statement says a purchase below a tenth of a round must not count, and an IMD/USDG pool that shallow is far below the ~2,200 IMD the sandwich argument needs, so paying as IMD after 30 days is defensible; but then the pool should not be called usable at 1% of a round. The two thresholds should agree: either require maxConvert() >= MAX_ROUND_IMD/10 for usability (every unclipped purchase is then at least 0.4 IMD, and a dust IMD/USDG position still counts as empty), or judge a real purchase against the round actually allowed (imdIn >= cap/10) with an absolute floor that keeps the 882666b4 finding 3 protection. Reported as info: value-neutral for holders and a consistency decision rather than a bug on its own.","line":721,"path":"contracts/src/CompanyToken.sol","reproduction":"Setup as test/Company.t.sol but the IMD/USDG full-range liquidity is 400e18 instead of 10,000e18. maxConvert() == 1e18 (0.25% of 400), which is >= MAX_ROUND_IMD/100, so the pool is usable; cap = 0.2 IMD per stock. alice and bob each buy 1,000 IMD; warp 1 minute; convert(): out[1] > 0 (NVDA bought), pendingConvert(1) == 5.8e18 (0.2 IMD spent), yet failingSince(1) != 0 and waitingSince(1) is unchanged: the successful purchase counts as failing. Reproduced in test/scratch/ShallowImdPoolProbe.t.sol (passes, i.e. confirms the state). Continuing daily for 31 days and then skipping once with a 4-day-old NVDA feed pays 4 IMD as IMD (audit_math's reproduction, consistent with the finding above).","severity":"info","snippet":"            refOk && roundCap >= MAX_ROUND_IMD / 100 && _within(_imdUsdSpot(), refUsdPerImd, IMD_TOLERANCE_BPS);","title":"IMD/USDG usability (1% of a full round) and 'real purchase' (10% of a stock's round) thresholds disagree: with 80-800 IMD of IMD/USDG depth every successful purchase counts as failing"},{"citation":"resolved","description":"Since the dddb75ec finding 1 fix, every fallback (_fallBackToImd from a stock-side failure, an empty or dust stock pool, or _skipOrFallBack) pays fullRound = min(pendingConvert[a], MAX_ROUND_IMD/5) = up to 4 IMD (CompanyToken.sol lines 727-729), explicitly never a swap-clipped amount. The README's 'Known and accepted' bullet still describes the old behaviour, which understates what a deliberately failed purchase moves: on the live GME/USDG pool a successful round is clipped to 3.11 IMD (fork today) and with IMD/USDG depth below 8,000 IMD the per-stock cap drops below 4 IMD, yet a forced failure still moves the full 4 IMD. Documentation fix only: state that a failed purchase moves min(reserve, 4 IMD), independent of maxConvert() and stockRoundLimit().","line":134,"path":"README.md","reproduction":"Unit harness: _buy(alice,1000e18); _buy(bob,1000e18) (6 IMD reserved per stock). Remove the IMD/USDG full-range liquidity and re-add 4,000e18 so maxConvert() = 10 IMD and the per-stock cap = 2 IMD; block GME for the token contract (stocks[3].setBlocked(address(token), true)); warp +1 minute; convert(). The README wording predicts 2 IMD moving to IMD for GME; actual: pendingConvert(4) goes 6e18 -> 2e18 and owed(0) rises by 4e18 (fullRound), as test_final4_1b also shows for a dust stock pool ('4 IMD paid, not the 0.045 the pool allows').","severity":"info","snippet":"- **IMD fallback.** Only one round's capped amount moves to IMD per failed purchase. Someone able to make a purchase fail on purpose (for example, a liquidity provider who pulls liquidity from a stock's pool in the same transaction) can turn that round's stock share into IMD. Holders still receive the full value, in IMD.","title":"README still says a failed purchase moves only 'one round's capped amount' to IMD; the code moves min(pending, 4 IMD) regardless of the cap or pool limit"},{"citation":"resolved","description":"_swapFee reads protocolFee from slot0 and picks the direction's 12 bits (lower for 0->1, upper for 1->0), then combines it with the LP fee as v4 does (pf + lp - pf*lp/1e6), so minUsdOut and minStockOut subtract the fee actually charged whatever the pool: request item 4 is confirmed correct. The comment is incomplete, though: on a fork of Robinhood Chain today the protocol fee is on for every route pool, not only the two named: IMD/USDG 0.1% each way, GME/USDG 0.1%, NVDA/USDG 0.0025%, GOOGL/USDG 0.05%, AAPL/USDG 0.05%, MSTR/USDG 0.04%. Nothing to fix in code; the NatSpec and README ('0.1% where it is on') should say the fee is read per pool and direction and is on for all six.","line":933,"path":"contracts/src/CompanyToken.sol","reproduction":"On a fork (FORK_RPC=https://robinhood.drpc.org) read getSlot0 of each pool returned by token.routeOf(1..5): protocolFee raw 4097000 (0x3E8|0x3E8<<12: 1000 ppm each way) for IMD/USDG and GME/USDG, 102425 (25 each way) for NVDA, 2048500 (500) for GOOGL and AAPL, 1638800 (400) for MSTR; test_final4_4 covers the arithmetic. Probe test/scratch/ForkProbe.t.sol logs these values.","severity":"info","snippet":"    ///      it is switched on (0.1% on IMD/USDG and GME/USDG on Robinhood Chain; audit dddb75ec, finding 4).","title":"_swapFee NatSpec says Uniswap's protocol fee is on only for IMD/USDG and GME/USDG; on the live chain all six route pools have it on (code reads it correctly)"}],"hash":"30e1f10a4c65789541a5d0255158337c3d8bd9f43f69174859a35285af05444c","nodeId":"fe6d1492-fcfb-4cf3-a461-61a1100add1d","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds seven findings, every one reproduced against the code at the pinned commit; the contracts are unchanged (only my `test/scratch/` directory is untracked).\n\n**What I confirmed from the four changes**\n\n- Fallback size: every fallback pays `min(pending, 4 IMD)` and a dust purchase leaves the clocks alone. Correct as described.\n- IMD/USDG usability and the `PriceOff` routing of first-hop failures: correct as described.\n- `_swapFee`: reads the protocol fee per pool and direction from slot0 and combines it as Uniswap does. The fork shows the protocol fee is on for all six route pools, not only the two named in the NatSpec.\n- Solvency of the six assets, the flash-borrow guards, the 100,000 minimum, expiry, and no external calls in transfers all hold. The existing 65 unit tests and the fork test pass.\n\n**Findings, after merging duplicates**\n\n1. **Medium.** Dust-only stock purchases arm `failingSince`, but the stuck rule is only evaluated on skip paths. A stock pool whose round limit sits between 0.04 and 0.4 IMD throttles its reserve forever and never falls back. Merged from the flow and economics specialists. Proof attached; the minimal fix keeps all 65 existing tests green.\n2. **Low.** `failingSince` never decays, so one isolated skip before a quiet month makes the first transient skip after it pay a healthy stock as IMD at once. Merged from the permissions and economics specialists. No proof attached: the continuity fix breaks five existing tests that pin the dead-feed case, so the requester must choose between stricter semantics and corrected documentation.\n3. **Low.** Zero in-range liquidity at the stock pool's tick pays a full round as IMD at once, although the purchase would fill within tolerance. Either fix breaks two existing tests that pin the current rule, so no proof.\n4. **Low.** A near-edge partial fill reverts `Slippage` and is treated as a stock failure. Proof attached; treating that `Slippage` like `PriceOff` keeps the suite green. Not present on the live GME pool today (next edge 643 ticks away).\n5. **Info.** The IMD/USDG usability threshold and the \"real purchase\" threshold disagree, so a pool with 80 to 800 IMD of depth is usable yet every round it allows counts as failing.\n6. **Info.** README line 134 still describes the old \"capped amount\" fallback.\n7. **Info.** The `_swapFee` NatSpec lists only two pools with the protocol fee on; the live chain has it on for all six.\n\n**Dropped or adjusted:** nothing was dropped outright. The economics specialist's medium rating was kept for finding 1; the math specialist's shallow-pool finding was downgraded to info because its \"expected\" outcome contradicts the requester's own dust rule.\n\nOne note for anyone writing further tests here: with `via_ir = true`, a `block.timestamp` cached in a local is re-materialised across `vm.warp`, so read clocks back from the contract instead.","treeHash":null,"usage":{"cachedInputTokens":4485572,"inputTokens":804,"model":"claude-fable-5-1","outputTokens":66472,"runtime":"claude","turns":40,"wallClockMs":2210206}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b5e3297a04468fd3","findings":[{"citation":"resolved","description":"_stockRoundLimit reads getLiquidity(id), the liquidity active at the pool's exact current tick. In a concentrated pool this is zero whenever the price sits outside every position, which an ordinary sell produces: the swap exhausts the active position and the price lands at the seller's price limit below it. The token's own purchase direction (USDG -> stock) re-enters that position immediately, at a price below Chainlink's, so the round could be bought and would pass minStockOut by a wide margin. _convertAll instead classifies poolLimit == 0 as 'no liquidity at the price' and calls _fallBackToImd(a, fullRound): 4 IMD are credited to holders as IMD at once, and every following round (one per minute, on every claim) does the same until some other trade moves the price back into a position. This bypasses the stated rule (AUDIT.md guarantee 7) that pool-state problems fall back only after 30 days without a real purchase and a day of failing, and it costs nothing to cause: one sell with a price limit, or the natural result of any trade that exhausts a range. The zero-limit shortcut predates the Chainlink check (f1d5def3 finding 2, which it was added for); with minStockOut now guarding the price, a far resting position is already rejected as PriceOff, so the shortcut only converts a transient, fillable state into an immediate IMD payout. Suggested fix: when poolLimit < cap / 100, call _skipOrFallBack instead of _fallBackToImd (a truly empty pool then falls back through the 30-day stuck rule, which is what guarantee 7 promises), or attempt the purchase and let minStockOut decide. GME's live pool (stockRoundLimit 3.17 IMD on the fork, i.e. roughly 634 IMD of virtual depth in range) is thin enough for ordinary trades to leave its price outside the active range.","line":739,"path":"contracts/src/CompanyToken.sol","reproduction":"Setup as Company.t.sol but the NVDA/USDG pool has one position [-600, 600] (liquidity 1e24) instead of full range; alice and bob each buy 1,000 IMD (6 IMD pending per stock). Sell 200,000 NVDA into the pool with a price limit at tick 660 (or -660 when NVDA is currency0): the price lands at the limit, pm.getLiquidity(pool) == 0, token.stockRoundLimit(1) == 0. A direct USDG -> NVDA swap of 3.96 USDG at this moment returns 4.192e18 NVDA against token.minStockOut(1, 3.96e18) = 3.829e18, so the round would pass the oracle check. Warp 1 minute, call convert(). Expected: NVDA bought, or the round skipped and retried (30-day rule). Actual: owed[0] rises from 30e18 to 34e18 and pendingConvert[1] drops from 6e18 to 2e18: the NVDA round is paid as IMD immediately, and each later minute pays another 4 IMD while the price stays outside the position. Proof test: contracts/test/scratch/ProofOutOfRange.t.sol fails on the current code with '34000000000000000000 != 30000000000000000000' and passes once the zero-limit branch skips instead of falling back.","severity":"low","snippet":"            if (poolLimit < cap / 100) {\n                _fallBackToImd(a, fullRound);","title":"Zero in-range liquidity at the stock pool's current tick is treated as an empty pool and pays a full round as IMD at once, although the purchase would fill within the oracle tolerance"},{"citation":"resolved","description":"_stockRoundLimit sizes the round from the virtual depth at the current tick (half the fee times depth), which says nothing about how far that depth extends in the direction the purchase moves. When the price sits inside a position but within the round's price move of its upper edge, with no liquidity beyond, _swapExactIn gets a partial fill, reverts Slippage, and the catch branch calls _fallBackToImd(a, fullRound) immediately: a healthy pool that would fill a smaller purchase at a price Chainlink agrees with is paid as IMD, 4 IMD per minute, until the price moves away from the edge. On the live GME/USDG pool (1% fee, tick spacing 200, stockRoundLimit 3.17 IMD on the fork) a round moves the price about 0.5%, so the top quarter of a one-spacing-wide position is in this state, and the token's own purchases walk the price upward toward it. The behaviour contradicts guarantee 7 (pool-state problems wait 30 days) and the request's framing that only stock-side refusals fall back at once. Suggested fix: treat Slippage from the stock hop like PriceOff (route it through _skipOrFallBack), or size imdIn to the depth available up to the next initialized tick (e.g. quote the swap, or retry once with a smaller amount) before giving up on the round.","line":761,"path":"contracts/src/CompanyToken.sol","reproduction":"Setup as Company.t.sol but the NVDA/USDG pool has one position [-600, 600] with liquidity 3,000e18 (about 3,000 USDG of virtual depth; stockRoundLimit(1) = 4.63 IMD, above the 4 IMD round). alice and bob each buy 1,000 IMD. Buy NVDA with USDG up to tick 590 (10 ticks below the position's upper edge at 600; mirrored when NVDA is currency0) and set the NVDA feed to the pool price (1.0608e8, i.e. e^0.059). A direct 1 USDG -> NVDA swap returns more than token.minStockOut(1, 1e18), so a quarter-size purchase is fine. Warp 1 minute, call convert(). Expected: stock bought (smaller size) or the round skipped and retried. Actual: the 3.96 USDG swap needs a 0.13% move but only 0.1% remains before liquidity ends, _swapExactIn reverts Slippage, and owed[0] rises from 30e18 to 34e18 with pendingConvert[1] = 2e18: the full round is paid as IMD at once. Proof test: contracts/test/scratch/ProofNearEdge.t.sol fails on the current code with '34000000000000000000 != 30000000000000000000' and passes once a Slippage revert from the stock hop is skipped instead of falling back.","severity":"low","snippet":"                // The stock side failed (its token refuses this contract, its pool can't fill): pay as IMD.\n                _fallBackToImd(a, fullRound);","title":"A stock-hop swap that cannot fill the whole round because the price is near the top of a thin position is treated as a stock failure and pays the full round as IMD at once"},{"citation":"resolved","description":"_convertAll declares the IMD/USDG pool usable when maxConvert() >= MAX_ROUND_IMD / 100 (0.2 IMD) and then spends cap = maxConvert() / 5 per stock, while convertStock only counts a purchase as real, resetting waitingSince and failingSince, when imdIn >= MAX_ROUND_IMD / 5 / 10 = 0.4 IMD, a fixed number that does not scale with the round actually allowed. Whenever maxConvert() is between 0.2 and 2 IMD (IMD/USDG virtual in-range depth between 80 and 800 IMD; the live pool holds about 8,550), every round is both executed and classified as dust: failingSince is set by the first purchase, waitingSince stays at the time the reserve filled, and because the reserve refills from trading it never empties, so the clocks never clear. After 30 days the next transient skip plus one more day (a weekend-length feed gap, an IMD/ETH drift, a momentary PriceOff) pays a full 4 IMD round as IMD, then 4 IMD per minute on every claim while the skip condition lasts, for a stock that has been bought successfully at every round. This is the 'healthy stock paid as IMD early' case the request asks about: the stock and its pool are fine; the IMD/USDG pool is merely shallow, and the two thresholds that should agree on what 'usable' means are measured on different scales (1% of 20 IMD for the pool, 10% of 4 IMD for the purchase). Suggested fix: make the two consistent, either by requiring maxConvert() >= MAX_ROUND_IMD / 10 for usability (so every unclipped purchase is at least 0.4 IMD and a dust position still counts as empty), or by judging a real purchase against the round actually allowed (imdIn >= cap / 10) together with an absolute floor that keeps 882666b4 finding 3's dust-position protection. No proof is attached because the two fixes lead to different, both legitimate, outcomes in the reproduction below (the first makes the pool unusable and falls back after 30 days of skips; the second keeps buying and never falls back).","line":807,"path":"contracts/src/CompanyToken.sol","reproduction":"Setup as Company.t.sol but the IMD/USDG full-range liquidity is 400e18 instead of 10,000e18 (maxConvert() == 1e18, cap == 0.2 IMD per stock, the pool is usable). alice and bob each buy 1,000 IMD. For 31 days: warp 1 day, refresh all feeds, arbitrage the IMD/USDG spot back to tick 0, call convert() (NVDA is bought every day: out[1] > 0, 6.12 NVDA in total), then bob buys 100 IMD to keep the reserve above the round. After day 31: failingSince(1) != 0 although NVDA was bought every day; waitingSince(1) is still the day the reserve first filled. Day 32: set the NVDA feed 4 days + 1 s old and call convert() (skip, no payment). Day 33: same stale feed, call convert(). Expected: nothing paid as IMD, the stock was never stuck (bought 31 rounds in a row). Actual: owed[0] rises by exactly 4e18 and pendingConvert[1] falls by 4e18: the full round is paid as IMD, and every following minute with the feed stale pays another 4 IMD. Reproduced in contracts/test/scratch/Probe.t.sol test_probeC_shallowImdPool_dustRoundsThenFallback (logs 'NVDA bought over 31 days: 6122696097871348249' and 'fallback paid as IMD: 4000000000000000000').","severity":"low","snippet":"        } else if (imdIn >= MAX_ROUND_IMD / (ASSETS - 1) / 10) {","title":"IMD/USDG usability (1% of a full round) and 'real purchase' (10% of a stock's full round) thresholds disagree, so with 80 to 800 IMD of virtual depth every successful purchase counts as failing and a "}],"hash":"66dc7db90542e7fa832b2c7b0e58f680c5882d5fc566b140b1c8ed62e08d00ad","nodeId":"807c8504-89e1-4409-a1f2-63ec7a2b86ed","outcome":"completed","summary":"Review complete. The findings file is written and verified against the tree.\n\n**Outcome.** The arithmetic in the changed code holds up. I found no critical, high or medium defects. Three low findings are recorded in `.imd-findings.json`, two with self-contained Foundry proofs that fail on the current code and pass on a patched copy.\n\n**The four changed items, confirmed:**\n- **Fallback size:** every fallback path passes `min(pendingConvert, 4 IMD)`, never a swap-clipped amount, and the subtraction cannot underflow. A purchase below 0.4 IMD leaves `waitingSince` untouched and arms `failingSince`.\n- **Stuck rule:** a broken stock cannot be kept from falling back. The only ways to clear the clocks are a real purchase or an emptied reserve, neither of which a third party can produce for a stock that is actually broken. A healthy stock can still be paid as IMD early, though, through the three paths below.\n- **IMD/USDG usability:** the 1% depth floor, the 10% spot-versus-reference band and the reference-priced `stockRoundLimit` are implemented as described. A first-hop fill failure reverts `PriceOff` and skips. Scale checks pass: at the live price of about 9.6 USDG per IMD, a 4 IMD round is about 38 USDG, so 6-decimal truncation is negligible.\n- **`_swapFee`:** the direction bits and the combined formula match v4's `ProtocolFeeLibrary.calculateSwapFee` exactly.\n\n**Nothing else broke.** Solvency reasoning holds for all six assets (`owed` only ever exceeds the sum of withdrawable amounts by rounding dust). The flash-borrow guards are consistent, including the ETH router's native transfer callback window. The 100,000 minimum, expiry boundaries and the no-external-calls property of `_transfer` are intact. All 66 unit tests and the live fork test pass.\n\n**Findings (all low, in `contracts/src/CompanyToken.sol`):**\n1. **Line 739.** When the stock pool's price sits just outside its only position after an ordinary sell, in-range liquidity reads zero and the round is paid as IMD at once, although a purchase in the token's direction would fill at a price better than Chainlink. Proof: `test/scratch/ProofOutOfRange.t.sol`.\n2. **Line 761.** When the price is near the top of a thin position, the full round cannot fill, `Slippage` is treated as a stock-side failure, and 4 IMD are paid as IMD at once, although a smaller purchase passes the oracle check. Proof: `test/scratch/ProofNearEdge.t.sol`.\n3. **Line 807.** The pool-usability floor is 1% of a full round but a \"real purchase\" needs 10% of a stock's full round, so with IMD/USDG depth between 80 and 800 IMD every successful daily purchase counts as failing, and after 30 days a two-day feed gap pays a stock that was bought every round as IMD. No proof attached since the two reasonable fixes lead to different legitimate outcomes.\n\nThe repository itself is unchanged. Only `.imd-findings.json` at the root and the scratch tests under `contracts/test/scratch/` were added.","treeHash":null,"usage":{"cachedInputTokens":4170777,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":102367,"runtime":"claude","turns":43,"wallClockMs":1497133}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9e51ef2afd7c2af8","findings":[{"citation":"resolved","description":"CompanyToken.convertStock treats a purchase below a tenth of a round (imdIn < 0.4 IMD) as 'not a working stock' and arms failingSince (the failingSince NatSpec at lines 222-225 and README 'How it works' list 'only dust bought' as a failing reason, and say a stock that has waited DEAD_AFTER and been failing for a day is paid as IMD). But the stuck rule lives only in _skipOrFallBack, which _convertAll calls on the three skip paths (IMD/USDG pool unusable, stale feed, PriceOff). A round that succeeds with dust takes the try branch and never reaches it. So while the stock pool's stockRoundLimit stays between 1% and 10% of a round (0.04-0.4 IMD; for live NVDA at 0.01% fee that is 800-8,000 USDG of in-range virtual depth), with fresh feeds and a price within 3% of Chainlink (arbitrage keeps it there), every round buys at most 0.4 IMD of stock, failingSince stays armed and waitingSince never moves, yet no round is ever paid as IMD. The reserve grows by 0.3% of volume per day and is drained at <= 0.4 IMD per minute of convert() calls; nothing is lost (IMD stays backed, solvency holds), but the fallback that DEAD_AFTER exists for never triggers for this failure class, so the design's own 'dust = not working' rule is inert on the success path. The same root cause applies when the IMD/USDG pool is thin (maxConvert between 0.2 and 2 IMD): all five stocks buy dust forever. The existing tests cover the dust rule only via a stale feed (test_final4_1b), which is a skip path. Minimal fix (verified: proof passes, existing 65 tests pass): in _convertAll, after clipping imdIn to poolLimit, if imdIn < MAX_ROUND_IMD/(ASSETS-1)/10 && pending >= MAX_ROUND_IMD/(ASSETS-1)/10 then call _skipOrFallBack(a, fullRound) and `continue` when it fell back (lastConvert[a] == block.timestamp); otherwise go on to buy the dust. This keeps the agreed rule (30 days waiting AND a day failing) and only adds the missing evaluation point.","line":811,"path":"contracts/src/CompanyToken.sol","reproduction":"State: NVDA/USDG pool with fee 100, tick spacing 1, 2,000e18 liquidity at the Chainlink price (stockRoundLimit = 0.1 IMD, between cap/100 = 0.04 IMD and a tenth of a round = 0.4 IMD); all feeds fresh at $1; IMD/USDG and IMD/ETH pools deep and at 1:1. Two 1,000 IMD buys reserve 6 IMD per stock (waitingSince = T0). Call convert() once a day for 30 days with fresh feeds: each round buys ~0.1 IMD of NVDA (out[1] > 0, never skipped), failingSince[1] is set on day 1 and never cleared, waitingSince[1] stays T0, pendingConvert[1] ~ 3 IMD. On day 31 (waitingSince 31 days old > DEAD_AFTER, failingSince 30 days old >= 1 day) call convert(). Expected per the stuck rule: ConversionFailed(1, 3e18 or 4e18), owed[0] grows by a full round, pendingConvert[1] drops by it. Actual: another 0.1 IMD dust purchase, owed[0] unchanged (30e18), no ConversionFailed; the same every day after. Proof: test/scratch/DustPoolNeverFallsBack.t.sol fails with 'a stuck stock's round is paid to holders as IMD: 30000000000000000000 < 31000000000000000000' on this code and passes with the fix above.","severity":"low","snippet":"        } else if (failingSince[asset] == 0) {\n            // only dust could be bought: that's not a working stock (audit dddb75ec, finding 1)\n            failingSince[asset] = block.timestamp;\n        }","title":"Dust-only stock pool arms failingSince but never falls back: the stuck rule is only evaluated on skip paths"}],"hash":"9a118880f8b581ca26971c389cf133652b293b26a53d38cee4cfac708709db75","nodeId":"7d360709-5051-4d95-8941-92d68d977879","outcome":"completed","summary":"Review complete. One low finding is recorded in `.imd-findings.json` with a proof test under `contracts/test/scratch/`; the contract sources are untouched.\n\n**Finding (low): a dust-only stock pool arms the failing clock but never falls back.** `convertStock` marks a purchase under a tenth of a round as \"not a working stock\" and sets `failingSince`, and the NatSpec and README both list \"only dust bought\" as a reason a stock waiting 30 days is paid as IMD. But the stuck rule is only evaluated in `_skipOrFallBack`, which runs on the three skip paths (pool unusable, stale feed, PriceOff). A round that succeeds with dust takes the try branch and never reaches it. With a stock pool whose round limit sits between 1% and 10% of a round, fresh feeds and an arbitraged price, every round buys dust forever and no round is ever paid as IMD. The same holds for all five stocks when the IMD/USDG pool is thin. Nothing is lost and solvency holds, so the reserve is slow rather than stuck, hence low. The proof test fails on this code and passes under a four-line fix in `_convertAll` that evaluates the stuck rule before buying dust, with the existing 65 tests still green. The current unit test for the dust rule only exercises it through a stale feed, which is a skip path.\n\n**The four changed areas are confirmed.**\n- **Fallback size:** every fallback pays `min(pending, 4 IMD)` on all three paths, never the clipped `imdIn`; a successful purchase under 0.4 IMD leaves `waitingSince` alone.\n- **Stuck rule:** a healthy stock cannot be paid early. Its `waitingSince` moves on every real purchase, so it needs a full quiet month plus two skips a day apart with no purchase between. The only way a broken stock avoids the fallback is the dust case above.\n- **Pool usability:** the 1% depth test, the 10% spot-versus-reference test and the reference-priced `stockRoundLimit` are correct for both currency orders. First-hop partial fills and `minUsdOut` both surface as PriceOff, so only stock-side failures fall back at once.\n- **Swap fee:** `_swapFee` matches v4's direction encoding and `calculateSwapFee`. On the live chain the protocol fee is 0.1% both ways on IMD/USDG, GME/USDG and IMD/ETH, and smaller non-zero values on the other four pools, all read dynamically.\n\n**Nothing else broke.** Transfers still make no external calls. The six-asset solvency accounting, the mid-unlock refusals in claim, recycle, distribute and convert, the from-pool tag, the 100,000 minimum and the expiry boundaries are unchanged since dddb75ec and re-checked. The full suite passes, and the fork test passes on live Robinhood Chain state.\n\n**Accepted risks noted, not reported:** anyone can force one immediate full-round fallback by parking a stock pool's price in a liquidity gap, which the audit brief already accepts, and a claim sized just above the gas threshold can revert if the catch handler runs short, which only hurts the caller.","treeHash":null,"usage":{"cachedInputTokens":2711598,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":66717,"runtime":"claude","turns":40,"wallClockMs":1081863}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"93c37f17670e4d98","findings":[{"citation":"resolved","description":"The stuck rule is meant to pay a stock as IMD only when it has waited 30 days without a real purchase AND 'has been failing for at least a day' (README, AUDIT.md section 3 item 7, the failingSince NatSpec: 'a quiet month alone never pays a healthy stock as IMD'). failingSince is armed by the first skip and is only ever cleared by a successful purchase (convertStock), by the reserve emptying, or by distribute() refilling an empty reserve. Nothing clears it when the transient condition ends, because a quiet period produces no purchase. So the clock measures 'time since the first skip with no success since', not 'continuous failing for a day'. Any single transient skip during a quiet month (a stale equity feed on a long weekend, a weekend drift of MSTR or GME beyond the 3% Chainlink tolerance, the IMD/ETH vs IMD/USDG gap exceeding 10% right after a large ETH-route buy, which arms all five stocks in one read) leaves failingSince armed for weeks. The first skip after waitingSince passes 30 days then satisfies 'failingSince + 1 days' immediately and pays a full 4 IMD round, and every following due round during that transient pays another 4 IMD. This is the dddb75ec finding 2 scenario with one extra, very ordinary precondition, and it is the regression of the 882666b4 finding 6 property (emptiness had to be re-confirmed within a day) that the unified W/F rule dropped. It answers the request's question 2: a healthy stock can still be paid as IMD early. Impact is limited (holders receive IMD of equal value, 4 IMD per stock per minute while the transient lasts), hence low, matching the severity the requester gave the parent finding. Griefing variant: anyone can arm a stock's clock on purpose in the same transaction as convert() by pushing a thin stock pool 3% off Chainlink (about $2 of fees on the live GME pool) or all five by pushing IMD/USDG 10% (about 8 IMD of fees), but the 30 quiet days are not attacker-controllable, so this stays griefing. Minimal fix that keeps 'no keeper needed': record lastSkip[asset] in _skipOrFallBack and restart failingSince when the previous skip is older than a window (for example `if (failingSince[asset] == 0 || block.timestamp > lastSkip[asset] + 7 days) failingSince[asset] = block.timestamp; lastSkip[asset] = block.timestamp;`), so the failing day must be observed by at least two skips a day apart within the window. Trade-off to decide: a genuinely broken stock then needs two convert() or claim() calls at least a day apart within the window after the 30 days, instead of one; alternatively document the weaker semantics in README and AUDIT.md.","line":770,"path":"contracts/src/CompanyToken.sol","reproduction":"Unit harness of Company.t.sol (all pools 1:1, feeds $1). 1) _buy(alice,1000e18); _buy(bob,1000e18): each stock's reserve pendingConvert = 6 IMD, waitingSince = T0, failingSince = 0. 2) warp T0+5d; refresh all feeds except NVDA; set NVDA feed updatedAt = now-4d-1 (one stale read); token.convert(): NVDA skipped, pendingConvert(1) stays 6e18, failingSince(1) = T0+5d. 3) Set NVDA feed fresh again (updatedAt = now); nobody claims for 26 days (quiet month). 4) warp T0+31d; refresh the other feeds; NVDA feed happens to be stale again (updatedAt = now-4d-1); token.convert(). Expected per the documented rule ('failing for at least a day', 'a quiet month alone never pays'): NVDA skipped, pendingConvert(1) == 6e18, owed(0) unchanged (this is exactly what test_final4_2a asserts when step 2 is omitted). Actual: _skipOrFallBack sees waitingSince T0 (> 30 days) and failingSince T0+5d (>= 1 day) and calls _fallBackToImd: pendingConvert(1) drops 6e18 -> 2e18 and owed(0) rises by 4e18 in the first call; a convert() one minute later pays the remaining 2e18 (reserve 0). Executed locally as test/scratch probe: 'pendingConvert NVDA after: 2000000000000000000, IMD credited: 4000000000000000000, pendingConvert NVDA after 2nd: 0'. Same result if step 2 is replaced by _pushImdEth() (IMD/ETH 12% off) which arms failingSince for all five stocks in one read.","severity":"low","snippet":"        if (failingSince[asset] == 0) failingSince[asset] = block.timestamp;\n        uint256 waiting = waitingSince[asset];\n        if (waiting != 0 && block.timestamp > waiting + DEAD_AFTER && block.timestamp >= failingSince[asset] + 1 days) {\n            _fallBackToImd(asset, amount);\n        }","title":"failingSince never decays: one isolated skip before a quiet month makes the first skip after it pay a healthy stock as IMD at once"},{"citation":"resolved","description":"Since the dddb75ec finding 1 fix, every fallback (_fallBackToImd from a stock-side failure, an empty or dust stock pool, or _skipOrFallBack) pays fullRound = min(pendingConvert[a], MAX_ROUND_IMD / 5) = up to 4 IMD, explicitly 'never a swap-clipped amount' (CompanyToken.sol lines 727-729). The README's 'Known and accepted' bullet still describes the old behaviour ('one round's capped amount'), which understates what a deliberately failed purchase moves: on the live GME/USDG pool a successful round is clipped to about 3.16 IMD (fork test stockRoundLimit(4) = 3.16e18) and with the IMD/USDG depth below 8,000 IMD the per-stock cap drops below 4 IMD, yet a forced failure still moves the full 4 IMD. Only a documentation fix is needed: state that a failed purchase moves min(reserve, 4 IMD), independent of maxConvert() and stockRoundLimit().","line":134,"path":"README.md","reproduction":"Unit harness: _buy(alice,1000e18); _buy(bob,1000e18) (6 IMD reserved per stock). Remove the IMD/USDG full-range liquidity and re-add 4,000e18 so maxConvert() = 10 IMD and the per-stock cap = 2 IMD; block GME for the token contract (stocks[3].setBlocked(address(token), true)); warp +1 minute; token.convert(). README wording predicts 2 IMD ('one round's capped amount') moving to IMD for GME; actual: pendingConvert(4) goes 6e18 -> 2e18 and owed(0) rises by 4e18 (fullRound), as test_final4_1b also shows for a dust stock pool (4 IMD paid 'not the 0.045 the pool allows').","severity":"info","snippet":"- **IMD fallback.** Only one round's capped amount moves to IMD per failed purchase. Someone able to make a purchase fail on purpose (for example, a liquidity provider who pulls liquidity from a stock's pool in the same transaction) can turn that round's stock share into IMD. Holders still receive the full value, in IMD.","title":"README still says a failed purchase moves only 'one round's capped amount' to IMD; the code now moves min(pending, 4 IMD) regardless of the cap or pool limit"}],"hash":"bea58c8f2e38f35c37f46a1642f8483bce50614776a2315b450e1a374b891fa0","nodeId":"66b1d127-5e6f-49c3-89aa-216b14911bd3","outcome":"completed","summary":"Review complete. The findings file is written at `.imd-findings.json` with two entries, and the scratch probe is removed.\n\n**What I confirmed on the four changed areas**\n\n- **Fallback size.** Every fallback path pays `min(pendingConvert, 4 IMD)` and never a swap-clipped amount. A purchase under 0.4 IMD arms the failing clock instead of resetting the waiting clock. Solvency holds because the amount moves from the reserve to `owed[0]` without a swap.\n- **Stuck rule.** The clock is armed on stale feed, unusable IMD/USDG pool, PriceOff and dust purchases, never by the low-gas skip, and cleared only by a real purchase, an emptied reserve or a refill from empty. A broken stock cannot be kept from falling back without making a real purchase succeed. A healthy stock can still be paid early in one case, reported below.\n- **IMD/USDG usability.** The pool is usable only with `maxConvert()` of at least 0.2 IMD and spot within 10% of the IMD/ETH plus Chainlink reference. The stock pool limit is priced at that reference, and a first-hop fill failure reverts PriceOff so only stock-side failures fall back at once. Arithmetic and decimal scaling check out against the live fork, where IMD trades near 9.6 USDG.\n- **Swap fee.** The lower 12 bits for zeroForOne and the upper 12 for oneForZero, combined as `pf + lp - pf*lp/1e6`, match v4's ProtocolFeeLibrary exactly, and the direction passed matches each hop.\n\n**Nothing broke elsewhere.** All 66 unit tests and the live fork test pass. Each stock purchase uses 277k to 328k gas on the fork against the 1,000,000 budget. The six-asset solvency identities, the mid-unlock guards on distribute, claim, recycle and convert, the transient from-pool tags, the 100,000 minimum and the expiry forfeits all trace cleanly. Transfers still make no external calls.\n\n**Findings**\n\n1. **Low, `_skipOrFallBack`.** `failingSince` never decays. One isolated transient skip during a quiet month leaves it armed, so the first skip after the 30 days pays 4 IMD immediately, contradicting the documented \"failing for at least a day\" rule. Reproduced: a stale read on day 5, 26 quiet days, a stale read on day 31 moves 4 IMD at once and the remaining 2 IMD a minute later. An IMD pool divergence arms all five stocks in one read. Suggested fix: restart the clock when the previous skip is older than a window, with the keeper trade-off stated.\n2. **Info, README.** The \"IMD fallback\" bullet still says a failed purchase moves only \"one round's capped amount\". The code now moves the full 4 IMD regardless of the cap or pool limit.\n\nBoth findings are in the JSON with exact reproductions. No critical or high issues were found, so no proof test was attached.","treeHash":null,"usage":{"cachedInputTokens":2224457,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":82972,"runtime":"claude","turns":38,"wallClockMs":1262738}}],"verification":[]}