# Audit report

> Audit IMDEX5 (src/Imdex5.sol), an index of the five largest IMD-paired tokens on Ethereum. Read THREAT_MODEL.md first: it lists the actors, the six invariants, the accepted behaviour that is not a
>  finding, and the severity scale. The code is launch 1668's deployed and audited contract with one change: mint(shares, maxImdIn, minShares) reverts TooFewShares when fewer than minShares shares  would be
> issued, and every buy rolls back. Check that change and its interaction with short fills, taxed coins (SwarmCity takes 2% on transfer), hooks that revert, burn gas or re-enter, and the  refund of unspent IMD.
> Check that no path issues shares for nothing, dilutes holders, leaves IMD unaccounted, or lets a caller keep value that belongs to holders. Report every finding with its  severity, a reproduction, and the line.
> 135 unit tests and 8 mainnet fork tests are in test/ (fork tests need --fork-url).

| | |
|---|---|
| Repository | https://github.com/blockmerchantlabs/imdex5.git |
| Commit | `d376aca115e3f9f44fe4ab5c14ec68bfc6e8ea0b` |
| Job | `886e7c22-763f-4efc-9fca-5f2838f28b15` |
| Judged | 2026-10-20 05:40 UTC |
| Findings | 1 medium · 3 low · 3 info |

Four agents audited the code as it is at `d376aca`, 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: A residual coin whose pool is dead or thin blocks mint across every later rebalance, not until the next one

`src/Imdex5.sol:399`

```
            (uint256 amountIn, uint256 out) = _swap(coin.token, coin.hook, true, need, true, false);
```

THREAT_MODEL.md accepts that 'a coin whose pool dies blocks mint until the next rebalance'. The code has no such recovery. _mintMore (lines 394-406) iterates every entry of _coins, including the residuals kept beyond listCount, and buys each with an unguarded exact-output _swap at line 399 (no try/catch, no gas cap, no price limit). _rebalance step (1) only _trySells a leaver with a 2% stop and step (2) keeps it as a residual whenever held > 0 (line 466); a dead pool fails every _trySell, so the residual survives every rebalance and every mint for the life of the index reverts in that coin's swap (or BuyFailed when the pool returns nothing, or out-of-gas when its hook burns gas). A thin pool is the realistic variant, since coins leave the top five precisely when liquidity leaves: the buy delivers a sliver, minted collapses to shares*out/need, a mint with the 97% floor reverts TooFewShares and one without a floor pays the four list coins in full for a sliver of shares. Only liquidity returning to the abandoned pool or every holder redeeming to zero supply restores mint; redeem and redeemToImd keep working. Who can cause it: the hook or the LPs of any coin that was ever in the list (hooks and pools are trusted for nothing). Medium under the brief's scale: a core function blocked by someone else, with the documented recovery path absent. The author's own test test_residualsAreRedeemedAndBlockMintWhileTheirPoolIsDead pins the blocking and the recovery when the pool comes back, never the recovery by rebalance the threat model promises. Fix needs a design decision: have _mintMore skip (try/catch) residual entries i >= listCount whose buy fails or short-fills, charging the minter that residual's spot value into idleImd so holders stay undiluted; or let a rebalance write off a residual whose sell has failed repeatedly by distributing it via owed; or correct THREAT_MODEL.md to say the block lasts while the abandoned pool stays dead. Merged from audit_flow (medium) and audit_permissions (low): same root cause, same line.

**Reproduction**

