# Audit report

> Audit the NFT pools: src/pools/ (MiyaPool, MiyaPoolFactory), at the pinned commit, for a mainnet launch. Read whatever else they depend on, but report on this scope.
>
> miyamints is an NFT launchpad on Ethereum (later Robinhood Chain 4663) built on ms2fun-contracts. MiyaRegistry (src/registry/MiyaRegistry.sol) is a slim, non-upgradeable registry of factories, collections and alignment vaults; it holds the treasury and the fee terms each collection snapshots at launch: a 1% protocol fee to the treasury, a collection-chosen alignment share (minimum 10%) sent to a Uniswap v4 alignment vault, the rest to the artist. Factories: ERC721 fixed-price drops, ERC721 auctions, ERC1155 editions (also used for music drops) with an optional dynamic pricing module. Vaults (src/vaults/uni/) buy the alignment token with collected ETH through zRouter and add full-range v4 liquidity; benefactors claim LP fees pro rata. MiyaPool (src/pools/) is an NFTX-v2-style zero-fee pool: deposit an NFT for 1e18 pool tokens, redeem TARGETED (pick the id) or RANDOM (commit-reveal, ERC721 only), fixed per pool at creation. The deployment is script/DeployMiyamints.s.sol (runbook DEPLOY.md, per-chain config script/config/). An internal audit and its fixes are already in: AUDIT-NOTES.md lists every finding with its status, including M5, which stays unfixed and off at deploy. A finding already listed counts only if its fix is wrong or incomplete.
>
> Answer each numbered question, including the ones where nothing is wrong, and report anything else you find in scope.
>
> 1. 1:1 backing: every pool token is backed by exactly one deposited NFT (1155 units included) at all times, across deposit, TARGETED redeem, RANDOM commit/reveal and swaps. Any path to mint unbacked tokens or withdraw an NFT without burning 1e18?
> 2. RANDOM commit-reveal (M1 fixed, see AUDIT-NOTES.md): each commit draws from a deposit-order queue up to its recorded end, and reveals run in commit order. Prove or break: once a target block is mined, no deposit, reveal order or other action can change the pick. Can any commit block the queue (gas at MAX_COMMIT_UNITS, recipients the collection refuses, escrow via claimEscrowed, a re-armed head)? Is every id drawn at most once, and can the escrow lose an id or pay the wrong address? Also: blockhash window without EIP-2935, and pendingUnits.
> 3. Factory detection of collection kind (ERC721 vs ERC1155 via ERC165), one pool per collection+mode, CREATE2 salts: can someone create a pool for a fake/malicious collection that harms users of the real one, or squat a pool address?
> 4. Hooks and callbacks: onERC721Received / onERC1155Received paths, state updated before transfers; any reentrancy through a malicious collection's transfer hooks?

| | |
|---|---|
| Repository | https://github.com/fa11up/miyamints-contracts |
| Commit | `a991e8c9208f3ccad5372db430633b235abc20ad` |
| Job | `c5f31340-cef1-4254-97e4-a6c9197f0a6a` |
| Judged | 2026-11-04 08:25 UTC |
| Findings | 6 low · 7 info |

Four agents audited the code as it is at `a991e8c`, 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: Receiver hooks credit pool tokens to `from` even when it is address(0) (safe-mint) or a custodial contract, so 1e18 per unit becomes unspendable and the NFT is stranded

`src/pools/MiyaPool.sol:354`

```
        if (to == address(0)) to = from;
```

