{"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":"363ab052-8299-402c-8111-849a10d68da1","kind":"audit","nodes":[{"acceptedSubmissionHash":"2e362c093d6777c8e479cf57c2a018eeb7d2d5df90aa22ab2fd2c839354dfc18","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":"25404ff1822ac5579e874150f941ee403c37b5fe0f3dc07eed97c981bb81445f","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":"bbea7c9424be7558f48f18c6cd2071a87b7e113d736928d39abb7b3bb4b3551e","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":"2905245a464d898d2e5531c5cbe5f70b607387777ef961f887af9a6bc8ecedc3","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":"5a4ab5e306907d88fd196e5df51da92640f24781d58353b9605adf0c0dfe0ad4","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 for The Zero Person Billion Dollar Company ($COMPANY) on Robinhood Chain (4663), after IMD Swarm audit 78c00339 and re-check f1d5def3. AUDIT.md sections 4 and 5 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: 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(). Only wallets holding at least 100,000 earn, and unclaimed rewards expire after 7 days.\n\nChanged since the re-check; review these hardest:\n1. Chainlink check on the stock hop (fix for re-check finding 1): minStockOut(asset, usdIn) uses the stock/USD and USDG/USD feeds (8 decimals, max age 4 days, 3% tolerance) and is enforced in unlockCallback, which reverts PriceOff. _convertAll skips the stock on PriceOff or a stale feed, and falls back to IMD only on other failures. Can a caller force PriceOff or the fallback, or get a purchase through at a manipulated price? Are the decimals (USDG 6, stocks 18, read at deployment) and the stale-feed handling right?\n2. GME (0x1b0E319c6A659F002271B69dB8A7df2F911c153E, the official Robinhood token) replaces AMC, which has no feed. Its v4 USDG pool (fee 1%, spacing 200, id 0x3d436b4f...063b) is thin (about $6k), so stockRoundLimit binds near 3.4 IMD per round.\n3. A zero stockRoundLimit now credits the round as IMD without swapping (re-check finding 2).\n4. Too little gas skips the stock instead of reverting (re-check finding 3).\n\nPlease confirm these, check that nothing broke the solvency of the six reward assets, the flash-borrow guard, the 100,000 minimum, expiry or the scanner-relevant properties, and report anything new.\n\nTests: cd contracts; git submodule update --init --recursive; forge test. Fork test (live feeds and pools): FORK_RPC=https://robinhood.drpc.org forge test --mc CompanyForkTest.","parentJobId":null,"planHash":"eddef4b667e1719c6fdd154ea84b1970ca8973df32d8ccfbe224b6b56a1bf2e2","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"363ab052-8299-402c-8111-849a10d68da1","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":"52262","feedbackHash":"a6a1f5529696f69116f485a55c8da64203042f08f5f51bcc333e003273d3d957","nodeKey":"audit_economics","submissionHash":"2e362c093d6777c8e479cf57c2a018eeb7d2d5df90aa22ab2fd2c839354dfc18","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51168","feedbackHash":"c2b4410a363a069f2e5024a5a23f4585187d4303ff778af9f6904b80cde8e243","nodeKey":"audit_flow","submissionHash":"25404ff1822ac5579e874150f941ee403c37b5fe0f3dc07eed97c981bb81445f","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52271","feedbackHash":"91db10fd21985472a2d8f2daf9735f4b58dc400500035ed18329a998676f7b4c","nodeKey":"audit_judge","submissionHash":"bbea7c9424be7558f48f18c6cd2071a87b7e113d736928d39abb7b3bb4b3551e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50903","feedbackHash":"560dfcb4137e271dec8cde45e4cc1f43d630b2bbce997c2ff9c20d0b6a5eab09","nodeKey":"audit_math","submissionHash":"2905245a464d898d2e5531c5cbe5f70b607387777ef961f887af9a6bc8ecedc3","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52253","feedbackHash":"2ce1637c7186001e909dbba0946e41c6187cbbe87d1cb2535e8a11a2f9921635","nodeKey":"audit_permissions","submissionHash":"5a4ab5e306907d88fd196e5df51da92640f24781d58353b9605adf0c0dfe0ad4","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"a527c42dc9e28fc428a9bf84d84d2b791ef0d15cad45e3e2b61540a1861105f5","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"6208734cdf5317a1","findings":[{"citation":"resolved","description":"`claim()` is callable while the PoolManager is unlocked by someone else: `flush` and `_convertAll` no-op in that state, but `_recycle(msg.sender)` still runs and `expiredRewardsOf` estimates the non-expiring 'recent' part as (magnifiedRewardPerShare - magAt(cutoff)) x weightOf(holder) with the holder's *current* weight (line 519). A contract holder can `pm.unlock()`, `pm.take()` the PoolManager's whole $COMPANY balance (the locked pool liquidity, hundreds of millions of tokens, free), call `claim()`, then transfer the tokens back and `settle()`. Receipts from the PoolManager do not touch `lastActive`, the borrowed weight does not change `accumulativeRewardOf` (corrections offset it), but it multiplies `recent` by (pool balance / holder balance), typically hundreds of times, so `recent` exceeds everything the holder is owed, `expiredRewardsOf` returns 0, nothing is recycled, the 7-day timer is reset and the holder is paid rewards that had expired. Victim: `feeRecipient`, which AUDIT.md guarantee 6 says receives every expired reward; guarantee 3 ('flash-borrowed pool tokens never earn ... nothing is credited while another caller has the PoolManager unlocked') is only enforced for distribute/distributeStock/convert, not for the expiry estimate used by claim. Precondition: at least one distribution of that asset in the last 7 days (always true for IMD on an active token; the attacker can also create one with a router buy in the same transaction). This is not the documented 'gift' limit of section 6: no counterparty is needed, the borrowed amount is the entire pool and the result is a payout of expired rewards, not a delay. Fix (keeps the design): refuse `claim()` and `recycle()` while `IPoolManager(poolManager).isUnlocked()` (same guard as `distribute`), e.g. `if (IPoolManager(poolManager).isUnlocked()) revert Reentrancy();` as the first line of both; routers never call claim, so nothing legitimate runs claim mid-unlock. Update `test_flashBorrowedTokens_cannotCaptureRewards` (FlashHolder mode 4 currently expects claim not to revert).","line":478,"path":"contracts/src/CompanyToken.sol","reproduction":"Unit harness (test/scratch/FlashExpiryBypass.t.sol): contract holder A buys 1 IMD of $COMPANY (>= 100,000 tokens), bob buys 50 IMD, then bob buys 2 IMD once a day for 30 days while A never claims or moves tokens. State: A is owed 0.8175 IMD of which expiredRewardsOf(A, 0) = 0.7765 IMD (older than 7 days) and 0.041 IMD is recent; the PoolManager holds > 100x A's balance. A calls pm.unlock -> take(all $COMPANY of the PoolManager) -> token.claim() -> transfer back -> settle. Expected: feeRecipient receives 0.7765 IMD and A receives 0.041 IMD. Actual: feeRecipient receives 0.0408 IMD (a rounding sliver) and A receives ~0.7767 IMD, i.e. 95% of the expired amount; A's lastActive is reset. Run: cd contracts && forge test --match-path test/scratch/FlashExpiryBypass.t.sol (fails: 'expired rewards reach the fee recipient: 40760005487273081 != 776501987370966762'). With the isUnlocked guard added to claim/recycle the same test passes.","severity":"medium","snippet":"    function claim() external nonReentrant returns (uint256[ASSETS] memory amounts) {\n        ICompanyHook(hook).flush(address(this));\n        _convertAll();","title":"claim() inside a foreign PoolManager unlock lets flash-borrowed pool tokens zero out expiry: expired rewards are paid to the holder instead of the fee recipient"},{"citation":"resolved","description":"`_convertAll` skips a stock whenever `_feedsFresh(a)` is false (answer <= 0, call reverting, or updatedAt older than MAX_ORACLE_AGE = 4 days) and never falls back to IMD on that path ('value waits, not moves'). Nothing bounds the wait. Feeds and pools are fixed at deployment, the token has no owner, and the only other exit from `pendingConvert` is the zero-liquidity fallback, which needs the stock pool to be empty at its price. So if a feed stops updating for good (Chainlink deprecating or migrating a feed, which is routine and announced, or the proxy's aggregator being retired; note the NVDA feed is already a differently named generation, 'RHNVDA / USD', than the other four 'Robinhood X / USD'), that stock's reserve is frozen: every round skips, the IMD stays in the contract counted in `_pendingTotal()` so `distribute()` never re-credits it, and every new holder fee keeps adding 10% to it. A dead USDG/USD feed freezes all five reserves (50% of holder fees). I verified the live cadence on Robinhood Chain today: USDG/USD updates every 24 h, the stock feeds resume at 00:00 UTC Monday with a worst observed gap of 3.26 days (Labor Day weekend), so 4 days is right for scheduled closures; the issue is only the unbounded case. Suggested fix within the design: after a long staleness (e.g. 30 days, far beyond any market closure) treat the feed as dead and pay that stock's rounds as IMD through the existing `_fallBackToImd` path, exactly as a stock that cannot be bought is handled; alternatively stop reserving for a stock whose feed has been dead that long. Either keeps holders whole in IMD instead of locking value permanently.","line":597,"path":"contracts/src/CompanyToken.sol","reproduction":"Unit harness (test/scratch/Extra.t.sol, test_deadFeed_freezesReserveForever): alice and bob buy 1,000 IMD each (pendingConvert(1) = 6 IMD). Warp 365 days; refresh the USDG and the four other stock feeds, leave NVDA's feed at its old updatedAt (never updated again). Run 10 rounds (1 minute apart) of convert() plus claim(). Expected (any sane outcome): the 6 IMD reach holders, as NVDA or as IMD. Actual: stockRoundLimit(1) > 0 (the pool is fine), pendingConvert(1) is still exactly 6e18 after a year and 10 rounds, no function can move it; after one more 1,000 IMD buy it is 9e18 while the other stocks converted normally. Test passes on the current code (it demonstrates the frozen state).","severity":"low","snippet":"            // A missing or stale price feed holds the stock until it updates (no fallback: value waits, not moves).\n            if (!_feedsFresh(a)) continue;","title":"Stale-feed hold is unbounded: a deprecated Chainlink feed freezes that stock's 10% reserve forever (USDG/USD: all five), with no release path and no admin"},{"citation":"resolved","description":"AUDIT.md describes the hook as owning 'the token's only Uniswap v4 pool' and blocking 'other pools and outside liquidity', and the brief says it 'takes 4% of every swap'. The hook can only refuse pools whose key names this hook (beforeInitialize / beforeAddLiquidity revert HookNotAllowed, skipped for the hook's own calls by v4's noSelfCall). The token has no transfer restriction (the scanner tests require that), so any holder can initialize a $COMPANY/IMD pool with hooks = address(0) (or any other venue) and provide liquidity; swaps there pay no protocol or holder fee, and CompanyRouter/CompanyEthRouter are not involved. This is inherent to fee-by-hook with an unrestricted token and cannot be fixed without a transfer restriction (which would break the scanner properties). Reported so the documentation claim and the fee-revenue assumptions are stated as what they are: fees are charged on the hook's pool, which holds the locked supply and is therefore the deepest venue, not on every swap of the token.","line":489,"path":"contracts/src/CompanyHook.sol","reproduction":"Unit harness (test/scratch/Extra.t.sol, test_secondaryHooklessPool_avoidsFee): alice buys 1,000 IMD of $COMPANY through CompanyRouter; the test initializes PoolKey(IMD, COMPANY, fee 3000, spacing 60, hooks 0) at the hook pool's current tick and alice adds 1e15 liquidity full range. bob swaps 100 IMD exact-in through PoolSwapTest on that key. Expected per the brief: 4 IMD of fees. Actual: bob receives $COMPANY, hook.pendingHolderFees(token) and hook.pendingProtocolFees(IMD) are unchanged. Test passes on the current code (it demonstrates the behaviour; no fix is proposed).","severity":"info","snippet":"    function beforeInitialize(address, PoolKey calldata, uint160) external pure returns (bytes4) {\n        revert HookNotAllowed();\n    }","title":"Nothing prevents a second, hookless $COMPANY pool: trades there pay no 4% fee"},{"citation":"resolved","description":"Re-check finding 2 special-cases `stockRoundLimit(a) == 0` (no liquidity at the stock pool's price): the round's IMD is credited to holders at once. The limit is proportional to the in-range liquidity (half the fee x virtual USDG depth), so a position of a few wei of liquidity kept in range (a permanent, essentially free full-range position anyone can mint; GME's pool has tick spacing 200 and about $6k of liquidity) turns the limit into dust instead of zero whenever the real liquidity is out of range. Each round then converts or falls back only `limit` wei (~1e11 wei of IMD per minute in the test), sets lastConvert and emits ConversionFailed/Converted, while the stock's reserve stays effectively unchanged: holders are not paid as IMD and no stock is bought until real liquidity returns. No value is lost or extracted (value waits, as it does for a stale feed), so this is informational; if the intent of the zero-limit rule was 'pay as IMD whenever the pool cannot take a meaningful round', a minimum limit (e.g. treat a limit below some dust threshold like 1e12 wei of IMD as zero) would make the two cases consistent.","line":592,"path":"contracts/src/CompanyToken.sol","reproduction":"Unit harness (test/scratch/Extra.t.sol, test_dustLiquidity_sidestepsZeroLimitFallback): alice and bob buy 1,000 IMD each (6 IMD pending per stock); remove all 1,000,000e18 NVDA pool liquidity (stockRoundLimit(1) == 0, the round would be paid as IMD), then add a full-range position with liquidityDelta 1000. stockRoundLimit(1) becomes > 0 but < 1e12 wei. Warp 1 minute, convert(): expected under re-check 2's rule: 4 IMD credited as IMD (or bought). Actual: owed(0) grows by at most the dust limit, pendingConvert(1) decreases by at most the dust limit, the reserve stays at ~6 IMD. Test passes on the current code (demonstration).","severity":"info","snippet":"            if (poolLimit == 0) {\n                _fallBackToImd(a, imdIn);\n                continue;\n            }","title":"Dust in-range liquidity sidesteps the zero-limit fallback: the round is neither bought nor paid as IMD"}],"hash":"25404ff1822ac5579e874150f941ee403c37b5fe0f3dc07eed97c981bb81445f","nodeId":"504ade1b-80d3-4333-bdc1-886cb820ad81","outcome":"completed","summary":"Review complete. The findings file is at `.imd-findings.json` in the repository root with four entries, and all scratch material lives under `contracts/test/scratch/`. No source, config or dependency file was changed.\n\n**What I confirmed about the four changes**\n\n- **Chainlink stock-hop check.** The math in `minStockOut` is unit-correct for USDG 6 / stocks 18 / feeds 8 decimals, which I verified live on Robinhood Chain (all six feeds report 8 decimals and sane prices, USDG is 6 decimals, every stock is 18). The `PriceOff` revert propagates intact through the PoolManager and `try`/`catch`, so a price push only skips the stock without touching state. Forcing the fallback is possible only by making a purchase genuinely fail, which section 6 of AUDIT.md already accepts. Sandwich profit is bounded by 3% of a round, at most about $1.20, below the fee cost of moving any of the five pools. Feed cadence: USDG/USD has a 24-hour heartbeat and every stock feed resumes at 00:00 UTC Monday with a worst observed gap of 3.26 days (Labor Day weekend), so the 4-day window covers scheduled closures.\n- **GME.** Pool id, fee, spacing and the live round limit (3.37 IMD) match the brief. The live fork test converts and pays all five stocks.\n- **Zero round limit and low gas.** Both behave as described. Each live stock purchase costs 250–306k gas against the 1,000,000 budget.\n- Solvency (fuzz), the flash-borrow guard on crediting, the 100,000 minimum, and the scanner properties all hold. The project's 41 unit tests and the live fork test pass.\n\n**Findings**\n\n1. **Medium: expiry can be zeroed with flash-borrowed pool tokens.** `claim()` still runs `_recycle` while someone else holds the PoolManager unlocked, and `expiredRewardsOf` uses the holder's current weight. A contract holder borrows the pool's entire $COMPANY balance inside its own unlock, claims, and returns it. In the proof the fee recipient receives 0.04 IMD where 0.78 IMD had expired; the holder keeps 95% of the expired amount and resets its timer. The proof under `test/scratch/FlashExpiryBypass.t.sol` fails on the current code and passes with an `isUnlocked()` guard on `claim` and `recycle` (validated on a scratch copy of the token).\n2. **Low: the stale-feed hold is unbounded.** A feed that stops updating for good freezes that stock's 10% reserve forever, with no release path and no admin; a dead USDG/USD feed freezes all five. Demonstrated with a one-year hold. Suggested fix: after a long staleness, route rounds through the existing IMD fallback.\n3. **Info: a hookless secondary $COMPANY/IMD pool pays no fee.** Inherent to fee-by-hook with unrestricted transfers; documented so the \"only pool, 4% of every swap\" wording is read correctly.\n4. **Info: dust in-range liquidity sidesteps the zero-limit fallback.** A few wei of liquidity turns the limit into dust instead of zero, so the round is neither bought nor paid as IMD. Value waits, nobody gains.\n\nPasses applied: execution trace, periphery and first principles in full, plus the entry-point inventory and the economic sandwich analysis. Not modelled: stock-token blocklist internals on chain and sequencer-downtime behaviour of the feeds.","treeHash":null,"usage":{"cachedInputTokens":2652777,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":86296,"runtime":"claude","turns":50,"wallClockMs":1371616}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"523ef565dd740e25","findings":[{"citation":"resolved","description":"expiredRewardsOf() estimates what an inactive wallet earned in the last 7 days as (per-share growth since the cutoff) x weightOf(holder), where weightOf reads the holder's CURRENT balance. The estimate is meant to be an upper bound in the holder's favour, but it scales linearly with a weight the holder can raise at zero cost at the moment of the claim: (a) a contract holder opens its own PoolManager unlock, takes the pool's $COMPANY (up to ~1e9 tokens) with pm.take, calls claim(), and transfers the tokens back before settle(); claim() does not refuse to run inside a foreign unlock (flush() and _convertAll() just no-op), and the borrowed tokens do not change withdrawableRewardOf (corrections), only the 'recent' estimate; (b) any holder receives a gift from an accomplice, claims, and returns it (receiving is not activity, and the sender keeps its own rewards). With most of the supply still in the pool, the multiplier is pool/own, e.g. x16 when holders own 6% of the supply, so a few IMD of trading in the last 7 days is enough to cover months of stale rewards: condition is recent_distributions >= stale_rewards x (eligibleSupply / borrowedWeight). The claim then pays the whole stale balance to the holder and resets its timer, instead of recycling the expired part to feeRecipient. This breaks guarantee 3.6 of AUDIT.md ('recycle never moves more than expiredRewardsOf' is kept, but the strict claim of section 4/`claim()`'s 'Expiry is strict' comment is not) and the 'known limit' text, which says a gift can only DELAY expiry 'to nobody's gain': combined with a claim it is a permanent bypass and the holder gains exactly what the protocol was owed. Fix: compute 'recent' from a weight that cannot be inflated at claim time, e.g. snapshot the holder's weight whenever lastActive is set and in _reweigh when the weight shrinks, and use min(snapshot, current) here (gifts after the last activity would then not count as recent, which is the conservative direction); additionally revert claim()/recycle() while IPoolManager.isUnlocked() so flash-borrowed weight can never reach this estimate.","line":519,"path":"contracts/src/CompanyToken.sol","reproduction":"Unit setup as in Company.t.sol (mock IMD/USDG/stocks, real PoolManager). 1) A contract wallet L buys 20 IMD, bob buys 20 IMD: L is credited 0.6 IMD. 2) warp 8 days: expiredRewardsOf(L,0) == 0.6e18 (all stale). 3) bob buys 2 IMD (any trade in the last 7 days): L's withdrawable = 0.6159 IMD, expired = 0.6 IMD, L's weight 59.0M tokens, eligible 116.4M, pool holds 883.6M. 4) L calls pm.unlock, inside: pm.take(COMPANY, L, 883.6M), token.claim(), transfer back, settle. Expected: fee recipient receives 0.6 IMD, L receives at most 0.0159 IMD. Actual: L receives 0.2537 IMD and the fee recipient 0.3622 IMD; a larger trigger trade (or borrowing against a smaller eligible supply) makes the bypass complete. Test: test/scratch/ExpiryFlashBypass.t.sol fails on this code with 'claimer must not get expired rewards: 253725302561290173 > 15885250024963534' and passes when claim() reverts inside an unlock (or with a weight snapshot).","severity":"medium","snippet":"        uint256 recent = FullMath.mulDivRoundingUp(magnifiedRewardPerShare[asset] - magCut, weightOf(holder), MAGNITUDE);","title":"Expiry can be dodged: a holder inflates its weight for free (flash-borrowed pool tokens or a round-trip gift) and claims rewards that had expired"},{"citation":"resolved","description":"minStockOut() requires stockOut >= 97% of the Chainlink-implied amount, and stockOut is net of the stock pool's own fee. For GME the pool fee is 1% (fee 10_000) and the pool is thin (about $6k), so the pool's effective price sits structurally above the feed. On the live fork (Robinhood Chain, 2026-10-07) the GME round received 1.198166 GME for 30.144785 USDG against a feed-implied 1.21878 GME (feed 24.735 USD, USDG/USD 1.00004): 98.31% of fair, i.e. 1.31 percentage points above the PriceOff floor, while NVDA/GOOGL/AAPL/MSTR cleared it by 2.4-3.1 points (ratios 1.0011, 0.9975, 0.9996, 0.9941). Any further 1.3% gap between the GME pool and the feed (after-hours move, a feed heartbeat lag, or a $100 trade in a $6k pool) makes every GME round revert PriceOff and skip. Because a skipped round neither converts nor falls back, pendingConvert[4] keeps receiving 10% of every holder fee with no ceiling, and when the gap closes it drains at most stockRoundLimit (3.57 IMD) per minute, credited to whoever holds at that time (accepted limit 6 of audit 78c00339), not to the holders who earned it. This is the documented 'value waits' behaviour, reported here with the measured margin so the requester can decide whether 3% is the intended tolerance for a 1%-fee pool (e.g. apply the tolerance to the pre-fee amount, or use a per-stock tolerance of fee + 2%).","line":135,"path":"contracts/src/CompanyToken.sol","reproduction":"FORK_RPC=https://robinhood.drpc.org forge test --mc CompanyForkTest -vvvv: Converted(asset 4, imdIn 3582320414502243337, usdOut 30144785, stockOut 1198166638740973833); feeds at that block: USDG/USD 100004000, Robinhood GME/USD 2473500000. fair = 30144785 * 100004000 * 1e18 / (2473500000 * 1e6) = 1.21878e18; minStockOut = 0.97 * fair = 1.18222e18; stockOut / fair = 0.9831. Unit analogue (verified): in the Company.t.sol setup (1:1 pools, 0.3% fee) set feeds[3] to 0.972e8 (pool 2.8% above the feed) and call convert() after 1 minute: out[4] == 0 and pendingConvert(4) stays at 6e18, repeating every round while the gap persists; at 0.974e8 the round still buys. On mainnet the 1% GME pool fee moves that threshold to a 2% price gap at the pool's mid.","severity":"info","snippet":"    uint256 public constant ORACLE_TOLERANCE_BPS = 300;","title":"GME's 1%-fee pool leaves only ~1.3% of the 3% oracle tolerance as real margin, so its rounds will often be held and its reserve grows unbounded"}],"hash":"2905245a464d898d2e5531c5cbe5f70b607387777ef961f887af9a6bc8ecedc3","nodeId":"8f32c0b9-968a-4dc8-9c06-cba26171f9f7","outcome":"completed","summary":"The review is complete. The findings file holds two entries, both snippets match the tree, and the scratch tests are removed. Here is the recap.\n\n**Outcome.** The math in the four changed areas holds up. I found one new defect outside them, in expiry, and one measured liveness note on GME. Both are in `.imd-findings.json`.\n\n**What I verified**\n\n- **Chainlink stock check (item 1).** The `minStockOut` scaling is right: USDG raw (6) × USDG/USD (8) × 10^18 ÷ (stock/USD (8) × 10^6) gives stock wei. I confirmed on Robinhood Chain that USDG has 6 decimals, all five stocks 18, and all six feeds 8 with fresh answers. Stale handling is consistent: `_feedsFresh` and `minStockOut` read the same feeds in the same transaction, so PriceOff on a stale feed can only come from the pre-check, which skips without fallback. A caller can force PriceOff only by pushing the stock pool more than 3% above the feed, which costs fees and merely delays. The fallback can be forced only by the already accepted LP path. The fork run shows purchases landing within 0.1% to 0.6% of the feeds.\n- **GME (item 2).** The live round limit is 3.57 IMD, matching the brief. Its purchase cleared the oracle floor by only 1.3 points because the 1% pool fee eats a third of the tolerance. Reported as info with the numbers and a verified unit analogue.\n- **Zero limit (item 3) and gas (item 4).** The zero-limit fallback moves exactly one capped round and keeps IMD tracked. The gas guard guarantees the inner call receives the full 1,000,000 under EIP-150, and real purchases use 250k to 305k, so a legitimate purchase cannot be starved into a fallback.\n- **Unchanged properties.** Fee rounding in both hook paths, the 50/10×5 split, per-share dust, the correction arithmetic, the overflow margins on casts, the eligible-supply invariant, the flash-borrow guards on distribution and conversion, and the scanner properties all check out. The full suite (42 tests) and the fork test pass.\n\n**Finding, medium.** `expiredRewardsOf` scales the \"recent\" estimate by the holder's current weight. A contract holder can borrow the pool's $COMPANY inside its own unlock, call `claim`, and hand the tokens back; a plain wallet can do the same with a round-trip gift. In a realistic early state the estimate grows about sixteenfold, so stale rewards that should have gone to the fee recipient are paid to the holder, and the timer resets. The proof test fails on this code and passes with a fix. Suggested fix: estimate from a weight snapshot taken at the last activity, and refuse `claim` and `recycle` while the PoolManager is unlocked.\n\n**Not covered.** No fuzzing beyond the project's own solvency fuzz, and the US-person sequencer filtering is not modelled, as the brief states.","treeHash":null,"usage":{"cachedInputTokens":2012049,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":61605,"runtime":"claude","turns":36,"wallClockMs":1241599}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"28e346843ec15530","findings":[{"citation":"resolved","description":"claim() is deliberately strict: it calls _recycle(msg.sender) before resetting lastActive, so a wallet inactive for more than 7 days hands everything but its last 7 days' earnings to feeRecipient (contract notice; test_expiry_claimIsStrict). _transfer is not: for any non-zero amount it sets lastActive[from] = block.timestamp (and lastActive[to] when the receiver pulled with transferFrom) without touching the rewards that have already expired. expiredRewardsOf is gated on `block.timestamp <= last + INACTIVITY_PERIOD`, so the moment the timer is reset it returns 0 for every asset: the expired backlog is alive again and the next claim pays it in full. The wallet needs nothing but 1 wei of its own tokens (transfer(self, 1)) or an accomplice to pull 1 wei from. It also front-runs any keeper's recycle(). Net effect: expiry only ever applies to holders who do not know the trick; the protocol (feeRecipient) loses every expired reward of everyone else, in all six assets. Fix: when a wallet becomes active again after more than 7 days, forfeit what has expired before resetting the timer, exactly as claim does. Cheapest form: in _transfer, before the balances change (so weightOf is still the old weight), for `from` (and for `to` when msg.sender == to) move expiredRewardsOf into withdrawnRewards/owed and into recycledHeld (no external calls inside a transfer; sendRecycled already ships recycledHeld to feeRecipient), or simply call _recycle there. Keep the expiry computation before _reweigh.","line":373,"path":"contracts/src/CompanyToken.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\n\nimport {CompanyHook} from \"src/CompanyHook.sol\";\nimport {CompanyToken} from \"src/CompanyToken.sol\";\nimport {CompanyRouter} from \"src/CompanyRouter.sol\";\nimport {DeployLib} from \"script/DeployLib.sol\";\n\ncontract ScratchERC20 {\n    uint8 public decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amt) external {\n        balanceOf[to] += amt;\n    }\n\n    function approve(address s, uint256 amt) external returns (bool) {\n        allowance[msg.sender][s] = amt;\n        return true;\n    }\n\n    function transfer(address to, uint256 amt) external returns (bool) {\n        balanceOf[msg.sender] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\ncontract ScratchFeed {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 1e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\ncontract ExpiryTransferReviveTest is Test {\n    address constant FEE_RECIPIENT = 0x8F5A29c82e8285Db3B2af8D0caF5404b0f9ce834;\n    uint256 constant SUPPLY = 1_000_000_000e18;\n\n    PoolManager pm;\n    ScratchERC20 imd;\n    ScratchERC20 usdg;\n    CompanyHook hook;\n    CompanyToken token;\n    CompanyRouter router;\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address owner = makeAddr(\"owner\");\n\n    function _key(address a, address b, uint24 fee, int24 ts) internal pure returns (PoolKey memory) {\n        (address c0, address c1) = a < b ? (a, b) : (b, a);\n        return PoolKey(Currency.wrap(c0), Currency.wrap(c1), fee, ts, IHooks(address(0)));\n    }\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        imd = new ScratchERC20();\n        usdg = new ScratchERC20();\n        PoolModifyLiquidityTest lp = new PoolModifyLiquidityTest(pm);\n        imd.mint(address(this), 10_000_000e18);\n        usdg.mint(address(this), 10_000_000e18);\n        imd.approve(address(lp), type(uint256).max);\n        usdg.approve(address(lp), type(uint256).max);\n\n        PoolKey memory imdEth = PoolKey(Currency.wrap(address(0)), Currency.wrap(address(imd)), 10_000, 100, IHooks(address(0)));\n        pm.initialize(imdEth, TickMath.getSqrtPriceAtTick(0));\n        PoolKey memory imdUsd = _key(address(imd), address(usdg), 9000, 90);\n        pm.initialize(imdUsd, TickMath.getSqrtPriceAtTick(0));\n        lp.modifyLiquidity(imdUsd, ModifyLiquidityParams(-887220, 887220, 10_000e18, 0), \"\");\n\n        address[5] memory stocks;\n        CompanyToken.Pool[5] memory pools;\n        address[5] memory feeds;\n        for (uint256 i; i < 5; i++) {\n            ScratchERC20 s = new ScratchERC20();\n            s.mint(address(this), 10_000_000e18);\n            s.approve(address(lp), type(uint256).max);\n            PoolKey memory k = _key(address(usdg), address(s), 3000, 60);\n            pm.initialize(k, TickMath.getSqrtPriceAtTick(0));\n            lp.modifyLiquidity(k, ModifyLiquidityParams(-887220, 887220, 1_000_000e18, 0), \"\");\n            stocks[i] = address(s);\n            pools[i] = CompanyToken.Pool(3000, 60);\n            feeds[i] = address(new ScratchFeed());\n        }\n\n        bytes memory initCode = abi.encodePacked(\n            type(CompanyHook).creationCode,\n            abi.encode(\n                pm,\n                address(imd),\n                owner,\n                FEE_RECIPIENT,\n                DeployLib.startTickForMarketCap(306e18, SUPPLY),\n                CompanyHook.ImdEthPool(10_000, 100, address(0))\n            )\n        );\n        uint160 flags = uint160((1 << 13) | (1 << 11) | (1 << 7) | (1 << 6) | (1 << 3) | (1 << 2));\n        (bytes32 salt, address expected) = DeployLib.mineSalt(address(this), flags, initCode, 0);\n        address deployed;\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n        require(deployed == expected, \"hook address\");\n        hook = CompanyHook(payable(deployed));\n        router = CompanyRouter(payable(hook.router()));\n        token = new CompanyToken(\n            address(hook), address(usdg), CompanyToken.Pool(9000, 90), stocks, pools, address(new ScratchFeed()), feeds\n        );\n        vm.prank(owner);\n        hook.openPool(address(token));\n\n        address[2] memory users = [alice, bob];\n        for (uint256 i; i < 2; i++) {\n            imd.mint(users[i], 1_000_000e18);\n            vm.prank(users[i]);\n            imd.approve(address(router), type(uint256).max);\n        }\n    }\n\n    function _buy(address who, uint256 imdIn) internal returns (uint256) {\n        vm.prank(who);\n        return router.buy(address(token), imdIn, 0, block.timestamp);\n    }\n\n    /// @dev Expected (contract notice, test_expiry_claimIsStrict): once a wallet has been inactive for more than 7 days,\n    ///      everything but its last 7 days' earnings has expired and goes to the protocol. Actual: `_transfer` resets\n    ///      `lastActive[from]` without forfeiting anything, so a 1 wei self-transfer makes `expiredRewardsOf` 0 again\n    ///      and the following claim pays the whole backlog to the wallet.\n    function test_selfTransferRevivesExpiredRewards() public {\n        _buy(alice, 20e18);\n        _buy(bob, 20e18);\n        uint256 old = token.withdrawableRewardOf(alice, 0);\n        assertApproxEqAbs(old, 0.6e18, 10);\n\n        vm.warp(block.timestamp + 8 days);\n        _buy(bob, 10e18); // a distribution inside the last 7 days\n        uint256 recent = token.withdrawableRewardOf(alice, 0) - old;\n        uint256 expired = token.expiredRewardsOf(alice, 0);\n        assertEq(expired, old, \"the 0.6 IMD earned 8 days ago have expired\");\n\n        uint256 feeBefore = imd.balanceOf(FEE_RECIPIENT);\n        vm.prank(alice);\n        token.transfer(alice, 1); // \"activity\"\n        vm.prank(alice);\n        uint256[6] memory paid = token.claim();\n\n        assertLe(paid[0], recent + 2, \"an inactive wallet is paid only its last 7 days\");\n        assertEq(\n            imd.balanceOf(FEE_RECIPIENT) - feeBefore + token.recycledHeld(0),\n            expired,\n            \"the expired part belongs to the protocol\"\n        );\n    }\n}","reproduction":"State: alice holds ~6% of supply, earned 0.6 IMD, no activity for 8 days, one 10 IMD trade inside the last 7 days (her recent share ~0.079 IMD). expiredRewardsOf(alice,0) == 0.6e18. Then: vm.prank(alice); token.transfer(alice, 1); vm.prank(alice); token.claim(). Expected: paid[0] <= recent (~0.079 IMD) and 0.6 IMD reaches feeRecipient (or recycledHeld). Actual: expiredRewardsOf drops to 0 after the self-transfer, claim pays 0.679 IMD to alice and feeRecipient gets 0. test/scratch/ExpiryTransferRevive.t.sol fails with 'an inactive wallet is paid only its last 7 days: 679426250124817669 > 79426250124817672'; it passes once _transfer forfeits expired rewards before resetting lastActive.","severity":"high","snippet":"            if (!fromSystem) lastActive[from] = block.timestamp;","title":"Expired rewards are revived by any send: _transfer resets lastActive without forfeiting, so an inactive wallet self-transfers 1 wei and claims its whole backlog"},{"citation":"resolved","description":"Independent of the transfer issue above (and still exploitable once that one is fixed, because the claim happens before any send). expiredRewardsOf estimates the last 7 days' earnings as (per-share growth since the cutoff) x weightOf(holder) read now, and the notice accepts that this can be over-estimated by tokens received recently. But claim() is callable while the PoolManager is unlocked: hook.flush is a no-op for a non-router caller, _convertAll returns, and nothing else refuses. Inside its own unlock a wallet can PoolManager.take the pool's whole $COMPANY balance (~88% of supply at launch, ~14% even in the 1,000 IMD test scenario) for free; a receipt from the PoolManager does not touch lastActive, so the wallet is still 'inactive', but its weight is now ~the supply and 'recent' is over-estimated by (poolBalance + own) / own. With any distribution in the window (a dust buy the attacker can make itself) recent exceeds the whole backlog, expiredRewardsOf returns 0, _recycle moves nothing, and the claim pays everything in all six assets; the tokens are then returned and settled. Guarantee 6 (expiry) is bypassed and guarantee 3 is weakened: flash-borrowed tokens do not earn, but they change what expires. The existing FlashHolder mode 4 test only covers a wallet with no prior rewards. Fix: claim() and recycle()/recycleMany() must not run while IPoolManager(poolManager).isUnlocked() (return early or revert; the routers never call them, so nothing legitimate is lost; test_flashBorrowedTokens_cannotCaptureRewards mode 4 then needs to expect the no-op/revert). Alternatively compute 'recent' from a weight that cannot be inflated mid-unlock.","line":519,"path":"contracts/src/CompanyToken.sol","reproduction":"State: contract D holds ~6% of supply (the pool keeps ~88%), earned 0.6 IMD, inactive 8 days, then one 10 IMD trade (recent ~0.079 IMD); expiredRewardsOf(D,0) == 0.6e18 and D's real recent share is ~0.079. D calls PoolManager.unlock; in unlockCallback: take(COMPANY, D, balanceOf(PM)); token.claim(); sync; transfer the tokens back; settle. Expected (test_expiry_claimIsStrict): D is paid at most ~0.079 IMD. Actual: D is paid 0.679 IMD, feeRecipient nothing. test/scratch/ExpiryFlashDodge.t.sol fails with 'an inactive wallet is paid only its last 7 days: 679426250124817669 > 79426250124817672' and passes when claim() refuses to run mid-unlock.","severity":"high","snippet":"        uint256 recent = FullMath.mulDivRoundingUp(magnifiedRewardPerShare[asset] - magCut, weightOf(holder), MAGNITUDE);","title":"claim() works mid-unlock and expiredRewardsOf scales 'recent' by the live weight, so a free flash-borrow of the pool's $COMPANY makes nothing expire"},{"citation":"resolved","description":"The new stale-feed rule is one-sided: a stock whose token or pool refuses the purchase has its round credited to holders as IMD (_fallBackToImd), but a stock whose Chainlink feed is older than MAX_ORACLE_AGE is skipped with no fallback, no matter for how long. A deprecated Chainlink aggregator (proxies keep returning the last round with a frozen updatedAt; Chainlink deprecates feeds regularly, and these are new feeds on a new L2) or a feed whose answer turns zero/negative therefore stops that stock for ever: every round `continue`s, distribute() keeps adding 10% of every holder-fee arrival to pendingConvert[a], and there is no function (no owner, no convert path, no time-out) that can ever move that IMD to holders. The same happens if the USDG/USD feed dies: then all five stocks (50% of holder fees) are held. Holding for a weekend is fine; holding for ever locks value the notice promises 'waits, not moves'. The four-day age is also tight for the live feeds: GME and GOOGL updated 15-21 hours apart on a trading day, so a four-day market closure (a long holiday weekend is ~3.7 days) sits close to the limit. Fix: bound the hold. For example, when a stock has not converted for longer than a long grace period (e.g. 30 days since lastConvert[a]) treat the stock as unbuyable and let the round fall back to IMD as the other failures do; or let a stale feed fall back after N consecutive stale rounds. Document whichever bound is chosen.","line":598,"path":"contracts/src/CompanyToken.sol","reproduction":"State: NVDA reserve 6 IMD, NVDA feed's updatedAt frozen 5 days in the past (deprecated), USDG and the other four feeds fresh. Run 400 daily rounds (convert()) with a 10 IMD buy each day (each adds 0.03 IMD to the NVDA reserve). Expected: within a year the reserve is released as stock or as IMD. Actual: owed(1) stays 0 and pendingConvert(1) == 6 + 400 x 0.03 = 18 IMD exactly; nothing ever leaves. test/scratch/StaleFeedHold.t.sol fails with '... must release its reserve ...: 18000000000000000000 >= 18000000000000000000'.","severity":"medium","snippet":"            if (!_feedsFresh(a)) continue;","title":"A feed that stops updating for good (deprecated aggregator) holds that stock's 10% share of all future holder fees forever, with no release path"},{"citation":"resolved","description":"The zero-limit fallback fires only when getLiquidity() at the current tick is exactly 0. A full-range position of 2e9 liquidity (about 2e9 wei of USDG and of the stock, i.e. nothing) makes the limit 3e6 wei IMD instead: the round is sized to 3e-12 IMD, the swaps run, minStockOut rounds to 0 so the price check passes, and the reserve moves by 3e-12 IMD per round. Anyone can place that dust in the hookless USDG/stock pools today, for one wei, and it does nothing while real liquidity covers the price. Whenever the real liquidity is gone or the price has left its range (GME's pool is ~$6k, most likely one concentrated position), the round no longer falls back to IMD as the re-check fix intends; the stock's 10% share of holder fees is held for as long as the dust is the only liquidity at the price, i.e. for ever if the LP has left. Pure griefing (nobody profits), but permanent, unprivileged and nearly free, and there is no owner or time-out to recover it. Fix: treat a limit that is too small to buy anything as zero, e.g. `if (poolLimit < MIN_ROUND_IMD) { _fallBackToImd(a, imdIn); continue; }` with a floor such as 1e15 wei (0.001 IMD) or a fraction of cap; combined with the time-bound suggested for the stale-feed hold, every held stock eventually pays out.","line":592,"path":"contracts/src/CompanyToken.sol","reproduction":"State: NVDA reserve 6 IMD; the NVDA pool's only real position (1e6e18 liquidity) is removed, stockRoundLimit(1) == 0. A griefer adds a full-range position of 2e9 liquidity: stockRoundLimit(1) == 3,000,000 wei IMD. Run 400 daily convert() rounds with fresh feeds. Expected (re-check finding 2 fix): each round credits maxConvert()/5 = 4 IMD of the reserve to holders as IMD. Actual: the reserve moves 27,270,894 wei (2.7e-11 IMD) in total, 0 IMD is credited to holders, 2.6e7 wei of NVDA is bought. test/scratch/DustLimitHold.t.sol fails with '... must move at least one full round out of the reserve: 27270894 < 4000000000000000000'.","severity":"medium","snippet":"            if (poolLimit == 0) {","title":"A dust full-range position keeps stockRoundLimit > 0, so an unusable stock pool is held instead of falling back to IMD (defeats the re-check finding 2 fix)"}],"hash":"2e362c093d6777c8e479cf57c2a018eeb7d2d5df90aa22ab2fd2c839354dfc18","nodeId":"d2d26fc7-f89e-41a4-abdb-71fec10baabf","outcome":"completed","summary":"The review is complete. Four findings are written to `.imd-findings.json`, each with a self-contained Foundry proof under `contracts/test/scratch/` that fails on the current code and passes under the proposed fix (verified with a temporary edit that I reverted; the tree is clean apart from the scratch directory).\n\n**The four changes since the re-check hold up.** The live fork test converts all five stocks, GME's round limit binds at 3.56 IMD, every feed is 8 decimals and fresh, GME is 18 decimals and USDG 6, and the decimal algebra in `minStockOut` is correct. A caller cannot shrink the inner gas budget below `CONVERT_GAS`, `PriceOff` only ever skips, and the fallback is reachable only through the already accepted \"make the purchase fail on purpose\" class. Solvency, the 100,000 minimum and the scanner properties are unchanged.\n\n**What is new** is in expiry and in the two new \"hold\" paths:\n\n1. **High: a 1 wei self-transfer revives expired rewards.** `claim()` is strict, but `_transfer` resets `lastActive[from]` without forfeiting anything, so a wallet inactive for 8 days sends 1 wei to itself and `expiredRewardsOf` returns 0 again. Proof: the wallet is paid 0.679 IMD where at most 0.079 was allowed, the fee recipient gets nothing. Fix: forfeit expired rewards (into `recycledHeld` or via `_recycle`) before resetting the timer in `_transfer`.\n\n2. **High: a free flash-borrow of the pool's $COMPANY dodges expiry.** `claim()` runs mid-unlock, and \"recent\" is scaled by the live weight, which `PoolManager.take` inflates to most of the supply. Separate mechanism and fix from finding 1 (still exploitable after it is fixed). Fix: `claim`/`recycle` return early or revert while the PoolManager is unlocked. The existing flash test's mode 4 then needs its expectation adjusted.\n\n3. **Medium: a dead feed holds a stock's reserve forever.** Unlike a refusing token or pool, a feed that never updates again (deprecated aggregator) has no fallback and no release path. Proof: 18 IMD held after a year of daily rounds, nothing moves. Fix: time-bound the hold, then fall back to IMD.\n\n4. **Medium: a dust full-range position defeats the zero-limit fallback.** 2e9 liquidity keeps `stockRoundLimit` at 3e6 wei, so an unusable pool moves 3e-12 IMD per round instead of 4 IMD as IMD. One wei to place, permanent if the real LP has left. Fix: treat a limit below a floor as zero.\n\nNot reported: the 3% tolerance lets a JIT LP keep up to roughly 4% of a GME round (tolerance plus its own pool fee), which is within the design's stated budget, and a `PriceOff` stock is retried on every claim at a bounded gas cost.","treeHash":null,"usage":{"cachedInputTokens":3546013,"inputTokens":706,"model":"claude-fable-5-1","outputTokens":87028,"runtime":"claude","turns":46,"wallClockMs":1405626}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"7591760a616c6429","findings":[{"citation":"resolved","description":"_convertAll treats a stale or unusable feed as 'hold the stock' (continue, no lastConvert update, no _fallBackToImd). That is correct for a weekend or a holiday, but the hold has no end: if a stock's /USD feed (or the shared USDG/USD feed) is deprecated by Chainlink, repointed, or simply stops being updated, _feedsFresh(a) is false on every call for the rest of the contract's life. distribute() keeps moving 10% of every holder-fee arrival into pendingConvert[a] (50% for all five stocks if the USDG/USD feed is the one that dies), and nothing can ever release it: convert/claim skip it, there is no owner, no setter for priceFeeds/usdFeed, and the only path to _fallBackToImd for that stock is the exact-zero stockRoundLimit branch, which needs the stock pool to be emptied at its price (impossible against a full-range LP). Compare: a stock whose token blocks this contract, or whose pool is empty, is paid to holders as IMD one capped round at a time; a dead feed strands the same money. The same no-exit behaviour exists when the IMD/USDG pool has no in-range liquidity (maxConvert() == 0 at line 583: cap = 0, every stock is skipped with no fallback) - a second scenario covered by the same fix. Chainlink has deprecated equity and stablecoin feeds before and announces it off-chain only; an immutable, ownerless contract cannot follow. Minimal fix preserving the design: keep the 4-day 'hold', but after an extended outage (e.g. updatedAt older than 30 days, or a feed call that reverts/returns 0 for that long) treat the stock like one that cannot be bought and _fallBackToImd(a, imdIn) one capped round at a time, so holders receive the value as IMD instead of losing it. Do the same when maxConvert() has been 0 for that long.","line":598,"path":"contracts/src/CompanyToken.sol","reproduction":"State: NVDA feed (priceFeeds[1]) has updatedAt = now - 5 days and never updates again; all other feeds fresh. Alice and Bob each buy 1,000 IMD through CompanyRouter, so pendingConvert[1] = 6 IMD. Then for 180 consecutive days: warp +1 day, refresh the other feeds, call convert() and have Alice claim(). Expected: the 6 IMD reserved for NVDA reach holders within a bounded time (as IMD, since the stock can never be bought), pendingConvert[1] == 0. Actual: pendingConvert[1] is still exactly 6e18 after 180 days, no NVDA was bought, owed[0] never received it, and every later distribution adds 10% more to the locked reserve. Second input (same fix): remove all in-range liquidity from the IMD/USDG pool so maxConvert() == 0; all five pendingConvert stay at 6e18 forever (30 IMD locked) and no round ever falls back. Proof: forge test --match-path test/scratch/StaleFeedLock.t.sol (fails on this code).","severity":"medium","snippet":"            if (!_feedsFresh(a)) continue;","title":"A Chainlink feed that stops updating for good locks that stock's 10% reserve forever: skipped every round, never falls back, no admin"},{"citation":"resolved","description":"The 'no liquidity at the stock pool's price' branch tests stockRoundLimit(a) for exactly zero. stockRoundLimit is fee/2 x the virtual USDG depth of whatever liquidity is in range, so one dust position (here 1e12 units of liquidity, about 1e-6 USDG) makes it a few wei instead of zero. The round then becomes imdIn = that handful of wei: convertStock swaps it, buys a few wei of stock (or hits Slippage/PriceOff), lastConvert is set, and the 4 IMD the round was meant to hand to holders as IMD stays in pendingConvert. Anyone can place that position in the hookless stock pool for the cost of one LP transaction, and it keeps the stock's whole 10% share frozen for as long as nobody provides real liquidity - exactly the abandoned-pool situation the fix was written for (a delisted or dead stock token). Pure griefing (the griefer gains nothing), reversible when real liquidity returns, hence low. Fix: treat a limit below a meaningful floor as empty - e.g. if (poolLimit < cap / 1000) or below a fixed MIN_ROUND (say 0.01 IMD), take the _fallBackToImd(a, imdIn) branch with the full capped round, so a dust position cannot keep the stock's reserve hostage.","line":592,"path":"contracts/src/CompanyToken.sol","reproduction":"Same setup as the project's test_recheck2_zeroLiquidityRoundPaidAsImd_noSwap: Alice and Bob buy 1,000 IMD each (pendingConvert[1] = 6 IMD), the NVDA pool's LP removes all 1,000,000e18 of liquidity, stockRoundLimit(1) == 0. A griefer then adds liquidity 1e12 in range [-60, 60] (about 1e-6 USDG of depth). stockRoundLimit(1) is now > 0 but < 1e10 wei. Warp +1 minute, round = maxConvert()/5 = 4 IMD, call convert(). Expected (re-check fix 2): owed[0] grows by 4e18 and pendingConvert[1] drops by 4e18. Actual: owed[0] stays at 30e18 (expected 34e18), pendingConvert[1] drops by only a few wei, lastConvert[1] is set; repeating every minute keeps the NVDA reserve frozen. Proof: forge test --match-path test/scratch/DustLiquidity.t.sol (fails on this code).","severity":"low","snippet":"            if (poolLimit == 0) {","title":"Re-check fix 2 (zero stockRoundLimit pays the round as IMD) is defeated by a dust liquidity position: the reserve is frozen instead of paid"},{"citation":"resolved","description":"The PriceOff check bounds how far from Chainlink a purchase may execute (ORACLE_TOLERANCE_BPS = 300) but does not make the round sandwich-proof; it caps the skim at 3% of each round's stock share. The attack is atomic and self-triggered (convert() is permissionless, once per minute per stock): (1) push the stock's USDG price up by p < tolerance - pool fee, cost = pool fee twice on an input of about depth x p/2; (2) add a narrow JIT position at the pushed tick, which lifts stockRoundLimit (same-transaction depth read) from fee/2 x real depth to the full MAX_ROUND_IMD/5 = 4 IMD; (3) call convert(): the round pays ~4 IMD of USDG into the JIT position at the pushed price and still clears minStockOut; (4) remove the JIT position and swap back. Net gain is about p x (V - fee x D_real) with V = 4 IMD (~$39): positive whenever fee x D_real < V, i.e. live in-range USDG depth below ~$390k for the 0.01% NVDA pool, ~$13k for the 0.3% GOOGL/AAPL pools, ~$16k for the 0.25% MSTR pool, ~$3.9k for the 1% GME pool. Live numbers today (fork of Robinhood Chain, 2026-10-07): NVDA limit 1,091 IMD => ~$214M virtual depth, GME 3.57 IMD => ~$7k; so no pool is currently exploitable, but NVDA's depth comes from one concentrated position whose virtual depth collapses the moment NVDA's price leaves its range, and GME's margin is under 2x. It contradicts AUDIT.md guarantee 4 ('conversion can't be profitably sandwiched') rather than a stated loss bound. Lowering the tolerance only scales the skim down (profit is linear in p); it cannot go below the GME pool's 1% fee + impact. Structural options: size the per-stock round from the pool's fee tier rather than its same-transaction depth (round <= fee x a conservative fixed depth floor per stock), route NVDA through a higher-fee USDG pool, or document the 3% as the accepted per-round loss bound in AUDIT.md section 6.","line":646,"path":"contracts/src/CompanyToken.sol","reproduction":"Mock environment, IMD/USDG 1:1 (so 4 IMD = 4 USDG), NVDA pool with fee 100 (0.01%), tick spacing 1, 20,000 USDG of full-range depth, all feeds at $1. Alice and Bob buy 1,000 IMD each: pendingConvert[1] = 6 IMD; stockRoundLimit(1) ~ 1 IMD (= fee/2 x depth). Warp +1 minute. Attacker: swap ~248.5 USDG for NVDA (price +2.5%, cost 0.025 USDG of fee each way); add liquidity 5,000,000e18 in [tick-1, tick+2] (stockRoundLimit(1) now > 4 IMD); call convert(): the full 4 IMD round converts, 3.865e18 NVDA bought for ~3.96 USDG (97.6% of fair, passes the 97% check); remove the JIT position; swap the pushed NVDA back. Expected (guarantee 4): attacker's USDG + NVDA (at the oracle's 1:1) does not exceed what it started with. Actual: attacker ends with 200,000.0473 vs 200,000 before - a profit of 0.0473 USDG, 1.2% of the round, taken from the NVDA credited to holders. Proof: forge test --match-path test/scratch/ToleranceSandwich.t.sol (fails on this code; its push size is read from ORACLE_TOLERANCE_BPS so a tolerance-only change does not make it pass).","severity":"low","snippet":"        if (stockOut < minStockOut(asset, usdOut)) revert PriceOff();","title":"Inside the 3% oracle tolerance a JIT sandwich of the stock hop is still profitable whenever a stock pool's live in-range depth x fee is below one 4 IMD round"}],"hash":"5a4ab5e306907d88fd196e5df51da92640f24781d58353b9605adf0c0dfe0ad4","nodeId":"3a6b3dbd-7e78-4150-90da-aba8036af0fc","outcome":"completed","summary":"Review complete. The findings file is written and all three reproductions fail on the current code.\n\n**Outcome: three substantiated findings, none critical or high.** The four changes you asked me to review hardest hold up. `.imd-findings.json` at the repository root carries the details and a self-contained failing Foundry test for each.\n\n**What I confirmed works**\n- Chainlink check: decimal math is right (USDG 6, stocks 18, both feeds 8), staleness and non-positive answers are rejected, `PriceOff` is matched by selector and cannot be forged by the pools or tokens. A caller can force `PriceOff` only by holding the pool price off the feed, which costs them capital and only delays the stock. Forcing the IMD fallback needs a real purchase failure, as AUDIT.md section 6 already accepts.\n- Gas: the 64/63 check guarantees the inner call always receives its full 1M. On a live fork a full five-stock claim used about 2.0M in total, so each purchase has a wide margin.\n- GME replacement, zero-limit fallback, flash-borrow guard, 100,000 minimum, expiry, solvency, and the scanner properties all behave as documented. The existing 42 tests pass. Live round limits on Robinhood Chain today: NVDA 1,091 IMD, GOOGL 1,721, AAPL 1,891, GME 3.57, MSTR 1,217.\n\n**Findings**\n1. **Medium.** A feed that stops updating for good locks that stock's 10% reserve forever. The stale branch at `CompanyToken.sol:598` holds the stock with no end, no admin can repoint the feed, and the only fallback path needs the stock pool emptied at its price. If the shared USDG/USD feed dies, 50% of all future holder fees are stranded. The same no-exit exists when the IMD/USDG pool has no in-range liquidity. Suggested fix: after an extended outage, fall back to IMD one capped round at a time, as an unbuyable stock already does.\n2. **Low.** The zero-limit fallback from re-check finding 2 tests for exactly zero. A dust liquidity position worth about one millionth of a USDG keeps the round at a few wei, so an abandoned stock's reserve is frozen instead of paid as IMD. Griefing only, reversible, cheap to place.\n3. **Low.** Inside the 3% tolerance a JIT sandwich of the stock hop is still profitable whenever a pool's live in-range depth times its fee is below one 4 IMD round. Reproduced with a profit of 1.2% of the round on an NVDA-like 0.01% pool. No live pool is exploitable today, but NVDA's depth comes from one concentrated position and GME's margin is under 2x. This contradicts guarantee 4 rather than a stated loss bound. The proof reads the tolerance from the contract, so lowering it alone does not make the test pass.\n\n**Scope notes.** No source or configuration file was changed. The scratch tests live only under `contracts/test/scratch/`. I did not re-examine the known-and-accepted items in AUDIT.md section 6, except to confirm the IMD/USDG depth cliff stays where that section places it.","treeHash":null,"usage":{"cachedInputTokens":3705481,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":93147,"runtime":"claude","turns":49,"wallClockMs":1500665}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b414b10f97bca557","findings":[{"citation":"resolved","description":"claim() is strict: it calls _recycle(msg.sender) before resetting lastActive, so a wallet inactive for more than 7 days hands everything but its last 7 days' earnings to feeRecipient (contract notice; test_expiry_claimIsStrict). _transfer is not: for any non-zero amount it sets lastActive[from] = block.timestamp (and lastActive[to] when the receiver pulled with transferFrom) without touching rewards that have already expired. expiredRewardsOf is gated on `block.timestamp <= last + INACTIVITY_PERIOD`, so once the timer is reset it returns 0 for every asset and the next claim pays the whole backlog in all six assets. The wallet needs nothing but 1 wei of its own tokens (transfer(self, 1)) or an accomplice to pull 1 wei from, and it can front-run any keeper's recycle() call. Net effect: expiry only ever applies to holders who do not know the trick, and feeRecipient loses every expired reward of everyone else; AUDIT.md guarantee 6 and the 'Expiry is strict' comment in claim() do not hold. This also undermines the fix for the flash-borrow finding below: returning borrowed tokens is itself a send. Fix (keeps the design): when a wallet becomes active again after more than 7 days, forfeit what has expired before resetting the timer, exactly as claim does. In _transfer, before the balances change (so weightOf is still the old weight): `if (!fromSystem && lastActive[from] != 0 && block.timestamp > lastActive[from] + INACTIVITY_PERIOD) _recycle(from);` and the same for `to` when msg.sender == to. Verified: with that change the attached proof passes and all 41 project tests still pass. (Reported by audit_economics; reproduced.)","line":373,"path":"contracts/src/CompanyToken.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\n\nimport {CompanyHook} from \"src/CompanyHook.sol\";\nimport {CompanyToken} from \"src/CompanyToken.sol\";\nimport {CompanyRouter} from \"src/CompanyRouter.sol\";\nimport {DeployLib} from \"script/DeployLib.sol\";\n\ncontract ScratchERC20 {\n    uint8 public decimals = 18;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amt) external {\n        balanceOf[to] += amt;\n    }\n\n    function approve(address s, uint256 amt) external returns (bool) {\n        allowance[msg.sender][s] = amt;\n        return true;\n    }\n\n    function transfer(address to, uint256 amt) external returns (bool) {\n        balanceOf[msg.sender] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 amt) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= amt;\n        balanceOf[f] -= amt;\n        balanceOf[to] += amt;\n        return true;\n    }\n}\n\ncontract ScratchFeed {\n    function decimals() external pure returns (uint8) {\n        return 8;\n    }\n\n    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {\n        return (1, 1e8, block.timestamp, block.timestamp, 1);\n    }\n}\n\ncontract ExpiryTransferReviveTest is Test {\n    address constant FEE_RECIPIENT = 0x8F5A29c82e8285Db3B2af8D0caF5404b0f9ce834;\n    uint256 constant SUPPLY = 1_000_000_000e18;\n\n    PoolManager pm;\n    ScratchERC20 imd;\n    ScratchERC20 usdg;\n    CompanyHook hook;\n    CompanyToken token;\n    CompanyRouter router;\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address owner = makeAddr(\"owner\");\n\n    function _key(address a, address b, uint24 fee, int24 ts) internal pure returns (PoolKey memory) {\n        (address c0, address c1) = a < b ? (a, b) : (b, a);\n        return PoolKey(Currency.wrap(c0), Currency.wrap(c1), fee, ts, IHooks(address(0)));\n    }\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        pm = new PoolManager(address(this));\n        imd = new ScratchERC20();\n        usdg = new ScratchERC20();\n        PoolModifyLiquidityTest lp = new PoolModifyLiquidityTest(pm);\n        imd.mint(address(this), 10_000_000e18);\n        usdg.mint(address(this), 10_000_000e18);\n        imd.approve(address(lp), type(uint256).max);\n        usdg.approve(address(lp), type(uint256).max);\n\n        PoolKey memory imdEth = PoolKey(Currency.wrap(address(0)), Currency.wrap(address(imd)), 10_000, 100, IHooks(address(0)));\n        pm.initialize(imdEth, TickMath.getSqrtPriceAtTick(0));\n        PoolKey memory imdUsd = _key(address(imd), address(usdg), 9000, 90);\n        pm.initialize(imdUsd, TickMath.getSqrtPriceAtTick(0));\n        lp.modifyLiquidity(imdUsd, ModifyLiquidityParams(-887220, 887220, 10_000e18, 0), \"\");\n\n        address[5] memory stocks;\n        CompanyToken.Pool[5] memory pools;\n        address[5] memory feeds;\n        for (uint256 i; i < 5; i++) {\n            ScratchERC20 s = new ScratchERC20();\n            s.mint(address(this), 10_000_000e18);\n            s.approve(address(lp), type(uint256).max);\n            PoolKey memory k = _key(address(usdg), address(s), 3000, 60);\n            pm.initialize(k, TickMath.getSqrtPriceAtTick(0));\n            lp.modifyLiquidity(k, ModifyLiquidityParams(-887220, 887220, 1_000_000e18, 0), \"\");\n            stocks[i] = address(s);\n            pools[i] = CompanyToken.Pool(3000, 60);\n            feeds[i] = address(new ScratchFeed());\n        }\n\n        bytes memory initCode = abi.encodePacked(\n            type(CompanyHook).creationCode,\n            abi.encode(\n                pm,\n                address(imd),\n                owner,\n                FEE_RECIPIENT,\n                DeployLib.startTickForMarketCap(306e18, SUPPLY),\n                CompanyHook.ImdEthPool(10_000, 100, address(0))\n            )\n        );\n        uint160 flags = uint160((1 << 13) | (1 << 11) | (1 << 7) | (1 << 6) | (1 << 3) | (1 << 2));\n        (bytes32 salt, address expected) = DeployLib.mineSalt(address(this), flags, initCode, 0);\n        address deployed;\n        assembly {\n            deployed := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n        require(deployed == expected, \"hook address\");\n        hook = CompanyHook(payable(deployed));\n        router = CompanyRouter(payable(hook.router()));\n        token = new CompanyToken(\n            address(hook), address(usdg), CompanyToken.Pool(9000, 90), stocks, pools, address(new ScratchFeed()), feeds\n        );\n        vm.prank(owner);\n        hook.openPool(address(token));\n\n        address[2] memory users = [alice, bob];\n        for (uint256 i; i < 2; i++) {\n            imd.mint(users[i], 1_000_000e18);\n            vm.prank(users[i]);\n            imd.approve(address(router), type(uint256).max);\n        }\n    }\n\n    function _buy(address who, uint256 imdIn) internal returns (uint256) {\n        vm.prank(who);\n        return router.buy(address(token), imdIn, 0, block.timestamp);\n    }\n\n    /// @dev Expected (contract notice, test_expiry_claimIsStrict): once a wallet has been inactive for more than 7 days,\n    ///      everything but its last 7 days' earnings has expired and goes to the protocol. Actual: `_transfer` resets\n    ///      `lastActive[from]` without forfeiting anything, so a 1 wei self-transfer makes `expiredRewardsOf` 0 again\n    ///      and the following claim pays the whole backlog to the wallet.\n    function test_selfTransferRevivesExpiredRewards() public {\n        _buy(alice, 20e18);\n        _buy(bob, 20e18);\n        uint256 old = token.withdrawableRewardOf(alice, 0);\n        assertApproxEqAbs(old, 0.6e18, 10);\n\n        vm.warp(block.timestamp + 8 days);\n        _buy(bob, 10e18); // a distribution inside the last 7 days\n        uint256 recent = token.withdrawableRewardOf(alice, 0) - old;\n        uint256 expired = token.expiredRewardsOf(alice, 0);\n        assertEq(expired, old, \"the 0.6 IMD earned 8 days ago have expired\");\n\n        uint256 feeBefore = imd.balanceOf(FEE_RECIPIENT);\n        vm.prank(alice);\n        token.transfer(alice, 1); // \"activity\"\n        vm.prank(alice);\n        uint256[6] memory paid = token.claim();\n\n        assertLe(paid[0], recent + 2, \"an inactive wallet is paid only its last 7 days\");\n        assertEq(\n            imd.balanceOf(FEE_RECIPIENT) - feeBefore + token.recycledHeld(0),\n            expired,\n            \"the expired part belongs to the protocol\"\n        );\n    }\n}","reproduction":"Unit setup as in Company.t.sol (mock IMD/USDG/stocks, real PoolManager). alice buys 20 IMD, bob buys 20 IMD: alice is credited 0.6 IMD. Warp 8 days; bob buys 10 IMD (one distribution inside the last 7 days): alice's recent share is 0.0794 IMD and expiredRewardsOf(alice, 0) == 0.6e18. Then vm.prank(alice); token.transfer(alice, 1); vm.prank(alice); token.claim(). Expected: paid[0] <= ~0.0794 IMD and 0.6 IMD reaches feeRecipient (or recycledHeld). Actual: expiredRewardsOf drops to 0 after the self-transfer, claim pays 679426250124817669 wei (0.679 IMD) to alice and feeRecipient receives 0. forge test --match-path test/scratch/ExpiryTransferRevive.t.sol fails with 'an inactive wallet is paid only its last 7 days: 679426250124817669 > 79426250124817672' and passes once _transfer forfeits expired rewards before resetting lastActive.","severity":"high","snippet":"            if (!fromSystem) lastActive[from] = block.timestamp;","title":"Any send resets the expiry timer without forfeiting: an inactive wallet self-transfers 1 wei and claims its whole expired backlog"},{"citation":"resolved","description":"expiredRewardsOf estimates the non-expiring 'recent' part as (per-share growth since the 7-day cutoff) x weightOf(holder), reading the holder's CURRENT weight; the comment assumes the weight can only have grown by tokens the holder legitimately earned on. claim() (line 478) is callable while the PoolManager is unlocked by someone else: hook.flush is a no-op for a non-router caller and _convertAll returns, but _recycle(msg.sender) still runs. A contract holder can pm.unlock(), pm.take() the pool's whole $COMPANY balance (~88% of supply at launch) for free, call claim(), then transfer the tokens back and settle(). A receipt from the PoolManager does not touch lastActive (the wallet is still 'inactive'), the borrowed weight does not change withdrawableRewardOf (corrections offset it), but it multiplies 'recent' by (pool balance + own) / own, so with any distribution in the window (the attacker can make a dust buy itself) recent exceeds the whole backlog, expiredRewardsOf returns 0, nothing is recycled, the timer is reset and the claim pays every expired reward in all six assets. The same inflation works without an unlock through a round-trip gift from an accomplice (receiving is not activity, the sender keeps its own rewards), which needs capital of about backlog / recent-growth tokens; the flash variant needs none. Independent of the self-transfer finding (the claim happens before any send). Victim: feeRecipient; AUDIT.md guarantee 6 is bypassed and section 6's 'a gift can only delay expiry, to nobody's gain' is wrong when combined with a claim. The existing FlashHolder mode-4 test only covers a wallet with no prior rewards. Fix: refuse claim() and recycle() while IPoolManager(poolManager).isUnlocked() (the routers never call them). `revert Reentrancy()` is the robust form (verified: the attached proof passes; test_flashBorrowedTokens_cannotCaptureRewards mode 4 then needs to expect the revert). A `return amounts` guard is only sufficient together with the forfeit-on-transfer fix of the previous finding, because returning the borrowed tokens is a send that resets the timer (verified both ways). To also close the gift variant, compute 'recent' from a weight that cannot be inflated at claim time, e.g. snapshot the weight whenever lastActive is set and use min(snapshot, current). (Reported by audit_math, audit_flow and audit_economics; merged, reproduced.)","line":519,"path":"contracts/src/CompanyToken.sol","reproduction":"Unit setup as in Company.t.sol. A contract wallet W buys 20 IMD of $COMPANY through CompanyRouter, bob buys 20 IMD: W is credited 0.6 IMD. Warp 8 days; bob buys 10 IMD: W's recent share is 0.0794 IMD, expiredRewardsOf(W, 0) == 0.6e18. W calls pm.unlock; in unlockCallback: pm.take(COMPANY, W, balanceOf(pm)); token.claim(); pm.sync; transfer the tokens back; pm.settle. Expected (test_expiry_claimIsStrict): W is paid at most ~0.0794 IMD and 0.6 IMD reaches feeRecipient. Actual: W is paid 679426250124817669 wei (0.679 IMD), feeRecipient receives nothing and W's timer is reset. forge test --match-path test/scratch/ExpiryFlashBypass.t.sol fails with 'an inactive wallet is paid only its last 7 days: 679426250124817669 > 79426250124817672'; it passes when claim() reverts mid-unlock, or with a return-early guard combined with forfeit-on-transfer.","severity":"high","snippet":"        uint256 recent = FullMath.mulDivRoundingUp(magnifiedRewardPerShare[asset] - magCut, weightOf(holder), MAGNITUDE);","title":"claim() runs inside a foreign PoolManager unlock and expiredRewardsOf scales 'recent' by the live weight, so flash-borrowed pool tokens (or a round-trip gift) make nothing expire"},{"citation":"resolved","description":"_convertAll treats a stale or unusable feed (answer <= 0, call reverting, or updatedAt older than MAX_ORACLE_AGE = 4 days) as 'hold the stock': continue, no lastConvert update, no _fallBackToImd. That is right for a weekend or a holiday, but the hold has no end. If a stock's /USD feed is deprecated by Chainlink, repointed, or simply stops updating (the five feeds are new, on a new L2, and the NVDA one is already a differently named generation, 'RHNVDA / USD', than the four 'Robinhood X / USD' feeds), _feedsFresh(a) is false on every call for the rest of the contract's life. distribute() keeps moving 10% of every holder-fee arrival into pendingConvert[a] (50% for all five stocks if the shared USDG/USD feed dies), the IMD stays counted in _pendingTotal() so distribute() never re-credits it, and nothing can release it: convert/claim skip it, there is no owner, no setter for priceFeeds/usdFeed, and the only path to _fallBackToImd for that stock is the exact-zero stockRoundLimit branch, which needs the stock pool to be empty at its price. The same no-exit behaviour exists when the IMD/USDG pool has no in-range liquidity (maxConvert() == 0 at line 583: cap = 0, every stock skipped, no fallback). Compare: a stock whose token blocks this contract or whose pool is empty is paid to holders as IMD one capped round at a time; a dead feed strands the same money. Minimal fix preserving the design: keep the 4-day hold, but after an extended outage (e.g. updatedAt older than 30 days, far beyond any market closure, or no successful conversion for that long while a reserve waits) treat the stock like one that cannot be bought and _fallBackToImd(a, imdIn) one capped round at a time; do the same when maxConvert() has been 0 that long. (Reported by audit_permissions, audit_flow and audit_economics; merged, reproduced.)","line":598,"path":"contracts/src/CompanyToken.sol","reproduction":"Unit setup as in Company.t.sol. alice and bob buy 1,000 IMD each (pendingConvert(1) == 6e18). Set the NVDA feed's updatedAt to now - 5 days and never update it again; keep USDG and the other four feeds fresh. For 365 days: warp +1 day, refresh the other feeds, call convert() and have alice claim(). Expected: the 6 IMD reserved for NVDA reach holders within a bounded time, as NVDA or as IMD. Actual: owed(1) == 0, pendingConvert(1) is still exactly 6e18 after a year while pendingConvert(2) == 0 (the other stocks converted), stockRoundLimit(1) > 0 (the pool is fine), and one more 1,000 IMD buy makes it 9e18. Second input: remove all in-range liquidity from the IMD/USDG pool so maxConvert() == 0; after 30 daily convert() calls all five pendingConvert stay at 6e18. Both are demonstrated by forge test --match-path test/scratch/StaleFeedLock.t.sol (passes on this code: it asserts the frozen state).","severity":"medium","snippet":"            if (!_feedsFresh(a)) continue;","title":"A Chainlink feed that stops updating for good (or an empty IMD/USDG pool) locks that stock's 10% reserve forever: skipped every round, never falls back, no admin"},{"citation":"resolved","description":"The 'no liquidity at the stock pool's price' branch tests stockRoundLimit(a) for exactly zero. stockRoundLimit is fee/2 x the virtual USDG depth of whatever liquidity is in range, so one dust position (2e9 liquidity, a few wei of each token, full range) makes it a few wei instead of zero. The round then becomes imdIn = that handful of wei: convertStock swaps it, minStockOut rounds to 0 so the price check passes (or Slippage falls back the same few wei), lastConvert is set, and the 4 IMD the round was meant to hand to holders as IMD stays in pendingConvert. Anyone can place that position in the hookless stock pools today for the cost of one LP transaction (it does nothing while real liquidity covers the price), and whenever the real liquidity is gone or the price has left its range (GME's pool is ~$6k, most likely one concentrated position) it keeps the stock's whole 10% share frozen instead of paid as IMD, for as long as nobody provides real liquidity. Pure griefing (no profit), reversible when real liquidity returns, hence low. Fix: treat a limit too small to buy anything as empty, e.g. `if (poolLimit < MIN_ROUND_IMD) { _fallBackToImd(a, imdIn); continue; }` with a floor such as 1e15 wei (0.001 IMD) or cap / 1000. (Reported by audit_permissions, audit_flow and audit_economics; merged, reproduced.)","line":592,"path":"contracts/src/CompanyToken.sol","reproduction":"Same setup as test_recheck2_zeroLiquidityRoundPaidAsImd_noSwap: alice and bob buy 1,000 IMD each (pendingConvert(1) == 6e18), the NVDA pool's LP removes all 1,000,000e18 of liquidity, stockRoundLimit(1) == 0. A griefer adds a full-range position of 2e9 liquidity: stockRoundLimit(1) is now > 0 but < 1e10 wei. Run 100 rounds of convert() one minute apart. Expected (re-check fix 2): each round credits maxConvert()/5 = 4 IMD of the reserve to holders as IMD, so the 6 IMD are paid within two rounds. Actual: the reserve moves less than 1e12 wei in total, owed(0) grows by less than 1e12 wei, pendingConvert(1) stays above 6e18 - 1e12, lastConvert(1) is set every round. forge test --match-path test/scratch/DustLiquidity.t.sol (passes on this code: it asserts the frozen state).","severity":"low","snippet":"            if (poolLimit == 0) {","title":"Re-check fix 2 (zero stockRoundLimit pays the round as IMD) is defeated by a dust in-range position: the reserve is held instead of paid"},{"citation":"resolved","description":"The PriceOff check bounds how far from Chainlink a purchase may execute (ORACLE_TOLERANCE_BPS = 300) but does not make the round sandwich-proof; it caps the skim at 3% of each round's stock share. The attack is atomic and self-triggered (convert() is permissionless, once per minute per stock): (1) push the stock's USDG price up by p < tolerance - pool fee; (2) add a narrow JIT position at the pushed tick, which lifts stockRoundLimit (same-transaction depth read) from fee/2 x real depth to the full MAX_ROUND_IMD/5 = 4 IMD; (3) call convert(): the round pays ~4 IMD of USDG into the JIT position at the pushed price and still clears minStockOut; (4) remove the position and swap back. Net gain is about p x (V - fee x D_real) with V = 4 IMD: positive whenever fee x D_real < V, i.e. live in-range USDG depth below ~$390k for the 0.01% NVDA pool, ~$13k for the 0.3% GOOGL/AAPL pools, ~$16k for MSTR (0.25%), ~$3.9k for the 1% GME pool. Live today (fork of Robinhood Chain, 2026-10-07): NVDA limit 474 IMD => ~$93M virtual depth, GME 3.6 IMD => ~$7k, so no pool is exploitable right now, but NVDA's depth comes from concentrated positions whose virtual depth collapses when the price leaves their range, and GME's margin is under 2x. It contradicts AUDIT.md guarantee 4 ('conversion can't be profitably sandwiched') rather than a stated loss bound, and the loss per round is at most 3% of 4 IMD per stock. Lowering the tolerance only scales the skim down and cannot go below the GME pool's 1% fee plus impact. Options: size the per-stock round from the pool's fee tier against a fixed conservative depth floor rather than its same-transaction depth, route NVDA through a higher-fee USDG pool, or document the 3% as the accepted per-round loss bound in AUDIT.md section 6. (Reported by audit_permissions; reproduced.)","line":646,"path":"contracts/src/CompanyToken.sol","reproduction":"Mock environment, IMD/USDG 1:1 (4 IMD = 4 USDG), NVDA pool with the mainnet tier (fee 100 = 0.01%, spacing 1) and 20,000 USDG of full-range depth, all feeds $1. alice and bob buy 1,000 IMD each: pendingConvert(1) == 6e18, stockRoundLimit(1) ~ 1 IMD. Warp +1 minute. Attacker (holding 100,000 USDG and 100,000 NVDA, both $1 at the oracle): swap 250 USDG for NVDA (price +2.5%); add liquidity 5,000,000e18 in [tick-1, tick+2] (stockRoundLimit(1) > 4 IMD); call convert(): the full 4 IMD round converts at the pushed price and passes the 97% check; remove the JIT position; swap the pushed NVDA back. Expected (guarantee 4): attacker's USDG + NVDA does not exceed 200,000e18. Actual: 200000047551847129067383, a profit of 0.0476 USDG (1.2% of the round) taken from the NVDA credited to holders. forge test --match-path test/scratch/ToleranceSandwich.t.sol fails with 'sandwich not profitable: 200000047551847129067383 > 200000000000000000000000'.","severity":"low","snippet":"        if (stockOut < minStockOut(asset, usdOut)) revert PriceOff();","title":"Inside the 3% oracle tolerance a JIT sandwich of the stock hop is still profitable when a stock pool's in-range depth x fee is below one 4 IMD round"},{"citation":"resolved","description":"minStockOut() requires stockOut >= 97% of the Chainlink-implied amount, and stockOut is net of the stock pool's own fee. For GME the pool fee is 1% (fee 10_000, spacing 200) and the pool is thin (~$6k), so the pool's effective price sits structurally above the feed. On the live fork today the GME round received 1.198164 GME for 30.144726 USDG against a feed-implied 1.225078 GME (Robinhood GME/USD 24.60735, USDG/USD 1.00004): 97.80% of fair, i.e. 0.80 points above the PriceOff floor (the previous specialist measurement a few hours earlier was 98.31%; the feed moved 0.5%). NVDA/GOOGL/AAPL/MSTR cleared it by 2.4-3.3 points (ratios 1.0028, 0.9935, 0.9996, 0.9959). Any further 0.8% gap between the GME pool and the feed (an after-hours move, a feed heartbeat lag, or a ~$50 trade in a $6k pool) makes every GME round revert PriceOff and skip. A skipped round neither converts nor falls back, so pendingConvert[4] keeps receiving 10% of every holder fee with no ceiling, and when the gap closes it drains at most stockRoundLimit (~3.6 IMD) per minute, credited to whoever holds then (accepted limit 6 of audit 78c00339), not to the holders who earned it. This is the documented 'value waits' behaviour, reported with the measured margin so the requester can decide whether 3% is the intended tolerance for a 1%-fee pool (e.g. apply the tolerance to the pre-fee amount, use a per-stock tolerance of fee + 2%, or pick a deeper GME venue). (Reported by audit_math; re-measured.)","line":135,"path":"contracts/src/CompanyToken.sol","reproduction":"FORK_RPC=https://robinhood.drpc.org forge test --mc CompanyForkTest -vvvv (run 2026-10-07, ~1791374022): Converted(asset 4, imdIn 3616432188786722807, usdOut 30144726, stockOut 1198164319145473944); feeds at that block: USDG/USD 100004000, Robinhood GME/USD 2460735000. fair = 30144726 * 100004000 * 1e18 / (2460735000 * 1e6) = 1225078352160634932; minStockOut = 0.97 * fair = 1188326001595815884; stockOut / fair = 0.9780. Unit analogue: in the Company.t.sol setup (1:1 pools, 0.3% fee) set feeds[3] to 0.972e8 and call convert() after 1 minute: out[4] == 0 and pendingConvert(4) stays at 6e18 every round while the gap persists.","severity":"info","snippet":"    uint256 public constant ORACLE_TOLERANCE_BPS = 300;","title":"GME's 1%-fee pool leaves under 1 point of the 3% oracle tolerance as live margin, so its rounds will often be skipped and its reserve grows without a ceiling"},{"citation":"resolved","description":"AUDIT.md describes the hook as owning 'the token's only Uniswap v4 pool' that 'blocks other pools and outside liquidity', and the brief says it 'takes 4% of every swap'. The hook can only refuse pools whose key names this hook (beforeInitialize / beforeAddLiquidity revert HookNotAllowed, skipped for its own calls). The token has no transfer restriction (the scanner tests require that), so any holder can initialize a $COMPANY/IMD pool with hooks = address(0) (or list the token anywhere else) and provide liquidity; swaps there pay no protocol or holder fee and the routers are not involved. Inherent to fee-by-hook with an unrestricted token; it cannot be fixed without a transfer restriction, which would break the scanner properties. Reported so the documentation and fee-revenue assumptions say what is true: fees are charged on the hook's pool, which holds the locked supply and is therefore the deepest venue, not on every swap of the token. (Reported by audit_flow; reproduced.)","line":489,"path":"contracts/src/CompanyHook.sol","reproduction":"Unit setup as in Company.t.sol. alice buys 1,000 IMD of $COMPANY through CompanyRouter; initialize PoolKey(IMD, COMPANY, fee 3000, spacing 60, hooks 0) at the hook pool's current tick and have alice add 1e15 liquidity full range (no revert). bob swaps 100 IMD exact-in through PoolSwapTest on that key. Expected per the brief: 4 IMD of fees (1 protocol, 3 holders). Actual: bob receives $COMPANY and hook.pendingHolderFees(token) and hook.pendingProtocolFees(IMD) are unchanged. forge test --match-path test/scratch/HooklessPool.t.sol (passes on this code: it asserts the behaviour).","severity":"info","snippet":"    function beforeInitialize(address, PoolKey calldata, uint160) external pure returns (bytes4) {","title":"Nothing prevents a second, hookless $COMPANY pool: trades there pay no 4% fee, so 'every swap' and 'blocks other pools' in AUDIT.md overstate the hook"}],"hash":"bbea7c9424be7558f48f18c6cd2071a87b7e113d736928d39abb7b3bb4b3551e","nodeId":"1664a129-b820-45f3-95ef-8f5234a0fc52","outcome":"completed","summary":"The review is complete. The findings file holds seven findings, every specialist finding was reproduced against the code, the thirteen specialist entries were merged into seven, and no source file was changed. The project's own 41 unit tests and the live fork test pass on the current commit.\n\n**What I kept, ranked**\n\n- **High, self-transfer revives expired rewards** (`contracts/src/CompanyToken.sol:373`). Any send resets the 7-day timer without forfeiting. A wallet inactive for 8 days transfers 1 wei to itself and the next claim pays 0.679 IMD instead of 0.079 IMD. The specialist's proof fails on this code and passes once the transfer forfeits first. Proof attached.\n- **High, flash-borrowed weight makes nothing expire** (line 519). Claim runs inside a foreign unlock, and the recent-rewards estimate scales with the live weight, so a contract wallet borrowing the pool's tokens for free gets the same 0.679 IMD. Three specialists reported this; merged into one. I found the two issues compound: a return-early guard alone is not enough because returning the borrowed tokens is itself a send. A revert guard, or return-early plus forfeit-on-transfer, makes the proof pass with the project suite still green. Proof attached.\n- **Medium, a dead Chainlink feed locks a stock's reserve forever** (line 598). A feed that never updates again holds 10 percent of all future holder fees with no exit and no admin. The same happens if the IMD/USDG pool has no in-range liquidity. Demonstrated over 365 simulated days.\n- **Low, dust liquidity defeats the zero-limit fallback** (line 592). A 2e9-liquidity position keeps the limit a few wei above zero, so the round neither buys nor pays as IMD. Three specialists merged.\n- **Low, JIT sandwich inside the 3 percent tolerance** (line 646). On a 0.01 percent pool with 20,000 USDG depth an attacker nets 0.0476 USDG per 4 IMD round. No live pool is exploitable today, but guarantee 4 of AUDIT.md is overstated.\n- **Info, GME margin**. I re-measured on the fork: the GME round executed at 97.80 percent of the feed-implied amount, only 0.8 points above the PriceOff floor, tighter than the specialist's 98.31 percent a few hours earlier.\n- **Info, hookless second pool** pays no fee. Inherent, documented for accuracy.\n\n**What I checked and found sound**: the decimals in minStockOut (USDG 6, stocks 18, feeds 8), the stale-feed age against live cadence, the gas-skip path, solvency of all six assets under the fuzz, the 100,000 minimum, and the flash-borrow guard on distribute and convert.\n\n**Not covered**: the gift round-trip variant of the second finding is described but has no separate test, and US-person sequencer filtering is not modelled, as the brief notes.","treeHash":null,"usage":{"cachedInputTokens":2507087,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":60060,"runtime":"claude","turns":39,"wallClockMs":1173792}}],"verification":[]}