Mocks of test/Imdex5Base.sol. alice mintFirst(1000e18); oracle delivers a four-coin list dropping tokens[4]; pm.setDead(key(tokens[4]), true); rebalance() -> coins().length == 5, listCount == 4, held[tokens[4]] > 0. bob mint(1e18, 100e18, 0) reverts 'dead pool'. Then two further full cycles (warp +7d, fund, ask, deliver the same four-coin list, submit, rebalance): coins().length stays 5 and mint(1e18, 100e18, 0) still reverts 'dead pool'; alice redeem(1e18) succeeds. Thin variant: pm.setMaxIn(key(tokens[4]), 1e12) instead of setDead; after rebalance mint(100e18, 2000e18, 97e18) reverts TooFewShares(4902441415605); mint(100e18, 2000e18, 0) issues 4,902,441,415,605 wei of shares for 82.16 IMD paid; after another cycle and rebalance the residual is still held and the floored mint still reverts. Expected per THREAT_MODEL.md: mint usable after the next rebalance. Verified by test/scratch/Judge.t.sol (test_deadResidualBlocksMintAcrossRebalances, test_thinResidualPoolStarvesMintAcrossRebalances, both pass on this code) and the attached proof, which fails with 'dead pool'.

### 2. Low: submit() never checks the attestation's expiry or issuedAt window although invariant 6 requires 'unexpired'

`src/Imdex5.sol:312`

```
        if (!SignatureChecker.isValidSignatureNowCalldata(oracleSigner, attestationDigest(a), signature)) {
```

Invariant 6 of THREAT_MODEL.md says only an answer that is signed, for this contract, unexpired and not replayed can change the list. submit verifies answerHash and calls SignatureChecker directly; it never reads a.expiresAt or a.issuedAt and bypasses the inherited OracleAttestationConsumer._verifyAttestation (src/OracleAttestation.sol:157-165) that performs exactly those checks. The request body asks for validForSeconds 86400, so the panel signs a one-day window, but the contract honours the delivered answer until the next ask() clears answerHash, at least 7 days later. Replay is prevented by clearing answerHash, so the impact is timing only: a submitter can hold the panel's answer and adopt a list computed for a window up to a week old. Low: the list is still the panel's, no value moves. Fix: in submit, replace the bare SignatureChecker call with _verifyAttestation(a, signature), or add 'if (block.timestamp > a.expiresAt) revert AttestationExpired(a.expiresAt);'. Merged from audit_flow and audit_permissions (identical finding).

**Reproduction**

Mocks of test/Imdex5Base.sol. fund(2e18); ask(); the Intake delivers attestation a with issuedAt = START, expiresAt = START + 86400, agreed = 5 through onOracleResult (answerHash set). vm.warp(a.expiresAt + 5 days). bob calls submit(a, validSignature). Expected (invariant 6): revert AttestationExpired. Actual: submit succeeds, nextList().length == 3 and bob receives the 0.25 IMD reward. test/scratch/Judge.t.sol test_submitAcceptsExpiredAttestation passes on this code.

### 3. Low: Quorum is enforced only on the unsigned struct the Intake delivers; submit() adopts a signed attestation with any agreed

`src/Imdex5.sol:311`

```
        if (answerHash == 0 || keccak256(a.answer) != answerHash) revert WrongAnswer();
```

a.agreed >= MIN_AGREED is checked only in onOracleResult (line 279) on calldata the Intake supplies without any signature check. submit then verifies a signature over a separately supplied struct and binds it to the delivery only by keccak256(a.answer) == answerHash (line 311); it does not re-check a.agreed, a.quorum, a.panelSize or a.requestId == openRequest on the signed struct. The attestation format explicitly allows the signer to issue attestations with agreed < quorum 'for chain evidence' (src/OracleAttestation.sol:48-51). So the guarantee 'four members agreed' rests entirely on the Intake's unsigned delivery: an Intake (hard-coded, not among the threat model's actors) that delivers agreed = 5 with the answer bytes of a low-agreement signed attestation gets that list adopted, and the signed evidence on chain says 0-3 agreed. Low: needs a misbehaving Intake plus a low-agreement signed attestation with the exact answer. Fix: in submit add 'if (a.agreed < MIN_AGREED) revert BadList();' (and optionally a.requestId == openRequest) so the quorum is read from the signed struct. From audit_permissions.

**Reproduction**

