# Audit report

> Final check 4 for The Zero Person Billion Dollar Company ($COMPANY) on Robinhood Chain (4663), after IMD Swarm audit 78c00339, re-check f1d5def3 and final checks 363ab052, 882666b4 and 986abba2. AUDIT.md sections 4 to 8 map every finding to its fix and its test.
>
> What the contracts are for: CompanyToken is a fixed 1,000,000,000 supply ERC-20; its ownership is renounced in the constructor. CompanyHook owns the token's only Uniswap v4 pool, paired with IMD, with liquidity locked forever, and takes 4% of every swap in that pool: 1% to the protocol, 3% to holders. Holder fees are split 50% IMD and 10% each to NVDA, GOOGL, AAPL, GME and MSTR Robinhood stock tokens, bought IMD -> USDG -> stock at the start of every claim(). Only wallets holding at least 100,000 earn. If a wallet goes more than 7 days without claiming, buying, selling or sending, its unclaimed rewards older than 7 days expire.
>
> Changed since 986abba2; review these hardest:
> 1. The IMD -> USDG hop is now checked too: minUsdOut(imdIn) prices IMD via the IMD/ETH pool (fee 1%, spacing 100, no hooks) and Chainlink ETH/USD and USDG/USD, less the IMD/USDG pool fee and IMD_TOLERANCE_BPS (10%); unlockCallback reverts PriceOff below it and the stock is skipped. Can a round still sell at a made-up price, or can the check be pushed (the IMD/ETH spot is read in the same transaction) to block or force anything? The 10% reflects IMD's two pools drifting about 5.6% apart after a 2 ETH buy on a fork.
> 2. One stuck rule replaces the dead-feed and empty-pool clocks: waitingSince[stock] starts when a reserve fills from empty (distribute), moves to now on every successful purchase (convertStock), clears when the reserve empties; a stock waiting more than DEAD_AFTER (30 days) is paid as IMD one capped round at a time, whatever blocks it (stale or unusable feed, empty IMD/USDG pool, PriceOff). Can anyone trigger it early on a healthy stock, or keep a stuck stock from ever falling back?
> 3. claim() now handles expiry (_recycle, lastActive) before flush and _convertAll; from-pool tags lapse after any distribution in the transaction (transient epoch bumped in _credit).
> 4. The IMD/USDG pool counts as empty below 1% of a full round; then nothing is swapped and only stuck stocks fall back.
>
> Please confirm these, check that nothing broke the solvency of the six reward assets, the flash-borrow guards, the 100,000 minimum, expiry or the scanner-relevant properties (no external calls in transfers), and report anything new.
>
> Tests: cd contracts; git submodule update --init --recursive; forge test. Fork test (live Chainlink feeds and pools): FORK_RPC=https://robinhood.drpc.org forge test --mc CompanyForkTest.

| | |
|---|---|
| Repository | https://github.com/imdmaxi/IMDINDEX.git |
| Commit | `9a4c33825cbecaa07c59e2994ec8f9c3191ea28e` |
| Job | `dddb75ec-5081-49b8-9204-f4de1725ca03` |
| Judged | 2026-10-07 14:59 UTC |
| Findings | 1 medium · 2 low · 3 info |

Four agents audited the code as it is at `9a4c338`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Medium: Stuck-stock IMD fallback moves only the swap-clipped imdIn, so a ~0.4 IMD dust position (or a thin live range) throttles the 30-day fallback about 100x and freezes the stock reserve

`contracts/src/CompanyToken.sol:746`

```
                    if (stuck) _fallBackToImd(a, imdIn);
```