Merged from audit_math, audit_economics, audit_flow and audit_permissions (same root cause). `_recipient(from, data)` uses `from` whenever the hook data is not a 32-byte address, and ignores the hook's `operator` argument. In an ERC721 `_safeMint` / ERC1155 mint-with-acceptance-check callback `from == address(0)`, and Solady `ERC20._mint` has no zero-address check, so `onERC721Received` (line 188) and `_receive1155` (line 348) mint UNIT per unit to address(0). The accounting invariant `totalSupply + UNIT*pendingUnits == UNIT*heldUnits` still holds, which is why the invariant suite does not see it. But nobody can ever spend those tokens: in a TARGETED pool that NFT can never be redeemed; in a RANDOM pool it stays in the draw queue and is handed to another committer, so the minter just loses it. The same fallback hits an in-repo path where `from` is a contract that cannot use ERC20s: `ERC721AuctionInstance.claimEscrowedToken(tokenId, pool)` calls `_safeTransfer(address(this), pool, tokenId, "")`, so the 1e18 goes to the auction instance (traced by reading the code, not executed). Fix: when the resolved recipient is address(0), use the `operator` argument, or revert `InvalidRecipient()` so the collection's acceptance check fails and the transfer is refused. Optionally do the same when `from` is not the operator and no data was given.

**Reproduction**

Solady ERC721 `SafeMint721` with `safeMint(to,id){_safeMint(to,id);}`; `pool = factory.createPool(nft, RANDOM)`; `nft.safeMint(address(pool), 7)`. Expected: the mint reverts or the minter/operator gets 1e18. Actual (ran test/scratch/PoolMintToZero.t.sol, specialist proof): the call succeeds and `pool.balanceOf(address(0)) == 1e18`. The test fails with `pool tokens minted to address(0): 1000000000000000000 != 0` and passes once the hook reverts or credits the operator.

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

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { Test } from "forge-std/Test.sol";
import { ERC721 } from "solady/tokens/ERC721.sol";
import { MiyaPool } from "src/pools/MiyaPool.sol";
import { MiyaPoolFactory } from "src/pools/MiyaPoolFactory.sol";

/// @dev Any ERC721 that exposes a safe mint to a caller-chosen recipient.
contract SafeMint721 is ERC721 {
    function name() public pure override returns (string memory) {
        return "SM";
    }

    function symbol() public pure override returns (string memory) {
        return "SM";
    }

    function tokenURI(uint256) public pure override returns (string memory) {
        return "";
    }

    function safeMint(address to, uint256 id) external {
        _safeMint(to, id);
    }
}

contract PoolMintToZeroTest is Test {
    uint256 constant UNIT = 1e18;

    function test_safeMintIntoPoolCreditsAddressZero() public {
        MiyaPoolFactory factory = new MiyaPoolFactory();
        SafeMint721 nft = new SafeMint721();
        MiyaPool pool = MiyaPool(factory.createPool(address(nft), MiyaPool.RedeemMode.RANDOM));

        // Mint callback: onERC721Received(operator, from = address(0), id, "").
        // _recipient(from, "") falls back to `from`, which is the zero address.
        (bool ok,) = address(nft).call(abi.encodeCall(nft.safeMint, (address(pool), 7)));
        ok; // the fixed pool either reverts or credits the operator; the current pool accepts the mint

        // Expected: no pool tokens are credited to the zero address. Actual on this code: 1e18 at address(0),
        // and id 7 sits in the RANDOM queue where another commit can draw it.
        assertEq(pool.balanceOf(address(0)), 0, "pool tokens minted to address(0)");
    }
}
```

### 2. Low: onERC721Received ignores the `_ids.add` result: a second receiver callback for an id already held mints 1e18 unbacked tokens (and duplicates the RANDOM queue entry)

`src/pools/MiyaPool.sol:185`

```
        _ids.add(id);