Mocks of test/Imdex5Base.sol. fund(2e18); ask(). Step 1 as INTAKE: onOracleResult(openRequest, delivered, '') with delivered.agreed = 5 and delivered.answer = abi.encode([1282,1283,737]) -> answerHash stored. Step 2 as bob: submit(signed, sig) where signed has the same answer bytes, signed.agreed = 0, a different requestId and panelJobId, and sig is the oracle signer's valid signature over signed. Expected: revert (the signed evidence shows no quorum). Actual: submit succeeds and nextList().length == 3. test/scratch/Judge.t.sol test_submitAcceptsSignedAttestationWithZeroAgreed passes on this code.

### 4. Low: redeemToImd charges the 0.50% fee to the last holder, whom redeem() and invariant 4 pay in full

`src/Imdex5.sol:442`

```
        uint256 fee = Math.mulDiv(proceeds, FEE_BPS, BPS, Math.Rounding.Ceil);
```

Invariant 4 of THREAT_MODEL.md: 'the last holder is paid in full'. _redeemToImd applies that rule only to the in-kind branch (line 422, keepBps = supply == shares ? BPS : BPS - FEE_BPS, used by _payInKind) and not to the IMD it pays: line 442 takes FEE_BPS of the whole of proceeds, which includes the sold coins and the last holder's entire idleImd, even when supply == shares and the supply reaches zero. redeem() (lines 205-215) pays 100% of every coin and of idleImd in the same situation. The 0.50% goes to feeImd, which can only ever pay the oracle price and caller rewards, so the value is not taken by anyone but is not paid to the last holder either. The natspec on lines 224-226 describes the fee on the IMD path without a last-holder exception, so this is a seam between the two redeem paths and the stated invariant rather than a loss to others. No existing test asserts feeImd at supply zero on the IMD path. Fix: 'uint256 fee = supply == shares ? 0 : Math.mulDiv(proceeds, FEE_BPS, BPS, Math.Rounding.Ceil);', or restrict invariant 4's last-holder clause to redeem(). From audit_math.

**Reproduction**

Mocks of test/Imdex5Base.sol (five pools at 1,000,000 coin / 10,000 IMD). alice mintFirst(1000e18, zeros) -> supply 995e18, feeImd 5e18. alice, the only holder, redeemToImd(995e18, 0). Expected under invariant 4: feeImd unchanged. Actual: alice receives 990024999999999999995 wei IMD and feeImd rises by 4975000000000000000 wei (4.975 IMD); totalSupply, held and idleImd are 0. The same alice calling redeem(995e18) instead leaves feeImd unchanged and every coin and idle balance at 0. test/scratch/Judge.t.sol test_lastHolderRedeemToImdStillPaysFee and test_lastHolderRedeemInKindPaysAll pass on this code.

### 5. Info: mint's 0.50% fee is charged on the buys' cost only, not on the idle IMD part; mintFirst and redeemToImd charge the whole amount

`src/Imdex5.sol:410`

```
        uint256 fee = Math.mulDiv(cost, FEE_BPS, BPS, Math.Rounding.Ceil);
```

The three fee sites use different bases. _mintFirst (line 370) takes FEE_BPS of all IMD paid in; _redeemToImd (lines 441-442) takes it of proceeds after adding idlePart; _mintMore (line 410) takes it of cost alone, so the idlePart the minter pays at line 408 carries no fee. In an index that is mostly idle (pools that could take only part of the first mint, or rebalance buys skipped) a mint pays almost no fee, and in a fully idle index none. The natspec at line 187 documents 'plus 0.50% of the buys' cost as fee', while launch.json speaks of 'the 0.50% fee on mints' without the qualification, and feeImd funds the weekly oracle round. No holder loses value (the idle IMD is paid at the rounded-up pro-rata amount). If a uniform fee is intended, use Math.mulDiv(cost + idlePart, FEE_BPS, BPS, Math.Rounding.Ceil); otherwise align the launch notes with the natspec. Merged from audit_permissions and audit_math (identical finding).

