# 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 | `705dd677-45f9-42ee-aec3-4437447e5350` |
| Judged | 2026-10-20 19:13 UTC |
| Findings | 1 medium · 4 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: rebalance books a swap that consumed its input and delivered nothing as a success: idle IMD is spent for no coins and coins are sold for no IMD

`src/Imdex5.sol:514`

```
            idleImd -= amountIn;
```

_tryBuy (lines 510-519) and _trySell (lines 499-508) accept whatever swapSelf returns. When the swap takes its input and returns amountOut == 0, _tryBuy still does `idleImd -= amountIn; held += 0` and _trySell still does `held -= amountIn; idleImd += 0`. mintFirst (line 379) and mint (lines 400-403) refuse exactly this outcome with BuyFailed, but rebalance, which spends the holders' own idle IMD and sells their holdings with no caller-side bound, has no output check at all. The 2% sqrt-price stop only bounds the pool curve; it does not bound a hook with return-delta permissions that takes the output, nor a coin that taxes 100% on transfer (amountOut is the measured balance delta at line 551). The threat model trusts hooks and coins for nothing. Per rebalance, step 4 spends up to idleImd/listCount (capped at a quarter of total value) on each list coin and steps 1 and 3 sell every leaver and every over-weight holding, so a single listed coin whose pool returns nothing can take up to about a quarter of the index's value per weekly rebalance, with accounting (invariants 1 and 2) still balanced, so nothing flags it. Invariant 5 does not cover it because the value goes to the hook/pool, not the caller. Severity medium per the scale: loss under unusual but reachable conditions (a top-5 coin whose hook or tax swallows swap output). Merged from audit_economics; reproduced with the specialist's proof and with a self-contained one (attached). Fix preserving the design: in swapSelf (or in _tryBuy/_trySell) revert when amountIn > 0 && amountOut == 0 so the try/catch skips the swap exactly as mint treats it; a stronger version requires amountOut to be at least the amount implied by the pre-swap sqrt price at the 2% limit, so a hook cannot bypass the stop by returning 1 wei. Design note: README accepts 'a pool's own hook fee'; a hook keeping 100% of output is theft, not a fee, and the code can defend against it cheaply, but the requester should decide where the acceptable fee ends.

**Reproduction**

Mocks as test/Imdex5Base.sol (five pools at 1,000,000 coin / 10,000 IMD, alice mintFirst(1000e18)). Deliver and submit a five-coin list where launch 2000 (NEW_TOKEN) replaces the fifth coin; make NEW_TOKEN's pool take its input and return 0 (pm.setShortBy(key, type(uint128).max) in the repo mock, or the zeroOut flag in the attached proof). Call rebalance(). Expected: the buy that delivers nothing is skipped (SwapSkipped), idle IMD unchanged by it. Actual: the pool's IMD reserve rose by 20397999999999999999 wei in the repo-mock run (39799999999999999999 wei in the attached self-contained run), idleImd fell by the same, held[NEW_TOKEN] stayed 0 and Swapped(NEW_TOKEN, true, amountIn, 0) was emitted. Sell side: with setShortBy on the leaving coin's pool, rebalance() moved held[leaver] from 19511716834983821943327 to 9607794782811941356897 while idleImd did not increase. Scratch runs: test/scratch/ProofRebalanceZero.t.sol (specialist's, both tests fail on this code) and test/scratch/ProofRebalanceZeroSelf.t.sol (attached, fails: 'idle IMD was paid for a buy that delivered nothing: 39799999999999999999 != 0').

### 2. Low: redeemToImd charges the 0.50% fee to the last holder; redeem and the in-kind leg pay 100% (invariant 4: the last holder is paid in full)

`src/Imdex5.sol:442`

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

THREAT_MODEL.md invariant 4 says a redeem pays pro-rata minus 0.50% and 'the last holder is paid in full'. redeem() honours it (keepBps = BPS when supply == shares, line 205) and so does the in-kind fallback of _redeemToImd (keepBps at line 422). The IMD leg of _redeemToImd does not: line 442 takes fee = ceil(proceeds * 50 / 10000) unconditionally on the sold coins plus the idle share, so the holder who burns the whole supply loses 0.50% to feeImd, which never returns to holders (it only pays the oracle and caller rewards). The two exit paths therefore disagree at the boundary supply == shares: redeem(all) pays 100% of idle IMD, redeemToImd(all) pays 99.50% of it. No third party gains, so low: a bounded rule inconsistency. Merged from audit_math, audit_permissions and audit_flow (identical mechanism and line). Fix that keeps the documented economics: `uint256 fee = supply == shares ? 0 : Math.mulDiv(proceeds, FEE_BPS, BPS, Math.Rounding.Ceil);` (keepBps is already computed for this case at line 422). If the unconditional fee on the IMD leg is intended, amend invariant 4 and the redeemToImd NatSpec instead.