```

Q1 (1:1 backing). The ERC721 hook checks only `msg.sender == collection` and `ownerOf(id) == pool`, both true when the pool already holds `id`. `_ids.add(id)` returns false and the result is dropped. In RANDOM mode the id is pushed into `_queue` a second time, and UNIT is minted anyway. A pool-to-pool safe transfer of a held id therefore inflates supply with nothing behind it. Examples: a collection-level operator or admin transfer helper calling `_safeTransfer(pool, pool, id, abi.encode(attacker))`, a migration helper, or a burn-and-remint of the same id. In RANDOM mode the duplicate entry can later be drawn again: `_ids.remove` returns false and is ignored, `transferFrom` reverts because the pool no longer owns the id, and `escrowedTo[id]` is set for an NFT the pool does not hold. That escrow claim can later take the id if it ever comes back. Precondition: the collection lets some actor run the receiver hook for a pool-owned token without the pool's approval, which is collection-side privilege. The standard Solady/OZ collections and the miyamints instances do not allow it. So this is low hardening, not an open exploit, but it is the only path found that breaks 1:1 backing. Fix: `if (!_ids.add(id)) revert TransferNotReceived(id);` (or a new AlreadyHeld error) before the queue push and the mint.

**Reproduction**

Ran test/scratch/PoolDuplicateHook.t.sol (specialist proof). Setup: ERC721 with an operator-only `operatorSafeTransfer(from,to,id,data)` that calls `_safeTransfer`. TARGETED pool; alice deposits ids 1,2 (supply 2e18, heldUnits 2); the operator calls `operatorSafeTransfer(pool, pool, 1, abi.encode(bob))`. Expected: the hook rejects it and supply stays 2e18. Actual: the test fails with `unbacked tokens minted: 3000000000000000000 != 2000000000000000000`. bob holds 1e18 with no deposit and can redeem id 2, leaving alice's token unbacked.

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

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { Test } from "forge-std/Test.sol";
import { ERC721 } from "solady/tokens/ERC721.sol";
import { MiyaPool } from "src/pools/MiyaPool.sol";
import { MiyaPoolFactory } from "src/pools/MiyaPoolFactory.sol";

/// @dev ERC721 with a collection-level operator that can move any token with a receiver callback
///      (the shape of marketplace proxy registries, "admin transfer" and migration helpers).
contract Operator721 is ERC721 {
    address public immutable operator;

    constructor(address _operator) {
        operator = _operator;
    }

    function name() public pure override returns (string memory) {
        return "Op";
    }

    function symbol() public pure override returns (string memory) {
        return "OP";
    }

    function tokenURI(uint256) public pure override returns (string memory) {
        return "";
    }

    function mint(address to, uint256 id) external {
        _mint(to, id);
    }

    /// @dev Moves `id` from `from` to `to` and runs the ERC721 receiver callback, bypassing owner approval.
    function operatorSafeTransfer(address from, address to, uint256 id, bytes calldata data) external {
        require(msg.sender == operator, "not operator");
        _safeTransfer(from, to, id, data);
    }
}

contract PoolDuplicateHookTest is Test {
    uint256 constant UNIT = 1e18;

    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    function test_secondHookCallForHeldIdMintsUnbackedTokens() public {
        MiyaPoolFactory factory = new MiyaPoolFactory();
        Operator721 nft = new Operator721(address(this));
        MiyaPool pool = MiyaPool(factory.createPool(address(nft), MiyaPool.RedeemMode.TARGETED));

        nft.mint(alice, 1);
        nft.mint(alice, 2);
        uint256[] memory ids = new uint256[](2);
        ids[0] = 1;
        ids[1] = 2;
        vm.startPrank(alice);
        nft.setApprovalForAll(address(pool), true);
        pool.deposit(ids, new uint256[](0), alice);
        vm.stopPrank();
        assertEq(pool.totalSupply(), 2 * UNIT);
        assertEq(pool.heldUnits(), 2);

        // The pool already tracks id 1. A pool->pool transfer with a receiver callback re-enters
        // onERC721Received for the same id: ownerOf(1) == pool passes, _ids.add(1) returns false
        // (ignored), and UNIT is minted to bob with no new NFT behind it.
        (bool ok,) = address(nft).call(
            abi.encodeCall(nft.operatorSafeTransfer, (address(pool), address(pool), 1, abi.encode(bob)))
        );
        ok; // the fixed pool reverts here; the current pool accepts the call

        // Expected: every pool token is backed by one held NFT. Actual on this code: 3e18 supply, 2 held.
        assertEq(pool.totalSupply(), UNIT * pool.heldUnits(), "unbacked tokens minted");
        assertEq(pool.balanceOf(bob), 0, "bob was credited without depositing");
    }
}
```

