# Audit report

> Audit the website for The Imdex 5 in docs/ (a static page: index.html, style.css, app.js, core.js, a vendored ethers 6.13.4). Read SITE_SECURITY.md first: it gives the trust model, what each button sends, the page hardening, and the accepted limits that are not findings. The contract it drives is launch 2361's Imdex5 at 0xC5a8A9F9B12480B47456E3338695f6038c56e779 on Ethereum (source identity-md-launches/launch-2361-the-imdex-5 at a72ebaac); the contract is out of scope except where the page calls it wrongly. Check: any way chain data or a token's name or symbol reaches the page as markup or script; that every transaction's limits (approval amount, minOut on the first mint, minShares and maxImdIn on later mints, minImdOut on redeem to IMD) protect the user against a moved pool price and are computed from the wallet's provider, not a public node; that no call can approve more than the mint amount or approve anything but the index; that the Content-Security-Policy and frame guard hold; that the vendored ethers file matches npm ethers@6.13.4; that wrong-chain, rejected, reverted and repeated clicks are handled; and that the quotes shown match what the contract does. Report every finding with its severity, a reproduction and the line. site-test/fork.js runs the site's own transaction code against a mainnet fork (instructions in the file).

| | |
|---|---|
| Repository | https://github.com/blockmerchantlabs/imdex5.git |
| Commit | `3c1208a193839815a966bc9d9b23d15cc8e7a80f` |
| Job | `c104e817-abd8-435e-a2cd-ac5d24507608` |
| Judged | 2026-10-21 19:31 UTC |
| Findings | 2 medium · 6 low · 1 info |

Four agents audited the code as it is at `3c1208a`, 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: Mint TooExpensive retry lowers the signed minShares below the 'at least' floor the page displayed, without re-quoting

`docs/core.js:216`

```
        shares = (shares * 97n) / 100n;
```

quoteBuy (app.js:144-145) shows 'About Q IMDEX5 (at least 0.97Q)'. mintFor recomputes minShares = 0.97*shares inside the loop (core.js:209) after cutting shares by 3% on each TooExpensive, up to six times, with maxImdIn still the full budget and no new figures shown. After k retries the signed floor is 0.97^(k+1)·Q (down to ~83% of Q), contradicting SITE_SECURITY.md's 'both bounds are shown before signing'. Retries happen exactly when pool prices moved/impact is high, i.e. when the floor matters. Merged from audit_math, audit_flow, audit_permissions. Fix: compute the floor once from the displayed quote and stop (or re-confirm) when reduced shares fall below it.

**Reproduction**

Mainnet fork after site-test/fork.js's 10 IMD first mint (NAV 0.984124). mintFor(ethers, signer, 400e18, 300): page shows 'About 390.819919 IMDEX5 (at least 379.095321)'; staticCall 1 mint(390.82,400,379.10) reverts TooExpensive; staticCall 2 succeeds and the wallet is sent mint(379.095321, 400, minShares=367.722462) = 94.1% of quote, below the displayed floor. Expected minShares >= 379.095321 or a fresh confirmation.

### 2. Medium: A failed pool-price read is treated as price 0, silently lowering redeemToImd's minImdOut computed on the wallet provider

`docs/core.js:120`

```
          poolPrice(ethers, provider, c.token, c.hook).catch(() => 0n),
```

load() maps any extsload error to price 0, dropping that coin from coinValue. redeemToImdFor (core.js:247-249) uses load() on the wallet provider just before signing and sets minImdOut = 97% of quoteRedeemToImd(s), so one transient RPC error on a coin's read lowers the floor by that coin's share (~20%) while the contract still sells every coin. Later mints likewise get a lower NAV and thus more shares (fails safe via TooExpensive), but the redeem path loses protection. firstMintMinOut refuses price 0; redeemToImdFor does not.

**Reproduction**

Supply 1000, five coins held 200 each at price 1 IMD, idle 0. Redeem 100 IMDEX5 for IMD: page shows 'about 97.0125 IMD (at least 94.1021)'. Make the wallet provider's eth_call to PoolManager.extsload fail for one coin's slot (e.g. 429): redeemToImdFor sends redeemToImd(100, minImdOut≈75.28 IMD). Expected ≈94.10 or an error before the wallet opens.

### 3. Low: Approval is sent before the no-price check and before the mint staticCall, so an impossible mint still costs an approve and leaves an allowance

`docs/core.js:199`

```
    await ensureApproval(ethers, signer, budget, onStep);
```

SITE_SECURITY.md says every write runs staticCall first and that a coin with no price stops the mint before the wallet opens. mintFor approves (line 199) before firstMintMinOut (line 201, which throws on price 0) and before mintFirst/mint.staticCall. The allowance is bounded to budget and spender is the index, so no theft; the cost is a wasted tx, a lingering allowance, and limits read before the approval was mined (load() at 197 is pre-approval, so prices can be stale by the time the mint is signed). Merged from audit_math, audit_flow, audit_economics, audit_permissions. Fix: run load()/firstMintMinOut after approval and the price-zero check before any prompt.

**Reproduction**