**Reproduction**

Mocks as test/Imdex5Base.sol: alice mintFirst(1000e18) and is the only holder with 995e18 shares; feeImd = 5e18. alice calls redeemToImd(995e18, 0). Expected (invariant 4): alice receives all sale proceeds plus all idle IMD, feeImd stays 5e18. Actual: alice receives 990024999999999999995 IMD and feeImd rises by 4975000000000000000 (= ceil(995e18 * 50 / 10000)); totalSupply is 0 afterwards. Compare redeem(995e18) by the same holder: feeImd unchanged, alice receives 100% of idle IMD and every coin. Scratch runs: test/scratch/Judge.t.sol::test_A_lastHolderRedeemToImdPaysNoFee fails '4975000000000000000 != 0'; the attached self-contained test/scratch/ProofLastHolderFee.t.sol fails 'the last holder was charged the 0.50% fee on the IMD leg: 9975000000000000000 != 5000000000000000000'.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Imdex5} from "src/Imdex5.sol";
import {IPoolManager, IUnlockCallback} from "src/Interfaces.sol";

/// A plain ERC-20 etched over IMD and the five coins.
contract PlainERC20 {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;
    function mint(address to, uint256 a) external { balanceOf[to] += a; }
    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
    function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }
    function transferFrom(address f, address t, uint256 a) external returns (bool) {
        allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[t] += a; return true;
    }
}

/// A minimal constant-product stand-in for the v4 PoolManager.
contract MiniPM {
    struct Pool { uint256 r0; uint256 r1; bool exists; }
    uint256 constant Q96 = 2 ** 96;
    bytes32 constant COUNT_SLOT = 0x7d4b3164c6e45b97e7d87b7125a44c5828d005af88f9d751cfd78729c5d99a0b;
    mapping(bytes32 => Pool) public pools;
    mapping(bytes32 => bytes32) slotToPool;
    mapping(address => int256) public delta;
    address[] touched;
    address synced;
    uint256 syncedBal;
    bool unlocked;

    function setPool(IPoolManager.PoolKey memory key, uint256 r0, uint256 r1) external {
        bytes32 id = keccak256(abi.encode(key));
        pools[id] = Pool(r0, r1, true);
        slotToPool[keccak256(abi.encode(id, uint256(6)))] = id;
    }
    function extsload(bytes32 slot) external view returns (bytes32) {
        Pool memory p = pools[slotToPool[slot]];
        if (!p.exists) return bytes32(0);
        return bytes32(Math.sqrt(Math.mulDiv(p.r1, Q96 * Q96, p.r0)));
    }
    function exttload(bytes32 slot) external view returns (bytes32) {
        if (slot != COUNT_SLOT) return bytes32(0);
        uint256 c;
        for (uint256 i; i < touched.length; ++i) if (delta[touched[i]] != 0) c++;
        return bytes32(c);
    }
    function unlock(bytes calldata data) external returns (bytes memory r) {
        require(!unlocked, "AlreadyUnlocked");
        unlocked = true;
        r = IUnlockCallback(msg.sender).unlockCallback(data);
        for (uint256 i; i < touched.length; ++i) require(delta[touched[i]] == 0, "CurrencyNotSettled");
        delete touched;
        unlocked = false;
    }
    function swap(IPoolManager.PoolKey memory key, IPoolManager.SwapParams memory p, bytes calldata) external returns (int256) {
        require(unlocked, "ManagerLocked");
        Pool storage pool = pools[keccak256(abi.encode(key))];
        require(pool.exists, "PoolNotInitialized");
        (uint256 rIn, uint256 rOut) = p.zeroForOne ? (pool.r0, pool.r1) : (pool.r1, pool.r0);
        uint256 aIn; uint256 aOut;
        if (p.amountSpecified < 0) {
            aIn = uint256(-p.amountSpecified);
            aOut = Math.mulDiv(rOut, aIn, rIn + aIn);
        } else {
            aOut = uint256(p.amountSpecified);
            require(aOut < rOut, "insufficient liquidity");
            aIn = Math.mulDiv(rIn, aOut, rOut - aOut, Math.Rounding.Ceil);
        }
        if (p.zeroForOne) { pool.r0 += aIn; pool.r1 -= aOut; } else { pool.r1 += aIn; pool.r0 -= aOut; }
        address inC = p.zeroForOne ? key.currency0 : key.currency1;
        address outC = p.zeroForOne ? key.currency1 : key.currency0;
        _touch(inC); _touch(outC);
        delta[inC] -= int256(aIn);
        delta[outC] += int256(aOut);
        int256 a0 = p.zeroForOne ? -int256(aIn) : int256(aOut);
        int256 a1 = p.zeroForOne ? int256(aOut) : -int256(aIn);
        return int256((uint256(a0) << 128) | uint256(uint128(int128(a1))));
    }
    function sync(address c) external { synced = c; syncedBal = IERC20(c).balanceOf(address(this)); }
    function settle() external payable returns (uint256 paid) {
        paid = IERC20(synced).balanceOf(address(this)) - syncedBal;
        _touch(synced);
        delta[synced] += int256(paid);
    }
    function take(address c, address to, uint256 a) external {
        _touch(c);
        delta[c] -= int256(a);
        require(IERC20(c).transfer(to, a), "take failed");
    }
    function _touch(address c) private {
        for (uint256 i; i < touched.length; ++i) if (touched[i] == c) return;
        touched.push(c);
    }
}