### 3. Low: On Robinhood Chain (4663) the EIP-2935 branch never works: block.number is the L1 number but the history contract is keyed by L2 number, so the RANDOM window is 256 L1 blocks (~51 min), not 8191

`src/pools/MiyaPool.sol:359`

```
        if (block.number - n > HISTORY_WINDOW || HISTORY.code.length == 0) return bytes32(0);
```

Q2 (blockhash window without EIP-2935). Robinhood Chain is Arbitrum Nitro. Checked live against https://rpc.mainnet.chain.robinhood.com: (a) EVM `block.number` (eth_call of bytecode NUMBER/MSTORE/RETURN) is 26340211, the Ethereum L1 height, while eth_blockNumber / ArbSys.arbBlockNumber() is 105755344. (b) Code exists at 0x0000F90827F1C53a10cb7A02335B175320002935, so `HISTORY.code.length != 0`. It is Arbitrum's adapted bytecode, which calls ArbSys(0x64).arbBlockNumber() (selector 0xa3b1b31d) and keeps a 393168-slot ring buffer indexed by L2 number. (c) Calling it with an L1-number target (26339911) reverts; calling it with an L2 number returns a hash. Since `commitRedeem` stores `block.number + 1` (an L1 number), every history lookup in `_blockHash` fails (`ok == false`), so the seed is 0 and the commit is re-armed. The effective window on 4663 is the 256-block BLOCKHASH window, about 51 minutes of L1 time. The NatSpec (lines 38-40) and AUDIT-NOTES both promise 8191 blocks, and the 'slow but not impossible' re-roll is in fact available about every 51 minutes. In a quiet pool nobody else is motivated to reveal, so a committer can keep leaving a bad result unrevealed until it re-arms. I found no predictable-seed case today: L2 numbers (1.06e8) are far above L1 numbers (2.6e7), so the ranges never overlap. Mainnet (chain 1) is unaffected. Fix: on Nitro chains, record and look up targets in L2 block space (ArbSys.arbBlockNumber() for the target and the history contract for the hash), or drop the history branch on 4663 and correct the NatSpec/AUDIT-NOTES to 256 blocks.

**Reproduction**

`cast call --rpc-url https://rpc.mainnet.chain.robinhood.com --create 0x4360005260206000f3` returns 0x191eb73 (26340211, matches Ethereum height 26340211 at that moment). `cast block-number` on the same RPC returns 105755344. `cast call 0x0000F90827F1C53a10cb7A02335B175320002935 <uint256 26339911>` reverts (execution reverted, data 0x). The same call with <L2 height - 300> returns 0x98142d94...9e34. So a commit with target T on 4663, revealed when 256 < block.number - T <= 8191, gets `_blockHash(T) == 0` and is re-armed instead of settled as the NatSpec says.

### 4. Low: revealRedeem forwards all gas into transferFrom inside try/catch: a recipient that burns gas (via a collection that calls recipients on transferFrom) makes the head commit unrevealable and blocks ever

`src/pools/MiyaPool.sol:315`

```
            try IERC721Pool(collection).transferFrom(address(this), c.recipient, picked[i]) {}
```

Q2 (can a commit block the queue). The escrow fallback assumes a refused transfer fails cheaply. Each `try` forwards 63/64 of the remaining gas, so if the callee burns it all, the catch runs on 1/64 and the next pick forwards 63/64 of that. After about 3 picks there is not enough gas left for the escrow SSTORE, whatever gas limit the revealer uses. The whole reveal reverts, `nextRevealId` never advances, and every later commit (tokens already burned) can never settle. The committer chooses `c.recipient`. The precondition is a collection whose `transferFrom` reaches recipient code, even through a soft try/catch notification. OZ, Solady and ERC721A, and the miyamints Drop and Auction, do not do this. But the factory is permissionless, so any such third-party collection's RANDOM pool is exposed. With a standard collection a 50-unit reveal costs about 1.9M gas (measured: 1,932,577 to an EOA, 1,887,703 to a contract), so MAX_COMMIT_UNITS itself is sound. Fix: cap the gas forwarded, e.g. `transferFrom{ gas: 300_000 }(...)`. A transfer that fails for gas is then escrowed, and `claimEscrowed` runs later on the recipient's own gas. Verified: the attached proof passes with that one-line change.