Merged from audit_math (medium) and audit_flow (low); both reproduce. The DEAD_AFTER rule (NatSpec at lines 146-149, AUDIT.md section 8 finding 4) promises that a stock waiting more than 30 days without a purchase is paid as IMD one capped round at a time, whatever blocks it; the imdPoolEmpty branch (lines 710-711, 719) does pay MAX_ROUND_IMD / 5 = 4 IMD per round. But on the other two stuck paths, the stale-feed branch (line 734) and the PriceOff catch (line 746), _fallBackToImd receives imdIn after it was clipped to cap = maxConvert() / 5 (line 715) and to stockRoundLimit(a) (line 730). Both clips are sized for a real swap; a fallback swaps nothing, so they have no reason to apply to it. Both are same-transaction reads of in-range virtual liquidity, so once the real liquidity has left a pool, whoever posts the last position sets the fallback size. (a) IMD/USDG pool abandoned (as in test_final3_1): a straddling position with ~80 IMD of virtual depth keeps maxConvert() just above the 0.2 IMD emptiness threshold (cap = 0.04 IMD per stock). Posted at a made-up price it costs 0.36 IMD plus dust USDG, fully withdrawable; every round reverts PriceOff, so (i) the stock is skipped for 30 days where a truly empty pool would be paid 4 IMD at once and (ii) after 30 days each round moves 0.04 IMD per stock instead of 4 IMD, i.e. 1/100 of the intended pace: a 6 IMD reserve needs ~150 rounds, a few hundred IMD needs years, and with inflow above ~0.2 IMD per minute of claims it never drains. This is the residual of 882666b4 finding 3 (dust position stops the fallback), which the 1% threshold moved from 'never' to '1% speed'. (b) Stock pool: a ~27 USDG full-range dust position in a 0.3% pool keeps stockRoundLimit just above cap / 100 = 0.04 IMD; with that stock's feed dead the stale-feed stuck path pays 0.045 IMD per round instead of 4 IMD. A related gap: with a fresh feed the same dust position lets a 0.045 IMD purchase succeed every round, which moves waitingSince to now (line 778), so a dead stock pool never counts as stuck while draining at 1%. The regime also arises without an attacker: on the live IMD/USDG pool (block 82558449) the in-range liquidity 2.52e16 gives 8,550 IMD of virtual depth, and the specialist's read of the tick bitmap found only about 1.3% of it below tick -263790, so an IMD price under ~$3.4 would make maxConvert() about 0.3 IMD and every stuck fallback ~0.06 IMD. No value is extracted; holders' stock share (50% of holder fees) is withheld. Fix (verified: the attached proof fails on this commit and passes with it): on all three stuck paths pass min(pendingConvert[a], MAX_ROUND_IMD / (ASSETS - 1)) to _fallBackToImd instead of the clipped imdIn, keeping the depth-derived clips for real purchases only. Consider also treating a pool as empty below a larger fraction of a full round than 1%, and moving waitingSince only on purchases of a meaningful fraction of a round.

**Reproduction**

Mock setup as in test/Company.t.sol (1:1 pools, $1 feeds). alice and bob each buy 1,000 IMD: pendingConvert = 6 IMD per stock, waitingSince = now. (a) The LP removes all 10,000e18 IMD/USDG liquidity (maxConvert() = 0). Attacker: 1-wei swap with sqrtPriceLimit at tick -207000 (IMD worth 1e-9 USDG), then modifyLiquidity(-207090, -206910, 2.6e15): costs 0.3647 IMD + 3.7e8 wei USDG; maxConvert() = 203065671655427927 (0.203 IMD >= 0.2, pool counts as non-empty, cap = 0.0406 IMD). convert() at +1 minute: every stock PriceOff, pendingConvert(1) stays 6e18. convert() at +31 days (feeds refreshed): owed[0] grows by 203065671655427925 wei in total and pendingConvert(1) = 5959386865668914415. Expected (empty-pool rule, control test without the dust position): owed[0] + 20e18, pendingConvert(1) = 2e18. (b) IMD/USDG healthy; the NVDA LP removes its 1,000,000e18 liquidity (stockRoundLimit(1) = 0, a convert() would pay 4 IMD at once); attacker adds a full-range position of liquidity 30e18: stockRoundLimit(1) = 0.045e18 >= cap / 100. NVDA feed set 31 days old, others fresh, warp +31 days, convert(): NVDA fallback moves 45000000000000000 wei (0.045 IMD) instead of 4e18. Run: cd contracts; forge test --match-path test/scratch/ProofStuckFallbackThrottle.t.sol -vv (test_A1_dustImdUsdPositionThrottlesStuckFallback fails '5959386865668914415 != 2000000000000000000'; test_A2_dustStockPoolThrottlesDeadFeedFallback fails '45000000000000000 != 4000000000000000000'; the control passes).