contract NoopHook {
    function beforeSwap(address) external {}
}

/// Fails on the current code: THREAT_MODEL.md invariant 4 says the last holder is paid in full, and
/// `redeem` honours it (keepBps = BPS when supply == shares), but `_redeemToImd` charges the 0.50%
/// fee on the IMD leg unconditionally, so the holder who burns the whole supply loses 0.50% of
/// the proceeds to `feeImd`. Passes once the fee is skipped when `supply == shares`.
contract LastHolderFeeProofTest is Test {
    uint256 constant COIN_RESERVE = 1_000_000e18;
    uint256 constant IMD_RESERVE = 10_000e18;

    Imdex5 index;
    PlainERC20 imd;
    MiniPM pm;
    address alice = makeAddr("alice");

    function setUp() public {
        index = new Imdex5();
        imd = PlainERC20(index.IMD());
        vm.etch(address(imd), address(new PlainERC20()).code);
        pm = MiniPM(index.POOL_MANAGER());
        vm.etch(address(pm), address(new MiniPM()).code);
        Imdex5.Coin[] memory coins = index.coins();
        for (uint256 i = 0; i < coins.length; ++i) {
            vm.etch(coins[i].token, address(new PlainERC20()).code);
            if (coins[i].hook.code.length == 0) vm.etch(coins[i].hook, address(new NoopHook()).code);
            (address c0, address c1) =
                address(imd) < coins[i].token ? (address(imd), coins[i].token) : (coins[i].token, address(imd));
            IPoolManager.PoolKey memory key = IPoolManager.PoolKey(c0, c1, index.POOL_FEE(), index.TICK_SPACING(), coins[i].hook);
            (uint256 r0, uint256 r1) = c0 == coins[i].token ? (COIN_RESERVE, IMD_RESERVE) : (IMD_RESERVE, COIN_RESERVE);
            pm.setPool(key, r0, r1);
            PlainERC20(coins[i].token).mint(address(pm), COIN_RESERVE);
            imd.mint(address(pm), IMD_RESERVE);
        }
        imd.mint(alice, 10_000e18);
        vm.prank(alice);
        imd.approve(address(index), type(uint256).max);
        uint256[] memory minOut = new uint256[](5);
        vm.prank(alice);
        index.mintFirst(1000e18, minOut);
    }

    function test_lastHolderRedeemToImdIsPaidInFull() public {
        uint256 shares = index.balanceOf(alice);
        assertEq(shares, index.totalSupply(), "alice is the only holder");
        uint256 feeBefore = index.feeImd();
        uint256 imdBefore = imd.balanceOf(alice);
        vm.prank(alice);
        index.redeemToImd(shares, 0);
        uint256 received = imd.balanceOf(alice) - imdBefore;
        assertEq(index.totalSupply(), 0);
        assertEq(index.idleImd(), 0);
        // invariant 4: the last holder is paid in full, so nothing moves to feeImd
        assertEq(index.feeImd(), feeBefore, "the last holder was charged the 0.50% fee on the IMD leg");
        // and everything the index holds beyond the pre-existing fee went to alice
        assertEq(imd.balanceOf(address(index)), feeBefore, "IMD that belongs to the last holder stayed behind");
        assertGt(received, 0);
    }
}
```

### 3. Low: submit binds only on keccak256(answer) and the signature: expiresAt, issuedAt, requestId, questionHash and agreed of the presented attestation are not checked (invariant 6 says 'unexpired')

`src/Imdex5.sol:312`

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

submit checks that keccak256(a.answer) equals the hash the Intake delivered and that the panel signed attestationDigest(a); it never calls the inherited _verifyAttestation (src/OracleAttestation.sol lines 157-165), so a.expiresAt and a.issuedAt are not enforced, and a.requestId, a.questionHash and a.agreed are only ever read from the Intake's unsigned delivery in onOracleResult, never from the signed struct. Consequences: (a) an attestation can be submitted any time after its expiry as long as no later ask() cleared answerHash, i.e. up to the 7-day ASK_INTERVAL against a 1-day validForSeconds, so a list the panel computed up to a week earlier can still be adopted and rebalanced on; (b) any attestation the panel ever signed for this consumer whose answer bytes hash to the delivered value passes, whatever its request id, question, window or agreed count. THREAT_MODEL.md invariant 6 requires 'unexpired and not replayed'; README 'Security notes' and ADAPTATION.md describe skipping expiresAt as deliberate (clearing at ask instead). The two documents disagree and the code follows the README. Impact is bounded because the hash binds the answer bytes to what the Intake delivered for the open request, so a different list cannot be substituted; the binding rests entirely on trusting the Intake. Merged from audit_economics, audit_flow (expiry) and audit_permissions (request binding): one mechanism, one line. Fix: replace the bare SignatureChecker call with _verifyAttestation(a, signature) (adds the window check, keeps the signature check) and check a.agreed >= MIN_AGREED; optionally store the delivered requestId in onOracleResult and compare a.requestId at submit. Or drop 'unexpired' from invariant 6.

**Reproduction**

Mocks as test/Imdex5Base.sol: alice fund(2e18), bob ask(), the Intake delivers attestation a for launch 2000 via onOracleResult (agreed 5, expiresAt START+86400). Build b = a with requestId = keccak256('some other request'), questionHash = keccak256('another question'), agreed = 1, issuedAt = START-100, expiresAt = START-1; sign b with the panel key; warp to START + 3 days; carol calls submit(b, sig). Expected per invariant 6: revert (AttestationExpired). Actual: succeeds, nextList() has 1 entry, carol is paid the 0.25 IMD reward. Scratch runs: test/scratch/Judge.t.sol::test_DE_submitAcceptsExpiredAndRetaggedAttestation fails 'next call did not revert as expected'; the specialist's self-contained test/scratch/ProofExpiry.t.sol (attached) fails the same way on an attestation with expiresAt = START-1 submitted at START + 30 days.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {Imdex5} from "src/Imdex5.sol";
import {OracleAttestation, OracleAttestationConsumer} from "src/OracleAttestation.sol";
import {IRegistry} from "src/Interfaces.sol";

contract Erc1271Signer {
    address private immutable KEY;
    constructor(address key) { KEY = key; }
    function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4) {
        (address recovered,,) = ECDSA.tryRecover(hash, signature);
        return recovered == KEY ? bytes4(0x1626ba7e) : bytes4(0xffffffff);
    }
}

contract PlainERC20 {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;
    function mint(address to, uint256 a) external { balanceOf[to] += a; }
    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
    function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }
    function transferFrom(address f, address t, uint256 a) external returns (bool) {
        allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[t] += a; return true;
    }
}

contract Registry is IRegistry {
    mapping(uint64 => bool) public rec;
    mapping(uint64 => Artifact[]) private arts;
    function record(uint64 l, address token, address hook) external {
        rec[l] = true;
        arts[l].push(Artifact(bytes32("token"), bytes32("Token"), token));
        arts[l].push(Artifact(bytes32("hook"), bytes32("Hook"), hook));
    }
    function isRecorded(uint64 l) external view returns (bool) { return rec[l]; }
    function artifactsOf(uint64 l) external view returns (Artifact[] memory) { return arts[l]; }
}

contract Intake {
    uint256 public count;
    bytes32 public lastRequestId;
    function requestWithFailure(bytes32, bytes calldata, Imdex5Callback calldata, bytes4, address asset, uint256 amount)
        external payable returns (bytes32 requestId)
    {
        require(PlainERC20(asset).transferFrom(msg.sender, address(this), amount));
        count += 1;
        requestId = keccak256(abi.encode("request", count));
        lastRequestId = requestId;
    }
    struct Imdex5Callback { address target; bytes4 selector; }
}

/// Fails on the current code: `submit` accepts an attestation whose `expiresAt` is in the past because it
/// checks only the stored answer hash and the signature, never `_verifyAttestation`. THREAT_MODEL.md
/// invariant 6 requires the answer to be "unexpired". Passes once `submit` enforces the validity window.
contract ExpiryProofTest is Test {
    uint256 constant KEY_PK = 0xA11CE;
    uint256 constant START = 1_800_000_000;
    Imdex5 index;
    PlainERC20 imd;
    Registry regA;
    Intake intake;
    address alice = makeAddr("alice");

    function setUp() public {
        vm.warp(START);
        index = new Imdex5();
        imd = PlainERC20(index.IMD());
        vm.etch(address(imd), address(new PlainERC20()).code);
        regA = Registry(address(index.REGISTRY_A()));
        vm.etch(address(regA), address(new Registry()).code);
        vm.etch(address(index.REGISTRY_B()), address(new Registry()).code);
        intake = Intake(index.INTAKE());
        vm.etch(address(intake), address(new Intake()).code);
        vm.etch(index.ORACLE_SIGNER(), address(new Erc1271Signer(vm.addr(KEY_PK))).code);
        regA.record(2000, address(0x1111111111111111111111111111111111111111), address(0x784FF9a3ac5d88A30bfff6F7f2a270161FBE6000));
        imd.mint(alice, 10e18);
        vm.prank(alice);
        imd.approve(address(index), type(uint256).max);
    }

    function test_submitRejectsExpiredAttestation() public {
        vm.prank(alice);
        index.fund(2e18);
        vm.prank(alice);
        index.ask();
        bytes32[] memory ids = new bytes32[](1);
        ids[0] = bytes32(uint256(2000));
        OracleAttestation.Attestation memory a = OracleAttestation.Attestation({
            requestId: keccak256("oracle uuid"), chainId: 1, questionHash: keccak256("q"),
            answerType: OracleAttestation.ANSWER_BYTES32_LIST, answer: abi.encode(ids), figure: 0,
            fromBlock: 100, toBlock: 200, blockHash: bytes32(uint256(7)), panelJobId: keccak256("panel"),
            panelSize: 5, quorum: 4, agreed: 5, issuedAt: uint64(START - 100), expiresAt: uint64(START - 1)
        });
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY_PK, index.attestationDigest(a));
        bytes memory sig = abi.encodePacked(r, s, v);
        bytes32 id = intake.lastRequestId();
        vm.prank(address(intake));
        index.onOracleResult(id, a, "");
        vm.warp(START + 30 days);
        vm.prank(alice);
        vm.expectRevert(abi.encodeWithSelector(OracleAttestationConsumer.AttestationExpired.selector, uint64(START - 1)));
        index.submit(a, sig);
        assertEq(index.nextList().length, 0, "an expired attestation must not set the next list");
    }
}
```