**Reproduction**

test/scratch/PoolQueueBlock.t.sol: Notify721 (Solady ERC721 whose transferFrom does `try to.onTransferNotify() {} catch {}` for contract recipients); RANDOM pool; alice deposits ids 1..6; commit 0 = commitRedeem(5, GasBurner) where GasBurner loops forever; bob commits 1 unit; roll 2 blocks; revealRedeem(0) with 30M gas. Expected: the refused ids are escrowed and bob's commit is next. Actual: the call fails (`head reveal runs out of gas at any gas limit`) and nextRevealId stays 0. The test passes once the try forwards at most 300k gas.

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

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { Test } from "forge-std/Test.sol";
import { ERC721 } from "solady/tokens/ERC721.sol";
import { MiyaPool } from "src/pools/MiyaPool.sol";
import { MiyaPoolFactory } from "src/pools/MiyaPoolFactory.sol";

interface INotified { function onTransferNotify(address, uint256) external; }

/// @dev ERC721 whose plain transferFrom soft-notifies contract recipients (try/catch, so it never refuses).
contract Notify721 is ERC721 {
    function name() public pure override returns (string memory) { return "N"; }
    function symbol() public pure override returns (string memory) { return "N"; }
    function tokenURI(uint256) public pure override returns (string memory) { return ""; }
    function mint(address to, uint256 id) external { _mint(to, id); }
    function transferFrom(address from, address to, uint256 id) public payable override {
        super.transferFrom(from, to, id);
        if (to.code.length != 0) try INotified(to).onTransferNotify(from, id) {} catch {}
    }
}

contract GasBurner {
    function onTransferNotify(address, uint256) external pure { while (true) {} }
}

contract PoolQueueBlockTest is Test {
    function test_gasBurningRecipientBlocksRevealQueue() public {
        MiyaPoolFactory factory = new MiyaPoolFactory();
        Notify721 nft = new Notify721();
        MiyaPool pool = MiyaPool(factory.createPool(address(nft), MiyaPool.RedeemMode.RANDOM));
        address alice = makeAddr("alice");
        address bob = makeAddr("bob");
        uint256[] memory ids = new uint256[](6);
        for (uint256 i; i < 6; ++i) { ids[i] = i + 1; nft.mint(alice, i + 1); }
        vm.startPrank(alice);
        nft.setApprovalForAll(address(pool), true);
        pool.deposit(ids, new uint256[](0), alice);
        uint256 head = pool.commitRedeem(5, address(new GasBurner()));
        pool.transfer(bob, 1e18);
        vm.stopPrank();
        vm.prank(bob);
        pool.commitRedeem(1, bob); // victim: tokens burned, waits behind the head commit
        vm.roll(block.number + 2);

        (bool ok,) = address(pool).call{ gas: 30_000_000 }(abi.encodeCall(pool.revealRedeem, (head)));
        // Expected (MiyaPool.sol:43-44): a commit never blocks the ones after it.
        assertTrue(ok, "head reveal runs out of gas at any gas limit");
        assertEq(pool.nextRevealId(), 1, "reveal queue stuck at the gas-burning commit");
    }
}
```

### 5. Low: No entry point rejects the pool itself as recipient: redeem/commitRedeem/claimEscrowed to address(this) burns the caller's tokens and leaves the NFT untracked (ERC1155: re-mints the units to the pool)

`src/pools/MiyaPool.sol:242`

```
                IERC721Pool(collection).transferFrom(address(this), to, ids[i]);