Fresh fork (supply 0), allowance 0, set one listed coin's pool slot0 to 0; mintFor(10 IMD): 'Approve exactly 10.0 IMD…' is shown, the approve is mined (allowance 10 IMD), then 'INFER has no pool price; the first mint would fail' is thrown. Same order for a 1000 IMD first mint that then reverts TooLittleOut in staticCall.

### 4. Low: Wallet chain change after first action: getNetwork throws 'network changed' so the switch prompt never runs

`docs/app.js:198`

```
    const net = await browser.getNetwork();
```

BrowserProvider is created without network 'any' (app.js:230) and never rebuilt on chainChanged. ethers 6.13.4 caches the first network and throws NETWORK_ERROR on mismatch, so the wallet_switchEthereumChain branch at 199-202 is only reachable if the wallet was on the wrong chain at the first action. No tx is sent on the wrong chain. Merged from four specialists. Fix: BrowserProvider(window.ethereum,'any') or read eth_chainId via window.ethereum.request, and rebuild on chainChanged.

**Reproduction**

Node with docs/ethers-6.13.4.umd.min.js and a mock EIP-1193 whose eth_chainId is 0x1: signer() resolves. Change it to 0x2105 and call signer() again: throws 'network changed: 1 => 8453', wallet_switchEthereumChain never requested; page shows 'Mint did not go through: network changed: 1 => 8453'.

### 5. Low: Max buttons are disabled by lockIfBusy and never re-enabled

`docs/app.js:17`

```
    document.querySelectorAll("#act button:not([role=tab]), #keepers button").forEach((b) => (b.disabled = true));
```

#buyMax and #sellMax (index.html:62,70) are inside #act, so any action disables them; no code sets their disabled back to false (only references are onclick at app.js:259-260). Fix: reset them in render() or exclude .fit from the lock.

**Reproduction**

Connect, click Mint, reject in wallet. After run() ends and refresh() renders, $('buyMax').disabled === true and $('sellMax').disabled === true until reload.

### 6. Low: ERC20InsufficientBalance raised by the index (over-budget/pool exhaustion) is shown as 'this wallet does not hold enough' and is never retried

`docs/core.js:83`

```
    ERC20InsufficientBalance: "this wallet does not hold enough",
```

explain() maps every ERC20InsufficientBalance to a wallet-side message without checking the sender argument. When buys cost more than the IMD the index holds, the index's own transfer to the PoolManager reverts with ERC20InsufficientBalance(sender=index,…); mintFor only retries TooExpensive, so the user who holds the full budget is told they lack funds. Merged from audit_math and audit_flow. Fix: check e.revert.args[0] against the user/index and word accordingly (and treat sender==index like TooExpensive).

**Reproduction**

Fork after the 10 IMD first mint; account B holds 550 IMD; mintFor(550e18): mint.staticCall reverts ERC20InsufficientBalance(0xC5a8…e779, 107.54e18, 110.47e18); page shows 'Mint did not go through: this wallet does not hold enough'.

### 7. Low: TooLittleOut on the first mint is explained with the redeem-to-IMD text

`docs/core.js:72`

```
    TooLittleOut: "the sale would pay less IMD than your minimum; try a smaller amount or redeem in coins",
```

The contract raises TooLittleOut both in _redeemToImd and in _mintFirst (a coin delivered under minOut). The single message speaks of a sale and redeeming in coins, impossible before the first mint (the live path today, supply 0). Merged from audit_math and audit_economics. Fix: choose the message per operation.

**Reproduction**

Fresh fork (supply 0), mintFor(1000 IMD): mintFirst.staticCall reverts TooLittleOut; page shows 'First mint did not go through: the sale would pay less IMD than your minimum; try a smaller amount or redeem in coins'.

### 8. Low: First-mint quote states the whole post-fee budget as each coin's part

`docs/app.js:142`

```
      $("buyQuote").textContent = "First mint: about " + fmt((amt * 9950n) / 10000n, 4) + " IMDEX5, buying " + s.rows.filter((r) => r.listed).length + " coins with equal parts of " + fmt((amt * 9950n) / 10000n, 4) + " IMD.";
```

firstMintMinOut and the contract split budget*0.995 by the number of listed coins; the text prints the undivided total as 'equal parts of'. Fix: divide by the listed count.

**Reproduction**

Supply 0, type 10: shows 'buying 5 coins with equal parts of 9.95 IMD'; expected 1.99 IMD.

### 9. Info: In-kind redeem quote uses unclamped amount while the IMD quote is clamped to supply

`docs/app.js:164`

```
    const kind = s.rows.filter((r) => r.held > 0n).map((r) => fmt((r.held * amt * keep) / (s.supply * 10000n), 2) + " " + esc(r.symbol));
```

Line 162 clamps amt to supply for the IMD quote; line 164-165 use raw amt, showing more coins than the index holds. Buttons stay disabled (amt > shares), so display only.

**Reproduction**

Supply 4.975, enter 10: IMD half quotes 4.975 shares, coin half quotes ~2x the index's holdings.

---

Judge's submission `81ec742a1a50e24fa2bace56322e6ebbceefa840fc7b33b4ee13bdef797d80aa`, 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.