### 4. Low: mint: a need >= 2**255 wraps in int256(amount) and turns the exact-output buy into an exact-input swap

`src/Imdex5.sol:534`

```
            .swap(key, IPoolManager.SwapParams(zeroForOne, exactOut ? int256(amount) : -int256(amount), limit), "");
```

_mintMore computes need = ceil(held * shares / supply) from the caller-chosen shares (line 397) and passes it to _swap as an exact-output amount. _swap casts it with int256(amount) without a range check: for need in (2**255, 2**256) the cast yields a negative amountSpecified, which Uniswap v4 (and the repo mock) reads as an exact-INPUT swap of (2**256 - need) wei of IMD. The mint then no longer 'requests exactly held * f of every coin' (README, Mint): it spends an attacker-chosen IMD amount per coin and issues floor(shares * out / need) shares, and a request that could never be filled succeeds instead of reverting. Because need derives from the same shares, minted still equals out * supply / held (fair, rounded down) and idle IMD is charged on the issued shares, so no dilution or theft was found (invariant 3 holds); what breaks is the exact-output guarantee, the short-fill semantics and the meaning of Minted. In practice every held must be (near-)identical, or only one coin held, for every wrapped need to be affordable; otherwise one coin's exact input hits the price limit and the mint reverts TooExpensive. The floor still applies. From audit_flow; reproduced with its proof. Fix: in _mintMore revert if need > uint256(type(int256).max), or use SafeCast.toInt256 in _swap.

