# Audit report

> Re-audit the website for The Imdex 5 in docs/ after the fixes for audit c104e817 (2 medium, 6 low, 1 info: report on the IMD jobs page). Read SITE_SECURITY.md first. Confirm each finding is fixed: the signed minShares can never be below the "at least" the page showed (quoteMint is shared by page and transaction; a TooExpensive retry lowers shares, never the floor); a pool price that cannot be read stops mint and redeem-to-IMD before any wallet prompt; no approval is sent before the exact mint call passes a simulation with the allowance overridden (eth_call state override on the wallet's node); the signed floor is the higher of the figure the page showed and the wallet node's figure; the chain is read from the wallet on each action; the Max buttons stay usable; errors read right per action. site-test/audit-c104e817.js reproduces each case on a mainnet fork. Also check what was added since: the max-slippage setting (1/3/5%), the price-impact estimate from each pool's active liquidity, the MEV hint, and the per-coin X and explorer links (a fixed list, never built from on-chain text). Same checks as before: no chain data or token text reaches the page as markup or script; every transaction limit protects the user and comes from the wallet's provider; approvals are exact and only to the index; the CSP and frame guard hold; the vendored ethers matches npm ethers@6.13.4. Report every finding with its severity, a reproduction and the line.

| | |
|---|---|
| Repository | https://github.com/blockmerchantlabs/imdex5.git |
| Commit | `94636c1a1a4ad7d05b8866e7c53b94fa8e5fe15f` |
| Job | `5ca9cd09-4a88-4bba-9d60-3c1c94f74d3f` |
| Judged | 2026-10-21 21:54 UTC |
| Findings | 4 low · 6 info |

Four agents audited the code as it is at `94636c1`, 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. Low: simulateApproved decides the public-node fallback from the word 'revert' in ethers' message, so a wallet node that answers the override call with any JSON-RPC error blocks every mint, while a transpor

`docs/core.js:283`

```
        if (/revert/i.test(m)) throw e;
```

SITE_SECURITY.md line 26 and the comment at docs/core.js:264-265 promise that the pre-approval simulation runs on the wallet's node and falls back to a public node only when the wallet's node lacks state overrides, and that a real revert of the mint stops the action. The discriminator at line 283 is a regex on ethers' error message, and it is wrong in both directions. (1) ethers 6.13.4 turns every JSON-RPC error reply to an eth_call into a CALL_EXCEPTION (JsonRpcApiProvider.getRpcError -> AbiCoder.getBuiltinCallException); when the reply carries no revert data the message is the literal 'missing revert data'. That string matches /revert/i, so a node that rejects the third eth_call parameter (geth-style -32602 'too many arguments, want at most 2'), cloudflare-eth's -32603 'Internal error', or a -32005 rate limit all make simulateApproved throw at once: readProvider() is never tried, lastUnsupported is never set, the 'could not be checked before approval' message is dead code, and mintFor shows 'Mint did not go through: missing revert data' for every budget, first mint and later mints alike, before any approval. (2) A transport-level failure (HTTP 429, timeout, disconnected RPC) is thrown by _send without passing through getRpcError, has no 'revert' in its message, and so falls through: the exact call is then simulated on ethereum-rpc.publicnode.com, and if that passes, the approval prompt is shown. The 'wallet's node' guarantee therefore does not hold in that case either; the check behind the approval comes from a node the trust model treats as display-only. Direction of safety: nothing is approved or sent in case (1), so the impact is availability plus a misleading error; in case (2) the public node only gates the approval, and the on-chain floors still hold, so no funds are at risk. Fix: classify from the JSON-RPC error itself, not from ethers' wording. Treat the call as a revert only when it carries revert data (decodeRevert succeeded, or e.data / e.info.error.data is non-empty) or the node's message contains 'execution reverted'; treat -32601/-32602 (and a CALL_EXCEPTION with no data) as 'this node cannot simulate' and fall through; treat transport failures and other codes as failures that stop the action with a message naming the RPC. Merged from four specialist reports (permissions, economics, flow, math) that describe the two faces of the same line.

**Reproduction**

Run with node from the repository root (no network needed; the public-node provider is stubbed to count eth_calls):

const W = './docs/'; const ethers = require(W + 'ethers-6.13.4.umd.min.js'); const X = require(W + 'core.js');
class ErrNode extends ethers.JsonRpcProvider { constructor(code, message) { super('http://127.0.0.1:1', 1, {staticNetwork:true}); this.err = {code, message}; } async _send(p) { return (Array.isArray(p)?p:[p]).map(q => ({id:q.id, jsonrpc:'2.0', error:this.err})); } }
class DeadNode extends ethers.JsonRpcProvider { constructor() { super('http://127.0.0.1:1', 1, {staticNetwork:true}); } async _send() { throw new Error('server response 429 Too Many Requests'); } }
let pub = 0; const orig = ethers.JsonRpcProvider.prototype.send; ethers.JsonRpcProvider.prototype.send = async function (m, p) { if (!(this instanceof ErrNode) && !(this instanceof DeadNode) && m === 'eth_call') pub++; return orig.call(this, m, p); };
const data = new ethers.Interface(X.INDEX_ABI).encodeFunctionData('mint', [1n, 2n, 1n]);
(async () => { for (const [l, n] of [['geth -32602', new ErrNode(-32602, 'too many arguments, want at most 2')], ['cloudflare -32603', new ErrNode(-32603, 'Internal error')], ['rate limit -32005', new ErrNode(-32005, 'request rate exceeded')], ['transport failure', new DeadNode()]]) { pub = 0; try { await X.simulateApproved(ethers, {provider:n, getAddress: async () => '0xE54d6571aCa515614927F3A70B8957c2B511603C'}, data, 2n); console.log(l, 'passed; public eth_calls', pub); } catch (e) { console.log(l, 'THROWN', JSON.stringify(X.explain(e,'mint')), 'revert', e.revert, 'public eth_calls', pub); } } })();

Expected (SITE_SECURITY.md line 26, core.js 264-265): the three JSON-RPC error cases are retried on the public node (public eth_calls 1) and, if both fail, the 'could not be checked before approval' message; the transport failure stops on the wallet's node or is at least named.
Actual output on this tree (2026-10-21):
geth -32602 THROWN "missing revert data" revert null public eth_calls 0
cloudflare -32603 THROWN "missing revert data" revert null public eth_calls 0
rate limit -32005 THROWN "missing revert data" revert null public eth_calls 0
transport failure THROWN "the first mint has not happened yet" revert FirstMintOnly public eth_calls 1
(the last line shows the public node answering the simulation on the wallet node's behalf: with live mainnet the public node ran the call and returned the contract's own revert). The same -32603 reply is what https://cloudflare-eth.com, one of the site's own read nodes, returns to eth_call today. Through the page: mintFor(ethers, signer, parseEther('10'), 300, step, shown) with such a wallet stops with status 'Mint did not go through: missing revert data' before any approval, for every amount.

### 2. Low: A wallet node that accepts but ignores the eth_call state override makes every first-time mint fail with 'the IMD approval is too low', and the page never requests the approval that would cure it

`docs/core.js:281`

```
        if (r.revert) throw r;
```

simulateApproved overrides the IMD allowance slot so the mint can be checked before any approval exists, then treats any decoded custom-error revert as the mint's own failure and throws. If the wallet's node accepts a third eth_call parameter but does not apply it (a proxy that validates parameter count loosely, a wallet middleware that forwards only [tx, block]), the simulated transferFrom runs against the real allowance of 0 and reverts ERC20InsufficientAllowance(index, 0, budget). decodeRevert parses it (the error is in INDEX_ABI), r.revert is set, line 281 throws, tooDear() is false, and mintFor surfaces ERRORS.ERC20InsufficientAllowance: 'the IMD approval is too low'. The user is told to approve, but the page only ever requests an approval after this simulation passes, so from such a wallet the mint can never start; the public-node fallback that exists for exactly this situation never runs because the answer was a decoded revert. By construction the allowance in the simulation equals `amount`, so an ERC20InsufficientAllowance revert naming the index as spender with allowance below `amount` can only mean the override was not applied. Impact: availability for first-time minters on affected wallets; no funds at risk. Fix: in simulateApproved, treat r.revert.name === 'ERC20InsufficientAllowance' with args[0] == C.INDEX and args[1] < amount as 'override not honoured' and continue to the next provider; if no provider honours it, throw the existing 'could not be checked before approval' error.

**Reproduction**

Mainnet state at block 26243780 (supply 0, five listed coins), owner 0xE54d6571aCa515614927F3A70B8957c2B511603C (holds IMD, allowance to the index 0). From the repository root:

const W='./docs/'; const ethers=require(W+'ethers-6.13.4.umd.min.js'); const X=require(W+'core.js'); const PUB='https://ethereum-rpc.publicnode.com'; const OWNER='0xE54d6571aCa515614927F3A70B8957c2B511603C';
class Ignoring extends ethers.JsonRpcProvider { constructor(){ super(PUB,1,{staticNetwork:true}); } async send(m,p){ if (m==='eth_call') p=p.slice(0,2); return super.send(m,p); } }
(async()=>{ const s=await X.load(ethers, X.readProvider(ethers)); const b=ethers.parseEther('1'); const data=new ethers.Interface(X.INDEX_ABI).encodeFunctionData('mintFirst',[b, X.firstMintMinOut(s,b)]);
 await X.simulateApproved(ethers, new ethers.JsonRpcSigner(new ethers.JsonRpcProvider(PUB,1,{staticNetwork:true}), OWNER), data, b); console.log('honest node: passed');
 try { await X.simulateApproved(ethers, new ethers.JsonRpcSigner(new Ignoring(), OWNER), data, b); console.log('ignoring node: passed'); } catch(e){ console.log('ignoring node THROWN', JSON.stringify(X.explain(e,'mint')), e.revert.name, [...e.revert.args].map(String)); } })();

Expected: the ERC20InsufficientAllowance(index, 0, 1e18) revert is recognised as the override not being applied and the public node is tried, where the simulation passes (as the honest run shows).
Actual (observed 2026-10-21): 'honest node: passed' then 'ignoring node THROWN "the IMD approval is too low" ERC20InsufficientAllowance [0xC5a8A9F9B12480B47456E3338695f6038c56e779, 0, 1000000000000000000]'. mintFor ends at line 332/340 before ensureApproval, so the approval that would cure the message is never requested.

### 3. Low: The MEV hint recommends Flashbots Protect, but that RPC rejects eth_call, and every site action reads and simulates through the wallet's node, so a user who follows the hint cannot mint, redeem or cla

`docs/index.html:68`

```
      <p class="tag">Tip: send through an MEV-protected RPC (for example Flashbots Protect) so bots cannot sandwich the trade.</p>
```

Following the hint means setting the wallet's Ethereum network RPC to https://rpc.flashbots.net (the Protect RPC). That endpoint answers eth_blockNumber but returns {code:-32601, message:'rpc method is not whitelisted'} for every eth_call. The site's design (SITE_SECURITY.md line 20: limits recomputed from the wallet's own provider) makes every action start with load(ethers, signer.provider) or a staticCall on the signer, i.e. eth_call on the wallet's node (core.js lines 321, 384, 388, 395), so mintFor, redeemToImdFor and call() all fail on the first read; ethers reports the -32601 as a CALL_EXCEPTION 'missing revert data' and explain() shows exactly that. The user is told to use an RPC on which the page cannot work, and the error does not say why. https://rpc.mevblocker.io answers eth_call (and the state-override form), so a working example exists. Fix: name an MEV-protected RPC that serves eth_call (verified at release time), or state that the protected RPC must support eth_call, and have explain() turn a CALL_EXCEPTION without revert data into a message naming the wallet's RPC.

**Reproduction**

curl -s -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0xC5a8A9F9B12480B47456E3338695f6038c56e779","data":"0x18160ddd"},"latest"]}' https://rpc.flashbots.net
-> {"jsonrpc":"2.0","error":{"code":-32601,"message":"rpc method is not whitelisted"},"id":1}  (observed 2026-10-21; eth_blockNumber on the same endpoint -> 0x19072c4; the same eth_call on https://rpc.mevblocker.io -> a result).
With a wallet whose Ethereum RPC is rpc.flashbots.net, press Mint with 10 IMD. Expected: a working mint, or a message naming the RPC. Actual: run() -> signer() passes (eth_chainId is served), mintFor -> load(ethers, signer.provider) -> x.coins() -> eth_call -> CALL_EXCEPTION, status 'Mint did not go through: missing revert data'. Redeem for IMD, Redeem for the coins and Claim fail the same way at their first staticCall.

### 4. Low: The price-impact estimate for later mints sizes each coin's buy against coin value only, ignoring idle IMD, so it overstates impact and the 3% warning fires on trades that do not reach it

`docs/core.js:137`

```
        : s.coinValue > 0n ? (budget * r.value) / s.coinValue : 0n;
```

A later mint buys held[token] x shares / supply of each coin (src/Imdex5.sol:416) and takes idleImd x shares / supply as idle (line 451), so the IMD spent in coin i's pool is about budget x value_i / total where total = coinValue + idle (the quote itself prices shares at nav = total / supply, line 295). mintImpactBps divides by s.coinValue instead of s.total. Each pool's buy is overstated by total / coinValue, so the figure shown as 'Price impact: about X%' is wrong whenever idle IMD is not zero, and the 'large for these pools' warning appears on trades that do not reach 3%. The warning only informs and no limit depends on it (SITE_SECURITY.md line 37), so it never loses funds. Fix: use s.total (or s.coinValue + s.idle) as the divisor at line 137.

**Reproduction**

From the repository root: const ethers=require('./docs/ethers-6.13.4.umd.min.js'); const X=require('./docs/core.js'); const e=ethers.parseEther; const s={supply:e('100'), coinValue:e('50'), idle:e('50'), rows:[{listed:true, value:e('50'), imdDepth:e('1000')}]}; console.log(X.mintImpactBps(s, e('60')).toString());
State: coinValue 50 IMD, idle 50 IMD (total 100), one coin with imdDepth 1000 IMD and value 50; mint budget 60 IMD. Real spend in the pool is about 60 x 50 / 100 = 30 IMD, impact = 30 / 1030 = 2.9% (no warning). Expected: about 291 bps. Actual output: 566 (part = 60 x 50 / 50 = 60, impact = 60 / 1060), and impactLine() in app.js renders the 'large for these pools' warning.

### 5. Info: cloudflare-eth.com, the third read node and a connect-src entry, no longer answers; the site has effectively one working read node and the override fallback uses only publicnode

`docs/core.js:17`

```
    RPCS: ["https://ethereum-rpc.publicnode.com", "https://eth.llamarpc.com", "https://cloudflare-eth.com"],
```

On 2026-10-21 https://cloudflare-eth.com returned {code:-32046, message:'Cannot fulfill request'} to eth_blockNumber and {code:-32603, message:'Internal error'} to eth_call; https://eth.llamarpc.com answered a plain POST with a Cloudflare challenge page (HTML), so it may or may not serve a browser. connectReader() skips a node whose getBlockNumber fails, so the page degrades correctly to 'Could not reach an Ethereum node' when publicnode is also down, but in practice the site has one working read node, and simulateApproved's readProvider(ethers) uses only RPCS[0]. Not a safety issue (the read nodes are trusted only for displayed figures); replace the dead entry and the matching CSP connect-src entry (index.html line 6) with a node that answers, and consider letting the simulation fallback walk the list.

**Reproduction**

curl -s -X POST -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' https://cloudflare-eth.com
-> {"jsonrpc":"2.0","error":{"code":-32046,"message":"Cannot fulfill request"},"id":1}  (observed 2026-10-21). Expected: a block number, as https://ethereum-rpc.publicnode.com returns (0x19072c4). Actual: an error, so the entry never serves the page.

### 6. Info: The 'at least' figure is rounded half-up to 4 decimals, so the page can show a floor a hair above the one signed

`docs/app.js:172`

```
      $("buyQuote").innerHTML = "About " + fmt(q.shares, 4) + " IMDEX5 (at least " + fmt(q.minShares, 4) + ", or nothing happens). Unused IMD is refunded." + impactLine(X.mintImpactBps(s, amt));
```

fmt() uses toLocaleString with maximumFractionDigits, which rounds half up. quoteMint's minShares is signed exactly (mintFor signs max(shown.minShares, fresh) from the same quoteMint), so the displayed 'at least' can exceed the signed floor by up to 0.00005 shares, and the redeem quote's 'at least' on line 194 by up to 0.00005 IMD. The claim 'the signed minShares is never below the at least the page showed' holds in substance, not to the last digit. Rounding the displayed floor down (truncate to the shown decimals) makes the statement exact.

**Reproduction**

With app.js's fmt copied verbatim into node (it only needs ethers.formatEther): fmt(999950000000000000n, 4) returns '1' while the transaction signs minShares = 0.99995; fmt(1234999950000000000000n, 4) returns '1,235' while 1234.99995 is signed (observed on this tree). Expected: the shown floor is never above the signed one; actual: it can be, by half a unit of the last shown digit.

### 7. Info: The Idle IMD row links to the X account @frenpetonbase, which nothing in the repository or on IMD's own sites confirms as IMD's handle

`docs/app.js:55`

```
    "0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7": "frenpetonbase",
```

SOCIAL is keyed by token address and this key is C.IMD, the IMD token itself. render() (line 124) calls social(X.C.IMD) for the Idle IMD row, so that row's X icon links to x.com/frenpetonbase, titled '@frenpetonbase on X'. The comment at lines 52-53 says the handles come from Radar's registry, but no record in the repository confirms that handle for IMD, imd.fun and explorer.imd.fun show no X handle at all (only Telegram), and explorer.imd.fun/token/<IMD address> returns 404, so neither the fallback nor the handle can be checked from here. The list is fixed and nothing comes from on-chain text, so this is not an injection; it is a link users may trust as IMD's official account. Confirm the entry against the registry, or drop it so the row falls back to the Etherscan link only.

**Reproduction**

Load docs/index.html and look at the last table row ('Idle IMD'): the X icon's href is https://x.com/frenpetonbase. Expected: IMD's confirmed handle, or no X link if none is confirmed. Checked 2026-10-21: https://imd.fun/ and https://explorer.imd.fun/ list no X/Twitter handle; https://explorer.imd.fun/token/0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7 -> HTTP 404 (the coin pages, e.g. /token/0x53d0eA0A685220718Dc6a0a6e4617194C6F37921, return 200).

### 8. Info: SITE_SECURITY.md says only the theme is kept in localStorage, but the max-slippage choice is stored too

`SITE_SECURITY.md:45`

```
- **Storage:** only the theme choice is kept, in localStorage.
```

Since commit 20fad07 app.js also persists the max-slippage choice under the key imdex5-slip (written at docs/app.js:299, read back at line 11). The read is validated against the fixed list [100, 300, 500], so there is no security impact, but the security model an auditor is told to read first no longer matches the page. Fix: list both keys (imdex5-theme, imdex5-slip) in the Storage line, or remove the word 'only'. Merged from three specialist reports.

**Reproduction**

Open docs/index.html, change the Max slippage select to 1%, then inspect localStorage for the origin. Expected per SITE_SECURITY.md line 45: a single key, the theme. Actual: imdex5-slip = '100' is stored by docs/app.js:299 (and imdex5-theme if the theme was ever switched).

### 9. Info: Nothing in site-test/ exercises the slippage setting, the price-impact estimate or the per-coin links, so a regression there would go unnoticed

`site-test/audit-c104e817.js:1`

```
// Regression tests for the site audit c104e817 (2 medium, 6 low, 1 info), run against a mainnet fork:
```

Neither site-test/fork.js nor site-test/audit-c104e817.js calls quoteMint or quoteRedeemToImd with 100 or 500 (that minShares and minImdOut follow the 1/3/5% choice), poolDepth / impactBps / mintImpactBps (the depth read and the warning threshold), or renders the SOCIAL / LOGOS table. The defect at docs/core.js:137 (impact divisor) is an example of what a test would have caught. Add cases for the three slippage values, a mint impact with idle IMD present, and a redeem impact.

**Reproduction**

grep -n -i 'slip\|impact\|depth\|SOCIAL\|LOGOS' site-test/*.js returns nothing; the only slippage argument used anywhere in site-test is the literal 300 (fork.js lines 23, 31, 35; audit-c104e817.js lines 44-128). Expected: at least one case per new feature.

### 10. Info: A first mint that races a rebalance shows the bare Solidity error name BadLength

`docs/core.js:233`

```
    if (name) return name;
```

explain() maps known custom errors to sentences and otherwise prints the raw error name. BadLength is reachable from the page: mintFirst requires minOut.length == listCount (src/Imdex5.sol:184), and a rebalance can change listCount (src/Imdex5.sol:508) between the wallet-node read in mintFor (line 321) and the simulated call. The status then reads 'First mint did not go through: BadLength'. ZeroAmount is the other reachable name without a sentence (a 0 budget is stopped by the page, but mintFor itself does not refuse it). Nothing is sent, so this is wording only. Fix: add BadLength (and ZeroAmount) to ERRORS.

**Reproduction**

const X=require('./docs/core.js'); console.log(X.explain({revert:{name:'BadLength', args:[]}}, 'first')) -> 'BadLength'. State that produces it: the index has no supply (mainnet today: supply 0, five listed coins); a visitor clicks 'Make the first mint' with 10 IMD; mintFor builds minOut with 5 entries (lines 329-331); a rebalance whose next list holds a different number of coins lands before the eth_call; the simulation reverts BadLength() (src/Imdex5.sol:184). Expected: a sentence like the other errors ('the coin list changed while you were minting; refresh and try again'). Actual: the bare name.

---

Judge's submission `9b76c7fbc7894af4f74adb4810d0f45d7831343712fc04251131c89d26aa9d25`, 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.