**Reproduction**

Mocks of test/Imdex5Base.sol. setMaxIn(100e18) on all five pools; alice mintFirst(1000e18) -> supply 995e18, idleImd 495e18; clear maxIn. bob mint(100e18, 2000e18, 0). Actual: bob pays 100807596837305238472 wei IMD and feeImd rises by 254024144869215292 wei (0.25 IMD, 0.50% of the buys only). 0.50% of everything bob paid would be 504037984186526192 wei. test/scratch/Judge.t.sol test_mintFeeExcludesIdlePart passes on this code and logs these values.

### 6. Info: minShares above shares is rejected only after every buy has run

`src/Imdex5.sol:407`

```
        if (minted < minShares) revert TooFewShares(minted);
```

minted starts at shares and only decreases, so any call with minShares > shares is certain to end in TooFewShares, but the check sits after the loop at lines 395-406: the caller first pays for the transferFrom of maxImdIn (line 194), the unlock and up to five exact-output swaps, each running the pool's hook, before the revert. Nothing is lost and no holder is affected; it is a gas and clarity note and a sharp edge for integrators. Fix: in mint(), next to the shares == 0 guard at line 193, add 'if (minShares > shares) revert TooFewShares(shares);'. Merged from audit_flow and audit_math (identical finding).

**Reproduction**

Mocks of test/Imdex5Base.sol after alice mintFirst(1000e18). bob mint(100e18, 2000e18, 100e18 + 1) reverts TooFewShares(100e18), which is only reachable after the loop. With pool 0 set dead the same call reverts 'dead pool' instead, showing the swaps run before the check. Expected: an immediate revert before the IMD pull and the swaps. test/scratch/Judge.t.sol test_minSharesAboveSharesRevertsLate passes on this code.

### 7. Info: Trust assumption: a hard-coded oracle price means an Intake price above 0.5 IMD stops ask() for good (list freezes, funds unaffected)

`src/Imdex5.sol:32`

```
    uint256 public constant ORACLE_PRICE = 0.5e18;
```

ask() pays the Intake exactly ORACLE_PRICE = 0.5 IMD (lines 257-266) and the index has no way to pay more or to read the Intake's price. The audit_economics specialist reports that the deployed Intake at 0xa43e6F75ee006411F79Ac1C84120606C2330DE82 has an owner-only setPrice(action, asset, amount) and reverts PriceNotMet(price, amount) when amount < price; this reviewer did not verify the Intake's source on chain, so that half is the specialist's claim. If it holds, raising the price of oracle.request@oracle-1 above 0.5 IMD makes every ask() revert; no new list can be delivered and the index stays on its current coins until the price is lowered. mint, redeem, redeemToImd and claim keep working, the revert rolls back the feeImd deduction, and no IMD is stranded. THREAT_MODEL.md lists only 'an oracle that never answers' and does not name the Intake's owner among the actors, so this is a dependency trust assumption to document, not a code defect; a fix (reading and bounding the Intake's price at ask time) would be a design decision.

**Reproduction**

Index side, mocks of test/Imdex5Base.sol with an Intake mock that reverts PriceNotMet(price, amount) when amount < price (the behaviour the specialist attributes to the deployed Intake): etch it at INTAKE, setPrice(0.6e18), fund(2e18), bob ask(). Expected by the threat model's reading: a request opens and bob receives 0.25 IMD. Actual: ask() reverts PriceNotMet(0.6e18, 0.5e18) and feeImd stays 2e18; after setPrice(0.5e18) the same ask() succeeds and feeImd becomes 1.25e18. test/scratch/Judge.t.sol test_intakePriceAboveConstantStopsAsk passes on this code. Not reproducible against the repository's MockIntake, which has no price.

---

Judge's submission `88bdb52fa494e6f1923e11878e31e830f53f58a0642374979e0cc0b5fb40da13`, 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.