**Reproduction**

Plain constant-product manager, five pools at 1,000,000e18 coin / 10,000e18 IMD, alice mintFirst(1000e18) so supply = 995e18 and held = h for every coin. bob calls mint(shares, 100e18, 0) with shares = mulDiv(2**256 - 1e18, supply, h) (about 5.9e75), so need = ceil(h * shares / supply) is in (2**255, 2**256). Expected: revert (an exact output of about 2**256 coins cannot be delivered; pools hold 1e24). Actual: five exact-input swaps of about 1e18 IMD each run, Minted(bob, 4901960784313725504, 5025000000000000016) is emitted and bob holds 4.9 shares for 5.025 IMD. Scratch run: test/scratch/ProofWrapCast.t.sol (attached) fails 'next call did not revert as expected' on this code.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Imdex5} from "src/Imdex5.sol";
import {IPoolManager, IUnlockCallback} from "src/Interfaces.sol";

/// A plain ERC-20 etched over IMD and the five coins.
contract PlainERC20 {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;
    function mint(address to, uint256 a) external { balanceOf[to] += a; }
    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
    function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }
    function transferFrom(address f, address t, uint256 a) external returns (bool) {
        allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[t] += a; return true;
    }
}

/// A minimal constant-product stand-in for the v4 PoolManager: unlock, swap (exact input when
/// amountSpecified < 0, exact output when > 0, as v4 does), sync/settle/take with deltas, extsload of
/// slot0 and exttload of the nonzero-delta count.
contract MiniPM {
    struct Pool { uint256 r0; uint256 r1; bool exists; }
    uint256 constant Q96 = 2 ** 96;
    bytes32 constant COUNT_SLOT = 0x7d4b3164c6e45b97e7d87b7125a44c5828d005af88f9d751cfd78729c5d99a0b;
    mapping(bytes32 => Pool) public pools;
    mapping(bytes32 => bytes32) slotToPool;
    mapping(address => int256) public delta;
    address[] touched;
    address synced;
    uint256 syncedBal;
    bool unlocked;

    function setPool(IPoolManager.PoolKey memory key, uint256 r0, uint256 r1) external {
        bytes32 id = keccak256(abi.encode(key));
        pools[id] = Pool(r0, r1, true);
        slotToPool[keccak256(abi.encode(id, uint256(6)))] = id;
    }
    function extsload(bytes32 slot) external view returns (bytes32) {
        Pool memory p = pools[slotToPool[slot]];
        if (!p.exists) return bytes32(0);
        return bytes32(Math.sqrt(Math.mulDiv(p.r1, Q96 * Q96, p.r0)));
    }
    function exttload(bytes32 slot) external view returns (bytes32) {
        if (slot != COUNT_SLOT) return bytes32(0);
        uint256 c;
        for (uint256 i; i < touched.length; ++i) if (delta[touched[i]] != 0) c++;
        return bytes32(c);
    }
    function unlock(bytes calldata data) external returns (bytes memory r) {
        require(!unlocked, "AlreadyUnlocked");
        unlocked = true;
        r = IUnlockCallback(msg.sender).unlockCallback(data);
        for (uint256 i; i < touched.length; ++i) require(delta[touched[i]] == 0, "CurrencyNotSettled");
        delete touched;
        unlocked = false;
    }
    function swap(IPoolManager.PoolKey memory key, IPoolManager.SwapParams memory p, bytes calldata) external returns (int256) {
        require(unlocked, "ManagerLocked");
        Pool storage pool = pools[keccak256(abi.encode(key))];
        require(pool.exists, "PoolNotInitialized");
        (uint256 rIn, uint256 rOut) = p.zeroForOne ? (pool.r0, pool.r1) : (pool.r1, pool.r0);
        uint256 aIn; uint256 aOut;
        if (p.amountSpecified < 0) {
            aIn = uint256(-p.amountSpecified);
            aOut = Math.mulDiv(rOut, aIn, rIn + aIn);
        } else {
            aOut = uint256(p.amountSpecified);
            require(aOut < rOut, "insufficient liquidity");
            aIn = Math.mulDiv(rIn, aOut, rOut - aOut, Math.Rounding.Ceil);
        }
        if (p.zeroForOne) { pool.r0 += aIn; pool.r1 -= aOut; } else { pool.r1 += aIn; pool.r0 -= aOut; }
        address inC = p.zeroForOne ? key.currency0 : key.currency1;
        address outC = p.zeroForOne ? key.currency1 : key.currency0;
        _touch(inC); _touch(outC);
        delta[inC] -= int256(aIn);
        delta[outC] += int256(aOut);
        int256 a0 = p.zeroForOne ? -int256(aIn) : int256(aOut);
        int256 a1 = p.zeroForOne ? int256(aOut) : -int256(aIn);
        return int256((uint256(a0) << 128) | uint256(uint128(int128(a1))));
    }
    function sync(address c) external { synced = c; syncedBal = IERC20(c).balanceOf(address(this)); }
    function settle() external payable returns (uint256 paid) {
        paid = IERC20(synced).balanceOf(address(this)) - syncedBal;
        _touch(synced);
        delta[synced] += int256(paid);
    }
    function take(address c, address to, uint256 a) external {
        _touch(c);
        delta[c] -= int256(a);
        require(IERC20(c).transfer(to, a), "take failed");
    }
    function _touch(address c) private {
        for (uint256 i; i < touched.length; ++i) if (touched[i] == c) return;
        touched.push(c);
    }
}