```

From audit_flow. ERC721 TARGETED `redeem(ids, [], address(pool))` burns 1e18 and removes the id from `_ids`, then `transferFrom(pool, pool, id)` succeeds (Solady/OZ allow from == to). The NFT is now owned by the pool but untracked. Nothing can move it again: the pool never approves anyone, and the hook only runs on inbound safe transfers. `commitRedeem(n, address(pool))` and `claimEscrowed(id, address(pool))` do the same for RANDOM ids. For ERC1155, `safeBatchTransferFrom(pool, pool, ...)` runs the pool's own `onERC1155BatchReceived` with from = pool and empty data, so the units are re-added and UNIT*total is minted to the pool, where nobody can use it. These are caller mistakes, but the loss is total and irreversible and the guard is one comparison. Fix: `if (to == address(this)) revert InvalidRecipient();` in redeem, commitRedeem and claimEscrowed. Also reject address(this) as the resolved recipient in `_recipient`, which covers deposit(..., address(pool)).

**Reproduction**

test/scratch/PoolLeads.t.sol test_redeemToSelf721_untracked: TARGETED pool, alice deposits id 1, `redeem([1], [], address(pool))`. Expected: revert. Actual: alice's balance is 0, id 1 is owned by the pool and `isHeld(1) == false` (assert `tokens burned for untracked self-transfer: 0 != 1e18`). test_redeemToSelf1155_mintsToPool: Solady ERC1155, alice deposits 3 units of id 1, then `redeem([1],[3],address(pool))`. Actual: `pool.balanceOf(address(pool)) == 3e18`.

### 6. Low: Kind detection takes the first `true` from supportsInterface with no ERC165 sanity probe: a collection answering true for everything becomes an ERC721-kind pool (RANDOM allowed) that cannot accept its

`src/pools/MiyaPoolFactory.sol:58`

```
        if (_supports(collection, ERC721_ID)) return MiyaPool.Kind.ERC721;
```

Q3. Merged from audit_flow and audit_permissions. `_detectKind` checks the ERC721 id first and does not check that the collection rejects 0xffffffff, nor that it does not also claim ERC1155 (the ERC-165 detection procedure, as in OZ ERC165Checker). An ERC1155 whose supportsInterface returns true for everything, or one that claims both interfaces, is deployed as Kind.ERC721, which also bypasses `RandomRequiresERC721`. Its tokens then cannot enter: `deposit` calls the 4-argument ERC721 safeTransferFrom, and a direct 1155 safeTransferFrom reaches `_receive1155`, which reverts NotCollection because kind != ERC1155. `poolOf` is never cleared, so that collection can never get a working pool for that mode. Anyone can trigger this, but the outcome is the same whoever calls, so it is a liveness problem, not theft. Other Q3 checks came back clean: the CREATE2 salt is keccak(collection, mode) with the factory as deployer, so nobody can deploy to a predicted pool address first. A fake collection only gets its own pool, and cannot touch the real pool (collection is immutable and the hooks check msg.sender == collection). Starving the 30k-gas probe is not feasible, because a caller with too little gas for the probe cannot pay for the pool deployment. Minor: `predictPool` takes `kind` from the caller, so a wrong kind predicts an address that will never be used. Fix: require `_supports(c, 0x01ffc9a7) && !_supports(c, 0xffffffff)` and revert when both ids report true; derive `kind` inside predictPool.

**Reproduction**

test/scratch/PoolLeads.t.sol test_allTrue1155_detectedAs721_randomAllowed: Solady ERC1155 with `supportsInterface(bytes4) returns true`. `factory.createPool(c, RANDOM)` succeeds (expected: RandomRequiresERC721). `c.safeTransferFrom(alice, pool, 1, 5, "")` then reverts (assert `1155 deposit into wrongly-kinded pool reverts`), and any further createPool for that mode reverts PoolExists.

### 7. Info: Re-arming an earlier unrevealed commit changes the pick of a later commit whose target block is already mined (the M1 'pick is fixed' guarantee holds only while all earlier commits are inside the hash

`src/pools/MiyaPool.sol:293`

```
            commits[commitId].targetBlock = uint64(target);