### 2. Low: waitingSince counts idle time, so after 30 days without any claim or convert the first transient skip (stale feed, IMD/ETH drift) pays a healthy stock's reserve as IMD, repeatable every minute

`contracts/src/CompanyToken.sol:717`

```
            bool stuck = waitingSince[a] != 0 && block.timestamp > waitingSince[a] + DEAD_AFTER;
```

Merged from audit_economics and audit_permissions (both low); both reproduce. waitingSince[a] starts when the reserve fills (distribute, line 490) and only a successful purchase moves it (convertStock, line 778). It measures time without a purchase, not time during which a purchase was tried and impossible: a month in which nobody calls claim() or convert() (router trades flush and distribute but never convert) makes every stock 'stuck' on the next attempt although each is perfectly buyable. In that state the stale-feed branch (lines 733-736) and the PriceOff catch (lines 745-748) fall back at once instead of skipping. Both conditions are transient by the contract's own design: MAX_ORACLE_AGE (4 days) exists so that equity feeds that pause over long weekends and holidays make the stock wait, and IMD_TOLERANCE_BPS exists so that IMD's two pools drifting apart after an ETH-route buy makes the round wait for arbitrage. The PriceOff variant is also attacker-triggerable at will: anyone can push the IMD/ETH spot more than 10% in the same transaction as convert() (on the live pool about 4 ETH through the 1% pool, ~0.08 ETH of fees for the round trip) and, because _fallBackToImd neither moves waitingSince nor clears stuck, repeat it every CONVERT_INTERVAL; the stale-feed variant repeats by itself until the feed updates, so a whole reserve can be gone before the next feed update. Holders receive full value in IMD, so no funds are lost; what is lost is the promised stock exposure and AUDIT.md guarantee 7 ('only on a real failure'), and the requester's question 2 (can anyone trigger it early on a healthy stock) is answered yes. Fix that keeps the no-keeper property: record per stock the time of the first failed or skipped attempt since the last success (set on a skip for a stale feed, an empty IMD/USDG pool or a PriceOff; cleared on a success) and require both waitingSince + DEAD_AFTER and that first failure being at least a day old (or the feed itself being older than DEAD_AFTER in the stale-feed branch) before the three stuck fallbacks (lines 719, 734, 746). A quiet month then arms the clock on its first skip instead of paying. Existing tests test_final3_deadFeedFallsBackToImdAfter30Days, test_final3_emptyImdPoolFallsBackToImdAfter30Days and test_final2_3_dustImdPoolStillCountsAsEmpty would need a second convert() a day later.

**Reproduction**

Mock setup as in test/Company.t.sol. alice and bob each buy 1,000 IMD (pendingConvert = 6e18 per stock, waitingSince = now). (1) vm.warp(+31 days) with no claim() or convert() in between; refresh every feed except NVDA; set NVDA updatedAt = now - 4 days - 1 (just past MAX_ORACLE_AGE). convert(): NVDA pendingConvert = 2e18, owed[0] += 4e18 (ConversionFailed). Expected, as on day 29 (control test) and as test_recheck1_staleFeed_holdsThatStock expects: pendingConvert(1) stays 6e18, owed[0] unchanged. convert() again at +1 minute: pendingConvert(1) = 0, the whole NVDA reserve paid as IMD. (2) Same quiet month, all feeds fresh; in the same transaction swap 600 ETH -> IMD in the 1:1 IMD/ETH pool (minUsdOut(1e18) becomes 1001004664283999998 while the IMD/USDG hop pays ~0.991e18), then convert(): every stock PriceOff and stuck, pendingConvert = 2e18 for all five, owed[0] += 20e18, nothing bought. Expected (control at day 1 with the same swap): all skipped, reserves unchanged. Run: cd contracts; forge test --match-path test/scratch/ProofQuietMonthFallback.t.sol -vv (test_B1_quietMonthThenWeekendStaleFeedPaysHealthyStockAsImd and test_B2_quietMonthThenImdEthPushPaysAllStocksAsImd fail '2000000000000000000 != 6000000000000000000'; the two controls and test_B1_repeatsEveryMinute pass).

### 3. Low: In an exhausted IMD/USDG pool a single-sided IMD position (~36 IMD) makes all five stocks fall back at once, bypassing the empty-pool rule and the 30-day wait