contract NoopHook {
    function beforeSwap(address) external {}
}

/// Fails on the current code: a `need` >= 2**255 is cast to a negative int256 and the "exact output"
/// request becomes an exact-INPUT swap of (2**256 - need) IMD, so the mint succeeds with a handful of
/// shares. Passes once `_mintMore` / `_swap` reject an amount that does not fit int256 (or the mint
/// otherwise reverts for such a request).
contract WrapCastProofTest is Test {
    uint256 constant COIN_RESERVE = 1_000_000e18;
    uint256 constant IMD_RESERVE = 10_000e18;

    Imdex5 index;
    PlainERC20 imd;
    MiniPM pm;
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");
    address[] tokens;

    function setUp() public {
        index = new Imdex5();
        imd = PlainERC20(index.IMD());
        vm.etch(address(imd), address(new PlainERC20()).code);
        pm = MiniPM(index.POOL_MANAGER());
        vm.etch(address(pm), address(new MiniPM()).code);
        Imdex5.Coin[] memory coins = index.coins();
        for (uint256 i = 0; i < coins.length; ++i) {
            vm.etch(coins[i].token, address(new PlainERC20()).code);
            if (coins[i].hook.code.length == 0) vm.etch(coins[i].hook, address(new NoopHook()).code);
            tokens.push(coins[i].token);
            (address c0, address c1) =
                address(imd) < coins[i].token ? (address(imd), coins[i].token) : (coins[i].token, address(imd));
            IPoolManager.PoolKey memory key = IPoolManager.PoolKey(c0, c1, index.POOL_FEE(), index.TICK_SPACING(), coins[i].hook);
            (uint256 r0, uint256 r1) = c0 == coins[i].token ? (COIN_RESERVE, IMD_RESERVE) : (IMD_RESERVE, COIN_RESERVE);
            pm.setPool(key, r0, r1);
            PlainERC20(coins[i].token).mint(address(pm), COIN_RESERVE);
            imd.mint(address(pm), IMD_RESERVE);
        }
        _give(alice);
        _give(bob);
        uint256[] memory minOut = new uint256[](5);
        vm.prank(alice);
        index.mintFirst(1000e18, minOut);
    }

    function _give(address who) internal {
        imd.mint(who, 10_000e18);
        vm.prank(who);
        imd.approve(address(index), type(uint256).max);
    }

    function test_needAboveInt256MaxMustNotMint() public {
        uint256 supply = index.totalSupply();
        uint256 h = index.held(tokens[0]); // every coin has the same held after mintFirst
        uint256 target = type(uint256).max - 1e18 + 1; // 2**256 - 1e18
        uint256 shares = Math.mulDiv(target, supply, h);
        uint256 need = Math.mulDiv(h, shares, supply, Math.Rounding.Ceil);
        assertGt(need, 2 ** 255, "need must exceed int256 max");
        uint256 imdBefore = imd.balanceOf(bob);
        vm.prank(bob);
        // A request for ~5.9e75 shares cannot be served by an exact output of `need` coins (pools hold 1e24):
        // the mint must revert. On the current code it does NOT: the cast wraps, five exact-input swaps of
        // ~1e18 IMD run and ~4.9 shares are minted for ~5.03 IMD.
        vm.expectRevert();
        index.mint(shares, 100e18, 0);
        assertEq(index.balanceOf(bob), 0);
        assertEq(imd.balanceOf(bob), imdBefore);
    }
}
```

### 5. Low: The minShares floor is checked only after every buy has run; minShares > shares is not rejected at entry, so doomed mints pay for all swaps and hooks before reverting

`src/Imdex5.sol:407`

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

minted starts at shares and can only decrease inside the loop (line 401), so once minted < minShares holds for coin i the outcome is fixed, and a call with minShares > shares can never succeed. The check sits after the loop, so both cases still perform every remaining exact-output swap (PoolManager.swap with the hook's beforeSwap/afterSwap, sync, transfer, settle, take, balance reads; mint calls _swap directly with no gas cap) and only then revert TooFewShares, rolling everything back. No funds are lost and nothing is issued; the cost is gas and clarity: a front-runner who forces a short fill on the first list coin makes every floored mint burn the gas of five full swaps, and a hook that burns gas makes the wasted part larger. Merged from audit_math (info), audit_economics (low) and audit_flow (low): same mechanism and line. Fix without semantic change: `if (minShares > shares) revert TooFewShares(shares);` before the loop, and `if (minted < minShares) revert TooFewShares(minted);` right after minted is reduced at line 401.

**Reproduction**

Mocks as test/Imdex5Base.sol, alice mintFirst(1000e18). bob calls mint(100e18, 2000e18, 101e18). Expected: immediate revert. Actual: the -vvvv trace shows five Swapped events (one per coin, amountIn 20438877755511022045 each) inside the unlock before the revert TooFewShares(100000000000000000000); bob's IMD balance is unchanged. Gas: the doomed call used 828788 gas against 873523 for a real mint(100e18, 2000e18, 0) in the same state (test/scratch/Judge.t.sol::test_C_gasOfDoomedMintVersusRealMint, fails '828788 >= 291174'). Likewise, with pm.setShortBy on coin 0 so it fills a sliver (as test_sliverFillBelowFloorRevertsAndRollsBack), mint(100e18, 2000e18, 97e18) buys coins 1..4 before reverting.

### 6. Info: A leftover coin whose pool has no liquidity blocks mint for as long as the pool is empty, not 'until the next rebalance' as THREAT_MODEL.md states

`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'. That bound does not hold for a coin already dropped from the list: _mintMore iterates all of _coins (list plus leftovers) and buys every coin with held > 0 through a direct _swap, not wrapped in try/catch, so a dead pool makes every mint revert. _rebalance step (1) only removes a leftover when _trySell succeeds; while the pool is dead the sale is skipped and line 466 re-appends the coin every cycle, so the block lasts as long as that pool's liquidity providers keep it empty. README 'Security notes' documents exactly this ('every mint reverts until the pool works again and a rebalance sells it'), so it is accepted behaviour; the threat model's wording is the inaccuracy. Holders can still redeem and redeemToImd, so no funds are frozen; the index cannot grow and the fee stream stops. From audit_math; reproduced. Options, each a design decision: align the threat model text with the README, or for coins beyond listCount buy through try swapSelf and treat a failure as a short fill of 0 only when the holding's value is below a dust threshold, or pay an unsellable leftover out to holders in kind at rebalance.

