{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"c5f31340-cef1-4254-97e4-a6c9197f0a6a","kind":"audit","nodes":[{"acceptedSubmissionHash":"90cbb7439e1d428fc4b9fbf097128b2f26c006b760b5eabc26b9c2781676341b","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"a1551a8dd9f86c5b2fd7a98d05b0c1a1b980809fc4c340930156db7add0daa21","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"0dd1b2beccbf2b1a20a6e468d1c513dd089003726b8c204e5d1f543d153bc7cc","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","subagents":4,"tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"f2ff29ac9aab6bfa6fd7ddc5e189a340a6b21c2c98ff491e97460865e5b885d9","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"5ad7334938305555f4c816f55741444e612fcb393a2dbbf8b4c1b0ea9ac21e6b","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"}],"objective":"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.\n\nmiyamints 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.\n\nAnswer each numbered question, including the ones where nothing is wrong, and report anything else you find in scope.\n\n1. 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?\n2. 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.\n3. 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?\n4. Hooks and callbacks: onERC721Received / onERC1155Received paths, state updated before transfers; any reentrancy through a malicious collection's transfer hooks?","parentJobId":null,"planHash":"c254dab278578c77da8699e9fd67b054d6d48eb805ac9d4f6a6f693fa362fdaa","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"c5f31340-cef1-4254-97e4-a6c9197f0a6a","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51886","feedbackHash":"b62af8b6c94e1aa810bfac94037aef2f830a70f88a91a54b52fa135b7886030c","nodeKey":"audit_economics","submissionHash":"90cbb7439e1d428fc4b9fbf097128b2f26c006b760b5eabc26b9c2781676341b","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51298","feedbackHash":"b6f594ef7275912882b741e2e4bbb9c25b894787570e0e8ebe1319403748ed1a","nodeKey":"audit_flow","submissionHash":"a1551a8dd9f86c5b2fd7a98d05b0c1a1b980809fc4c340930156db7add0daa21","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52614","feedbackHash":"4fa68386f3fe90c3b8a428c4b80cdbdc1472f7dfb2387cb4d6ccf13bea148eed","nodeKey":"audit_judge","submissionHash":"0dd1b2beccbf2b1a20a6e468d1c513dd089003726b8c204e5d1f543d153bc7cc","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50961","feedbackHash":"8604c8795cf2424fd6a42d3562b3a7d0d03c6c932fd08a269bc8fed53970886d","nodeKey":"audit_math","submissionHash":"f2ff29ac9aab6bfa6fd7ddc5e189a340a6b21c2c98ff491e97460865e5b885d9","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51083","feedbackHash":"b1d1eae93689f9b8d2913a32b3a851a59093cb9f3a4fdb13116e6b19a9967c14","nodeKey":"audit_permissions","submissionHash":"5ad7334938305555f4c816f55741444e612fcb393a2dbbf8b4c1b0ea9ac21e6b","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"2dddd921f141a9b18586df280ec1bc301935050e60c721acfaa737b963594e4a","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9506ecec995bdbf9","findings":[{"citation":"resolved","description":"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.","line":354,"path":"src/pools/MiyaPool.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport { Test } from \"forge-std/Test.sol\";\nimport { ERC721 } from \"solady/tokens/ERC721.sol\";\nimport { MiyaPool } from \"src/pools/MiyaPool.sol\";\nimport { MiyaPoolFactory } from \"src/pools/MiyaPoolFactory.sol\";\n\n/// @dev Any ERC721 that exposes a safe mint to a caller-chosen recipient.\ncontract SafeMint721 is ERC721 {\n    function name() public pure override returns (string memory) {\n        return \"SM\";\n    }\n\n    function symbol() public pure override returns (string memory) {\n        return \"SM\";\n    }\n\n    function tokenURI(uint256) public pure override returns (string memory) {\n        return \"\";\n    }\n\n    function safeMint(address to, uint256 id) external {\n        _safeMint(to, id);\n    }\n}\n\ncontract PoolMintToZeroTest is Test {\n    uint256 constant UNIT = 1e18;\n\n    function test_safeMintIntoPoolCreditsAddressZero() public {\n        MiyaPoolFactory factory = new MiyaPoolFactory();\n        SafeMint721 nft = new SafeMint721();\n        MiyaPool pool = MiyaPool(factory.createPool(address(nft), MiyaPool.RedeemMode.RANDOM));\n\n        // Mint callback: onERC721Received(operator, from = address(0), id, \"\").\n        // _recipient(from, \"\") falls back to `from`, which is the zero address.\n        (bool ok,) = address(nft).call(abi.encodeCall(nft.safeMint, (address(pool), 7)));\n        ok; // the fixed pool either reverts or credits the operator; the current pool accepts the mint\n\n        // Expected: no pool tokens are credited to the zero address. Actual on this code: 1e18 at address(0),\n        // and id 7 sits in the RANDOM queue where another commit can draw it.\n        assertEq(pool.balanceOf(address(0)), 0, \"pool tokens minted to address(0)\");\n    }\n}","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.","severity":"low","snippet":"        if (to == address(0)) to = from;","title":"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"},{"citation":"resolved","description":"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.","line":185,"path":"src/pools/MiyaPool.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport { Test } from \"forge-std/Test.sol\";\nimport { ERC721 } from \"solady/tokens/ERC721.sol\";\nimport { MiyaPool } from \"src/pools/MiyaPool.sol\";\nimport { MiyaPoolFactory } from \"src/pools/MiyaPoolFactory.sol\";\n\n/// @dev ERC721 with a collection-level operator that can move any token with a receiver callback\n///      (the shape of marketplace proxy registries, \"admin transfer\" and migration helpers).\ncontract Operator721 is ERC721 {\n    address public immutable operator;\n\n    constructor(address _operator) {\n        operator = _operator;\n    }\n\n    function name() public pure override returns (string memory) {\n        return \"Op\";\n    }\n\n    function symbol() public pure override returns (string memory) {\n        return \"OP\";\n    }\n\n    function tokenURI(uint256) public pure override returns (string memory) {\n        return \"\";\n    }\n\n    function mint(address to, uint256 id) external {\n        _mint(to, id);\n    }\n\n    /// @dev Moves `id` from `from` to `to` and runs the ERC721 receiver callback, bypassing owner approval.\n    function operatorSafeTransfer(address from, address to, uint256 id, bytes calldata data) external {\n        require(msg.sender == operator, \"not operator\");\n        _safeTransfer(from, to, id, data);\n    }\n}\n\ncontract PoolDuplicateHookTest is Test {\n    uint256 constant UNIT = 1e18;\n\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n\n    function test_secondHookCallForHeldIdMintsUnbackedTokens() public {\n        MiyaPoolFactory factory = new MiyaPoolFactory();\n        Operator721 nft = new Operator721(address(this));\n        MiyaPool pool = MiyaPool(factory.createPool(address(nft), MiyaPool.RedeemMode.TARGETED));\n\n        nft.mint(alice, 1);\n        nft.mint(alice, 2);\n        uint256[] memory ids = new uint256[](2);\n        ids[0] = 1;\n        ids[1] = 2;\n        vm.startPrank(alice);\n        nft.setApprovalForAll(address(pool), true);\n        pool.deposit(ids, new uint256[](0), alice);\n        vm.stopPrank();\n        assertEq(pool.totalSupply(), 2 * UNIT);\n        assertEq(pool.heldUnits(), 2);\n\n        // The pool already tracks id 1. A pool->pool transfer with a receiver callback re-enters\n        // onERC721Received for the same id: ownerOf(1) == pool passes, _ids.add(1) returns false\n        // (ignored), and UNIT is minted to bob with no new NFT behind it.\n        (bool ok,) = address(nft).call(\n            abi.encodeCall(nft.operatorSafeTransfer, (address(pool), address(pool), 1, abi.encode(bob)))\n        );\n        ok; // the fixed pool reverts here; the current pool accepts the call\n\n        // Expected: every pool token is backed by one held NFT. Actual on this code: 3e18 supply, 2 held.\n        assertEq(pool.totalSupply(), UNIT * pool.heldUnits(), \"unbacked tokens minted\");\n        assertEq(pool.balanceOf(bob), 0, \"bob was credited without depositing\");\n    }\n}","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.","severity":"low","snippet":"        _ids.add(id);","title":"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)"},{"citation":"resolved","description":"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.","line":359,"path":"src/pools/MiyaPool.sol","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.","severity":"low","snippet":"        if (block.number - n > HISTORY_WINDOW || HISTORY.code.length == 0) return bytes32(0);","title":"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"},{"citation":"resolved","description":"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.","line":315,"path":"src/pools/MiyaPool.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport { Test } from \"forge-std/Test.sol\";\nimport { ERC721 } from \"solady/tokens/ERC721.sol\";\nimport { MiyaPool } from \"src/pools/MiyaPool.sol\";\nimport { MiyaPoolFactory } from \"src/pools/MiyaPoolFactory.sol\";\n\ninterface INotified { function onTransferNotify(address, uint256) external; }\n\n/// @dev ERC721 whose plain transferFrom soft-notifies contract recipients (try/catch, so it never refuses).\ncontract Notify721 is ERC721 {\n    function name() public pure override returns (string memory) { return \"N\"; }\n    function symbol() public pure override returns (string memory) { return \"N\"; }\n    function tokenURI(uint256) public pure override returns (string memory) { return \"\"; }\n    function mint(address to, uint256 id) external { _mint(to, id); }\n    function transferFrom(address from, address to, uint256 id) public payable override {\n        super.transferFrom(from, to, id);\n        if (to.code.length != 0) try INotified(to).onTransferNotify(from, id) {} catch {}\n    }\n}\n\ncontract GasBurner {\n    function onTransferNotify(address, uint256) external pure { while (true) {} }\n}\n\ncontract PoolQueueBlockTest is Test {\n    function test_gasBurningRecipientBlocksRevealQueue() public {\n        MiyaPoolFactory factory = new MiyaPoolFactory();\n        Notify721 nft = new Notify721();\n        MiyaPool pool = MiyaPool(factory.createPool(address(nft), MiyaPool.RedeemMode.RANDOM));\n        address alice = makeAddr(\"alice\");\n        address bob = makeAddr(\"bob\");\n        uint256[] memory ids = new uint256[](6);\n        for (uint256 i; i < 6; ++i) { ids[i] = i + 1; nft.mint(alice, i + 1); }\n        vm.startPrank(alice);\n        nft.setApprovalForAll(address(pool), true);\n        pool.deposit(ids, new uint256[](0), alice);\n        uint256 head = pool.commitRedeem(5, address(new GasBurner()));\n        pool.transfer(bob, 1e18);\n        vm.stopPrank();\n        vm.prank(bob);\n        pool.commitRedeem(1, bob); // victim: tokens burned, waits behind the head commit\n        vm.roll(block.number + 2);\n\n        (bool ok,) = address(pool).call{ gas: 30_000_000 }(abi.encodeCall(pool.revealRedeem, (head)));\n        // Expected (MiyaPool.sol:43-44): a commit never blocks the ones after it.\n        assertTrue(ok, \"head reveal runs out of gas at any gas limit\");\n        assertEq(pool.nextRevealId(), 1, \"reveal queue stuck at the gas-burning commit\");\n    }\n}","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.","severity":"low","snippet":"            try IERC721Pool(collection).transferFrom(address(this), c.recipient, picked[i]) {}","title":"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"},{"citation":"resolved","description":"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)).","line":242,"path":"src/pools/MiyaPool.sol","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`.","severity":"low","snippet":"                IERC721Pool(collection).transferFrom(address(this), to, ids[i]);","title":"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)"},{"citation":"resolved","description":"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.","line":58,"path":"src/pools/MiyaPoolFactory.sol","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.","severity":"low","snippet":"        if (_supports(collection, ERC721_ID)) return MiyaPool.Kind.ERC721;","title":"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"},{"citation":"resolved","description":"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.","line":293,"path":"src/pools/MiyaPool.sol","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`).","severity":"info","snippet":"            commits[commitId].targetBlock = uint64(target);","title":"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"},{"citation":"resolved","description":"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.","line":317,"path":"src/pools/MiyaPool.sol","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`).","severity":"info","snippet":"                escrowedTo[picked[i]] = c.recipient;","title":"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"},{"citation":"resolved","description":"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.","line":280,"path":"src/pools/MiyaPool.sol","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.","severity":"info","snippet":"        commits[commitId] = Commit(to, uint64(target), uint32(amount), uint32(_queueTail));","title":"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"},{"citation":"resolved","description":"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).","line":367,"path":"src/pools/MiyaPool.sol","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).","severity":"info","snippet":"        return abi.decode(ret, (string));","title":"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"},{"citation":"resolved","description":"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.","line":47,"path":"src/pools/MiyaPool.sol","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`).","severity":"info","snippet":"contract MiyaPool is ERC20, ReentrancyGuard {","title":"Pool tokens give the canonical Permit2 address an infinite allowance by default (Solady ERC20)"},{"citation":"resolved","description":"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.","line":343,"path":"src/pools/MiyaPool.sol","reproduction":"test/scratch/PoolLeads.t.sol test_sentinelId: mint id 0xfbb67fda52d4bfb8bf to alice; `pool.deposit([id], [], alice)` reverts ValueIsZeroSentinel().","severity":"info","snippet":"            _ids.add(ids[i]);","title":"Token id 0xfbb67fda52d4bfb8bf can never be pooled (Solady EnumerableSetLib zero sentinel)"},{"citation":"resolved","description":"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;`.","line":150,"path":"src/pools/MiyaPool.sol","reproduction":"test/scratch/PoolLeads.t.sol test_heldIdsOverflow: pool holding 3 ids; `heldIds(1, type(uint256).max)` reverts with panic 0x11 (expected 2 ids).","severity":"info","snippet":"        if (start + count > len) count = len - start;","title":"heldIds(start, count) overflows on start + count instead of clamping"}],"hash":"0dd1b2beccbf2b1a20a6e468d1c513dd089003726b8c204e5d1f543d153bc7cc","nodeId":"52702883-b72b-4a7f-9d98-0d43811f63ac","outcome":"completed","summary":"I found no critical, high or medium issues in `src/pools/`. I kept 13 findings (6 low, 7 info), and each one was reproduced against the code. The 4 lows with the most impact carry Foundry proofs that fail on the current code. I also patched a copy of the repo in `/tmp` and confirmed three of those proofs pass with the minimal fix. The findings are in `.imd-findings.json`.\n\n## Answers to the four questions\n\n**1. 1:1 backing.** It holds on every normal path: deposit, TARGETED redeem, RANDOM commit/reveal, escrow and ERC1155 batches. There is one exception and one weak spot:\n- **Unbacked tokens (low, proof).** The ERC721 hook ignores the result of `_ids.add(id)` (`MiyaPool.sol:185`). If something triggers the hook again for an id the pool already holds, it mints another 1e18 with nothing behind it; the proof ends with 3e18 supply against 2 NFTs. This needs a collection that lets some actor move pool-owned tokens, so it is hardening, not an open exploit.\n- **Dead tokens (low, proof).** A safe-mint straight into a pool credits 1e18 to address(0) (`:354`). The NFT is then stuck in a TARGETED pool, or handed to another committer in a RANDOM pool. The same fallback means `ERC721AuctionInstance.claimEscrowedToken(id, pool)` would credit the auction contract itself; I traced that path by reading the code, not by running it.\n\n**2. RANDOM commit-reveal.**\n- **What holds:** later deposits and reveal order cannot move a pick. The draw range never comes up empty, each id is drawn at most once, and `pendingUnits` stays consistent. A 50-unit reveal on a standard collection costs about 1.9M gas, so `MAX_COMMIT_UNITS` is fine.\n- **What breaks:**\n  - **Blocked queue (low, proof).** If a collection calls the recipient on plain `transferFrom`, a commit to a recipient that burns gas makes the head reveal fail even with 30M gas. Every later commit, whose tokens are already burned, is then stuck for good. Capping the gas on that transfer (e.g. 300k) fixes it.\n  - **Robinhood Chain window (low).** I checked this live on chain 4663. The EVM's `block.number` there is the Ethereum block number, but the block-hash history contract there is indexed by the chain's own block number, so every lookup reverts. The real window on 4663 is 256 blocks (about 51 minutes), not the 8191 the NatSpec and AUDIT-NOTES promise, so a committer can force a re-roll roughly every 51 minutes. Mainnet is unaffected.\n  - **The \"pick is fixed\" guarantee is too strong (info).** If an earlier commit is left unrevealed past the window, it gets re-armed, and that changes a later commit's pick even though the later commit's target block is already mined.\n  - **Escrow can lose an id (info).** Escrow only triggers when the transfer reverts. A non-standard collection that refuses by returning silently leaves the id owned by the pool but neither held nor escrowed. When a transfer does revert, escrow always records the commit's recipient, so the escrow never pays the wrong address.\n  - **Fairness (info).** RANDOM mode does not stop cherry-picking: a committer's own same-transaction deposits count toward its draw range.\n\n**3. Factory.** Nobody can take a pool's address first, and a pool for a fake collection cannot affect the real collection's pool. The defect found (low) is in kind detection: a collection whose `supportsInterface` returns true for everything becomes an ERC721-kind pool (RANDOM allowed) that cannot accept its own tokens, and that collection+mode slot is used up forever.\n\n**4. Hooks and reentrancy.** No exploitable reentrancy. State is updated before every outbound transfer, the state-changing entry points are `nonReentrant`, and re-entering through the hooks leaves the accounting consistent. Separately (low): no entry point rejects the pool itself as recipient. Redeeming to `address(pool)` burns the caller's tokens and leaves the NFT untracked; for ERC1155 the units are re-minted to the pool, where nobody can spend them.\n\n## Other notes (info)\n- **Live `name()`:** the pool's ","treeHash":null,"usage":{"cachedInputTokens":1763734,"inputTokens":46,"model":"claude-opus-5-5","outputTokens":40071,"runtime":"claude","turns":25,"wallClockMs":509307}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dc7103e773ea194a","findings":[{"citation":"resolved","description":"The ERC721 receiver hook only checks that the caller is the collection and that ownerOf(id) is the pool. It does not check that the id is new: EnumerableSetLib.add returns false for an id already in the set and the result is discarded, the RANDOM queue is appended unconditionally, and UNIT is minted. Any collection path that runs the receiver callback for a transfer whose `from` and `to` are both the pool (a collection-wide operator such as a marketplace proxy registry or admin transfer helper calling safeTransferFrom(pool, pool, id, abi.encode(attacker)), a migration helper that re-sends tokens, or a re-mint of a burned id) therefore inflates totalSupply with no NFT behind it. In a RANDOM pool the same id then sits twice in _queue while _ids holds it once: commitRedeem's `held < pendingUnits + amount` check uses _ids.length(), so a later reveal can draw the id a second time, _ids.remove returns false, transferFrom reverts because the pool no longer owns it, and escrowedTo[id] is set for an NFT the pool does not hold. This needs a collection-side privilege, so it is a trust-boundary hardening rather than an open exploit: a collection with such an operator can already move pool-held NFTs directly. The pool should still refuse the inconsistent state instead of minting. Fix: `if (!_ids.add(id)) revert AlreadyHeld(id);` before the queue push and the mint (same for the ERC1155 branch there is nothing to do, as units are additive).","line":185,"path":"src/pools/MiyaPool.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport { Test } from \"forge-std/Test.sol\";\nimport { ERC721 } from \"solady/tokens/ERC721.sol\";\nimport { MiyaPool } from \"src/pools/MiyaPool.sol\";\nimport { MiyaPoolFactory } from \"src/pools/MiyaPoolFactory.sol\";\n\n/// @dev ERC721 with a collection-level operator that can move any token with a receiver callback\n///      (the shape of marketplace proxy registries, \"admin transfer\" and migration helpers).\ncontract Operator721 is ERC721 {\n    address public immutable operator;\n\n    constructor(address _operator) {\n        operator = _operator;\n    }\n\n    function name() public pure override returns (string memory) {\n        return \"Op\";\n    }\n\n    function symbol() public pure override returns (string memory) {\n        return \"OP\";\n    }\n\n    function tokenURI(uint256) public pure override returns (string memory) {\n        return \"\";\n    }\n\n    function mint(address to, uint256 id) external {\n        _mint(to, id);\n    }\n\n    /// @dev Moves `id` from `from` to `to` and runs the ERC721 receiver callback, bypassing owner approval.\n    function operatorSafeTransfer(address from, address to, uint256 id, bytes calldata data) external {\n        require(msg.sender == operator, \"not operator\");\n        _safeTransfer(from, to, id, data);\n    }\n}\n\ncontract PoolDuplicateHookTest is Test {\n    uint256 constant UNIT = 1e18;\n\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n\n    function test_secondHookCallForHeldIdMintsUnbackedTokens() public {\n        MiyaPoolFactory factory = new MiyaPoolFactory();\n        Operator721 nft = new Operator721(address(this));\n        MiyaPool pool = MiyaPool(factory.createPool(address(nft), MiyaPool.RedeemMode.TARGETED));\n\n        nft.mint(alice, 1);\n        nft.mint(alice, 2);\n        uint256[] memory ids = new uint256[](2);\n        ids[0] = 1;\n        ids[1] = 2;\n        vm.startPrank(alice);\n        nft.setApprovalForAll(address(pool), true);\n        pool.deposit(ids, new uint256[](0), alice);\n        vm.stopPrank();\n        assertEq(pool.totalSupply(), 2 * UNIT);\n        assertEq(pool.heldUnits(), 2);\n\n        // The pool already tracks id 1. A pool->pool transfer with a receiver callback re-enters\n        // onERC721Received for the same id: ownerOf(1) == pool passes, _ids.add(1) returns false\n        // (ignored), and UNIT is minted to bob with no new NFT behind it.\n        (bool ok,) = address(nft).call(\n            abi.encodeCall(nft.operatorSafeTransfer, (address(pool), address(pool), 1, abi.encode(bob)))\n        );\n        ok; // the fixed pool reverts here; the current pool accepts the call\n\n        // Expected: every pool token is backed by one held NFT. Actual on this code: 3e18 supply, 2 held.\n        assertEq(pool.totalSupply(), UNIT * pool.heldUnits(), \"unbacked tokens minted\");\n        assertEq(pool.balanceOf(bob), 0, \"bob was credited without depositing\");\n    }\n}","reproduction":"State: TARGETED (or RANDOM) pool for an ERC721 whose collection operator can call _safeTransfer(from, to, id, data) without the owner's approval. Alice deposits ids 1 and 2 (totalSupply 2e18, heldUnits 2). Input: the operator calls a safe transfer of id 1 from the pool to the pool with data = abi.encode(bob). Expected: the pool rejects the callback (id 1 is already tracked), totalSupply stays 2e18. Actual: onERC721Received passes both checks, _ids.add(1) returns false and is ignored, bob receives 1e18 pool tokens; totalSupply is 3e18 against 2 held NFTs, so bob can redeem id 2 and Alice's last token is unbacked. Test: test/scratch/PoolDuplicateHook.t.sol (written, not executed: the shell sandbox was unavailable in this session).","severity":"low","snippet":"        _ids.add(id);\n        if (mode == RedeemMode.RANDOM) _queue[_queueTail++] = id;","title":"onERC721Received ignores the _ids.add result: a second receiver callback for an id the pool already holds mints unbacked tokens and duplicates the RANDOM queue entry"},{"citation":"resolved","description":"_recipient falls back to `from` when the transfer data does not carry a recipient. In an ERC721 or ERC1155 mint callback `from` is address(0) by the standards, and Solady's ERC20._mint has no zero-address check, so onERC721Received / _receive1155 mint UNIT per unit to address(0). Nothing can ever move those tokens, so the NFT is locked in the pool for good: in a TARGETED pool it can never be redeemed (no holder has the tokens), and in a RANDOM pool it stays in the deposit queue and will be handed to whichever later commit draws it, with the supply at address(0) still counting in totalSupply. The accounting invariant holds, so no other holder loses, but the minter loses the NFT with no warning and the pool carries dead supply forever. Reachable from any third-party collection that exposes safeMint(to) or a safe mint path with a caller-chosen recipient; the miyamints Drop and 1155 instances mint without callbacks and the Auction only safe-mints to a bidder, so it needs an external collection or a user error. Fix: revert when the resolved recipient is address(0) (`if (to == address(0)) revert InvalidRecipient();`) or credit the `operator` argument of the hook instead of `from` when `from` is zero.","line":354,"path":"src/pools/MiyaPool.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport { Test } from \"forge-std/Test.sol\";\nimport { ERC721 } from \"solady/tokens/ERC721.sol\";\nimport { MiyaPool } from \"src/pools/MiyaPool.sol\";\nimport { MiyaPoolFactory } from \"src/pools/MiyaPoolFactory.sol\";\n\n/// @dev Any ERC721 that exposes a safe mint to a caller-chosen recipient.\ncontract SafeMint721 is ERC721 {\n    function name() public pure override returns (string memory) {\n        return \"SM\";\n    }\n\n    function symbol() public pure override returns (string memory) {\n        return \"SM\";\n    }\n\n    function tokenURI(uint256) public pure override returns (string memory) {\n        return \"\";\n    }\n\n    function safeMint(address to, uint256 id) external {\n        _safeMint(to, id);\n    }\n}\n\ncontract PoolMintToZeroTest is Test {\n    uint256 constant UNIT = 1e18;\n\n    function test_safeMintIntoPoolCreditsAddressZero() public {\n        MiyaPoolFactory factory = new MiyaPoolFactory();\n        SafeMint721 nft = new SafeMint721();\n        MiyaPool pool = MiyaPool(factory.createPool(address(nft), MiyaPool.RedeemMode.RANDOM));\n\n        // Mint callback: onERC721Received(operator, from = address(0), id, \"\").\n        // _recipient(from, \"\") falls back to `from`, which is the zero address.\n        (bool ok,) = address(nft).call(abi.encodeCall(nft.safeMint, (address(pool), 7)));\n        ok; // the fixed pool either reverts or credits the operator; the current pool accepts the mint\n\n        // Expected: no pool tokens are credited to the zero address. Actual on this code: 1e18 at address(0),\n        // and id 7 sits in the RANDOM queue where another commit can draw it.\n        assertEq(pool.balanceOf(address(0)), 0, \"pool tokens minted to address(0)\");\n    }\n}","reproduction":"State: any pool for an ERC721 that implements safeMint(to, id). Input: collection.safeMint(address(pool), 7) with no data. Expected: the pool refuses the mint callback (so the collection's _safeMint reverts and the token is never created in the pool), or credits the minter. Actual: onERC721Received runs with from = address(0), data.length == 0, _recipient returns address(0), _mint(address(0), 1e18) succeeds; pool.balanceOf(address(0)) == 1e18, heldUnits == 1, and id 7 is in the RANDOM queue. Test: test/scratch/PoolMintToZero.t.sol (written, not executed: shell unavailable).","severity":"low","snippet":"        if (to == address(0)) to = from;","title":"Safe-minting an NFT straight into the pool credits the pool tokens to address(0) (from == 0 in a mint callback), permanently stranding one NFT's worth of supply"},{"citation":"resolved","description":"The escrow fallback assumes a refused transfer reverts cheaply. The try forwards 63/64 of the remaining gas (EIP-150). If the callee burns everything it is given, the catch branch runs with only 1/64 of what was left, and the next iteration forwards 63/64 of that again, so after a handful of picks the reveal is out of gas no matter how much the revealer supplies; the whole call reverts, nothing is escrowed, nextRevealId does not advance, and every later commit (tokens already burned) can never be settled. The committer controls c.recipient, so the only precondition is a collection whose transferFrom reaches recipient code: ERC721 variants that notify the receiver on every transfer (not only safeTransferFrom), collections with an external transfer-validator or hook contract whose behaviour depends on `to`, or an ERC721 wrapper that forwards to a recipient hook. Standard OpenZeppelin, Solady and ERC721A transferFrom never call the recipient, so the miyamints Drop and Auction collections are not affected; third-party collections pooled through the permissionless factory may be. Fix: cap the gas forwarded in the reveal transfer (for example `transferFrom{ gas: 300_000 }`), which is safe here because a transfer that fails for gas is escrowed and claimEscrowed later runs with the recipient's own gas; optionally also bound the catch's returndata by not copying it (the empty catch already ignores it).","line":315,"path":"src/pools/MiyaPool.sol","reproduction":"State: RANDOM pool for an ERC721 whose transferFrom invokes `to.onERC721Received`-style code on contract recipients. Attacker deposits 50 ids (or holds 50e18 tokens), deploys a recipient contract whose callback loops until out of gas, and calls commitRedeem(50, recipientContract). A victim then calls commitRedeem(1, victim). After both targets are mined, revealRedeem(attackerCommit) is called with 30M gas. Expected (per the contract comment at lines 43-44): the 50 refused ids are escrowed and the victim's commit is revealable next. Actual: iteration 1 forwards ~29.5M and loses it, iteration 2 forwards ~460k and loses it, ... the loop exhausts gas inside the try chain; the transaction reverts with no state change; nextRevealId stays at the attacker's id forever and the victim's 1e18 is burned with nothing returned.","severity":"low","snippet":"            try IERC721Pool(collection).transferFrom(address(this), c.recipient, picked[i]) {}","title":"revealRedeem forwards all remaining gas into the collection's transferFrom inside try/catch: a collection that calls the recipient back lets one 50-unit commit to a gas-burning recipient block the que"},{"citation":"resolved","description":"_blockHash and commitRedeem use block.number and blockhash(). On Nitro-based chains (Robinhood Chain 4663 is an Arbitrum Orbit L2) block.number returns an estimate of the Ethereum block number and blockhash(n) a sequencer-derived pseudo-random hash for the last 256 Ethereum block numbers. ArbOS 40 adapts EIP-2935 to store roughly 27 hours of L2 block hashes, so the history contract, where present, is indexed by L2 block number, and the chain produces blocks every 100 ms (far more L2 blocks than Ethereum blocks). The pool therefore queries HISTORY with an Ethereum block number: it is either out of the contract's [L2 height - 8191, L2 height - 1] range and reverts (ok == false, seed 0, re-arm), or, if the two number spaces ever overlap, returns the hash of an unrelated old L2 block that was public at commit time, which would make the late-reveal seed predictable and let a committer choose to commit only when the deterministic outcome suits them. In the expected (revert) case the effective window is 256 Ethereum blocks, about 51 minutes, after which the commit re-arms. The documented re-roll (`a committer who waits out the window therefore gets a re-roll`) is then available once an hour instead of once a day: in a quiet pool where no one else is motivated to reveal, a committer can keep re-arming until a rare id is drawn, and the remaining holders are left with the floor. The mainnet (chain 1) path is correct. I could not run `cast` against rpc.mainnet.chain.robinhood.com in this session (no shell), so the exact history-contract state on 4663 is unverified; the block.number semantics are Nitro defaults. Fix: make the history path chain-aware (skip HISTORY unless `block.chainid == 1` or a constructor flag says the chain's block.number is local), or on Nitro chains use ArbSys.arbBlockNumber()/arbBlockHash() for both the target and the seed; and correct the 8191-block claim in the NatSpec and AUDIT-NOTES for 4663.","line":358,"path":"src/pools/MiyaPool.sol","reproduction":"State: RANDOM pool deployed on chain 4663; a commit with target T (an Ethereum block number). Input: revealRedeem called when block.number - T == 300 (about 1 hour later). Expected per NatSpec: the EIP-2935 path returns blockhash(T) and the commit settles. Actual: HISTORY is queried with T, which is not an L2 block height in the contract's window; the staticcall fails or returns an unrelated hash; with ok == false the seed is zero and the commit is re-armed to block.number + 1. Repeating this every ~257 Ethereum blocks re-rolls the pick at will.","severity":"low","snippet":"        if (block.number - n <= 256) return blockhash(n);\n        if (block.number - n > HISTORY_WINDOW || HISTORY.code.length == 0) return bytes32(0);\n        (bool ok, bytes memory ret) = HISTORY.staticcall(abi.encode(n));","title":"On Robinhood Chain (Arbitrum Nitro) block.number is the parent-chain block number, so the EIP-2935 lookup is indexed in the wrong block space and the RANDOM window is ~51 minutes, not the ~27 hours th"},{"citation":"resolved","description":"Commit B's pick depends on B's seed and on which ids earlier commits removed from [head, queueEnd). Those removals depend on the earlier commits' seeds. If commit A (older) is not revealed inside the block-hash window it is re-armed to a fresh target, so A's seed changes, A removes different ids, and B's pick changes even though B's target hash never changed. The property that holds is weaker than the comment at lines 41-42 and the M1 note in AUDIT-NOTES: the pick is fixed once the target blocks of this commit and of every older unsettled commit are mined and stay inside the window. This is not an exploit by itself: B's recipient can protect their pick by revealing A themselves within the window (reveal is permissionless), and A's re-roll is already documented. It matters in the gas/window finding above, where the window is short, and should be stated in the NatSpec so integrators (and the test testFuzz_random_depositBetweenRevealsCannotSteer, which never exercises a re-arm) do not rely on the stronger claim.","line":293,"path":"src/pools/MiyaPool.sol","reproduction":"State: deposits 1..4; commit A = commitRedeem(1, carol) at block N (target N+1); commit B = commitRedeem(1, bob) at N (target N+1). Both targets mined; nobody reveals for 257 blocks on a chain without EIP-2935 (or 8192 with). Input: revealRedeem(A) now re-arms A to a new target; later revealRedeem(A) then revealRedeem(B). Expected per the comment: bob's id equals the id he would have received had A been revealed at N+2. Actual: A's new seed selects a different id out of positions [0,4), so B draws from a different remaining set and bob's id differs.","severity":"info","snippet":"            commits[commitId].targetBlock = uint64(target);","title":"Re-arming the head commit also re-rolls every later commit whose target block is already mined, contrary to the 'once a target block is mined the pick is fixed' statement"},{"citation":"resolved","description":"Solady's ERC20 computes the EIP-712 domain separator from name() on every permit/DOMAIN_SEPARATOR call because _constantNameHash is not overridden. _collectionString forwards all gas to the collection and abi.decodes whatever comes back. A collection whose name() returns 64+ bytes that are not a valid string encoding (offset past the data, length larger than the payload) makes abi.decode revert, so pool.name(), symbol(), DOMAIN_SEPARATOR() and permit() revert while transfer/transferFrom keep working; a collection with a mutable name silently changes the permit domain, invalidating signed permits; a collection whose name() burns gas makes every permit cost that gas. Also, since the pool's name and symbol are derived from the collection's, a look-alike collection produces a pool whose ERC20 name/symbol are byte-identical to the real pool's: integrators must key on MiyaPoolFactory.poolOf(collection, mode), never on name/symbol. Fix: snapshot the name/symbol hash at construction (override _constantNameHash) or cache the strings in the constructor with a bounded-gas call, and treat decode failures as the fallback.","line":367,"path":"src/pools/MiyaPool.sol","reproduction":"State: pool for a collection whose name() returns abi.encode(uint256(0x20), uint256(type(uint256).max)) (64 bytes, passes the `ret.length < 64` check). Input: pool.name() or pool.permit(...). Expected: the fallback 'NFT' and a working permit. Actual: abi.decode reverts in _collectionString; name(), symbol(), DOMAIN_SEPARATOR() and permit() all revert.","severity":"info","snippet":"        return abi.decode(ret, (string));","title":"name()/symbol() and therefore DOMAIN_SEPARATOR()/permit() depend on a live staticcall to the collection; a collection returning malformed or oversized name data bricks permit and the pool's metadata"},{"citation":"resolved","description":"No cross-collection harm was found: pools are keyed by collection address, the CREATE2 salt is keccak(collection, mode) under the factory as deployer, only the factory can deploy at that address, and `code.length == 0` blocks pre-deployment squatting, so a fake collection only gets its own pool and cannot touch the real one's state. Two one-way foot-guns remain because the factory is ownerless and poolOf is never cleared: (1) a contract whose supportsInterface returns true for both 0x80ac58cd and 0xd9b67a26 (hybrid or misconfigured 1155) becomes an ERC721-kind pool in which deposit() calls a 4-argument safeTransferFrom that does not exist and the 1155 receiver hook reverts with NotCollection, so the (collection, mode) slot is burned with an unusable pool; (2) predictPool takes `kind` from the caller, so an integrator passing the wrong kind computes an address nobody will ever deploy to. The miyamints ERC1155Instance only reports 1155 interfaces and the Drop/Auction instances only 721, so the deployed collections are fine. Fix: detect ERC1155 first (or revert when both are reported), and derive `kind` inside predictPool via _detectKind so the prediction matches creation.","line":58,"path":"src/pools/MiyaPoolFactory.sol","reproduction":"State: a collection C whose supportsInterface returns true for both ids and whose transfers are ERC1155. Input: createPool(C, TARGETED). Expected: an ERC1155 pool, or a revert. Actual: an ERC721 pool at poolOf[C][TARGETED]; deposit() reverts (no safeTransferFrom(address,address,uint256,bytes)), C.safeTransferFrom(user, pool, id, 1, '') reverts with NotCollection; the slot can never be reassigned.","severity":"info","snippet":"        if (_supports(collection, ERC721_ID)) return MiyaPool.Kind.ERC721;","title":"Factory kind detection prefers ERC721 and is one-shot per (collection, mode): a collection that reports both interfaces, or whose supportsInterface exceeds the 30k gas stipend, gets a permanently wron"}],"hash":"5ad7334938305555f4c816f55741444e612fcb393a2dbbf8b4c1b0ea9ac21e6b","nodeId":"0dbcb400-c818-4b40-990c-09db6cd25552","outcome":"completed","summary":"The review is complete. The findings file at the repository root holds four Low and three Info findings. No Critical, High or Medium defect was found in MiyaPool or MiyaPoolFactory for honest, standard collections.\n\n**One limitation up front.** The shell sandbox failed to initialize for the whole session, so forge never ran. I read the code, the existing unit and invariant tests, and the Solady dependencies by hand. The two scratch tests under test/scratch/ were written to fail on the current code but were not executed. Nothing in the repository was modified apart from the findings file and those scratch tests.\n\n**Answers to the four questions**\n\n1. **1:1 backing holds on every path for compliant collections.** Deposits mint only inside the receiver hooks, the ERC721 hook checks `ownerOf`, ERC1155 units are tracked per id, and every redeem, commit and reveal burns or reserves before transferring. The existing invariant test matches my trace. Two gaps need a collection-side privilege or a user mistake, so they are Low. A second receiver callback for an id the pool already tracks mints unbacked tokens because the `_ids.add` result is ignored at MiyaPool.sol:185. A safe mint straight into the pool credits address(0) because `_recipient` falls back to `from` at MiyaPool.sol:354.\n\n2. **The deposit-order queue is correct.** Each id is drawn at most once, the modulus is never zero, `pendingUnits` and the queue head stay consistent, escrow cannot lose an id or pay the wrong address, and no commit can block the queue with an OpenZeppelin, Solady or ERC721A collection. Three qualifications. The \"pick is fixed once the target is mined\" claim is weaker than stated: re-arming an older commit re-rolls every later one. A collection whose `transferFrom` reaches recipient code lets a 50-unit commit to a gas-burning recipient exhaust the reveal through the 63/64 rule and block the queue permanently (MiyaPool.sol:315). On Robinhood Chain the history-contract lookup is done with an Ethereum block number, so the effective window is about 51 minutes rather than 27 hours, which makes the documented re-roll cheap in a quiet pool (MiyaPool.sol:358). That last point could not be checked on chain without a shell.\n\n3. **No cross-collection harm.** Pools are keyed by collection address, CREATE2 is under the factory as deployer, and `code.length == 0` blocks pre-deployment squatting. The one-shot slot per collection and mode means a hybrid collection reporting both interfaces burns its slot with an unusable ERC721 pool, and `predictPool` trusts the caller's `kind`. Both are Info.\n\n4. **Hooks and reentrancy are sound.** State is finalized before every outward transfer, the entry points are `nonReentrant`, and the receiver hooks are correctly left unguarded because they run inside `deposit`. A malicious collection can drain its own pool, but that is the stated trust boundary and reentrancy adds nothing to it.\n\n**Also noted (Info):** the pool's `name()`, `symbol()`, `DOMAIN_SEPARATOR()` and `permit()` depend on a live, unbounded staticcall to the collection, and a look-alike collection yields an identically named pool token.\n\n**Not covered in this session:** execution of any test, on-chain verification of the 4663 history contract, and the vault, registry and factory code outside the pool scope. Sources used for the chain facts: [ArbOS 40 Callisto](https://docs.arbitrum.io/run-arbitrum-node/arbos-releases/arbos40), [ArbOS 40 AIP](https://forum.arbitrum.foundation/t/constitutional-aip-arbos-version-40-callisto/28436), [ArbOS 40 audit summary](https://docs.arbitrum.io/assets/files/2025-05-Offchain-Labs-ArbOS-40-Nitro-Summary-Report-769a942c08fe0f917eef523cba81a459.pdf), [Robinhood Chain overview](https://www.dwellir.com/blog/what-is-robinhood-chain), [Chainstack Robinhood docs](https://docs.chainstack.com/reference/robinhood-getting-started), [BitGo Robinhood Chain](https://developers.bitgo.com/docs/robinhood-chain).","treeHash":null,"usage":{"cachedInputTokens":1786112,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":63000,"runtime":"claude","turns":47,"wallClockMs":909379}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dd3018ab6b18e7bc","findings":[{"citation":"resolved","description":"_recipient(from, data) falls back to `from` when `data` does not carry a recipient. ERC-721 `_safeMint(to, id)` and ERC-1155 `_mint(to, id, amount, data)` call the receiver hook with `from == address(0)`, so a collection that safe-mints directly into a pool (any third-party collection with a `mint(address to)` that uses `_safeMint`, airdrop contracts, or an operator mint with empty data) resolves `to` to address(0). onERC721Received (line 188) and _receive1155 (line 348) then call Solady `_mint(address(0), UNIT)`, which has no zero-address check: totalSupply and heldUnits both grow, so the accounting invariant holds, but the 1e18 tokens sit at address(0) and can never be burned. In a TARGETED pool that unit of backing is permanently unredeemable (the last NFT can never leave); in a RANDOM pool it is an involuntary donation drawable by other committers. The minter loses the NFT with no error and no way to recover it, unlike the documented plain-`transferFrom` case, because here the hook *did* run and reported success.","line":354,"path":"src/pools/MiyaPool.sol","reproduction":"Setup: Mock721 (Solady) with `function safeMintTo(address to, uint256 id) external { _safeMint(to, id); }`; pool = factory.createPool(nft, TARGETED). Call nft.safeMintTo(address(pool), 7). Expected: the mint reverts (InvalidRecipient) or tokens go to a real account. Actual: call succeeds; pool.balanceOf(address(0)) == 1e18, pool.totalSupply() == 1e18, pool.heldUnits() == 1, nft.ownerOf(7) == pool, and no address can ever call redeem for id 7 because the only tokens are at address(0). Same result for an ERC-1155 collection calling `_mint(address(pool), id, 1, \"\")`. Fix: after the fallback, `if (to == address(0)) revert InvalidRecipient();` so the collection's acceptance check fails and the token stays with the minter.","severity":"low","snippet":"        if (to == address(0)) to = from;","title":"Receiver hooks mint pool tokens to address(0) when a collection safe-mints into the pool without recipient data, locking the NFT forever"},{"citation":"resolved","description":"Each commit draws only from ids deposited before it, which fixes the M1 steering issue, but a committer's own deposits count. Because the pool is zero-fee and 1:1, a committer can enlarge the draw range with their own worthless ids in the same transaction and then burn enough tokens to draw almost the whole range back. MAX_COMMIT_UNITS caps one commit, not one transaction: commits 2..k record the same queueEnd and draw from the same range minus earlier picks, so k consecutive 50-unit commits in one tx cover 50k ids. The rare id is therefore obtainable with probability (n-1)/n for the price of a single pool token, which is the TARGETED price. This is the inherent economics of an NFTX-style floor pool, and nobody loses more than they would in TARGETED mode; it is reported because the RANDOM mode's stated purpose (pseudo-random ids) suggests it is relied on as a fairness mechanism, which it is not against a depositor.","line":272,"path":"src/pools/MiyaPool.sol","reproduction":"Pool in RANDOM mode holds [R, j1, j2] (R rare). Attacker owns 48 floor ids and 1 pool token. One tx: deposit(48 ids) -> 48 tokens; commitRedeem(49, attacker). queueEnd = 51, pendingUnits = 49. After the target block, revealRedeem draws 49 of 51 ids: P(R drawn) = 49/51 = 96%. Net cost to the attacker: 1 pool token plus gas, the same as redeem([R]) in a TARGETED pool. With 100 floor ids and two commits of 50 in one tx, P = 100/103. Expected under a fairness reading: the probability of receiving R is independent of what the committer deposits in the same block. Actual: the committer controls it. No code fix is proposed; document that RANDOM mode is not a fair lottery against a depositor, or (design change) exclude ids deposited in the commit's own block from that commit's range.","severity":"info","snippet":"        if (amount > MAX_COMMIT_UNITS) revert CommitTooLarge();","title":"RANDOM mode does not prevent cherry-picking: own-deposit dilution plus consecutive commits takes a chosen id with near certainty for one token"},{"citation":"resolved","description":"revealRedeem removes each picked id from _ids and the queue before transferring, then relies on `try/catch` to detect refusal. The catch only fires on a revert. A collection whose transfer logic refuses silently (returns without moving the token, e.g. a pre-0.8 contract with `if (blocked[to]) return;` in its transfer path, or one that routes blocked transfers to a no-op) leaves the id owned by the pool but absent from _ids, from the queue and from escrowedTo, so it can never be redeemed, claimed or re-deposited. The same holds for claimEscrowed (line 328). The committer's tokens are already burned, so they lose the unit. This is collection behaviour outside the ERC-721 standard, which is why it is informational; the check that onERC721Received already performs (line 184, `ownerOf(id) == address(this)`) would be the natural counterpart here.","line":315,"path":"src/pools/MiyaPool.sol","reproduction":"Collection: Solady ERC721 subclass overriding transferFrom with `if (to == blocked) return;` before the super call. pool = createPool(collection, RANDOM); deposit ids 1,2; commitRedeem(1, blocked); mine the target block; revealRedeem(0). Expected: the id is escrowed to `blocked` (escrowedTo[id] == blocked) or the reveal fails. Actual: the call succeeds, collection.ownerOf(id) == pool, pool.isHeld(id) == false, pool.escrowedTo(id) == address(0), pool.heldUnits() == 1; the id is unrecoverable. Fix: after the try block, `if (IERC721Pool(collection).ownerOf(picked[i]) == address(this)) escrowedTo[picked[i]] = c.recipient;` instead of keying escrow on the revert alone (and the same ownership check in claimEscrowed before deleting the entry).","severity":"info","snippet":"            try IERC721Pool(collection).transferFrom(address(this), c.recipient, picked[i]) {}","title":"Escrow at reveal only covers collections that revert; a transferFrom that refuses by returning normally drops the id from tracking without escrowing it"},{"citation":"resolved","description":"EnumerableSetLib.Uint256Set reserves the value 0xfbb67fda52d4bfb8bf as its internal zero sentinel and reverts with ValueIsZeroSentinel() when it is added. The pool uses the token id directly as the set member here and in _receive1155 (line 343), so an NFT with exactly that id (about 4.64e21, plausible only for collections with hashed or user-chosen ids) reverts inside the receiver hook, which makes the collection's safeTransferFrom revert. Nothing is lost; that id is simply unpoolable, and in an ERC-1155 batch it blocks the whole batch. Probability for random 256-bit ids is negligible, so this is informational.","line":185,"path":"src/pools/MiyaPool.sol","reproduction":"Mock721.mint(alice, 0xfbb67fda52d4bfb8bf); pool = createPool(nft, TARGETED); alice: pool.deposit([0xfbb67fda52d4bfb8bf], [], alice). Expected: 1e18 tokens minted. Actual: revert ValueIsZeroSentinel() (0xf5a267f1) from EnumerableSetLib.add; the same for editions.safeBatchTransferFrom(alice, pool, [0xfbb67fda52d4bfb8bf, 1], [1, 1], \"\") which refuses the whole batch. Fix (if wanted): store `id ^ SOME_CONSTANT` or `id + 1` style remapping in the set, or document the exclusion.","severity":"info","snippet":"        _ids.add(id);","title":"A token id equal to Solady's EnumerableSetLib zero sentinel can never be deposited"},{"citation":"resolved","description":"Solady ERC20 computes DOMAIN_SEPARATOR from keccak256(bytes(name())) on every permit() call unless _constantNameHash is overridden. MiyaPool.name() staticcalls the collection's name() each time. Collections commonly expose setName()/setNameAndSymbol(); after a rename every outstanding permit signature for the pool token fails with InvalidPermit, and if the collection's name() returns ABI-malformed data of 64+ bytes, abi.decode (line 367) reverts so name(), DOMAIN_SEPARATOR() and permit() all revert while transfer/approve keep working. Routers that rely on permit (and off-chain signers that cache the domain) break. Only convenience is affected, no funds, so informational.","line":134,"path":"src/pools/MiyaPool.sol","reproduction":"Collection with `function setName(string n) external { _name = n; }`. pool = createPool(collection, TARGETED). Alice signs a permit over pool.DOMAIN_SEPARATOR() for Bob (deadline far in future). Collection owner calls setName(\"renamed\"). Bob submits pool.permit(alice, bob, value, deadline, v, r, s). Expected: the permit is valid until its deadline. Actual: revert InvalidPermit() because the domain separator changed. Fix: capture the collection name once in the constructor (or override _constantNameHash / _versionSignature) so the domain is immutable.","severity":"info","snippet":"        return string.concat(\"miya pool: \", _collectionString(0x06fdde03, \"NFT\"));","title":"Pool token name, symbol and EIP-2612 domain separator track the collection's live name(); a rename or a reverting name() invalidates or disables permit"}],"hash":"90cbb7439e1d428fc4b9fbf097128b2f26c006b760b5eabc26b9c2781676341b","nodeId":"831ee89a-f93b-4c62-b5f8-0411295b315b","outcome":"completed","summary":"Shell access never came up in this session (the sandbox failed to initialize on every attempt), so no Foundry tests were run. The review is static, against the pinned tree, and the findings file holds five entries.\n\n**Findings written to .imd-findings.json**\n\n- **Low. Safe-mint into the pool mints tokens to address(0).** The receiver hooks fall back to `from` as recipient, and `from` is zero on a mint. Solady's ERC20 mint has no zero check, so the 1e18 tokens are unburnable and that NFT can never leave a TARGETED pool. Fix is a one-line revert on a zero recipient.\n- **Info. RANDOM mode is not a fair lottery against a depositor.** Own deposits in the same block join the draw range, and consecutive commits share it, so a chosen id is obtainable with probability (n-1)/n for one token. Inherent to a zero-fee 1:1 pool, reported because the mode's purpose suggests fairness.\n- **Info. Escrow keys on a revert only.** A collection that refuses a transfer by returning normally leaves the id owned by the pool but untracked and unescrowed. An ownership check after the try block would cover it.\n- **Info. Token id equal to Solady's set sentinel cannot be deposited.**\n- **Info. Permit domain tracks the collection's live name().** A rename invalidates outstanding permits; a malformed name() disables permit entirely.\n\n**Answers to the four questions**\n\n1. **1:1 backing holds.** Every mint sits in a hook gated by the collection and an ownerOf check; every burn precedes the outbound transfer; 1155 units are re-read per iteration so duplicate ids cannot over-redeem. The only path to tokens nobody can spend is the address(0) case above, and it still satisfies the accounting identity. Redeeming to the pool's own address strands the NFT but costs only the caller.\n2. **Commit-reveal is sound.** The queue check at commit guarantees each commit's range `[head, queueEnd)` has at least `amount` entries at its reveal, so no division by zero and every id is drawn at most once. Later deposits land past every pending `queueEnd`, and reveal order is forced, so the pick is fixed once the target block is mined. The one exception is the documented re-arm: an earlier commit that expires re-rolls and so also reshuffles the ranges of commits behind it. Zero recipients, the 50-unit cap and escrow keep the queue unblockable for standard collections. The blockhash and EIP-2935 window arithmetic is correct, including the history contract's absence. pendingUnits is reserved and released symmetrically, including on escrow.\n3. **Factory is safe.** Detection is view-only, one pool per collection and mode is enforced before CREATE2, and a fake collection's pool cannot receive the real collection's hooks because the pool rejects any sender other than its own collection. A collection claiming both interfaces gets the 721 kind, which only affects itself.\n4. **Hooks are correctly ordered.** All state writes precede outbound transfers, the entry points carry nonReentrant, and the hooks are deliberately unguarded so the pull-deposit path works. A malicious collection can mint unbacked tokens in its own pool, which is a trust assumption rather than a defect.\n\n**Not verified.** No test or compile ran. The reproductions describe exact inputs and expected versus actual results, but none carries an executed proof. No finding reached high severity, so no proof file was required.","treeHash":null,"usage":{"cachedInputTokens":841413,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":49250,"runtime":"claude","turns":34,"wallClockMs":855073}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2d027bc56749d95c","findings":[{"citation":"resolved","description":"Both receiver hooks resolve the token recipient with _recipient(from, data) (MiyaPool.sol:352-355): when data is not exactly 32 bytes the recipient falls back to `from`. A collection that mints straight into the pool with _safeMint (ERC721) or _mint with the acceptance check (ERC1155) calls the hook with from == address(0). The hook then passes the ownerOf/kind checks, adds the id to _ids (and to the RANDOM queue), and calls Solady's ERC20._mint(address(0), UNIT) (lib/solady/src/tokens/ERC20.sol:496, which has no zero-address check). totalSupply rises, the backing invariant still holds, but the 1e18 tokens sit at address(0) and can never be burned, so the NFT is permanently stuck in the pool while every view reports it as held and redeemable. The `operator` argument that identifies the minter is ignored. A creator seeding a pool with a mint-to-pool (common with third-party collections; the repo's own Drop uses _mint and so is simply untracked) loses the piece.","line":354,"path":"src/pools/MiyaPool.sol","reproduction":"Mock721 nft (Solady ERC721 with a `safeMint(address,uint256)` wrapper around _safeMint); pool = factory.createPool(nft, TARGETED). Call nft.safeMint(address(pool), 7). Expected: either revert or 1e18 pool tokens credited to the minter (operator). Actual: pool.totalSupply() == 1e18, pool.balanceOf(address(0)) == 1e18, pool.isHeld(7) == true, nft.ownerOf(7) == pool, and no account can ever redeem id 7 (redeem needs 1e18 tokens that only address(0) holds). Same with Mock1155 `_mint(address(pool), 1, 3, \"\")`. Fix: in the hooks treat from == address(0) specially: `if (to == address(0)) to = operator;` (use the first hook argument) or revert InvalidRecipient() when the resolved recipient is address(0).","severity":"low","snippet":"        if (to == address(0)) to = from;","title":"Safe-mint or safe-transfer into the pool with from == address(0) and no recipient data mints the pool tokens to address(0), locking the NFT forever"},{"citation":"resolved","description":"No entry point rejects the pool's own address as recipient. ERC721 TARGETED redeem burns 1e18, removes the id from _ids and calls collection.transferFrom(pool, pool, id); Solady and OZ ERC721 accept from == to, so the call succeeds, the id is now owned by the pool but absent from _ids, and nothing can ever bring it back (the pool never approves anyone and the hook only runs on an inbound transfer). The same happens in RANDOM mode via commitRedeem(amount, address(pool)) (every picked id is dropped) and in claimEscrowed(id, address(pool)). For ERC1155 TARGETED redeem(ids, amounts, address(pool)), the collection's safeBatchTransferFrom(pool, pool, ...) runs the pool's own onERC1155BatchReceived (ERC1155Instance.sol:595 calls the hook whenever `to` has code), which re-adds the units and mints UNIT*total to the pool itself: the redeemer's tokens are gone and the re-minted tokens are stuck at the pool address. deposit(ids, amounts, address(pool)) likewise mints to the pool. These are caller mistakes, but the guard is one comparison and the loss is total and irreversible; a front end that defaults `to` to the pool (as the factory's `creator`/`pool` fields invite) would brick every user action.","line":242,"path":"src/pools/MiyaPool.sol","reproduction":"ERC721: pool = factory.createPool(nft, TARGETED); alice deposits id 1 (balance 1e18); alice calls pool.redeem([1], [], address(pool)). Expected: revert. Actual: succeeds; pool.balanceOf(alice) == 0, pool.isHeld(1) == false, pool.heldUnits() == 0, nft.ownerOf(1) == address(pool); the id can never be deposited or redeemed again. ERC1155: alice deposits (id 1, 3 units); alice calls pool.redeem([1], [3], address(pool)). Expected: revert. Actual: alice's 3e18 are burned, then the hook mints 3e18 to address(pool); pool.balanceOf(address(pool)) == 3e18, heldUnits() == 3, editions.balanceOf(pool, 1) == 3; the units are backed only by tokens nobody can move. Fix: `if (to == address(this) || to == address(0)) revert InvalidRecipient();` in deposit, redeem, commitRedeem and claimEscrowed, and reject address(this) as the resolved recipient in _recipient.","severity":"low","snippet":"                IERC721Pool(collection).transferFrom(address(this), to, ids[i]);","title":"redeem / commitRedeem / claimEscrowed / deposit accept to == address(this); the NFT stays in the pool but is untracked (or the minted tokens are locked at the pool)"},{"citation":"resolved","description":"_detectKind probes 0x80ac58cd first and returns ERC721 on `true` without checking that the collection does not also claim ERC1155 or that it rejects 0xffffffff (the ERC-165 sanity probe that OZ's ERC165Checker and most integrators apply). An ERC1155 collection whose supportsInterface is `return true;` (a lazy but real pattern) or that advertises both ids is therefore deployed as Kind.ERC721, and because the ERC721 kind is also what gates RANDOM mode, RandomRequiresERC721 is bypassed as well. Every inbound transfer then fails: deposit() calls the 4-argument ERC721 safeTransferFrom selector which the 1155 does not have, and a direct 1155 safeTransferFrom runs onERC1155Received -> _receive1155, which reverts NotCollection because kind != ERC1155 (MiyaPool.sol:337). kind and collection are immutable and createPool reverts PoolExists for that (collection, mode) forever, so the collection can never get a working pool on that factory. Anyone can trigger it (createPool is permissionless) but the outcome is the same whoever calls, so this is a liveness/footgun defect rather than a theft.","line":58,"path":"src/pools/MiyaPoolFactory.sol","reproduction":"Deploy an ERC1155 (Solady ERC1155 or ERC1155Instance) with `function supportsInterface(bytes4) external pure returns (bool) { return true; }`. factory.createPool(coll, RANDOM) -> succeeds (expected: RandomRequiresERC721 or UnsupportedCollection); MiyaPool(pool).kind() == Kind.ERC721. alice.mint(id 1, 5 units); coll.safeTransferFrom(alice, pool, 1, 5, \"\") -> reverts NotCollection (expected: 5e18 tokens). factory.createPool(coll, TARGETED) -> same broken pool; a second createPool for either mode reverts PoolExists. Fix: in _detectKind require `!_supports(collection, 0xffffffff)` and `_supports(collection, 0x01ffc9a7)`, and revert (e.g. AmbiguousCollection) when both ERC721_ID and ERC1155_ID return true, so an ERC1155 that lies about ERC721 is rejected instead of locked in.","severity":"low","snippet":"        if (_supports(collection, ERC721_ID)) return MiyaPool.Kind.ERC721;","title":"Kind detection trusts the first `true` from supportsInterface; a collection that answers true for every interface id gets an ERC721-kind pool it cannot use, and the (collection, mode) slot is burned f"},{"citation":"resolved","description":"MiyaPool.sol:41-42 and AUDIT-NOTES (M1) state that once a commit's target block is mined its pick is fixed. The pick of commit B is a function of B's seed and of the queue contents in [head, queueEnd_B) at B's reveal, and that prefix is permuted by every earlier reveal (the swap at lines 307-309). Those permutations are deterministic only while every earlier commit still has its original seed. If commit A (A < B) is not revealed within the block-hash window (256 blocks, or 8191 where EIP-2935 is live), revealRedeem(A) re-arms it to a new target; A's seed changes, A draws a different id, and B, whose target block was mined long ago and whose hash is still readable, now receives a different id than it would have. On a chain without the EIP-2935 contract (the code checks HISTORY.code.length, so Robinhood Chain falls back to 256 blocks if the system contract is absent there, which could not be verified in this review) the window is about 51 minutes of nobody calling reveal. Anyone can reveal A to prevent this, so it is a liveness assumption rather than an exploit, but the documented guarantee is stronger than what the code gives and a committer who holds the head commit and a later one can use the wait to re-roll both.","line":293,"path":"src/pools/MiyaPool.sol","reproduction":"RANDOM pool, alice deposits ids 1..4. Block 100: A = commitRedeem(1, alice), B = commitRedeem(1, bob) (both target 101). Roll to 102, set blockhash(101) = h. Snapshot. Path 1: revealRedeem(A); revealRedeem(B) -> bob gets id X. Revert to snapshot. Path 2: roll to 101 + 8192 (or 101 + 257 with HISTORY absent), revealRedeem(A) -> RedeemRearmed to T'; roll to T'+1, set blockhash(T') = h2; revealRedeem(A); then revealRedeem(B), B still uses blockhash(101) via the history contract (or, past the window, is itself re-armed). Expected per the documentation: bob gets X in both paths. Actual: bob's id depends on h2 through A's new draw (for most h2 values a different id). Fix: document the guarantee as 'fixed once every earlier commit has been revealed', or make the re-arm independent of the draw order (e.g. on re-arm, record a per-commit seed derived from the new block but keep queue permutation deterministic by drawing A from a snapshot index range that does not overlap B's).","severity":"low","snippet":"            commits[commitId].targetBlock = uint64(target);","title":"A later commit's pick is not fixed once its own target block is mined: re-arming any earlier unrevealed commit reshuffles the queue prefix it draws from"},{"citation":"resolved","description":"MiyaPool inherits Solady's ERC20 without overriding _givePermit2InfiniteAllowance(), which returns true (lib/solady/src/tokens/ERC20.sol:667-669). allowance(holder, 0x000000000022D473030F116dDEE9F6B43aC78BA3) reports type(uint256).max and transferFrom skips the allowance check when the caller is that address (ERC20.sol:171-173, 260-264). The pool is documented as having no admin and no special powers; this is one. It is safe only where that address holds the canonical Permit2 (verified for chain 4663 in script/config/4663.json and present on Ethereum), and any chain where it holds other code or is later deployed with other code would let that contract move every holder's pool tokens.","line":47,"path":"src/pools/MiyaPool.sol","reproduction":"pool.allowance(alice, 0x000000000022D473030F116dDEE9F6B43aC78BA3) == type(uint256).max without any approve; vm.prank(0x000000000022D473030F116dDEE9F6B43aC78BA3); pool.transferFrom(alice, bob, 1e18) succeeds. Expected per the 'no admin' documentation: 0 allowance. Decide explicitly: keep it and document it, or override `_givePermit2InfiniteAllowance()` to return false.","severity":"info","snippet":"contract MiyaPool is ERC20, ReentrancyGuard {","title":"Pool tokens grant the canonical Permit2 address an infinite allowance by default (Solady ERC20)"},{"citation":"resolved","description":"EnumerableSetLib.Uint256Set reserves the value 0xfbb67fda52d4bfb8bf as its internal zero sentinel and reverts ValueIsZeroSentinel() on add/remove/contains of exactly that value (lib/solady/src/utils/EnumerableSetLib.sol:41,169). An ERC721 or ERC1155 with that exact id (hash-derived id spaces, or 1155 editions keyed by arbitrary ids) cannot be pooled: the receiver hook reverts, so the safe transfer reverts and nothing is lost, but the id is permanently excluded. Token id 0 itself is handled (mapped to the sentinel internally).","line":185,"path":"src/pools/MiyaPool.sol","reproduction":"nft.mint(alice, 0xfbb67fda52d4bfb8bf); vm.prank(alice); pool.deposit([0xfbb67fda52d4bfb8bf], [], alice) -> reverts with EnumerableSetLib.ValueIsZeroSentinel(). Expected: deposit succeeds like any other id. No funds at risk; document or map the id to a different set implementation.","severity":"info","snippet":"        _ids.add(id);","title":"Token id 0xfbb67fda52d4bfb8bf can never be deposited (Solady EnumerableSetLib zero sentinel)"},{"citation":"resolved","description":"name()/symbol() staticcall the collection on every call, and Solady's DOMAIN_SEPARATOR()/permit() hash name() on the fly because _constantNameHash() is not overridden (ERC20.sol:408-410, 472-475). Consequences: (1) anyone can createPool for a look-alike collection that returns the real collection's name and symbol, so its pool token shows the same `miya pool: X` / `mX` metadata as the real one on DEX front ends; the real pool itself is unaffected (collection is immutable, hooks check msg.sender == collection, separate token). (2) If a legit collection changes its name (Drop/1155 instances expose metadata setters), permits signed before the change no longer verify. (3) A collection whose name() returns >=64 bytes of malformed ABI makes abi.decode revert, which bricks name(), DOMAIN_SEPARATOR() and permit() for that pool (only that collection's own pool). No pool funds are at risk.","line":134,"path":"src/pools/MiyaPool.sol","reproduction":"Deploy FakeNFT (ERC721) with name() == 'miyamaker mini' and symbol() == 'MIYAMINI'; factory.createPool(fake, TARGETED). MiyaPool(fakePool).name() == 'miya pool: miyamaker mini' and symbol() == 'mMIYAMINI', identical to the real pool's. For (2): real collection with a settable name; alice signs a permit; owner renames; pool.permit(...) reverts InvalidPermit. Fix: snapshot name/symbol at construction (or override _constantNameHash) and consider prefixing the collection address in the symbol.","severity":"info","snippet":"        return string.concat(\"miya pool: \", _collectionString(0x06fdde03, \"NFT\"));","title":"Pool token name, symbol and EIP-712 domain are live reads of the collection; a fake collection's pool token is indistinguishable by metadata and a collection rename invalidates outstanding permits"}],"hash":"a1551a8dd9f86c5b2fd7a98d05b0c1a1b980809fc4c340930156db7add0daa21","nodeId":"9525b703-71e0-4990-b36f-a1fef724bac4","outcome":"completed","summary":"The shell sandbox cannot start in this session, so no forge run was possible. The review is complete from static analysis and the findings file is written. Final report follows.\n\n**Outcome.** No critical or high defects in `src/pools/`. The 1:1 backing, the M1 commit-reveal fix, the factory's one-pool-per-(collection, mode) rule, and the hook ordering all hold against the inputs I tried. Four lows and three info items are in `.imd-findings.json`. I could not execute any Foundry test: the Bash sandbox failed to initialize on every attempt, so nothing under test/scratch was written and the existing suite was not re-run here. All claims below are from reading the code and the Solady primitives it inherits.\n\n**Q1, 1:1 backing: holds.** Tokens are minted only inside the two receiver hooks, which require msg.sender to be the collection; the ERC721 hook also checks ownerOf is the pool. Every withdrawal burns first and updates `_ids`, `unitsOf`, `_units1155` and `pendingUnits` before the external transfer. The ERC1155 `redeem` and the RANDOM reveal cannot be used to take more than burned. The only ways to reach a bad state need the caller to hurt themselves, and those are the two lows:\n- Mint-to-pool with from equal to address(0) credits the pool tokens to address(0), locking the NFT (finding 1, line 354).\n- Any `to` equal to the pool itself drops the id from tracking while the NFT stays inside, or in the 1155 case re-mints the tokens to the pool (finding 2, line 242).\n\n**Q2, RANDOM commit-reveal: the M1 fix is correct, with one over-claim.** In RANDOM mode the live queue length always equals `_ids.length()`, so the `held < pendingUnits + amount` check guarantees each commit's range `[head, queueEnd)` still holds at least `amount` ids when it is revealed, and the swap-with-head keeps the range equal to \"ids depositable at commit minus ids already drawn\". Each id is drawn at most once. Later deposits land past `queueEnd` and cannot move a pick. Escrow stores the recipient before the queue entry is gone, overwrite is impossible because an escrowed id is out of both `_ids` and the queue, and `claimEscrowed` clears the mapping before transferring. Blocking: the zero-recipient check, the 50-unit cap and the try/catch around `transferFrom` do prevent one commit from stalling the rest; re-arm is reachable by anyone and needs only one more block. Block-hash window: the `blockhash` branch is used for 1..256 blocks, the EIP-2935 contract for 257..8191 with the right inclusive bounds, and absence of the history contract or an unpopulated slot degrades safely to a re-arm. The over-claim is finding 4: a later commit's pick is fixed only while every earlier commit keeps its seed, since re-arming an earlier commit reshuffles the prefix the later one draws from. On a chain without EIP-2935 that is a 256-block window. `pendingUnits` is consistent on every path.\n\n**Q3, factory detection and salts: safe against fakes, one footgun.** A fake collection gets its own pool with its own immutable `collection`, hook checks and token, so it cannot touch the real pool or its holders. Pool creation is permissionless but deterministic, so there is nothing to squat. The footgun is finding 3: detection returns ERC721 on the first `true` without the 0xffffffff sanity probe, so an ERC1155 that answers true for everything is locked into an unusable ERC721-kind pool and the slot can never be recreated. Metadata impersonation and the live `name()` dependency of permits are info items.\n\n**Q4, hooks and reentrancy: no path found.** `deposit`, `redeem`, `commitRedeem`, `revealRedeem` and `claimEscrowed` are nonReentrant; the hooks are unguarded by necessity but only accept the collection as caller. All ERC721 outbound transfers use `transferFrom`, so no recipient callback runs. The ERC1155 outbound transfer calls the recipient's hook after all pool state is final, and anything the recipient does re-enters into guarded functions or the deposit path, which is accounting-neutr","treeHash":null,"usage":{"cachedInputTokens":1127850,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":51084,"runtime":"claude","turns":41,"wallClockMs":819779}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d68ba89c0b30801c","findings":[{"citation":"resolved","description":"`_recipient` falls back to `from` when the hook carries no 32-byte recipient. On an ERC721 `_safeMint(pool, id)` (and on any ERC1155 mint delivered through `onERC1155Received`/`onERC1155BatchReceived`) the standard passes `from == address(0)`, so `to` stays zero and `_mint(address(0), UNIT)` runs (MiyaPool.sol:188 / :348). Solady's ERC20 `_mint` has no zero-address check (lib/solady/src/tokens/ERC20.sol:496-520), so totalSupply grows by 1e18 credited to address(0), an account nobody controls. The NFT is now tracked in `_ids` (and, in RANDOM pools, in the draw queue) but the tokens needed to redeem it can never be spent, so the id is permanently stuck in the pool and, in RANDOM mode, it is still drawable: another committer can receive it while the 1e18 at address(0) stays as a dead, unbacked-in-practice balance. The invariant `totalSupply + UNIT*pendingUnits == UNIT*heldUnits` keeps holding, so the invariant suite cannot see this. Reachable with any collection that mints with `_safeMint`/`_mint(to,...,data)` to an arbitrary `to` (ERC721 `mintTo`-style functions, airdrops, ERC1155 mints to a contract), which is ordinary ERC721/ERC1155 behaviour rather than a malicious collection. Fix: in `_recipient`, when `from == address(0)` use the `operator` argument (first hook parameter, currently discarded) or revert `InvalidRecipient()`, so a mint into the pool either credits the minter or is refused.","line":354,"path":"src/pools/MiyaPool.sol","reproduction":"Deploy a Solady ERC721 `Mock721` with `function safeMint(address to, uint256 id) external { _safeMint(to, id); }`; create `pool = factory.createPool(nft, RANDOM)`. Call `nft.safeMint(address(pool), 7)`. Expected: either the tx reverts or the minter/operator receives 1e18 pool tokens. Actual: the tx succeeds, `pool.balanceOf(address(0)) == 1e18`, `pool.totalSupply() == 1e18`, `pool.isHeld(7) == true`, `nft.ownerOf(7) == pool`, and no account can ever burn those tokens, so id 7 can only leave the pool through someone else's RANDOM draw (or never, in TARGETED mode). Same result for ERC1155: `editions.mint(address(pool), 1, 3)` using a mint that invokes the receiver hook credits 3e18 to address(0).","severity":"low","snippet":"        if (to == address(0)) to = from;","title":"Safe-mint into a pool (from == address(0), no data) mints the 1e18 pool tokens to address(0) and locks the NFT forever"},{"citation":"resolved","description":"A later commit B draws by position from `[head, B.queueEnd)`, and the contents of those positions are produced by the swaps of every earlier reveal. Earlier commit A's seed is only fixed while A's target hash is within the blockhash window; once `block.number - A.targetBlock > 8191` (or > 256 without the EIP-2935 history contract), `revealRedeem(A)` re-arms A to a new target (MiyaPool.sol:291-296) and A's picks change, which permutes the positions B reads from. B's pick therefore changes although B's target block was mined, no deposit happened and the reveal order is unchanged. Nobody can force this (anyone may reveal A in time), so it adds no steering power beyond the documented self re-roll, but the comment at lines 41-42 and AUDIT-NOTES M1 state an unconditional guarantee that does not hold. Fix: document that the pick is fixed only while every earlier commit's target is still inside the hash window, or decouple B's pick from A's seed (e.g. draw B's indices into a snapshot of its own range).","line":41,"path":"src/pools/MiyaPool.sol","reproduction":"RANDOM pool with ids 1..4 deposited. Alice: `A = commitRedeem(1, alice)` at block 99 (target 100). Later, Bob: `B = commitRedeem(1, bob)` at block 4999 (target 5000). Snapshot at block 8300 with `blockhash(5000)` set. Path 1: at block 8300 call `revealRedeem(A)` twice: the first call re-arms A to 8301 (8300-100 = 8200 > 8191 so `_blockHash` returns 0); roll to 8302, set `blockhash(8301)`, reveal A then B. Path 2 (counterfactual, same B seed): had A been revealed at block 101 with `blockhash(100)`, B at 8302 reads different queue positions. With seeds chosen so A's two picks differ (e.g. `blockhash(100) = keccak('a')`, `blockhash(8301) = keccak('b')` and A picks 1 in one case and 3 in the other), Bob receives a different id in the two paths even though `blockhash(5000)` is identical. Expected per lines 41-42: Bob's id depends only on his own mined target. Actual: it depends on when A was revealed.","severity":"info","snippet":" *      - Each commit draws only from ids deposited before it, and reveals run in commit order, so once a target\n *        block is mined the pick is fixed: later deposits and the choice of reveal order cannot move it.","title":"Re-arming an expired earlier commit changes the pick of a later commit whose target block is already mined, contrary to the documented M1 guarantee"},{"citation":"resolved","description":"Solady ERC20 computes the EIP-712 domain separator from `keccak256(bytes(name()))` on every `permit`/`DOMAIN_SEPARATOR` call because `_constantNameHash` is not overridden (lib/solady/src/tokens/ERC20.sol:408-410, 473-475). `_collectionString` only guards `ret.length < 64`; a 64-byte-plus return whose offset or length is out of bounds makes `abi.decode` revert, so `pool.name()`, `pool.permit()` and `pool.DOMAIN_SEPARATOR()` revert for that pool while transfers keep working. A collection whose `name()` changes (upgradeable or renameable collections) silently changes the domain separator, invalidating permits signed before the rename and making signed permits replay-sensitive to the rename timing. Fix: snapshot the name/symbol in the constructor (or override `_constantNameHash` with a constructor-time hash) and keep the fallback for malformed returns.","line":366,"path":"src/pools/MiyaPool.sol","reproduction":"Collection `BadName` with ERC165 ERC721 support whose `name()` is `fallback`-implemented to return `abi.encode(uint256(0x20), type(uint256).max)` (64 bytes, length word out of range). `factory.createPool(badName, TARGETED)` succeeds (detection does not touch `name()`). Expected: `pool.name()` returns the fallback \"miya pool: NFT\" and `permit` works. Actual: `pool.name()` reverts (abi.decode out-of-bounds), and `pool.DOMAIN_SEPARATOR()` / `pool.permit(...)` revert for the pool's whole life. Second input: a collection whose `setName(\"B\")` is called after a holder signs a permit under name \"A\": the permit now fails `InvalidPermit`.","severity":"info","snippet":"        if (!ok || ret.length < 64) return fallback_;\n        return abi.decode(ret, (string));","title":"Pool `name()`/`symbol()` are live reads of the collection, so a malformed or mutable collection name breaks or silently changes the ERC20 permit domain"},{"citation":"resolved","description":"The clamp computes `start + count` in checked arithmetic before comparing to `len`, so a caller that passes `type(uint256).max` as `count` to mean \"everything from start\" gets a Panic(0x11) whenever `start >= 1`. The view is the only enumeration the pool offers (the invariant handler and UIs call it). Fix: `if (count > len - start) count = len - start;`.","line":150,"path":"src/pools/MiyaPool.sol","reproduction":"Pool holding ids 1,2,3 (`heldIdsCount() == 3`). Call `heldIds(1, type(uint256).max)`. Expected: `[id at 1, id at 2]`. Actual: revert with arithmetic overflow panic. `heldIds(0, type(uint256).max)` happens to work because `0 + max` does not overflow.","severity":"info","snippet":"        if (start + count > len) count = len - start;","title":"heldIds pagination reverts on `start + count` overflow instead of clamping"},{"citation":"resolved","description":"Solady's `EnumerableSetLib.Uint256Set.add` reverts with `ValueIsZeroSentinel()` for the value `0xfbb67fda52d4bfb8bf` (lib/solady/src/utils/EnumerableSetLib.sol:41, 424-428) because it uses that value internally to encode 0. Both receiver hooks call `_ids.add(id)` unconditionally (MiyaPool.sol:185, :343), so a transfer of that exact id into any pool reverts inside the hook and the id is permanently unpoolable. No funds are at risk and the probability is negligible for sequential ids, but collections with hash-derived or caller-chosen ids (common for ERC1155) make it a reachable edge; the pool otherwise supports the full uint256 id range. Fix: none needed beyond documenting, or map ids through a bijection that avoids the sentinel.","line":185,"path":"src/pools/MiyaPool.sol","reproduction":"Mock721 `mint(alice, 0xfbb67fda52d4bfb8bf)`; `alice: pool.deposit([0xfbb67fda52d4bfb8bf], [], alice)`. Expected: 1e18 minted. Actual: revert `ValueIsZeroSentinel()` bubbled from `onERC721Received` through `safeTransferFrom`.","severity":"info","snippet":"        _ids.add(id);","title":"Token id 0xfbb67fda52d4bfb8bf can never be deposited (Solady EnumerableSetLib zero sentinel)"}],"hash":"f2ff29ac9aab6bfa6fd7ddc5e189a340a6b21c2c98ff491e97460865e5b885d9","nodeId":"b8bfe6cd-2864-4ef2-abfc-6a55e29f8075","outcome":"completed","summary":"The review is complete and the findings file is written. Bash never initialized in this session (every call failed with \"Failed to create bridge sockets\"), so no forge command ran; everything below rests on reading the code and its dependencies, and I wrote no proof tests because I could not execute them. No finding reached high or critical, so none required a proof.\n\n**Answers to the four questions**\n\n1. **1:1 backing holds.** Every mint happens inside a receiver hook gated on `msg.sender == collection`, with the ERC721 hook also requiring `ownerOf(id) == pool`. Every withdrawal path burns or reserves first: targeted redeem burns before `transferFrom`, the 1155 path debits `unitsOf` and `_units1155` before the batch transfer, and `commitRedeem` burns and raises `pendingUnits` before any id leaves. Duplicate ids in a request revert in both kinds. I found no path to unbacked tokens or an unburned withdrawal with a standards-compliant collection. The one edge is a safe-mint straight into the pool, which credits address(0) and strands the NFT (finding 1).\n\n2. **RANDOM commit-reveal is sound in its math.** At commit, `held >= pendingUnits + amount` and `_ids.length()` equals the live queue length in RANDOM mode, so at reveal `queueEnd - head >= amount` and the modulus never reaches zero. The swap-with-head draw removes each pick from every later range, so no id is drawn twice. Later deposits land at or beyond `queueEnd` and cannot shift a pick. The blockhash boundaries are correct on both sides (`<= 256` for BLOCKHASH, `<= 8191` for the history contract, and `block.number > target` rules out the zero hash of the target block). `pendingUnits` is incremented once per commit and decremented once per reveal, so it never exceeds held units. The queue cannot be blocked: zero recipient is refused, 50 units cap gas, a refused transfer is escrowed, and only the head can re-arm. Escrow is keyed by id, deleted before transfer and restored on revert, so it cannot pay the wrong address. The one gap is documentary: re-arming an expired earlier commit changes a later mined commit's pick (finding 2).\n\n3. **Factory is safe.** Pools share no state, so a fake collection's pool cannot touch the real one's. CREATE2 salts are derived from collection and mode, and the init code embeds the detected kind, so only the factory can occupy the address and the first creator gets the canonical pool. A collection must have code and answer ERC165, so counterfactual squatting is impossible. One pool per collection and mode is enforced permanently.\n\n4. **Hooks and reentrancy are clean.** All state writes in `redeem`, `commitRedeem` and `revealRedeem` precede external transfers, the entry points are `nonReentrant`, and the hooks only accept the collection as caller. A malicious collection can only damage its own pool.\n\n**Findings written to .imd-findings.json**\n\n- **Low.** `_safeMint(pool, id)` or an ERC1155 mint into the pool passes `from == address(0)`, so `_recipient` returns zero and 1e18 pool tokens are minted to address(0). The NFT is tracked but can never be redeemed by anyone. Fix: fall back to the operator argument or revert.\n- **Info.** Re-arming an expired earlier commit permutes the queue positions a later, already mined commit reads, so its pick changes. The comment at lines 41 to 42 and AUDIT-NOTES M1 overstate the guarantee.\n- **Info.** `name()` and `symbol()` are live reads of the collection. A malformed return reverts `permit` and `DOMAIN_SEPARATOR` for the pool's life, and a renameable collection silently changes the permit domain.\n- **Info.** `heldIds(start, count)` reverts on `start + count` overflow instead of clamping.\n- **Info.** Solady's set sentinel means id 0xfbb67fda52d4bfb8bf can never be deposited.\n\n**Open items I could not verify offline.** On Robinhood Chain, `block.number` and `blockhash` follow Arbitrum semantics (parent-chain block numbers, sequencer-derived hashes) and EIP-2935 may be absent, which shortens the re-arm window to 256","treeHash":null,"usage":{"cachedInputTokens":759495,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":41895,"runtime":"claude","turns":32,"wallClockMs":699622}}],"verification":[]}