```

Q2 (prove or break). Merged from audit_math, audit_flow and audit_permissions. What holds: deposits after a commit land beyond its `queueEnd`, and earlier commits draw only inside their own subranges, so later deposits and reveal order cannot move a pick. The range-size argument also holds: the range at reveal is (queueEnd - head at commit) - (units pending at commit) >= amount, so `% (queueEnd - head)` never divides by zero. And each id is drawn at most once (swap-with-head, then pop). What does not hold: B's pick depends on which ids earlier commit A removed, and A's seed changes if A is re-armed. Leaving A past the window therefore re-rolls B even though B's target hash is fixed. Anyone may reveal A in time, so this is a liveness assumption and adds little beyond the documented self re-roll. But a holder of both A and B can re-roll B too, and on 4663 the window is only about 51 minutes (see the 4663 finding). Fix: state the guarantee as 'fixed once this and every earlier commit's target is mined and still inside the window' in the NatSpec and AUDIT-NOTES M1, or give B a pick that does not depend on A's seed.

**Reproduction**

test/scratch/PoolLeads.t.sol test_rearmChangesLaterPick: ids 1..4 in a RANDOM pool; A = commitRedeem(1, carol) at block 100 (target 101); B = commitRedeem(1, bob) at block 300 (target 301); blockhash(301) fixed. Path 1: reveal A and B at block 302. Path 2: at block 360 revealRedeem(A) re-arms it to 361; at 362 reveal A (new hash) and then B. Within 8 trial hashes for block 361, bob's id differs between the paths (assert `B's pick changed although its target hash did not`).

### 8. Info: Escrow keys on a revert only: a collection whose transferFrom silently returns without moving the token makes the id owned by the pool but neither held nor escrowed

`src/pools/MiyaPool.sol:317`

```
                escrowedTo[picked[i]] = c.recipient;
```

Q2 (can the escrow lose an id). From audit_economics. `revealRedeem` removes the id from `_ids` and the queue before transferring and escrows only in the catch. A non-compliant collection that refuses by returning normally (e.g. `if (blocked[to]) return;`) leaves the id owned by the pool but untracked: it cannot be redeemed, claimed or re-deposited, and the committer's tokens are burned. `claimEscrowed` has the same blind spot. ERC-721 requires transferFrom to throw, so this only affects non-compliant collections. Fix: after the try, `if (IERC721Pool(collection).ownerOf(id) == address(this)) escrowedTo[id] = c.recipient;`. In claimEscrowed, check that ownership moved.

**Reproduction**

test/scratch/PoolLeads.t.sol test_silentRefusalLosesId: Silent721 returns early when `to == blocked`; deposit ids 1,2; commitRedeem(1, bob) with bob blocked; reveal. Actual: id 2 is owned by the pool, `isHeld(2) == false`, `escrowedTo(2) == address(0)` (assert `id owned by pool but neither held nor escrowed: 2 != 0`).

### 9. Info: RANDOM mode is not fair against a depositor: a committer can enlarge its own draw range with same-transaction deposits and take a chosen id with high probability for one token

`src/pools/MiyaPool.sol:280`

```
        commits[commitId] = Commit(to, uint64(target), uint32(amount), uint32(_queueTail));
```

From audit_economics. `queueEnd` is the tail at commit time, including the committer's own deposits in the same transaction. Pool tokens are 1:1, so depositing k floor ids and committing k+1 draws k+1 of the n+k held ids. That takes a rare id R with probability (k+1)/(n+k), net cost one token, the same as a TARGETED redeem. Consecutive 50-unit commits in one tx share the same `queueEnd`, so MAX_COMMIT_UNITS does not limit this. This is inherent to an NFTX-style floor pool and nobody loses more than in TARGETED mode. It matters only if RANDOM is presented as protection against cherry-picking. Fix: document it, or (a design change) exclude ids deposited in the commit's own block.