**Reproduction**

Mocks as test/Imdex5Base.sol: alice mintFirst(1000e18); pm.setDead on coin 4's pool; deliver a 4-launch list dropping coin 4; rebalance. State: coins().length == 5 (coin 4 is a leftover with held > 0), listCount == 4. bob mint(10e18, 2000e18, 0): reverts. Warp 8 days, deliver the same list, rebalance again: coins().length still 5 and mint reverts again, while alice redeem(1e18) succeeds. Expected per THREAT_MODEL: mint works after the next rebalance. Actual: blocked across every rebalance while the pool stays dead. Scratch run: test/scratch/Judge.t.sol::test_B_leftoverDeadPoolBlocksMintAcrossRebalances passes, i.e. demonstrates the block.

### 7. Info: mint's ABI changed from launch 1668: the two-argument selector mint(uint256,uint256) no longer exists and reverts with no data

`src/Imdex5.sol:191`

```
    function mint(uint256 shares, uint256 maxImdIn, uint256 minShares) external nonReentrant {
```

The floor was added as a third parameter rather than an overload, and the contract has no fallback, so any site, router or script built against the deployed launch 1668 ABI that calls mint(shares, maxImdIn) hits an unknown selector and reverts with empty return data. The threat model says 'everything else is unchanged', true of behaviour but not of the external interface. Not a vulnerability; an integration note for the launch. From audit_permissions; reproduced. If compatibility matters add `function mint(uint256 shares, uint256 maxImdIn) external { mint(shares, maxImdIn, 0); }` (README says minShares = 0 keeps the old behaviour); otherwise publish the new ABI.