`contracts/src/CompanyToken.sol:726`

```
            if (poolLimit < cap / 100) {
```

From audit_economics (low); reproduced, and widened by a second variant found while reproducing. The requester's question 4 states the intended rule for an IMD/USDG pool without real liquidity at its price: nothing is swapped and only stuck stocks fall back. 986abba2 finding 1 closed the made-up LOW price with minUsdOut. Two paths remain that pay every stock's capped round as IMD immediately, with no stuck check, from a position that holds no USDG at all: (1) Made-up HIGH price: stockRoundLimit (lines 850-856) converts each stock pool's USDG depth into IMD at the IMD/USDG sqrtPrice read in the same transaction. A single-sided IMD position whose range starts at the current tick is in range (maxConvert() reads its virtual depth, here the full 20 IMD round) while the inflated price makes every deep stock pool's limit read below cap / 100, so line 727 credits min(pending, cap) of each reserve to holders as IMD without any swap and without the 30-day wait. (2) True price: the same single-sided position at the real price passes every limit, convertStock tries the swap, the position has no USDG to give, _swapExactIn reverts Slippage (not PriceOff), and the catch at line 749 falls back at once. Both cost about 36-40 IMD of real tokens (recoverable) after the IMD/USDG pool is out of range; both hit all five stocks and repeat every minute until the reserves are empty. Precondition: the IMD/USDG pool exhausted at its price, either abandoned or pushed out of its concentrated range (the specialist measured ~110,000 USDG to do that on a fork, ~2,000 USDG of fees round trip; the live pool at block 82558449 has 8,550 IMD / 74,500 USDG of virtual depth in range). Holders receive full value in IMD, so this is the class AUDIT.md section 9 accepts for a purchase made to fail on purpose, but it is cheaper and broader than the example given there (no stock-pool LP role, no failed purchase at the stock hop) and it defeats the empty-pool rule the requester asked to confirm. Fix: compare the IMD/USDG spot with the IMD/ETH + Chainlink reference (the comparison minUsdOut makes) before any swap and, when it is more than IMD_TOLERANCE_BPS away, treat the pool as unusable exactly like the imdPoolEmpty branch (skip, fall back only if stuck); price stockRoundLimit's USDG-to-IMD conversion at that reference rather than the spot; and in the catch, fall back immediately only for failures the IMD/USDG pool cannot cause (route a Slippage from the first hop through the stuck rule as PriceOff already is).

**Reproduction**

Mock setup as in test/Company.t.sol. alice and bob buy 1,000 IMD each (6e18 per stock); the LP removes all IMD/USDG liquidity (maxConvert() = 0, as in test_final3_1). (1) Attacker: 1-wei swap with sqrtPriceLimit at tick 108000 (1 IMD = 49,000 USDG), then modifyLiquidity(108000, 108090, 2e24): 40.57 IMD and 0 USDG leave the attacker. Now maxConvert() = 20e18 and stockRoundLimit(s) = 30615782074609986 (0.031 IMD < cap / 100 = 0.04 IMD) for all five stocks although each pool still has 1,000,000e18 liquidity at Chainlink's price. warp +1 minute, convert(): no swap (token IMD balance unchanged), pendingConvert = 2e18 for all five, owed[0] += 20e18. (2) Same pool state, position at the true price: modifyLiquidity(0, 90, 8000e18) (35.9 IMD, 0 USDG) with the tick at 0; maxConvert() = 20e18, stockRoundLimit(1) > 1,000 IMD; convert(): every stock's swap gets zero fill, Slippage, immediate fallback: pendingConvert = 2e18 for all five, owed[0] += 20e18. Expected in both: nothing swapped and reserves unchanged (only a stock stuck for DEAD_AFTER may fall back). Run: cd contracts; forge test --match-path test/scratch/ProofExhaustedPoolImmediateFallback.t.sol -vv (both tests fail 'healthy stock reserve must stay: 2000000000000000000 != 6000000000000000000').

### 4. Info: Uniswap v4 protocol fee (0.1%) is switched on for the route pools on Robinhood Chain; minUsdOut and minStockOut subtract only the LP fee, so the effective tolerances are 9.9% and 2.9%