**Reproduction**

From the code at line 280 and the draw at line 305: pool holds [R, j1, j2]; attacker deposits 48 floor ids and calls commitRedeem(49) in the same tx; queueEnd - head = 51, so 49 of 51 positions are drawn and P(R) = 49/51.

### 10. Info: Pool name()/symbol() and the EIP-712 domain are live reads of the collection: malformed name data bricks DOMAIN_SEPARATOR/permit, and a rename invalidates signed permits

`src/pools/MiyaPool.sol:367`

```
        return abi.decode(ret, (string));
```

Merged from audit_math, audit_economics, audit_flow and audit_permissions. Solady ERC20 hashes `name()` on every permit/DOMAIN_SEPARATOR call because `_constantNameHash` is not overridden. `_collectionString` only guards `ret.length < 64`, so 64+ bytes of invalid ABI make it revert, and name(), symbol(), DOMAIN_SEPARATOR() and permit() then revert for that pool. A renameable collection changes the domain, invalidating outstanding permits. A look-alike collection yields a pool token with identical name and symbol, so integrators must key on `poolOf`. No pool funds are at risk. Fix: snapshot the name hash at construction (override `_constantNameHash`) and fall back to the default string on decode failure (e.g. validate the offset and length before decoding).

**Reproduction**

test/scratch/PoolLeads.t.sol test_badNameBricksPermitDomain: collection `name()` returns abi.encode(0x20, type(uint256).max) (64 bytes); `pool.DOMAIN_SEPARATOR()` reverts with panic 0x41 (memory allocation error).

### 11. Info: Pool tokens give the canonical Permit2 address an infinite allowance by default (Solady ERC20)

`src/pools/MiyaPool.sol:47`

```
contract MiyaPool is ERC20, ReentrancyGuard {
```

From audit_flow. `_givePermit2InfiniteAllowance()` is not overridden and returns true, so `allowance(holder, 0x000000000022D473030F116dDEE9F6B43aC78BA3) == type(uint256).max` and Permit2 skips the allowance check. That address holds canonical Permit2 on Ethereum and on 4663 (script/config/4663.json), so this is safe on the launch chains. But it is an implicit spender the 'no admin, no special powers' NatSpec does not mention. Document it, or override the hook to return false.

**Reproduction**

test/scratch/PoolLeads.t.sol test_permit2Allowance: on a fresh pool, `allowance(alice, PERMIT2)` returns 2^256-1 with no approve (assert `permit2 infinite`).

### 12. Info: Token id 0xfbb67fda52d4bfb8bf can never be pooled (Solady EnumerableSetLib zero sentinel)

`src/pools/MiyaPool.sol:343`

```
            _ids.add(ids[i]);
```

Merged from audit_math, audit_economics and audit_flow. EnumerableSetLib reserves 0xfbb67fda52d4bfb8bf as its internal encoding of 0 and reverts `ValueIsZeroSentinel()` when it is added. Both hooks (line 185 for ERC721, line 343 for ERC1155) add the raw id, so a deposit of that id reverts, and so does any ERC1155 batch containing it. Nothing is lost. Only relevant for hashed or user-chosen id spaces. Document it, or store a remapped id.

**Reproduction**

test/scratch/PoolLeads.t.sol test_sentinelId: mint id 0xfbb67fda52d4bfb8bf to alice; `pool.deposit([id], [], alice)` reverts ValueIsZeroSentinel().

### 13. Info: heldIds(start, count) overflows on start + count instead of clamping

`src/pools/MiyaPool.sol:150`

```
        if (start + count > len) count = len - start;
```

From audit_math. Passing count = type(uint256).max to mean 'everything' panics for any start >= 1. View only. Fix: `if (count > len - start) count = len - start;`.

**Reproduction**

test/scratch/PoolLeads.t.sol test_heldIdsOverflow: pool holding 3 ids; `heldIds(1, type(uint256).max)` reverts with panic 0x11 (expected 2 ids).

---

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