**Reproduction**

After alice mintFirst(1000e18), bob calls address(index).call(abi.encodeWithSignature('mint(uint256,uint256)', 1e18, 100e18)). Expected by a launch-1668 integrator: a mint of up to 1e18 shares. Actual: ok == false with empty revert data; no IMD pulled, no shares. Scratch run: test/scratch/Judge.t.sol::test_F_oldMintSelectorReverts passes (asserts the empty revert).

### 8. Info: The stateful fuzz suites never exercise a nonzero minShares; the new floor is covered only by two unit tests and one fork test

`test/Imdex5.invariant.t.sol:129`

```
        try index.mint(shares, maxIn, 0) {
```

The invariant handler's mint always passes minShares = 0, and the taxed-coin invariant suite (test/Imdex5TaxEverywhere.t.sol) reuses it and also passes 0 in all its direct mint calls, so none of the stateful sequences (partial fills via setPoolMaxIn, dead pools, taxed coins, misbehaving hooks, rebalances) ever reach TooFewShares or assert that a floored mint rolls back cleanly in those states. The floor is pinned only by test_sliverFillBelowFloorRevertsAndRollsBack, test_fullFillPassesFloor and the fork test test_floorPassesAt97_cancelsAt99. There is no test for minShares > shares, for a floor combined with a hook that re-enters or burns gas, or for a floor on the idle-only mint. From audit_economics; verified by grep. Suggest fuzzing minShares in [0, shares] in the handler, asserting minted >= minShares on success and full rollback (shares, IMD, held, idle, fee, pool reserves) on TooFewShares.

**Reproduction**

grep -n 'index.mint(' test/Imdex5.invariant.t.sol test/Imdex5TaxEverywhere.t.sol: every call passes the literal 0 as the third argument (invariant line 129; TaxEverywhere lines 81, 263, 296, 382). grep -rn TooFewShares test/*.t.sol: only test/Imdex5ShortFill.t.sol lines 144-153 reference it.

---

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