`contracts/src/CompanyToken.sol:893`

```
        fair = (fair * (1_000_000 - imdUsdPool.fee)) / 1_000_000;
```

From audit_math (info); verified on-chain. The live PoolManager (0x8366a39CC670B4001A1121B8F6A443A643e40951, block 82558449) has protocolFeeController 0x6d0009504D129CF5002Dba61D9Ae8575AA79314c and slot0.protocolFee set on the route pools: 0x3e83e8 (1000 pips = 0.1% in each direction) on IMD/USDG (0xaf5bcd88...406f) and GME/USDG (0x3d436b4f...063b), 0x019019 (25 pips) on USDG/NVDA (0x6444a8e0...29c5). v4 charges ProtocolFeeLibrary.calculateSwapFee(protocolFee, lpFee) = protocolFee + lpFee - protocolFee * lpFee / 1e6 on the input, so the IMD -> USDG hop costs 0.9991% rather than 0.9% and the GME hop 1.099% rather than 1%. minUsdOut (line 893) and minStockOut (line 870) remove only the pool's lpFee before applying the 10% and 3% tolerances, so the margins actually available are about 9.9% and 2.9%, and the '0.9% fees' sandwich-cost figures in the MAX_ROUND_IMD NatSpec (lines 118-124) and README line 29 understate the attacker's cost by 0.1 percentage point (in the protocol's favour). Not exploitable; it only narrows headroom, most for GME whose 1% fee already uses most of its 3% margin. Fix: subtract the swap fee actually charged (read slot0.protocolFee and apply ProtocolFeeLibrary.calculateSwapFee) instead of the fixed lpFee, or document the 0.1% as part of the tolerance budget.

**Reproduction**

cast call 0x8366a39CC670B4001A1121B8F6A443A643e40951 'extsload(bytes32)(bytes32)' $(cast keccak $(cast abi-encode 'f(bytes32,uint256)' 0xaf5bcd88bb2b6084ca18319385b61fcbe17ec579032074861004f2668168406f 6)) --rpc-url https://robinhood.drpc.org returns 0x0000000023283e83e8fc1d31000000000000000000003188ad123aa665115a0b: lpFee 0x002328 = 9000, protocolFee 0x3e83e8 = 1000|1000 pips. GME (0x3d436b4f...063b): 0x0000000027103e83e8fc45ea...: lpFee 10000, protocolFee 1000|1000. NVDA (0x6444a8e0...29c5): 0x0000000000640190190361ab...: lpFee 100, protocolFee 25|25. Expected by the code: a 0.9% hop fee; actual: 0.9991%.

### 5. Info: README and NatSpec misstate the conversion price checks: 'no minimum output' and 'however thin the pool' are wrong, the ~19,750 IMD depth figure is stale, a fork-test comment says 5% tolerance

`README.md:130`

```
- **Conversion pricing.** Conversions run at the pool price of the moment and have no minimum output. The 20 IMD round ceiling, the per-stock pool limits and the 1-minute spacing keep sandwiching unprofitable while the IMD/USDG pool holds more than about 2,200 IMD of depth (about 19,750 at launch).
```

Merged from audit_math and audit_permissions (both info); all four statements checked against the tree and the live pool. (1) README line 130 says conversions 'run at the pool price of the moment and have no minimum output', contradicting line 30 and the code: every stock purchase must receive at least 97% of the Chainlink-implied amount (minStockOut, unlockCallback line 791) and the IMD -> USDG hop at least 90% of IMD's IMD/ETH + Chainlink value (minUsdOut, line 789). (2) README line 30 ends 'A price manipulated inside a transaction can't pass this, however thin the pool': true for the stock hop (Chainlink), false for the first hop, whose reference is the IMD/ETH pool's slot0 read in the same transaction (minUsdOut, line 888). Anyone can move it with a swap before convert(): in the mock setup a 600 ETH swap into the 1:1 IMD/ETH pool makes every round PriceOff (blocking direction, see the quiet-month finding), and selling IMD into IMD/ETH lowers the minimum and widens the room for an IMD/USDG sandwich beyond 10%. What protects the first hop is the cost of moving a deep IMD/ETH pool plus the 20 IMD ceiling, not an oracle; if the IMD/ETH pool thins out the check protects nothing. AUDIT.md section 9's wording is the accurate one. (3) README line 130 and the NatSpec at contracts/src/CompanyToken.sol lines 123-124 give the IMD/USDG depth as about 19,750 IMD at launch; the live pool (block 82558449, in-range liquidity 25239561974856391 at sqrtPriceX96 0x3188ad123aa665115a0b) has about 8,550 IMD / 74,500 USDG of virtual depth, 4x (not 9x) above the ~2,200 IMD break-even. (4) contracts/test/Company.fork.t.sol line 69 says 'after fee and 5% tolerance' while IMD_TOLERANCE_BPS is 10%. Fix: reword the risk item to describe the two oracle-bounded minimums and the 10%/3% residual tolerances; limit the 'manipulated inside a transaction' claim to the stock hop and state the first-hop assumption (IMD/ETH liquidity must stay deep relative to 20 IMD per minute of reserves); refresh or drop the depth figure in both places; fix the fork test comment.

**Reproduction**

Read README.md line 130 against contracts/src/CompanyToken.sol lines 788-791 (two PriceOff minimum-output checks): the README says there is no minimum. Read README.md line 30 against minUsdOut (line 884-895): the reference is a same-transaction pool spot. Mock setup as in test/Company.t.sol: alice and bob buy 1,000 IMD, warp 1 minute, swap 600 ETH -> IMD in the IMD/ETH pool, convert(): minUsdOut(1e18) = 1001004664283999998, every stock skipped, reserves unchanged (test_B2_control_day1ImdEthPushJustSkips in test/scratch/ProofQuietMonthFallback.t.sol). Live depth: the liquidity slot keccak(poolId, 6) + 3 of IMD/USDG reads 0x59ab3f75cc3ac7 = 2.52e16; L * 2^96 / sqrtPriceX96 = 8,549 IMD. Expected: documentation matches the code and the pool; actual: four stale or wrong statements.

### 6. Info: No unit test covers the first-hop drift skip (IMD/ETH spot moved more than 10%) or a stale ETH/USD or USDG/USD feed

`contracts/test/Company.t.sol:1243`

```
    function test_final3_1_madeUpPricePositionGetsNothing() public {
```

From audit_permissions (info); confirmed by inspection. The only test of minUsdOut is test_final3_1_madeUpPricePositionGetsNothing (an emptied IMD/USDG pool with a position at a made-up price). Nothing in the suite exercises the ordinary case the brief asks about in question 1: the IMD/ETH spot drifting more than IMD_TOLERANCE_BPS from the IMD/USDG price (every stock must skip with PriceOff and nothing may fall back before 30 days), nor the ETH/USD or USDG/USD feed being stale (both are read in _feedsFresh and minUsdOut and hold all five stocks). ethFeed and usdFeed are only ever set fresh (lines 704-705 and the _refreshFeedsExcept helper at 1133-1139) and the IMD/ETH pool is only used by the ETH-router tests. Both paths behave as designed in scratch reproductions (test_B2_control_day1ImdEthPushJustSkips in test/scratch/ProofQuietMonthFallback.t.sol covers the drift skip), so this is a coverage gap, not a defect; it matters because the two new checks are the main change under review and because the quiet-month finding shows the same paths misbehaving after 30 days. Suggested tests: (1) two 1,000 IMD buys, warp 1 minute, swap 600 ETH -> IMD in the IMD/ETH pool, convert(): out[s] == 0 and pendingConvert[s] unchanged for all s, owed[0] unchanged; (2) ethFeed.set(1e8, now - 4 days - 1) with the others fresh, convert(): same assertions; the same for usdFeed; (3) the day-31 variants of both once the stuck rule is revised.

**Reproduction**

grep -n 'ethFeed\|usdFeed' contracts/test/Company.t.sol: only lines 704-705 and 1134-1135, all setting a fresh timestamp; grep for a swap on the imdEth key outside the ETH-router tests: none. Expected: at least one test that drifts the IMD/ETH price or stales ethFeed/usdFeed before convert(); actual: none.

---

Judge's submission `37ea3a97aed907216baab81f4fc277e8f91920de1a70bf5d02dfd768080ca2a6`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
