{"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":"6dfbf85f-57ff-475c-912b-d47c64731c93","kind":"audit","nodes":[{"acceptedSubmissionHash":"163c595287c19df1607252474394bcc57aece9b6d67aff0131de9365af4881d5","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":"80b95407a903258234f75bb332451c57a1a52b0cf0cc764e48b95eee05a21df6","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":"773592fa0adef752ffab340724bfac272de6aca78b2a9b5345b46bc70bbe4426","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":"d6751eaa23c9cf76988db54eb74a89f9943c5a6ebb13b4d89bf8a0837b569eac","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":"246a11ea534bccf9932d1d5183b3782abe19683316e2b339a0d77a25030ac4e9","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 WHOLE system at the pinned commit, for a mainnet launch: every contract in src/, script/DeployMiyamints.s.sol, script/config/, DEPLOY.md and test/deploy/DeployMiyamints.t.sol, each mechanism in turn and how they interact. This is the full sweep before deployment. Run forge and report exactly what you executed: state plainly anything you could only check by reading. Findings need a concrete sequence and, for medium or higher, a proof test you have run.\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/). The vault's alignment token on mainnet is INFER, in a hookless ETH pool at 1% fee and tick spacing 200; only MiyaRegistry.conversionKeeper can swap, through a private mempool.\n\nWhy a sweep: three external area audits and a final review are in, and the last two rounds found high and medium issues, some in fixes made the round before, so treat recent code with suspicion. AUDIT-NOTES.md records every finding, its fix, and what was accepted or deferred with its bound. An accepted item is a finding only if its stated bound is wrong. Newest and least reviewed: git diff 7906d3d..HEAD (UniAlignmentVault batch accounting and _swapAndAdd), and git diff a991e8c..7906d3d.\n\nThe vaults get the deepest look: questions 1 to 9 are the dedicated vault panel, because every earlier round found high or medium issues there. Answer each numbered question, including where nothing is wrong, and report anything else you find.\n\n1. Prove or break the batch ledger (convertedBatches, closedBatches, _pendingBatch, _pending, _shares, _collected, _settleFees): across every order of contribute, convert, depositFees, claim, delegate and claim-as-delegate, can anyone lose or gain shares or fees, or collect twice? Is a benefactor ever in two uncollected batches? Do fees earned before a batch closes stay with earlier holders?\n2. Solvency: do claims, protocol fees, pending ETH and the caller reward always fit in the ETH held? Is `owed` in _swapAndAdd complete on every path, including a rejected caller reward and totalShares == 0? Can LP funding ever spend ETH or fee tokens owed to someone?\n3. Rounding and extremes: sharesPerEth at very small and very large batches, a contributor rounded to zero shares, _balancedSwap at extreme prices and depth. Can any input make a conversion revert forever?\n4. Value extraction through what a conversion leaves unpaired: test positions placed around the pool's price (token-only below spot, narrow in range, moved or withdrawn between conversions) against batches of 1x, 2x and 20x the pool's depth, with a real PoolManager. Check the accepted edge in AUDIT-NOTES.md against your numbers. Who ends up with the leftover, and is the windfall bounded as stated?\n5. The multi-pass loop: can a later pass revert when one pass would have succeeded, or leave the vault worse off? Does checking minOutTarget after the loop, with amountLimit 0 on each swap, weaken protection against a sandwich? Gas in the worst case, with many initialised ticks.\n6. Keeper: list exactly what a wrong or malicious keeper can do, including a fee-token floor of 0 and low floors, and whether AUDIT-NOTES.md states it fully. Does anything permissionless still swap or move price-sensitive value?\n7. Real zRouter behaviour on a mainnet fork: ETH-in refunds during the collecting window, amountLimit 0, token-in swaps, and reentrancy through the PoolManager unlock callback. Can ETH arrive during the collecting window from anyone else and be mis-accounted?\n8. UniswapVaultPriceValidator: value-based proportion, depth floors, TWAP for each token ordering. Can anyone block a conversion cheaply, and does it wrongly ignore real pools?\n9. Vault creation: initialisation can't be front-run or repeated, the pool key is set once and hookless, owner-only deployVault, the implementation can't be harmed, and protocolTreasury and the LP-yield cut can't be redirected.\n10. MiyaPool and MiyaPoolFactory: 1:1 backing on every path; RANDOM commit-reveal fairness on mainnet and on Robinhood Chain (AUDIT-NOTES.md says RANDOM must stay off there: is that right, and sufficient?); ordered reveals, gas caps and escrow; can any commit block the queue?\n11. Sale paths: fee split on drop, auction and 1155, parked vault cuts, permissionless withdraws. Can a share be skipped, lost or redirected? Drop reserve, phase caps and mintTo; auction settlement gas, escrow and refunds; ERC1155Instance.\n12. Registry and factories: who can register what; what the owner can change for existing collections; creator-bound CREATE3 salts; owner-only deployVault; can a fake instance or vault reach users?\n13. Cross-contract: flows between a collection, its vault and a pool holding its NFTs; reentrancy across them; ETH that arrives unexpectedly anywhere; what happens if a vault, the keeper or the treasury fails.\n14. Deployment: is every config address the intended contract on its chain (check on chain); does the order leave a window; does ownership end with OWNER; is seeding the alignment pool safe as DEPLOY.md describes; does the fork test prove what it claims?","parentJobId":null,"planHash":"29a8e3b744e21afc52f5589af7c998331dafcb6985c025e8967f745396cd5912","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"6dfbf85f-57ff-475c-912b-d47c64731c93","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":"52669","feedbackHash":"27f05f2a6b731e48fc831f9ab366e5423db7933cc3fc44c5b3e24075a2e062c3","nodeKey":"audit_economics","submissionHash":"163c595287c19df1607252474394bcc57aece9b6d67aff0131de9365af4881d5","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51884","feedbackHash":"eb0c88a4283fc91a867ea539f82fba05210fb1565d921aba503bcb3e0f4b2dea","nodeKey":"audit_flow","submissionHash":"80b95407a903258234f75bb332451c57a1a52b0cf0cc764e48b95eee05a21df6","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52679","feedbackHash":"5e64b0131579febb86611560c687f6d0fad5dd1d853ee40bb957fb189bd0563d","nodeKey":"audit_judge","submissionHash":"773592fa0adef752ffab340724bfac272de6aca78b2a9b5345b46bc70bbe4426","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51481","feedbackHash":"e0a11ead77e462988593b6ba5d0b3349d42d6a5e1817ccc9dd16b48b105614ff","nodeKey":"audit_math","submissionHash":"d6751eaa23c9cf76988db54eb74a89f9943c5a6ebb13b4d89bf8a0837b569eac","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"54234","feedbackHash":"1b7bb44b1226016b12b5650939cf3eb8a8be3fdcc4b5679c47fc2c4225029336","nodeKey":"audit_permissions","submissionHash":"246a11ea534bccf9932d1d5183b3782abe19683316e2b339a0d77a25030ac4e9","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"a066d1975b95d5e2c12dbe765055fbfa9537d994232569fc8b35786106ad098d","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"7cb215928f3c9473","findings":[{"citation":"resolved","description":"convertAndAddLiquidity force-collects LP yield once (line 376), before any swap. _swapAndAdd then swaps the batch through the vault's own pool (zRouter, 1% tier), so the vault's existing full-range position earns that swap fee, and then calls modifyLiquidity with a positive delta on the same position (salt 0). v4 returns principal + feesAccrued in one callerDelta, so the fee comes off what the vault pays in. It is never passed to _creditYield: it sits in the balance as 'free' ETH (address(this).balance - owed), and a later pass or the next batch picks it up as LP. The holders before the close (whose liquidity earned it) get nothing, and the protocol misses its 1% cut. This breaks question 1's property that fees earned before a batch closes stay with earlier holders. AUDIT-NOTES (round 1, 'Leftovers', Low) says fees 'net off what it pays in' and calls the result a 'small windfall' for the next batch. It does not say the fee comes out of existing holders' yield on every conversion, or that on a thin pool, where the vault is most of the liquidity, it is almost all the yield from that volume (about 0.33% of each batch here). Fix: credit the fees inside the add. Either poke the position (liquidityDelta 0) and _creditYield the ETH before each pass's add, or read feesAccrued from modifyLiquidity's second return value in unlockCallback and credit it (ETH via _creditYield, tokens to pendingFeeTokens) before measuring leftovers.","line":481,"path":"src/vaults/uni/UniAlignmentVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport { Test, console2 } from \"forge-std/Test.sol\";\nimport { UniAlignmentVault } from \"src/vaults/uni/UniAlignmentVault.sol\";\nimport { MiyaRegistry } from \"src/registry/MiyaRegistry.sol\";\nimport { IVaultPriceValidator } from \"src/interfaces/IVaultPriceValidator.sol\";\nimport { IMiyaRegistry } from \"src/registry/interfaces/IMiyaRegistry.sol\";\nimport { LibClone } from \"solady/utils/LibClone.sol\";\nimport { ERC20 } from \"solady/tokens/ERC20.sol\";\nimport { PoolManager } from \"v4-core/PoolManager.sol\";\nimport { IPoolManager } from \"v4-core/interfaces/IPoolManager.sol\";\nimport { IUnlockCallback } from \"v4-core/interfaces/callback/IUnlockCallback.sol\";\nimport { PoolModifyLiquidityTest } from \"v4-core/test/PoolModifyLiquidityTest.sol\";\nimport { Currency } from \"v4-core/types/Currency.sol\";\nimport { PoolKey } from \"v4-core/types/PoolKey.sol\";\nimport { IHooks } from \"v4-core/interfaces/IHooks.sol\";\nimport { BalanceDelta } from \"v4-core/types/BalanceDelta.sol\";\nimport { TickMath } from \"v4-core/libraries/TickMath.sol\";\nimport { StateLibrary } from \"v4-core/libraries/StateLibrary.sol\";\nimport { PoolIdLibrary } from \"v4-core/types/PoolId.sol\";\n\ncontract Tok is ERC20 {\n    constructor() { _mint(msg.sender, 1e30); }\n    function name() public pure override returns (string memory) { return \"T\"; }\n    function symbol() public pure override returns (string memory) { return \"T\"; }\n}\n\ncontract Val {\n    function validatePrice(address, uint256) external pure {}\n    function calculateSwapProportion(address, int24, int24, address, bytes32) external pure returns (uint256) { return 5e17; }\n}\n\n/// zRouter-like: exact-in swap on the real PoolManager, refunds its ETH balance to the payer.\ncontract Router is IUnlockCallback {\n    PoolManager pm; PoolKey key;\n    constructor(PoolManager p, PoolKey memory k) { pm = p; key = k; }\n    function swapV4(address to, bool, uint24, int24, address tokenIn, address, uint256 amt, uint256 limit, uint256)\n        external payable returns (uint256, uint256 out)\n    {\n        bool z = tokenIn == address(0);\n        if (!z) ERC20(tokenIn).transferFrom(msg.sender, address(this), amt);\n        out = abi.decode(pm.unlock(abi.encode(z, amt, to)), (uint256));\n        require(out >= limit, \"limit\");\n        if (address(this).balance > 0) payable(msg.sender).transfer(address(this).balance);\n    }\n    function unlockCallback(bytes calldata d) external returns (bytes memory) {\n        (bool z, uint256 amt, address to) = abi.decode(d, (bool, uint256, address));\n        BalanceDelta dl = pm.swap(key, IPoolManager.SwapParams(z, -int256(amt),\n            z ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1), \"\");\n        if (z) { pm.settle{ value: amt }(); uint256 o = uint256(int256(dl.amount1())); pm.take(key.currency1, to, o); return abi.encode(o); }\n        pm.sync(key.currency1); ERC20(Currency.unwrap(key.currency1)).transfer(address(pm), amt); pm.settle();\n        uint256 o2 = uint256(int256(dl.amount0())); pm.take(key.currency0, to, o2); return abi.encode(o2);\n    }\n    receive() external payable {}\n}\n\ncontract FeeNetTest is Test {\n    using StateLibrary for PoolManager;\n    using PoolIdLibrary for PoolKey;\n    PoolManager pm; Tok token; UniAlignmentVault vault; PoolKey key; MiyaRegistry reg; Router zr;\n    address alice = makeAddr(\"alice\"); address bob = makeAddr(\"bob\");\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        token = new Tok();\n        key = PoolKey(Currency.wrap(address(0)), Currency.wrap(address(token)), 10000, 200, IHooks(address(0)));\n        pm.initialize(key, 79228162514264337593543950336);\n        PoolModifyLiquidityTest lper = new PoolModifyLiquidityTest(pm);\n        token.approve(address(lper), type(uint256).max);\n        vm.deal(address(this), 1000 ether);\n        // thin third-party seed: 1 ETH / 1 token full range\n        lper.modifyLiquidity{ value: 2 ether }(key, IPoolManager.ModifyLiquidityParams(-887200, 887200, 1 ether, 0), \"\");\n        reg = new MiyaRegistry(address(this), address(0xFEE), 100, 1000);\n        address[] memory toks = new address[](1); toks[0] = address(token);\n        reg.addTarget(\"t\", toks);\n        reg.setConversionKeeper(address(this));\n        zr = new Router(pm, key);\n        vault = UniAlignmentVault(payable(LibClone.clone(address(new UniAlignmentVault()))));\n        vault.initialize(address(this), address(0xBEEF), address(pm), address(token), address(zr), 10000, 200,\n            IVaultPriceValidator(address(new Val())), IMiyaRegistry(address(reg)), 0);\n        vault.setV4PoolKey(key);\n        vm.deal(alice, 100 ether); vm.deal(bob, 100 ether);\n    }\n    receive() external payable {}\n\n    function _contribute(address who, uint256 amt) internal {\n        vm.prank(who);\n        vault.receiveContribution{ value: amt }(Currency.wrap(address(0)), amt, who);\n    }\n\n    /// Alice's position earns the 1% fee on the swap made during Bob's conversion, before Bob's batch closes.\n    /// That fee belongs to Alice (shareholders before the close) but is netted into Bob's LP add.\n    function test_feesEarnedDuringConversionSwapStayWithEarlierHolders() public {\n        _contribute(alice, 20 ether);\n        vault.convertAndAddLiquidity(1, 1);\n        uint256 aliceBefore = vault.getUnclaimedFees(alice);\n        uint256 lpBefore = vault.totalLPUnits();\n        uint128 poolL = StateLibrary.getLiquidity(IPoolManager(address(pm)), key.toId());\n        console2.log(\"vault share of pool liquidity (bps)\", lpBefore * 10000 / poolL);\n\n        (uint256 g0Before,) = StateLibrary.getFeeGrowthGlobals(IPoolManager(address(pm)), key.toId());\n        _contribute(bob, 20 ether);\n        vault.convertAndAddLiquidity(1, 1);\n        (uint256 g0After,) = StateLibrary.getFeeGrowthGlobals(IPoolManager(address(pm)), key.toId());\n        // ETH fees the pre-existing (Alice's) liquidity earned from the conversion's own swaps. Full range, so\n        // inside growth == global growth.\n        uint256 earnedByAlicesLiquidity = (g0After - g0Before) * lpBefore >> 128;\n        console2.log(\"ETH fee earned by alice's liquidity during the conversion\", earnedByAlicesLiquidity);\n        uint256 aliceFees = vault.getUnclaimedFees(alice) - aliceBefore;\n        console2.log(\"alice fees credited during bob's conversion\", aliceFees);\n        console2.log(\"bob share bps\", vault.benefactorShares(bob) * 10000 / vault.totalShares());\n        console2.log(\"vault eth left\", address(vault).balance);\n        // Bob's conversion swaps several ETH at 1%; Alice's position (~95% of the pool) earns most of that fee.\n        assertGt(earnedByAlicesLiquidity, 0.05 ether);\n        // Expected: Alice (less the 1% protocol cut) is credited what her liquidity earned before Bob's batch closed.\n        assertGe(aliceFees, earnedByAlicesLiquidity * 98 / 100, \"fee earned by alice's liquidity was netted into the add\");\n    }\n}","reproduction":"Real v4 PoolManager, hookless ETH/token pool at fee 10000 / tick spacing 200, seeded by a third party with 1 ETH-equivalent of full-range liquidity (L=1e18). A zRouter stand-in swaps exact-in on that pool and refunds ETH like zRouter. Alice contributes 20 ETH; keeper converts, and the vault holds 78% of pool liquidity. Bob contributes 20 ETH; keeper converts. feeGrowthGlobal0 shows Alice's liquidity earned 0.06576 ETH in fees from that conversion's swaps. Expected: getUnclaimedFees(alice) rises by about 0.0651 ETH (99% after the protocol cut). Actual: it rises by 0. The vault ends with 0.0995 ETH unpaired, which the next batch collects. Run: forge test --match-path test/scratch/FeeNet.t.sol -vv (fails: 0 < 64445057981834730).","severity":"low","snippet":"            r.liquidityUnitsAdded += uint256(_addToLpPosition(amount0, amount1, tickLower, tickUpper));","title":"Fees the existing position earns from the conversion's own swaps are netted into the new LP add and go to the new/next batch, not to the holders who earned them"}],"hash":"163c595287c19df1607252474394bcc57aece9b6d67aff0131de9365af4881d5","nodeId":"e7599a30-d59f-4796-9788-57baf521660f","outcome":"completed","summary":"The sweep is not complete. I found one defect, rated low, with a real-PoolManager test that fails on the current code. I covered questions 1, 2, 3, 5 and 9 in depth. I covered the rest only partly, mostly by reading, and I did not run the mainnet-fork checks. `.imd-findings.json` holds the one finding and its test.\n\n## Finding (low): fees from the conversion's own swap go to the wrong holders\nAt `src/vaults/uni/UniAlignmentVault.sol:481`, LP yield is collected once before the swaps start. The vault then swaps on its own pool, so its existing position earns the 1% fee. When it adds liquidity to that same position, Uniswap v4 subtracts the earned fee from what the vault pays in. That fee is never credited as yield, so it becomes unpaired ETH that the current or next batch picks up. The earlier holders who earned it get nothing, and the protocol misses its 1% cut.\n\n- **Test:** a real v4 PoolManager and a stand-in for zRouter that swaps on that pool and refunds ETH the same way. Alice puts in 20 ETH and is converted; the vault then holds 78% of the pool's liquidity. Bob puts in 20 ETH and is converted.\n- **Expected:** Alice is credited about 0.065 ETH, which is what her liquidity earned from Bob's swap.\n- **Actual:** she is credited 0, and 0.0995 ETH stays unpaired for the next batch.\n- **Fix:** credit the fees inside the add, either by collecting them before each pass's add or by reading `feesAccrued` from `modifyLiquidity`.\n- **Relation to the notes:** AUDIT-NOTES.md calls this netting a \"small windfall\" for the next batch. It doesn't say the fee comes out of existing holders' yield on every conversion. Here that is about 0.33% of each batch, and almost all the yield from that volume when the vault dominates a thin pool.\n\n## What I ran\n- **Ran:** `forge test --match-path test/scratch/FeeNet.t.sol` (the proof; it fails as described). The test file was deleted afterwards; its full source is in the findings file.\n- **Ran:** `test/vaults/UniAlignmentVaultFeeAccounting.t.sol`: 13 passed.\n- **Failed to run:** the full suite (`SKIP_FORK_TESTS=true forge test`). The solc 0.8.26 build crashed (SIGSEGV), so I have no full-suite result.\n- **Checked on chain:** INFER at `0x53d0…7921` is a plain 18-decimal ERC20, not a proxy, total supply 1e27.\n- **Not run:** no mainnet-fork test. Question 7 (real zRouter behaviour) was checked only by reading.\n\n## The 14 questions (checked by reading unless I say so)\n1. **Batch ledger:** I found no loss, gain or double collection across the orders asked about. Topping up in the open batch, or contributing again after a conversion, collects the old batch first, so nobody is ever in two uncollected batches. Fees earned before a close stay with earlier holders, except the defect above.\n2. **Solvency:** `owed` covers every path, including a rejected caller reward and `totalShares == 0`. The LP add can't overspend: rounding the liquidity down and the amounts up keeps what it pays at or below what it supplied. LP funding never touches `pendingFeeTokens`.\n3. **Rounding and extremes:** `_balancedSwap` can't overflow inside its 1e32 caps. With the 0.001 ETH minimum contribution, nobody rounds to zero shares. The only revert I found needs an unrealistic price near the minimum sqrt price.\n4. **Leftover from a conversion:** not measured. I did not test positions placed around the price at 1x, 2x or 20x the pool's depth, so I can't confirm or refute the bound AUDIT-NOTES.md states.\n5. **Multi-pass loop:** a later pass breaks or adds; I found no input that reverts where a single pass would succeed. The floor applies to tokens bought across all passes. A pre-trade that makes tokens dearer lowers the tokens bought, so the floor still catches it. Worst-case gas with many initialised ticks was not measured.\n6. **Keeper:** AUDIT-NOTES.md states the keeper's powers fully: a low floor lets it sandwich its own batch, and a floor of 0 lets it skip fee tokens before a share change. Nothing permissionless swaps anything.\n7. **zRout","treeHash":null,"usage":{"cachedInputTokens":2071077,"inputTokens":47,"model":"claude-opus-5-5","outputTokens":29965,"runtime":"claude","turns":26,"wallClockMs":639315}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2042b7cb7b952e30","findings":[{"citation":"resolved","description":"AUDIT-NOTES.md and DEPLOY.md accept the 4663 RANDOM-mode weakness with the bound that RANDOM 'must stay off' there. Nothing enforces it: script/DeployMiyamints.s.sol deploys MiyaPoolFactory on every chain, the factory has no owner, no chain gate and no mode allowlist, and createPool(collection, RANDOM) is open to anyone for any ERC721. On an Arbitrum-stack chain the weakness is worse than on mainnet: block.number is the L1 block estimate, blockhash is a sequencer-known pseudo-random value, and the EIP-2935 history contract has no code, so MiyaPool._blockHash returns 0 after 256 L1 blocks (about 51 minutes) and revealRedeem re-arms the commit instead of settling it. A committer who computes the pick from the mined target block (pick = keccak(seed, pool, commitId, i) over the deposit-order queue) and dislikes it can simply not reveal; on a quiet pool nobody else does, and after 256 L1 blocks the next reveal gives a fresh draw. Repeated until a desirable id comes up. The accepted item's stated bound (keep it off) is therefore wrong for the code as it ships: it is an operational wish with no mechanism behind it.","line":30,"path":"src/pools/MiyaPoolFactory.sol","reproduction":"State: chainId 4663, MiyaPoolFactory deployed by the script. 1) Anyone calls createPool(collection, RedeemMode.RANDOM) for an ERC721 collection; it succeeds (only the ERC1155 + RANDOM combination is refused). 2) Alice deposits ids 1..5. 3) Bob (holding 1e18 pool tokens) calls commitRedeem(1, bob); targetBlock = block.number + 1. 4) Once that L1 block number is reached Bob reads blockhash(targetBlock) off chain and derives the id he would receive. 5) If he dislikes it he waits until block.number - targetBlock > 256 and calls revealRedeem(id): _blockHash returns 0 (HISTORY has no code on 4663), the commit is re-armed with a new target and Bob gets a new draw. Expected per AUDIT-NOTES: RANDOM is unavailable on 4663. Actual: RANDOM pools are creatable by anyone and re-rolls cost one hour of waiting. Not run: forge could not be executed on this machine (sandbox failed to initialise); this is established by reading MiyaPoolFactory.createPool, MiyaPool.commitRedeem/revealRedeem/_blockHash and the script's unconditional MiyaPoolFactory deployment. Fix: make createPool revert for RANDOM unless the chain is on an allowlist (or unless HISTORY has code and the chain id is 1), or give MiyaPool a randomness source that is safe on 4663, before deploying the pool factory there.","severity":"medium","snippet":"        if (kind == MiyaPool.Kind.ERC1155 && mode == MiyaPool.RedeemMode.RANDOM) revert RandomRequiresERC721();","title":"RANDOM pools are not disabled on Robinhood Chain: the ownerless, permissionless MiyaPoolFactory lets anyone create one, so the accepted bound ('RANDOM must stay off on 4663') cannot be met by this dep"},{"citation":"resolved","description":"Every instance snapshots registry.treasury() as an immutable and pays the protocol cut with SafeTransferLib.safeTransferETH, which reverts if the treasury rejects ETH. The same pattern is in ERC721AuctionInstance.reclaimUnsold (line 393), ERC721DropInstance.withdraw (line 239) and ERC1155Instance.withdraw (line 477). The owner-side payments were already hardened against this (forceSafeTransferETH for the deposit refund, SmartTransferLib for the artist, parking for the vault cut), but the treasury leg was not. For auctions this is the only path that releases the winning bid and the creator's deposit, so a non-receiving treasury locks them forever; for drops it locks all mint proceeds; the registry owner changing the treasury does not help because the instance's copy is immutable. The deploy script's only check on TREASURY is _requireCode, which the INFER token address or any non-payable contract passes. The mainnet fork test exercises a split against the real imdUSD Treasury at the fork block, and the verified source on Etherscan (contract 'Treasury', not a proxy) has a payable receive, so the current mainnet treasury is known to accept ETH today; the risk is a treasury that is upgradeable, replaced on 4663 by an arbitrary 'Safe/treasury you control', or any future state in which it rejects plain ETH.","line":329,"path":"src/factories/erc721/ERC721AuctionInstance.sol","reproduction":"Deploy with TREASURY set to a contract with code but no payable receive/fallback (e.g. an ERC20 such as the INFER token: _requireCode passes). Create an auction instance, queuePiece{value: 1 ether}, createBid{value: 2 ether}, warp past endTime, call settleAuction(1). Expected: the token is minted to the winner, the deposit goes back to the creator, the bid is split. Actual: safeTransferETH(protocolTreasury, 0.02 ether) reverts with ETHTransferFailed and settleAuction reverts; the 2 ETH bid, the 1 ETH deposit and the line stay locked with no recovery path. Not run (no forge on this machine). Fix: send the protocol cut with forceSafeTransferETH (as the deposit refund already does) or park a failed cut in a pendingProtocolCut the way vault cuts are parked, and have the deploy script prove the treasury accepts a 1-wei call before broadcasting.","severity":"low","snippet":"            SafeTransferLib.safeTransferETH(protocolTreasury, s.protocolCut);","title":"Protocol cut is sent with a reverting safeTransferETH to an immutable treasury: a treasury that cannot receive ETH permanently blocks auction settlement, unsold reclaims, drop withdraws and 1155 withd"},{"citation":"resolved","description":"convertAndAddLiquidity books LP yield with _collectAndAccumulateVaultFees(true) before the swap, but every pass of _swapAndAdd then swaps ETH through the vault's own pool. The vault's full-range position earns its share of the 1% fee on that swap. The following _addToLpPosition calls modifyLiquidity, whose returned delta is principal net of feesAccrued, so the vault pays that much less ETH than amount0 and the fee ETH stays in the balance. The next pass (or the next batch) measures it as ethForLP and adds it as liquidity, so it is credited as shares to the current or next batch rather than as fees (accFeePerShare) to the holders who owned the position when it was earned. Question 1 asks whether fees earned before a batch closes stay with earlier holders: fees generated by the batch's own swap do not. The amount per conversion is the vault's share of pool liquidity times 1% of the ETH swapped (about half the batch), so at most 0.5% of each batch, and it grows as the vault becomes most of the INFER pool. AUDIT-NOTES' Leftovers item describes 'fees earned by the position net off what it pays in' as a windfall to the next batch, so the bound is stated; this is reported because it is a systematic transfer from old holders to new batches rather than rounding dust.","line":476,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"State: vault holds a full-range position equal to 50% of the ETH/INFER pool's liquidity; a 10 ETH batch is pending. Keeper calls convertAndAddLiquidity. Pass 0 swaps about 5 ETH; the pool charges 0.05 ETH, of which 0.025 ETH accrues to the vault's position. _addToLpPosition computes liquidity for the full ethForLP/tokensForLP but settles 0.025 ETH less. Expected (per the comment at lines 374-375): yield accrued to existing holders is booked to accFeePerShare. Actual: accFeePerShare is unchanged; the 0.025 ETH is added as liquidity in pass 1 or the next batch, and _closeBatch assigns the resulting shares to the batch. Not run (no forge on this machine); traced through v4 PoolManager.modifyLiquidity returning callerDelta = principal + feesAccrued and the vault's unlockCallback settling callerDelta. Fix: inside each pass, after the swap and before _addToLpPosition, collect the position's fees (a modifyLiquidity with liquidityDelta 0, as _claimVaultFees does) and route them through _creditYield; or read feesAccrued from modifyLiquidity in unlockCallback and credit it.","severity":"low","snippet":"            uint256 ethForLP = address(this).balance - owed;","title":"LP fees earned by the vault's own position on each conversion swap are netted into the new LP add and become shares for the batch instead of yield for existing shareholders"},{"citation":"resolved","description":"withdraw(amount) refuses amounts above totalProceeds - totalWithdrawn, and claimVaultFees/claimAllFees forward exactly the value returned by vault.claimFees(). There is no sweep. ETH that reaches the instance any other way (marketplace royalties paid to the collection address, a direct transfer, a vault payout that arrives in a transaction where the forwarding call reverts and is retried, selfdestruct) is unrecoverable by the artist, the protocol or anyone. ERC721DropInstance handles the same case by splitting whatever stays in the contract on the next withdraw(); the 1155 instance does not.","line":542,"path":"src/factories/erc1155/ERC1155Instance.sol","reproduction":"Deploy an ERC1155 instance, addEdition, mint 1 edition for 0.1 ether (totalProceeds = 0.1 ether). Send 1 ether directly to the instance (any EOA, plain transfer). withdraw(1.1 ether) reverts InsufficientBalance; withdraw(0.1 ether) works and leaves 1 ether; claimVaultFees reverts NoFeesToClaim or forwards only the claimed amount. Expected: the artist (or the split) can recover the 1 ether. Actual: it is locked in the instance forever. Not run (no forge on this machine). Fix: add a permissionless sweep that routes address(this).balance - (totalProceeds - totalWithdrawn) - pendingVaultCut through the same split, mirroring ERC721DropInstance.withdraw.","severity":"low","snippet":"    receive() external payable {}","title":"ERC1155Instance strands any ETH it receives outside mint proceeds: receive() accepts it but withdraw is capped at proceeds and claimVaultFees forwards only the amount just claimed"},{"citation":"resolved","description":"AUDIT-NOTES states the keeper 'can sandwich its own conversion with low floors and take up to the whole batch being converted. It cannot touch earlier LP positions or settled fees.' The token side of LP yield, accumulated in pendingFeeTokens by every claim and conversion, belongs to existing holders and is sold only by the keeper through _convertPendingFeeTokens with the keeper-chosen minEthOut (any value >= 1 passes). A keeper that front-runs its own convertFeeTokens(1) in the pool and back-runs it captures essentially all of that yield, which is neither 'the batch being converted' nor 'settled fees'. The same applies inside convertAndAddLiquidity via minEthOutForFees. The keeper can also hold fee tokens back indefinitely with a floor of 0. This is a trust assumption that already exists (the keeper is trusted), reported only so the written bound matches the code.","line":626,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"State: vault position has earned 1,000,000 INFER of fees, collected into pendingFeeTokens by a claimFees call. Keeper (or whoever holds its key) sends, in one bundle: a large INFER sell into the ETH/INFER pool, convertFeeTokens(1), a buy-back. Expected per AUDIT-NOTES: the keeper cannot touch yield already earned. Actual: _convertPendingFeeTokens sells 1,000,000 INFER at the depressed price with minEthOut = 1 wei, _creditYield credits the few wei received, and the keeper's buy-back recovers the difference. Fix: document it, or bound minEthOut by a quote from the pool state at call time (e.g. require ethReceived >= tokens valued at a stored or TWAP price minus a tolerance).","severity":"info","snippet":"        if (tokenAmount == 0 || minEthOut == 0) return;","title":"AUDIT-NOTES' keeper-trust bound omits pending fee tokens: a malicious keeper can sandwich convertFeeTokens with a 1-wei floor and take yield already earned by existing holders, not only the batch bein"},{"citation":"resolved","description":"CreateX applies the msg.sender guard (guardedSalt = keccak256(abi.encodePacked(bytes32(sender), salt))) only when the salt's first 20 bytes equal msg.sender and byte 21 is 0x00; for any other salt it uses keccak256(abi.encode(salt)) (or reverts with InvalidSalt for sender-prefixed salts with an invalid flag byte). computeVaultAddress always applies the sender-guard formula, so for an unprefixed salt it returns an address different from the one deployVault will actually produce. The deploy script builds a correctly prefixed salt, so the default vault is unaffected; a later owner call with a vanity salt would get a different address from the preview. No access-control impact (deployVault is owner-only, and the guard means nobody can squat a factory-prefixed salt through CreateX).","line":103,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"Call computeVaultAddress(bytes32(uint256(1))) and then deployVault(bytes32(uint256(1)), token, 0, address(0)) as owner. Expected: the two addresses match. Actual: the preview used keccak256(factory ‖ salt) while CreateX used keccak256(abi.encode(salt)), so the deployed vault lands elsewhere. Not run (no forge on this machine). Fix: require the salt to be prefixed with address(this) and 0x00 in deployVault (as the collection factories do with _creatorSalt), or mirror CreateX's _guard in computeVaultAddress.","severity":"info","snippet":"        bytes32 guardedSalt = keccak256(abi.encodePacked(uint256(uint160(address(this))), salt));","title":"UniAlignmentVaultFactory.computeVaultAddress predicts the wrong address for any salt whose first 20 bytes are not the factory address"},{"citation":"resolved","description":"UniAlignmentVaultFactory's constructor does `new UniAlignmentVault()` and never initialises it. The first caller of initialize on the implementation becomes its owner and can set its pool key. EIP-1167 clones use their own storage, so this cannot affect any deployed vault, and the implementation has no delegatecall or selfdestruct, so it cannot be bricked. Reported for completeness against question 9 (the implementation can't be harmed): it cannot, but it can be claimed, which confuses explorers and monitoring.","line":254,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"After deployment, anyone calls UniAlignmentVault(vaultImplementation).initialize(attacker, weth, poolManager, INFER, zRouter, 10000, 200, validator, registry, 0). Expected: reverts. Actual: succeeds; owner() of the implementation is the attacker. Clones are unaffected. Fix: in the factory constructor, call initialize on the implementation with the factory as owner (or add a constructor to UniAlignmentVault that sets _initialized = true, which clones do not execute).","severity":"info","snippet":"        if (_initialized) revert AlreadyInitialized();","title":"The vault implementation deployed by the factory constructor is left uninitialised, so anyone can call initialize on it and become its owner"}],"hash":"246a11ea534bccf9932d1d5183b3782abe19683316e2b339a0d77a25030ac4e9","nodeId":"504f67c3-e391-42ee-8182-57b06a444df5","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds seven findings. Here is the report.\n\n## What I executed, and what I could only read\n\nNothing ran. Every shell command, including `forge --version`, failed with \"Sandbox is required but failed to initialize: Failed to create bridge sockets\", and the unsandboxed path is disabled in this session. So no forge build, no tests, no fork, no `cast code`, and no proof tests under `test/scratch/`. Everything below is from reading the pinned tree, the recent diffs by inspection, and two explorer page fetches. Per the task's own rule, the one medium finding carries no run proof and is reported as such.\n\nOn-chain checks done by explorer page only: INFER at the configured address is the verified, non-proxy `InferToken`; the mainnet treasury is a verified, non-proxy `Treasury` with a payable `receive`. WETH, PoolManager, zRouter, CreateX, v2/v3 factories and every 4663 address were not checked.\n\n## Findings written to the file\n\n- **Medium.** RANDOM pools are not off on Robinhood Chain. `MiyaPoolFactory` is ownerless and permissionless, the script deploys it unconditionally on 4663, and `createPool(collection, RANDOM)` succeeds for any ERC721. The accepted bound (\"must stay off\") has no mechanism, and the EIP-2935 absence there shortens the re-roll window to about 51 minutes.\n- **Low.** Protocol cut uses reverting `safeTransferETH` to an immutable treasury in auction settle, reclaim, drop withdraw and 1155 withdraw. A treasury that rejects ETH locks bids, deposits and mint proceeds permanently. The deploy script only checks code length. Mainnet treasury accepts ETH today; 4663 takes an arbitrary `TREASURY`.\n- **Low.** LP fees that the vault's own position earns on each conversion swap are netted by `modifyLiquidity` and re-added as shares for the batch, not credited as yield to existing holders. Bounded at about 0.5% of a batch, growing with the vault's share of the pool.\n- **Low.** `ERC1155Instance` strands any ETH it receives outside mint proceeds; no sweep exists.\n- **Info.** The keeper-trust note omits `pendingFeeTokens`: a keeper with a 1-wei floor can sandwich fee-token sales and take yield already earned by existing holders.\n- **Info.** `computeVaultAddress` predicts the wrong address for salts not prefixed with the factory address.\n- **Info.** The vault implementation can be initialised by anyone. Clones are unaffected.\n\n## Answers to the numbered questions\n\n1. **Batch ledger.** I could not break it. A benefactor is in at most one uncollected batch because `_trackBenefactorContribution` settles before assigning `closedBatches + 1`. `_collected` only adds `pending * sharesPerEth` while `batchId <= closedBatches`, and `_settleFees` deletes both on collection, so no double collection. Checkpoints never exceed `accFeePerShareAtClose` because every settle path sets the checkpoint to the current index and the close index is read in the same transaction after fee booking. Fees from before close stay with earlier holders except for the own-swap fee netting in the Low finding above.\n2. **Solvency.** `owed` covers the caller reward, all unclaimed benefactor fees (settled and unsettled, as `accumulatedFees - totalFeesClaimed`) and protocol fees. Pending ETH is wholly consumed by the batch. A rejected reward is redistributed after close with ETH still held. The `totalShares == 0` branch is only reachable before the first conversion and books to protocol fees. Balance minus owed cannot underflow because every credit was backed by ETH actually received.\n3. **Rounding and extremes.** `_balancedSwap` fits 256 bits at its 1e32 caps. At the seeded price, shares per wei are about 1643, so no contributor rounds to zero at the 0.001 ETH minimum. The only forever-revert I found needs `validatePrice` to see a 10 WETH v2 pair and a 10 WETH v3 pool disagreeing by over 5%, which matches the accepted bound.\n4. **Unpaired value.** Not tested. No PoolManager run was possible, so the accepted edge numbers in th","treeHash":null,"usage":{"cachedInputTokens":1143414,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":38953,"runtime":"claude","turns":31,"wallClockMs":593601}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9812c5faf226f7c7","findings":[{"citation":"resolved","description":"Merged from three specialists (audit_flow, audit_math, audit_permissions). AUDIT-NOTES.md ('Accepted or deferred') and DEPLOY.md accept that RANDOM redeem is unsafe on chain 4663 (Arbitrum stack: block.number is an L1 estimate, blockhash is sequencer-influenced pseudo-randomness, the EIP-2935 history contract has no code so MiyaPool._blockHash returns 0 after 256 L1 blocks and revealRedeem re-arms the commit) with the bound 'RANDOM needs a different source of randomness, or must stay off, before any 4663 deployment'. Verified against the code: the only mode restriction in MiyaPoolFactory.createPool is the ERC1155+RANDOM check on line 30; the factory is ownerless and has no chain or mode gate; script/DeployMiyamints.s.sol line 259 deploys MiyaPoolFactory unconditionally on every chain (the config for 4663 exists and DEPLOY.md gives the 4663 broadcast command). So on 4663 anyone can call createPool(collection, RANDOM) for any ERC721 and depositors into that pool get the insecure draw: a committer reads blockhash(targetBlock) once mined and, disliking the pick, waits 256 L1 blocks for a re-arm (MiyaPool.sol lines 295-302, 369-371), and the sequencer can influence the hash. The stated bound is an operational wish with no mechanism; as shipped, the 4663 deployment offers the mode the notes say must be off. Fix: gate RANDOM in createPool (e.g. revert unless block.chainid == 1, or unless HISTORY.code.length != 0), or an immutable per-chain flag set by the deploy script, or leave MiyaPoolFactory out of the 4663 deployment. Not executed: the shell sandbox never started in this session, so forge was not run; this is established by reading MiyaPoolFactory.createPool, MiyaPool.commitRedeem/revealRedeem/_blockHash and the script. The attached proof is the audit_flow specialist's test, read and judged sound (solady ERC721 answers ERC-165 for 0x01ffc9a7 and 0x80ac58cd and false for 0xffffffff, so _detectKind passes and the pool is created, which makes vm.expectRevert fail on the current code); it was not run here.","line":30,"path":"src/pools/MiyaPoolFactory.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport { Test } from \"forge-std/Test.sol\";\nimport { MiyaPoolFactory } from \"src/pools/MiyaPoolFactory.sol\";\nimport { MiyaPool } from \"src/pools/MiyaPool.sol\";\nimport { ERC721 } from \"solady/tokens/ERC721.sol\";\n\ncontract ProofNFT is ERC721 {\n    function name() public pure override returns (string memory) { return \"P\"; }\n    function symbol() public pure override returns (string memory) { return \"P\"; }\n    function tokenURI(uint256) public pure override returns (string memory) { return \"\"; }\n    function mint(address to, uint256 id) external { _mint(to, id); }\n}\n\n/// AUDIT-NOTES.md accepts that RANDOM pools \"must stay off\" on Robinhood Chain (4663). Nothing enforces that:\n/// MiyaPoolFactory is ownerless and permissionless and the script deploys it unchanged on 4663, so anyone can\n/// open a RANDOM pool there. Fails on the current code (the pool is created) and passes once RANDOM pools are\n/// refused on chains without secure block hashes (chain-id gate, or a gate on the EIP-2935 history contract).\ncontract RandomPoolOnArbitrumStackTest is Test {\n    function test_randomPoolCannotBeCreatedOnRobinhoodChain() public {\n        vm.chainId(4663);\n        MiyaPoolFactory f = new MiyaPoolFactory();\n        ProofNFT nft = new ProofNFT();\n        vm.expectRevert();\n        f.createPool(address(nft), MiyaPool.RedeemMode.RANDOM);\n    }\n}","reproduction":"State: block.chainid == 4663 (vm.chainId(4663) in a test), MiyaPoolFactory deployed as the script does. Input: anyone calls createPool(<any ERC-165 ERC721>, MiyaPool.RedeemMode.RANDOM). Expected (per the AUDIT-NOTES bound): refused on this chain. Actual: a RANDOM MiyaPool is deployed and registered in poolOf/allPools; deposits mint pool tokens, commitRedeem burns them, revealRedeem draws ids from blockhash(targetBlock) (sequencer-influenced on 4663), and since HISTORY has no code there, any commit older than 256 L1 blocks is re-armed to a fresh draw.","severity":"medium","snippet":"        if (kind == MiyaPool.Kind.ERC1155 && mode == MiyaPool.RedeemMode.RANDOM) revert RandomRequiresERC721();","title":"RANDOM pools can be opened by anyone on Robinhood Chain (4663); the accepted bound 'RANDOM must stay off there' is enforced by nothing in the code or the deploy script"},{"citation":"resolved","description":"Merged from audit_permissions and audit_economics. convertAndAddLiquidity books LP yield once with _collectAndAccumulateVaultFees(true) (line 376) before any swap, and the comment on lines 374-375 states this is so fees accrued before the new shares are minted stay with existing holders. Every pass of _swapAndAdd then swaps ETH through the vault's own pool (lines 467-470), so the vault's full-range position earns its share of the 1% fee on that swap. The following _addToLpPosition calls PoolManager.modifyLiquidity with a positive delta on the same position (salt 0); Uniswap v4 returns callerDelta = principalDelta + feesAccrued, and unlockCallback/_settleLPDelta settles callerDelta, so the vault pays that much less than amount0/amount1 and the fee ETH and fee tokens stay in its balance. They are never passed to _creditYield: the next pass (line 457/459, ethAvailable and tokensHeld) or the next batch measures them as free and adds them as liquidity, and _closeBatch assigns the resulting shares to the batch. Holders before the close get nothing and the protocol misses its 1% cut. Magnitude: vault share of pool liquidity x 1% x ETH swapped (about half the batch), so up to about 0.5% of each batch, growing toward that as the vault becomes most of the INFER pool. AUDIT-NOTES round 1 'Leftovers' says fees 'net off what it pays in' and calls the result a small windfall for the next batch, so the size is stated, but it does not say the windfall is taken from existing holders' yield on every conversion, which contradicts the H2 guarantee that fees accrued before a join stay with existing holders (question 1). Fix: inside each pass, after the swap and before _addToLpPosition, poke the position (liquidityDelta 0, as _claimVaultFees does) and route the ETH through _creditYield and the tokens into pendingFeeTokens; or read feesAccrued (modifyLiquidity's second return value) in unlockCallback and credit it before measuring leftovers. Not executed: forge could not be run in this session; traced through v4 PoolManager.modifyLiquidity semantics and the vault code. The audit_economics specialist attached a real-PoolManager test (.imd/reads/proofs/Proof_447c93c0ac74.t.sol) reporting a 0.0644 ETH shortfall for the earlier holder on a 20 ETH batch against 1 ETH of third-party depth; I could not run it, so its numbers are reported as the specialist's.","line":481,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"State: vault holds a full-range position that is a large share of the ETH/token pool (e.g. after Alice's 20 ETH batch against a thin 1 ETH third-party seed, about 78-95%); Bob contributes 20 ETH. Keeper calls convertAndAddLiquidity(1, 1). Pass 0 swaps several ETH at 1%; the vault's position accrues most of that fee. Expected (comment at lines 374-375, AUDIT-NOTES H2): getUnclaimedFees(alice) rises by the fee her liquidity earned, less the 1% protocol cut. Actual: accFeePerShare is unchanged by the conversion's swaps; the fee ETH and tokens are netted off the add, remain in the vault balance, and are added as liquidity in pass 1 or the next batch, with the shares credited to Bob's (or the next) batch.","severity":"low","snippet":"            r.liquidityUnitsAdded += uint256(_addToLpPosition(amount0, amount1, tickLower, tickUpper));","title":"LP fees the vault's existing position earns from a conversion's own swaps are netted into the new LP add and become shares for the batch, not yield for the holders who earned them"},{"citation":"resolved","description":"Merged from audit_flow, audit_math and audit_permissions. Each instance snapshots registry.treasury() as an immutable at launch and pays the protocol cut with SafeTransferLib.safeTransferETH, which reverts the whole call on failure: ERC721AuctionInstance.settleAuction (line 329) and reclaimUnsold (line 393), ERC721DropInstance.withdraw (line 239) and ERC1155Instance.withdraw (line 477). The other legs were hardened (forceSafeTransferETH for the deposit refund, SmartTransferLib WETH fallback for the artist, try/catch parking for the vault cut), but the treasury leg was not, and MiyaRegistry.setTreasury only affects future launches, so there is no recovery path for an existing instance. For an auction, settleAuction is the only path that releases the winning bid, mints the token and refunds the creator's deposit; reclaimUnsold reverts HasBids, so a rejecting treasury locks the bid, the deposit and the line forever. The mainnet treasury is the imdUSD Treasury contract; the specialists report its verified source has a payable receive() and the fork test asserts it receives the cut, so mainnet is safe as configured today; I could not confirm this on chain (Etherscan refused the unauthenticated request). On 4663 TREASURY is an env value the script only checks with _requireCode (any contract passes, including a token), and a future treasury that is upgradeable or paused would trigger it on mainnet too. Fix: park a failed protocol cut in a pendingProtocolCut with a permissionless flush (mirroring pendingVaultCut), or use forceSafeTransferETH; and have the deploy script prove the treasury accepts a 1-wei call. Not executed: reading only.","line":329,"path":"src/factories/erc721/ERC721AuctionInstance.sol","reproduction":"Deploy an ERC721AuctionInstance whose protocolTreasury is a contract with no payable receive/fallback (on 4663, TREASURY=<any ERC20 address> passes _requireCode). Artist queuePiece{value: 1 ether}, bidder createBid{value: 2 ether}, warp past endTime, call settleAuction(tokenId). Expected: token to winner, deposit refunded, bid split with the protocol cut parked or forwarded. Actual: safeTransferETH(protocolTreasury, 0.02 ether) reverts ETHTransferFailed, settleAuction reverts, auction.settled stays false, the 2 ETH bid and the 1 ETH deposit stay locked, reclaimUnsold reverts HasBids, and the line never advances. Same for ERC721DropInstance.withdraw() and ERC1155Instance.withdraw(amount).","severity":"low","snippet":"            SafeTransferLib.safeTransferETH(protocolTreasury, s.protocolCut);","title":"Protocol cut is a revert-on-failure transfer to an immutable treasury on every sale path; a treasury that rejects ETH freezes auction settlement, unsold reclaims, drop withdraws and 1155 withdraws for"},{"citation":"resolved","description":"Merged from audit_flow and audit_permissions. deployVault passes the owner-supplied salt to CreateX.deployCreate3 unchanged (line 73) and nothing shapes it. CreateX's _guard applies the msg.sender-permissioned hash keccak256(sender ‖ salt) only when the salt's first 20 bytes equal msg.sender and byte 21 is 0x00 (0x01 adds the chain id; any other flag byte reverts InvalidSalt); a salt whose first 20 bytes are zero is chain-id-guarded, and any other salt is hashed as keccak256(abi.encode(salt)), i.e. unpermissioned. computeVaultAddress always applies the sender-guard formula, so for any salt not prefixed factory ‖ 0x00 it returns an address the vault will never have, and because that salt is unpermissioned anyone who sees the owner's pending deployVault can deploy anything at the real CREATE3 address through CreateX first, making the owner's transaction revert (denial only; the owner picks a new salt). The deploy script builds a correctly prefixed salt (DeployMiyamints.s.sol lines 276-280), so the default vault is unaffected; the factory is a long-lived owner entry point and the auction and 1155 factories already derive their salts internally (_creatorSalt). Fix: derive the CreateX salt inside deployVault from the caller-supplied salt (factory ‖ 0x00 ‖ bytes11(salt)) and compute the preview from the same derivation, or revert when the salt is not so prefixed. Not executed: reading of the factory and CreateX's _guard.","line":103,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"salt = keccak256('vault-2') (first 20 bytes != factory). computeVaultAddress(salt) returns computeCreate3Address(keccak256(factory ‖ salt)). Owner calls deployVault(salt, INFER, 0, address(0)): CreateX deploys at computeCreate3Address(keccak256(abi.encode(salt))) instead, so the returned vault != the preview. Front-run: before the owner's tx lands, anyone calls CreateX.deployCreate3(salt, anyInitCode); the owner's deployVault then reverts inside CreateX because the address already has code.","severity":"low","snippet":"        bytes32 guardedSalt = keccak256(abi.encodePacked(uint256(uint160(address(this))), salt));","title":"UniAlignmentVaultFactory.computeVaultAddress predicts the wrong address for any salt not prefixed with the factory address, and such a salt can be pre-claimed by anyone through CreateX"},{"citation":"resolved","description":"Merged from audit_flow and audit_math. Two ways the backstop vetoes a conversion it should not, both only for a token that has a v2 pair holding at least 10 WETH and a v3 pool passing the depth checks (INFER has neither today, so the default vault is unaffected). (1) priceV2 is the pair's current reserves (lines 246/250), not a TWAP, while priceV3 is a 30-minute TWAP; a swap in the v2 pair that moves spot by more than maxPriceDeviationBps (500) in the same block as the keeper's conversion makes validatePrice revert PriceDeviationTooHigh at the top of convertAndAddLiquidity (vault line 386), before the keeper's floors matter. The private mempool stops a targeted sandwich, but a griefer who keeps the pair skewed (paying arbitrage) blocks every conversion while they fund it; AUDIT-NOTES bounds this at '10 WETH (recoverable)' in the v3 pool, but the v2 side only needs to be off during the keeper's blocks. (2) Both prices are wei of WETH per whole token as integers: when token0 == weth, price = 1e36 / rawPrice (line 337) is 0 once a token trades above 1e18 raw units per WETH, and priceV2 = reserveWETH*1e18/reserveToken (line 246) is 0 at the same ratio; _checkPriceDeviation treats a zero as unacceptable (lines 377-379), so validatePrice reverts unconditionally. Between about 1e-18 and 1e-16 ETH per unit the prices are small integers, so one tick of TWAP rounding yields deviations of thousands of bps and the conversion reverts although the pools agree. Fix: compare in higher precision (Q96 or 1e36 via fullMulDiv), use the v2 pair's cumulative price or drop the v2 leg, and skip rather than revert when either price rounds to zero. Not executed: arithmetic checked by hand.","line":337,"path":"src/peripherals/UniswapVaultPriceValidator.sol","reproduction":"(1) Token T with a v2 pair (>= 10 WETH) and a v3 3000 pool (>= 10 WETH, cardinality > 1). Griefer swaps in the v2 pair to move spot 6% and re-skews after each arbitrage; keeper calls convertAndAddLiquidity: expected pass (the v4 pool is untouched), actual PriceDeviationTooHigh. (2) Token T, 18 decimals, address > WETH so WETH is token0 in both pools, market price 5e-18 ETH per T: v2 reserves 10e18 WETH / 2e36 T give priceV2 = 10e18*1e18/2e36 = 5; v3 TWAP mean tick rounds down one tick, rawPrice = 2.0002e35, price = 1e36/2.0002e35 = 4; _checkPriceDeviation(5, 4): diff 1, avg 4, deviation 2500 bps > 500, revert on every conversion. At 9e-19 ETH per T both prices are 0 and the revert is unconditional.","severity":"low","snippet":"                price = (1e18 * 1e18) / rawPrice;","title":"validatePrice compares a v2 spot price against a v3 TWAP and truncates both to 1e18-scaled integers, so a token with two deep reference pools can be blocked by a same-block v2 skew or, below about 1e-"},{"citation":"resolved","description":"From audit_permissions, verified against the code. withdraw(amount) refuses amounts above totalProceeds - totalWithdrawn (line 470) and claimVaultFees/claimAllFees forward exactly the value vault.claimFees() returned (lines 525-536). There is no sweep, so ETH that reaches the instance any other way (a direct transfer, a marketplace payout to the collection address, a vault claim whose forwarding call fails and is retried in a later transaction where claimFees returns 0, selfdestruct) can never be withdrawn by the artist, the protocol or anyone. ERC721DropInstance handles the same case by splitting whatever stays in the contract on the next withdraw() (line 236). Fix: add a permissionless sweep that routes address(this).balance - (totalProceeds - totalWithdrawn) - pendingVaultCut through the same split, mirroring the Drop. Not executed: reading only.","line":542,"path":"src/factories/erc1155/ERC1155Instance.sol","reproduction":"Deploy an ERC1155 instance, addEdition, mint 1 unit for 0.1 ether (totalProceeds = 0.1 ether). Any EOA sends 1 ether to the instance with a plain transfer (accepted by receive()). withdraw(1.1 ether) reverts InsufficientBalance; withdraw(0.1 ether) succeeds and leaves 1 ether; claimVaultFees reverts NoFeesToClaim or forwards only the amount just claimed. Expected: the 1 ether can be recovered through the split. Actual: it is locked in the instance forever.","severity":"low","snippet":"    receive() external payable {}","title":"ERC1155Instance strands any ETH it receives outside mint proceeds: receive() accepts it, withdraw is capped at proceeds and the claim paths forward only what was just claimed"},{"citation":"resolved","description":"From audit_permissions, verified. AUDIT-NOTES 'Keeper trust (M2)' says the keeper 'can sandwich its own conversion with low floors and take up to the whole batch being converted. It cannot touch earlier LP positions or settled fees.' The token side of LP yield accumulates in pendingFeeTokens from every claim and conversion and belongs to existing holders; it is sold only by the keeper through _convertPendingFeeTokens with a keeper-chosen minEthOut, where any value >= 1 passes (lines 622-644) and 0 leaves the tokens pending. A keeper (or whoever holds its key) that front-runs convertFeeTokens(1) with a large sell into the ETH/INFER pool and back-runs it captures essentially all of that yield, which is neither the batch being converted nor settled fees; the same applies to minEthOutForFees inside convertAndAddLiquidity. Question 6 asks whether the notes state the keeper's powers fully: they do not. This is an existing trust assumption (the keeper is trusted and replaceable), reported so the written bound matches the code. Fix: document it, or bound minEthOut against a quote from pool state at call time. Not executed: reading only.","line":626,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"State: the vault position has earned 1,000,000 INFER of fees, collected into pendingFeeTokens by a claimFees call. Keeper sends, in one bundle: a large INFER sell into the pool, convertFeeTokens(1), a buy-back. Expected per AUDIT-NOTES: the keeper cannot touch yield already earned. Actual: _convertPendingFeeTokens sells 1,000,000 INFER at the depressed price with minEthOut = 1 wei, _creditYield credits the few wei received, and the keeper's buy-back recovers the difference.","severity":"info","snippet":"        if (tokenAmount == 0 || minEthOut == 0) return;","title":"Keeper trust bound in AUDIT-NOTES omits pending fee tokens: a malicious keeper can sandwich convertFeeTokens with a 1-wei floor and take yield already earned by existing holders, or withhold it indefi"},{"citation":"resolved","description":"From audit_flow, verified as a property of the code (numbers not run). AUDIT-NOTES says 'The keeper's floors are the only price protection'. The floor only catches the direction in which the vault receives fewer tokens than quoted. If an attacker sells INFER into the thin 1% pool in the block before the keeper's conversion, the keeper's live quote already reflects the depressed price, the vault buys more tokens than minOutTarget, _swapAndAdd adds full-range liquidity at the depressed spot (validatePrice is a no-op for INFER, which has no v2/v3 pool, and calculateSwapProportion only sets the split), and the attacker buys back through the now-deeper pool. The vault's loss is impermanent loss on the new position across the deviation; the attacker pays the 1% fee twice. At the 1% tier this only pays when the batch is a sizeable fraction of pool depth, the regime the notes already tell the keeper to avoid, but the notes attribute the protection to the floors, which cannot see this direction. Fix options that keep the design: have the keeper pass a maximum tokens-out or a sqrtPrice band alongside minOutTarget, or skip conversion when the batch exceeds a fraction of the pool's virtual depth. Not executed.","line":483,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"Pool: ETH/INFER 1%, virtual depth D ETH. Block N: attacker sells INFER worth about 0.3 D, price falls 40-50%. Block N+1: keeper (private mempool, quote taken at the depressed price) calls convertAndAddLiquidity(minOutTarget = 99% of quoted tokens, 0) for a batch >= 0.1 D. Expected: the floor protects the conversion. Actual: the swap returns more than the floor, the position is added at the depressed price, and the attacker's buy-back in block N+2 realises the vault's impermanent loss.","severity":"info","snippet":"        if (swapped && r.targetTokenReceived < minOutTarget) revert InsufficientOutput();","title":"minOutTarget bounds only an upward price move: a downward pre-positioning before the keeper's quote makes the vault LP at a depressed price and the floor passes"},{"citation":"resolved","description":"Merged from audit_flow and audit_permissions. The EIP-1167 target is never initialised; the first caller of UniAlignmentVault.initialize on it (line 254 guards only a repeat) becomes its owner and can set its pool key, and its receive() credits contributions to whoever sends ETH, which is then unrecoverable. Clones delegatecall its code but keep their own storage, the vault has no delegatecall or selfdestruct, and the registry only marks clones as isVault, so no deployed vault or user is affected (question 9: the implementation cannot be harmed, but it can be claimed, which confuses explorers and monitoring). Fix: initialise the implementation in the factory constructor with the factory as owner, or add a constructor to UniAlignmentVault that sets _initialized = true (clones do not run constructors). Not executed.","line":52,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"After deployment, anyone calls UniAlignmentVault(factory.vaultImplementation()).initialize(attacker, weth, poolManager, INFER, zRouter, 10000, 200, validator, registry, 0). Expected: reverts. Actual: succeeds and owner() of the implementation is the attacker; clones are unaffected.","severity":"info","snippet":"        vaultImplementation = address(new UniAlignmentVault());","title":"The vault implementation deployed by the factory constructor is left uninitialised, so anyone can call initialize on it and become its owner"},{"citation":"resolved","description":"From audit_math, arithmetic verified by hand. calculateBatchCost multiplies basePrice by r^start and by (r^amount - 1e18) with mulWad before dividing by (r - 1e18); for small basePrice the intermediate basePrice * (r^amount - 1e18) / 1e18 truncates to 0, so the cost is 0 while calculatePrice for the same mint returns basePrice, and ERC1155Instance.mint charges calculateMintCost, so such an edition mints for free. Threshold: basePrice * amount * rateBps * 1e14 < 1e18, i.e. basePrice below about 1e4 / (amount * rateBps) wei; addEdition only rejects basePrice == 0, and no real edition prices below 1e4 wei. Fix: divide last with fullMulDiv (basePrice * rStart * (rAmount - 1e18) / (r - 1e18) / 1e18). Not executed.","line":61,"path":"src/factories/erc1155/DynamicPricingModule.sol","reproduction":"calculateBatchCost(basePrice = 1000, priceIncreaseRate = 1, startMinted = 0, amount = 1): r = 1e18 + 1e14, rStart = 1e18, rAmount = 1e18 + 1e14; 1000.mulWad(1e18) = 1000; 1000.mulWad(1e14) = 1000 * 1e14 / 1e18 = 0; 0.divWad(1e14) = 0. calculatePrice(1000, 1, 0) returns 1000. Expected: one unit costs the unit price. Actual: 0, so mint(editionId, 1, ...) with msg.value 0 succeeds.","severity":"info","snippet":"        uint256 numerator = basePrice.mulWad(rStart).mulWad(rAmount - 1e18);","title":"DynamicPricingModule.calculateBatchCost rounds the whole batch cost to zero for sub-1e4-wei base prices, diverging from calculatePrice"},{"citation":"resolved","description":"From audit_flow, verified in the test source. setUp swallows any createSelectFork failure (lines 95-97) and every test then vm.skip()s, so `forge test` on a machine without network exits green while the deployment is untested; CI must assert the fork tests ran. When they do run and the real pool is absent, setUp seeds its own ETH/INFER pool with 100 ETH (lines 107-112), so the 'large batches' test converts batches of about a tenth of depth rather than batches far larger than the pool, and the no-windfall threshold is loose against a 1-in-110 share. The conversions use floors of (1, 1), so the keeper's price protection is never exercised. On the Q14 on-chain checks: in this session the shell did not start, so no cast calls were made, and both unauthenticated lookups I tried (Robinhood Blockscout API, Etherscan v2 API) were refused; the config addresses for 1 and 4663 remain 'as configured, unverified by this review' beyond the specialists' Etherscan reports for zRouter, the treasury and INFER.","line":125,"path":"test/deploy/DeployMiyamints.t.sol","reproduction":"Run `forge test --match-path test/deploy/DeployMiyamints.t.sol` with no network: every test reports skipped, exit code 0. With network and no real INFER pool: setUp deploys InferPoolSeeder with 100 ETH, then the large-batch test converts 10 ETH batches, i.e. 10% of depth, not the 'far bigger than the pool' case AUDIT-NOTES describes.","severity":"info","snippet":"        vm.skip(!forked);","title":"Fork deployment test skips silently without an RPC and seeds its own 100 ETH pool, so a green run proves nothing offline and its large-batch and windfall claims do not describe the production pool"}],"hash":"773592fa0adef752ffab340724bfac272de6aca78b2a9b5345b46bc70bbe4426","nodeId":"6f922997-638b-4a49-9ccd-ba16765ddee3","outcome":"completed","summary":"The findings file is complete with the merged, code-verified set. Final recap follows.\n\n**What I could and could not run.** The shell sandbox refused to start for every command in this session, so forge was never executed, neither specialist proof was run, and no `cast code` checks were made. Both unauthenticated on-chain lookups I tried (Robinhood Blockscout API, Etherscan v2 API) were refused. Every finding below was reproduced by reading the code at the pinned commit, and each entry in `.imd-findings.json` says so plainly.\n\n**Findings kept (11, merged from 19 specialist entries):**\n\n- **Medium: RANDOM pools on Robinhood Chain.** Three specialists reported it; merged into one. The factory has no chain or mode gate and the script deploys it unconditionally, so the accepted bound \"RANDOM must stay off on 4663\" has no mechanism behind it. The audit_flow proof test is attached, read and judged sound, but not run.\n- **Low: conversion-swap fees netted into the LP add.** Two specialists; merged. Fees the existing position earns during the batch's own swaps come off what the vault pays into the position and become shares for the batch, contradicting the H2 guarantee that fees earned before a close stay with earlier holders.\n- **Low: protocol cut is a reverting transfer to an immutable treasury.** Three specialists; merged. A rejecting treasury freezes auction settlement, reclaims and both withdraw paths with no recovery for launched collections.\n- **Low: computeVaultAddress disagrees with CreateX for unprefixed salts,** and such salts can be pre-claimed. Two specialists; merged.\n- **Low: validatePrice v2 spot versus v3 TWAP and integer-price truncation.** Two specialists; merged. Does not affect INFER.\n- **Low: ERC1155Instance strands ETH received outside mint proceeds.**\n- **Info (5):** keeper bound omits pending fee tokens; minOutTarget catches only upward moves; uninitialised vault implementation (merged from two); DynamicPricingModule batch-cost rounding; fork test skips silently and seeds its own pool.\n\n**Dropped or merged:** no specialist finding failed to reproduce by reading. Duplicate RANDOM, treasury, fee-netting, validator and implementation entries were folded into single findings.\n\n**Vault panel answers where nothing was wrong (by reading):** the batch ledger settles each benefactor's converted batch before any share change, so nobody is in two uncollected batches and nobody collects twice. The owed amount in `_swapAndAdd` covers the reward, unclaimed benefactor fees and protocol fees on every path, including a rejected reward, so claims always fit in held ETH. Pool key is set once and hookless, deployVault is owner-only, and protocolTreasury follows the registry. What I could not check without a run: the real-PoolManager extraction numbers for question 4, the multi-pass gas worst case, and live zRouter behaviour on a fork.","treeHash":null,"usage":{"cachedInputTokens":935483,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":33407,"runtime":"claude","turns":31,"wallClockMs":490672}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"4aa8623bfb8d8e5e","findings":[{"citation":"resolved","description":"AUDIT-NOTES.md accepts, as a bound, that RANDOM pools 'must stay off' on chain 4663 because the Arbitrum-stack blockhash is 'cryptographically insecure, pseudo-random' and 'does not come from the parent chain' (Arbitrum docs, Solidity support page; Nitro's arbos/blockhash fills skipped L1 numbers with keccak of known data). The bound is wrong as a bound: MiyaPoolFactory is ownerless and permissionless, has no chain gate, and DeployMiyamints deploys it unchanged on 4663 (step 8, same CREATE3 address on every chain by design). Anyone can call createPool(collection, RANDOM) there. MiyaPool._blockHash (src/pools/MiyaPool.sol:369-374) then uses blockhash() for the last 256 'blocks' (which on 4663 are estimated L1 numbers) and the EIP-2935 lookup returns 0 because HISTORY has no code there, so every reveal draws from a sequencer-controlled value and anything unrevealed for 256 L1 blocks is re-armed. Redeemers who commit lose the fairness the mode promises, and the sequencer (or whoever controls the first L2 block that sees the target L1 number) can pick which ids a commit receives. The code, not the runbook, has to keep the mode off on that chain. Fix: refuse RANDOM in createPool unless the chain has secure block hashes, e.g. `if (mode == RANDOM && (block.chainid != 1 || HISTORY.code.length == 0)) revert RandomUnsupportedHere();` or an immutable `randomAllowed` set by the deploy script per chain. Proof test: test/scratch/RandomPoolOnArbitrumStack.t.sol (written, not executed: the shell sandbox never started in this session; it expects createPool RANDOM to revert with chainId 4663 and fails on the current code because the pool is created).","line":27,"path":"src/pools/MiyaPoolFactory.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport { Test } from \"forge-std/Test.sol\";\nimport { MiyaPoolFactory } from \"src/pools/MiyaPoolFactory.sol\";\nimport { MiyaPool } from \"src/pools/MiyaPool.sol\";\nimport { ERC721 } from \"solady/tokens/ERC721.sol\";\n\ncontract ProofNFT is ERC721 {\n    function name() public pure override returns (string memory) {\n        return \"P\";\n    }\n\n    function symbol() public pure override returns (string memory) {\n        return \"P\";\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\n/// AUDIT-NOTES.md accepts that RANDOM pools \"must stay off\" on Robinhood Chain (4663), where blockhash is\n/// documented as insecure pseudo-randomness that does not come from the parent chain. Nothing enforces that:\n/// MiyaPoolFactory is ownerless and permissionless and deploys the same bytecode on 4663, so anyone can open a\n/// RANDOM pool there. This test fails on the current code (the pool is created) and passes once RANDOM pools\n/// are refused on chains without secure block hashes (chain-id gate, or a gate on the EIP-2935 history contract).\ncontract RandomPoolOnArbitrumStackTest is Test {\n    function test_randomPoolCannotBeCreatedOnRobinhoodChain() public {\n        vm.chainId(4663);\n        MiyaPoolFactory f = new MiyaPoolFactory();\n        ProofNFT nft = new ProofNFT();\n        vm.expectRevert();\n        f.createPool(address(nft), MiyaPool.RedeemMode.RANDOM);\n    }\n}","reproduction":"On chain 4663 (or in a test after vm.chainId(4663)): deploy MiyaPoolFactory; deploy any ERC-165 ERC721; call createPool(nft, MiyaPool.RedeemMode.RANDOM). Expected (per AUDIT-NOTES bound): refused. Actual: a RANDOM MiyaPool is deployed and registered in poolOf, deposits mint pool tokens, commitRedeem burns them, and revealRedeem draws ids from blockhash(target), which on 4663 is not an Ethereum block hash and is influenced by the sequencer; HISTORY.code.length == 0 there so the fallback window is 256 and anything older is re-armed.","severity":"medium","snippet":"    function createPool(address collection, MiyaPool.RedeemMode mode) external returns (address pool) {","title":"RANDOM pools can be opened permissionlessly on Robinhood Chain; the accepted 'must stay off' bound is not enforced by anything"},{"citation":"resolved","description":"AUDIT-NOTES states 'The keeper's floors are the only price protection' and bounds a malicious keeper at 'the whole batch'. For an outside attacker the floor only catches the direction where the vault receives fewer tokens. If the attacker sells INFER into the thin 1% pool in the block before the keeper's (private-mempool) conversion, the keeper's live quote already reflects the depressed price, the vault buys more tokens than minOutTarget, _swapAndAdd adds full-range liquidity at the depressed spot (no band check: validatePrice is a no-op for INFER, which has no v2/v3 pool, and calculateSwapProportion only sets the split), and the attacker buys back through the now-deeper pool. The vault's loss is the impermanent loss of the new position across the price deviation; the attacker pays the 1% fee twice on their leg. At the 1% tier this is only profitable when the batch is a sizeable fraction of pool depth (roughly: IL of ~5-6% on the batch at a 50% deviation versus ~2% fee on a sale of ~0.3x depth), which is exactly the regime the notes already tell the keeper to avoid ('convert often enough that batches stay small against the pool'), but the notes attribute the protection to the floors, which cannot see this direction. Not run: numbers above are from the constant-product/IL approximations, not from a PoolManager run. Fix options that preserve the design: have the keeper pass a maximum tokens-out (or a sqrtPrice band) alongside minOutTarget and revert when spot is outside it, or have the keeper skip conversion when the batch exceeds a fraction of the pool's virtual depth.","line":483,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"Pool: ETH/INFER 1%, virtual depth D ETH. Attacker (block N): sell INFER worth ~0.3 D ETH, price falls ~40-50%. Keeper (block N+1, private mempool, quote taken at the depressed price): convertAndAddLiquidity(minOutTarget = 99% of quoted tokens, 0) for a batch b >= 0.1 D. Expected: the conversion is protected by its floor. Actual: the swap returns more tokens than the floor, the position is added at the depressed price, and the attacker's buy-back in block N+2 through the deeper pool realises the vault's IL; the vault's shareholders hold a position whose token side was bought at the manipulated price.","severity":"low","snippet":"        if (swapped && r.targetTokenReceived < minOutTarget) revert InsufficientOutput();","title":"minOutTarget bounds only an upward price move; a downward pre-positioning before a conversion makes the vault LP at a depressed price and the floor passes"},{"citation":"resolved","description":"computeVaultAddress assumes CreateX's 'msg.sender-permissioned, no redeploy protection' branch (first 20 bytes of salt == factory, byte 21 == 0x00). deployVault passes the owner's salt to CreateX unchanged (line 73) and nothing in the factory shapes it. For any other salt CreateX's _guard hashes it as keccak256(abi.encode(salt)), an unpermissioned salt that anyone can use through CreateX first. Consequences: (1) the preview address is wrong, so an app or runbook that announces computeVaultAddress(salt) announces an address the vault will never have; (2) an attacker who sees the owner's pending deployVault can front-run the CREATE3 address with any contract, making the owner's transaction revert (denial only: the owner picks a new salt). The deploy script avoids this by building the salt as factory || 0x00 || 11 bytes, but the factory is a long-lived owner entry point. The auction and 1155 factories already build the salt internally (_creatorSalt); do the same here: derive the CreateX salt inside deployVault from the caller-supplied salt, and compute the preview from the derived salt.","line":103,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"salt = keccak256('vault-2') (first 20 bytes != factory). computeVaultAddress(salt) returns CreateX.computeCreate3Address(keccak256(abi.encodePacked(uint256(uint160(factory)), salt)), CREATEX). Owner calls deployVault(salt, INFER, 0, 0): CreateX deploys at computeCreate3Address(keccak256(abi.encode(salt))) instead, so the returned vault != the preview. Front-run: before the owner's tx, anyone calls CreateX.deployCreate3(salt, anyInitCode); the owner's deployVault then reverts in CreateX (address already has code).","severity":"low","snippet":"        bytes32 guardedSalt = keccak256(abi.encodePacked(uint256(uint160(address(this))), salt));","title":"deployVault / computeVaultAddress disagree with CreateX for any salt not prefixed by the factory address; such an address can be pre-claimed by anyone"},{"citation":"resolved","description":"The vault cut is parked on failure (pendingVaultCut) on all three paths, but the protocol cut is sent with safeTransferETH, which reverts the whole call: ERC721DropInstance.withdraw (line 239), ERC721AuctionInstance.settleAuction (line 329) and reclaimUnsold (line 393), ERC1155Instance.withdraw (line 477). The treasury is immutable per collection and snapshotted from the registry at launch. If it cannot accept a plain ETH call, a drop's and an edition's entire proceeds (artist share and vault cut included) are stuck, and an auction can never settle: the winner's bid stays in the contract, the token is never minted, and the line never advances. I checked the mainnet treasury 0x6Ac8fF96Fb8DCF5d79A8eF1E59D9AC23f2BD27Bc on Etherscan: verified 'Treasury', not a proxy, has a payable receive(), so mainnet is safe as configured; the fork test's treasury-balance assertion also confirms it. On 4663 TREASURY is an env value the script only checks for code (_requireCode), and setTreasury on the registry can later point new collections anywhere. Fix: park the protocol cut like the vault cut (pendingProtocolCut + permissionless flush), or at least have DeployMiyamints._check send 1 wei to the treasury and revert if it is refused.","line":329,"path":"src/factories/erc721/ERC721AuctionInstance.sol","reproduction":"TREASURY = any contract without receive/fallback (e.g. a token contract) on 4663, or a future registry.setTreasury to such an address on mainnet. Create a collection, sell one piece. ERC721AuctionInstance.settleAuction(tokenId): expected settlement with the cut parked or forwarded; actual: revert in safeTransferETH, every retry reverts, the winner's ETH and the artist's proceeds are locked and the auction line is frozen. Same for ERC721DropInstance.withdraw() and ERC1155Instance.withdraw(amount).","severity":"low","snippet":"            SafeTransferLib.safeTransferETH(protocolTreasury, s.protocolCut);","title":"Protocol cut is a revert-on-failure transfer to an immutable treasury in every sale path; a treasury that rejects ETH freezes withdrawals and auction settlement forever"},{"citation":"resolved","description":"Two ways the backstop can veto a conversion it should not. (1) priceV2 is the pair's current reserves (lines 246/250), not a TWAP, while priceV3 is a 30-minute TWAP; once a token has a v2 pair holding >= 10 WETH and any v3 pool with >= 10 WETH, a swap in the pair that moves spot by more than maxPriceDeviationBps (5%) in the same block as the keeper's conversion makes validatePrice revert. The keeper's private mempool stops a targeted sandwich, but a griefer who keeps the v2 pair skewed (paying the arbitrage) blocks every conversion of that token for as long as they fund it; the notes bound this at '10 WETH (recoverable)', but the v2 side does not even need to be at a wrong price permanently, only during the keeper's blocks. (2) _rawPrice/price lose precision for very low prices: when token0 == weth, price = 1e36 / rawPrice is 0 once a token trades above 1e18 raw units per WETH, and priceV2 = reserveWETH*1e18/reserveToken is 0 at the same ratio; _checkPriceDeviation treats a zero as 'not acceptable' (line 377-379) and validatePrice reverts, so a real v2+v3 token at that price could never convert. Neither applies to INFER today (no v2 or v3 pool, ~2.7e6 per ETH), so impact is limited to future alignment targets. Fix: use the v2 pair's cumulative price (or drop the v2 leg, as the notes already consider it a backstop), and skip rather than revert when either price rounds to zero.","line":127,"path":"src/peripherals/UniswapVaultPriceValidator.sol","reproduction":"(1) Token T with a legitimate v2 pair (>= 10 WETH) and v3 3000 pool (>= 10 WETH, cardinality > 1). Griefer swaps in the v2 pair to move spot 6% and keeps it there by re-skewing after arbitrage; keeper calls convertAndAddLiquidity: expected pass (v4 pool untouched), actual PriceDeviationTooHigh. (2) Token with 1e19 raw units per WETH in both pools (token0 == weth in v3): priceV3 = 1e36/1e37 = 0, _checkPriceDeviation returns (false,0), validatePrice reverts on every conversion.","severity":"low","snippet":"            (bool isAcceptable, ) = _checkPriceDeviation(priceV2, priceV3);","title":"validatePrice compares a v2 spot price against a v3 TWAP and returns zero prices for very low-priced tokens, so a qualifying v2 pair lets a same-block skew or a tiny price block conversions"},{"citation":"resolved","description":"The EIP-1167 target is never initialised, so anyone can call initialize on it with their own registry and become its owner, and its receive() credits contributions to whoever sends ETH. Clones delegatecall its code and keep their own storage, there is no selfdestruct or delegatecall in the vault, and the registry only marks clones as isVault, so no clone or user is affected; ETH sent to the implementation by mistake is unrecoverable. Standard hardening: call initialize in the constructor with dead values or add a constructor that sets _initialized = true.","line":52,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"attacker calls UniAlignmentVaultFactory.vaultImplementation().initialize(attacker, weth, pm, token, zr, 10000, 200, validator, attackerRegistry, 0): succeeds, attacker is owner() of the implementation. No effect on deployed vaults.","severity":"info","snippet":"        vaultImplementation = address(new UniAlignmentVault());","title":"Vault implementation deployed by the factory is left uninitialised and accepts ETH"},{"citation":"resolved","description":"setUp swallows any createSelectFork failure and every test then vm.skip()s, so a green `forge test` on a machine without network (as in this review session) proves nothing about the deployment; CI must assert the fork tests ran. When they run, setUp seeds its own ETH/INFER pool with 100 ETH at about 2.7M INFER/ETH if the real pool is absent, so the 'largeBatches' test (10 ETH against ~100 ETH of depth) is a 0.1x-depth case, not the 'one far bigger than the pool' its comment claims, and the no-windfall test's 12% threshold is loose against a 1-in-110 share. The conversions use floors of (1, 1), so the keeper's price protection is never exercised. The on-chain checks for Q14 I could not perform: the shell was unavailable, so no cast code; Etherscan confirms zRouter 0x0000...00e4 (verified, swapV4 present), the treasury (verified, payable receive) and INFER (18 decimals, plain ERC-20 ABI); the 4663 addresses (PoolManager, WETH, v3 factory, USDG) could not be verified because Blockscout refused the requests, so they stay 'as configured, unverified'.","line":125,"path":"test/deploy/DeployMiyamints.t.sol","reproduction":"Run `forge test --match-path test/deploy/DeployMiyamints.t.sol` with no network: all tests report skipped, exit code 0. With network and no real pool: setUp deploys InferPoolSeeder with 100 ETH, then test_fork_leftoversStaySmall_largeBatches converts 10 ETH batches, i.e. 10% of depth.","severity":"info","snippet":"        vm.skip(!forked);","title":"Fork test skips silently without an RPC and seeds its own 100 ETH pool, so its 'large batch' and windfall claims do not describe the production pool"}],"hash":"80b95407a903258234f75bb332451c57a1a52b0cf0cc764e48b95eee05a21df6","nodeId":"37a28fa6-bb57-40e1-8e58-3446c05c6a50","outcome":"completed","summary":"The findings file is written with seven entries. Below is the full report, including answers to each numbered question.\n\n## What ran and what did not\n\nNothing executed. The Bash sandbox failed to initialise on every attempt (\"Failed to create bridge sockets\"), so `forge build`, `forge test`, `git diff` and `cast code` never ran. Every statement below comes from reading the pinned tree. I checked three external facts through fetchable pages: Arbitrum's documented `blockhash` semantics, and the Etherscan pages for the mainnet treasury, zRouter and INFER. Blockscout refused requests, so the four Robinhood Chain addresses stay unverified. The proof test in `test/scratch/RandomPoolOnArbitrumStack.t.sol` was written but not run.\n\n## Findings (in `.imd-findings.json`)\n\n- **Medium.** RANDOM pools can be opened on Robinhood Chain by anyone. The accepted bound \"must stay off\" is a runbook promise, but `MiyaPoolFactory.createPool` is ownerless, permissionless and deployed unchanged on 4663. Arbitrum docs state blockhash there is insecure pseudo-randomness not from the parent chain, and the EIP-2935 contract has no code there, so reveals are sequencer-influenced and the window is 256. Fix is a chain or history-contract gate in the factory.\n- **Low.** `minOutTarget` only catches an upward price move. A seller who depresses the thin 1% pool before a predictable conversion passes the keeper's floor, the vault LPs at the depressed spot, and the buy-back realises the position's impermanent loss. Profitable only when the batch is large against depth, which is already the regime the notes warn about, but the notes credit the floors with protection they cannot give in that direction. Numbers are approximations, not a PoolManager run.\n- **Low.** `UniAlignmentVaultFactory.deployVault` passes the owner's salt to CreateX raw, while `computeVaultAddress` assumes a factory-prefixed salt. Any other salt is hashed unpermissioned, so the preview is wrong and the address can be pre-claimed, which reverts the owner's deploy.\n- **Low.** The protocol cut is a revert-on-failure transfer to an immutable treasury in all three sale paths, unlike the parked vault cut. A treasury that rejects ETH freezes drop and edition proceeds and makes auction settlement impossible. Mainnet is safe: Etherscan shows the treasury as verified, non-proxy, with a payable receive. The 4663 TREASURY is only checked for code.\n- **Low.** `validatePrice` compares v2 spot to a v3 TWAP, and rounds both prices to zero for very low-priced tokens, so a qualifying v2 pair lets a kept-skewed spot, or a tiny price, veto conversions. INFER has no v2 or v3 pool, so this is for future targets.\n- **Info.** The vault implementation is never initialised. Harmless to clones.\n- **Info.** The fork tests skip silently without an RPC, seed their own 100 ETH pool, and test a 0.1x-depth batch where the comment claims one \"far bigger than the pool\".\n\n## Answers to the fourteen questions\n\n1. **Batch ledger.** By reading, sound. A contribution always runs `_settleFees` first, which collects any closed batch and moves fees into `settledFees`, so a benefactor is in at most one uncollected batch and `_pendingBatch` always points at the open one or a closed one. `feeCheckpoint` is only written in `_settleFees`, so it never exceeds a batch's `accFeePerShareAtClose` and `earlierFees` cannot underflow. Fees are booked before `_closeBatch`, so earlier holders keep yield earned before new shares arrive. Share rounding is down per benefactor, and the unowned dust stays in the vault as the notes say.\n2. **Solvency.** `owed` in `_swapAndAdd` counts caller reward, unclaimed benefactor fees and protocol fees; pending ETH is the batch itself. The rejected-reward path distributes after `_closeBatch`, when `totalShares > 0`, so it never reaches the protocol-fee fallback. The only leak I see is wei-level: v4 rounds the binding side up and the vault's liquidity formula rounds down, so an ETH-binding add may dip one wei into `owed","treeHash":null,"usage":{"cachedInputTokens":2205359,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":64541,"runtime":"claude","turns":57,"wallClockMs":955073}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3f91b58cf7cd2d45","findings":[{"citation":"resolved","description":"Both reference prices are integers scaled by 1e18 (wei of WETH per whole token). For a token worth less than 1e-18 ETH per unit the V3 price (line 337, token0 == WETH case) and the V2 price (line 246, `priceV2 = (uint256(reserve0) * 1e18) / uint256(reserve1)`) truncate to 0, and _checkPriceDeviation (line 377) treats a zero price as unacceptable, so validatePrice reverts PriceDeviationTooHigh on every call. Between about 1e-18 and 1e-16 ETH per unit the prices are small integers (1..100), so one tick of TWAP rounding or one wei of reserve imbalance produces a deviation of hundreds to thousands of bps against the 500 bps limit, and the conversion reverts although the pools agree. The vault calls validatePrice at the top of convertAndAddLiquidity (UniAlignmentVault.sol:386), so the pending ETH of that vault can never be converted while the two reference pools exist; the keeper's floors do not help because the revert happens before the swap. Only tokens with a V2 pair holding at least 10 WETH and a V3 pool holding at least 10 WETH with a TWAP are affected. INFER (about 2.7e6 per ETH) is not in that range, so the deployed default vault is unaffected; any future vault for a high-supply low-price token is. Fix: compare prices in a higher-precision fixed point (e.g. Q96 or 1e36 scale, computed with fullMulDiv), or skip the cross-check when either price is below a precision floor instead of treating it as a deviation.","line":337,"path":"src/peripherals/UniswapVaultPriceValidator.sol","reproduction":"Token T with 18 decimals, address greater than WETH so WETH is token0 in both pools. Market price 5e-18 ETH per T, i.e. 2e35 units per 1e18 wei. V2 pair reserves: reserveWETH = 10e18, reserveToken = 2e36 -> priceV2 = 10e18 * 1e18 / 2e36 = 5. V3 pool at the same price with at least 10 WETH: rawPrice = 2e35 (units per WETH times 1e18 / 1e18); the TWAP mean tick rounds down by up to one tick (0.01%), so rawPrice = 2.0002e35 and price = 1e36 / 2.0002e35 = 4 (integer division). _checkPriceDeviation(5, 4): diff = 1, avg = (5 + 4) / 2 = 4, deviation = 10000 / 4 = 2500 bps > 500 -> validatePrice reverts PriceDeviationTooHigh. Expected: identical market prices pass the check. Actual: every convertAndAddLiquidity for a vault on T reverts while both pools exist. At 9e-19 ETH per T both integer prices are 0 and the revert is unconditional.","severity":"low","snippet":"                price = (1e18 * 1e18) / rawPrice;","title":"validatePrice blocks every conversion for a token priced under about 1e-16 ETH once it has two deep reference pools (1e18-scaled integer prices round to 0..100)"},{"citation":"resolved","description":"AUDIT-NOTES.md and DEPLOY.md accept that RANDOM redeem must not be offered on chain 4663 because blockhash there is sequencer-influenced pseudo-randomness and the EIP-2935 lookup fails. The only mode restriction in the factory is the ERC1155 check on line 30. The factory is ownerless and deployed on 4663 by the same script, so any user can create a RANDOM pool for any ERC721 collection on 4663 with one call, and depositors into it get the insecure draw. The app hiding the mode does not bound this: pools are reachable by address and the factory is listed in deployments/4663.json. The stated bound ('must stay off before any 4663 deployment') is therefore not met by the contracts: it needs a chainid gate in createPool (or an allow-list of chains for RANDOM), or the 4663 deployment must leave the pool factory out.","line":30,"path":"src/pools/MiyaPoolFactory.sol","reproduction":"On chain 4663 (or any fork with block.chainid = 4663) after DeployMiyamints runs: anyone calls MiyaPoolFactory.createPool(<ERC721 collection>, MiyaPool.RedeemMode.RANDOM). Expected per AUDIT-NOTES: the call is refused on 4663. Actual: a RANDOM MiyaPool is deployed and registered in poolOf/allPools; commitRedeem/revealRedeem then draw from blockhash(targetBlock), which the sequencer controls.","severity":"low","snippet":"        if (kind == MiyaPool.Kind.ERC1155 && mode == MiyaPool.RedeemMode.RANDOM) revert RandomRequiresERC721();","title":"RANDOM pools are not disabled on Robinhood Chain (4663): MiyaPoolFactory is permissionless and has no chain or mode gate, so the AUDIT-NOTES bound 'RANDOM must stay off there' is a policy nobody can e"},{"citation":"resolved","description":"The treasury address is snapshotted as an immutable in each instance (auction protocolTreasury, drop treasury, 1155 protocolTreasury) and MiyaRegistry.setTreasury only affects future launches. The protocol cut is paid with safeTransferETH, which reverts on failure, while the vault cut and the artist share have fallbacks (try/catch parking, WETH fallback). On mainnet the treasury is the imdUSD Treasury contract (config note: a 25 kB contract). The fork test asserts it accepts plain ETH today, but if it is ever upgraded, paused, or replaced so that its receive path reverts, then for every collection already launched: settleAuction reverts (the winner's bid and the token are locked in the instance forever, since the auction cannot be settled or reclaimed), ERC721DropInstance.withdraw reverts (proceeds locked), and ERC1155Instance.withdraw reverts. There is no owner path to change the treasury of an existing instance. Not a code defect in the split math, but the treasury is the single reverting dependency in all three paths and should be documented as a trust assumption or given the same parking fallback the vault cut has (park protocolCut in a pendingProtocolCut and let anyone flush it).","line":329,"path":"src/factories/erc721/ERC721AuctionInstance.sol","reproduction":"Deploy an ERC721AuctionInstance whose protocolTreasury is a contract with `receive() external payable { revert(); }` (or etch such code at the treasury address on a fork). Artist queuePiece{value: 0.01 ether}, bidder createBid{value: 1 ether}, warp past endTime, call settleAuction(tokenId). Expected: settlement completes with the protocol cut parked or skipped. Actual: safeTransferETH(protocolTreasury, 0.01 ether) reverts ETHTransferFailed, settleAuction reverts, auction.settled stays false, the 1 ETH bid and the unminted token stay in the instance with no other path out (reclaimUnsold reverts HasBids).","severity":"low","snippet":"            SafeTransferLib.safeTransferETH(protocolTreasury, s.protocolCut);","title":"Every sale path sends the protocol cut to the immutable treasury with a reverting transfer and no fallback, so a treasury that stops accepting ETH freezes auction settlement (bidder ETH and token stuc"},{"citation":"resolved","description":"calculateBatchCost multiplies basePrice by r^start and by (r^amount - 1e18) with mulWad before dividing by (r - 1e18). For small basePrice the intermediate basePrice * (r^amount - 1e18) / 1e18 truncates to 0, so the cost is 0 while calculatePrice for the same mint returns basePrice. ERC1155Instance.mint charges calculateMintCost, so such an edition mints for free. The threshold is basePrice * amount * rate * 1e14 < 1e18, i.e. basePrice below about 1e4 / (amount * rateBps) wei: only sub-1e4-wei prices, which no real edition uses (addEdition only rejects basePrice == 0). Reordering to divide last (basePrice * rStart * (rAmount - 1e18) / (r - 1e18) / 1e18 with fullMulDiv) removes the gap.","line":61,"path":"src/factories/erc1155/DynamicPricingModule.sol","reproduction":"calculateBatchCost(basePrice = 1000, priceIncreaseRate = 1, startMinted = 0, amount = 1): r = 1e18 + 1e14, rStart = 1e18, rAmount = 1e18 + 1e14. numerator = 1000.mulWad(1e18) = 1000; 1000.mulWad(1e14) = 1000 * 1e14 / 1e18 = 0; result = 0.divWad(1e14) = 0. calculatePrice(1000, 1, 0) returns 1000. Expected: batch cost of one unit equals the unit price. Actual: 0, so mint(editionId, 1, ...) with msg.value 0 succeeds.","severity":"info","snippet":"        uint256 numerator = basePrice.mulWad(rStart).mulWad(rAmount - 1e18);","title":"DynamicPricingModule.calculateBatchCost rounds the whole batch cost to zero for very small base prices, diverging from calculatePrice"}],"hash":"d6751eaa23c9cf76988db54eb74a89f9943c5a6ebb13b4d89bf8a0837b569eac","nodeId":"4400fb97-0116-46c8-a84e-d34833347888","outcome":"completed","summary":"The findings file is written and verified against the tree. The shell never started, so here is the report.\n\n**What I executed.** Nothing in forge. Every Bash call in this session failed with the sandbox error \"Failed to create bridge sockets after 5 attempts\", so `forge build`, `forge test`, the fork tests and `cast code` could not run. Everything below was checked by reading the pinned tree with the file tools, plus one Etherscan lookup that confirmed the mainnet `alignmentToken` address is the verified INFER token contract. No proof tests were run, so no finding carries a proof and none is rated medium or above.\n\n**Findings written to .imd-findings.json** (all low or info, each with a concrete input):\n\n- **Low, price validator precision.** Both reference prices are 1e18-scaled integers. A token priced under about 1e-16 ETH with a 10 WETH v2 pair and a 10 WETH v3 pool gets prices like 5 and 4, so deviation is 2500 bps and every conversion reverts. Under 1e-18 ETH both prices are 0 and the revert is unconditional. INFER is far outside this range.\n- **Low, RANDOM on chain 4663.** The pool factory has no chain or mode gate beyond the ERC1155 check, so anyone can create a RANDOM pool there. AUDIT-NOTES's \"must stay off\" bound is a policy, not a control.\n- **Low, treasury dependency.** All three sale paths pay the immutable treasury with a reverting transfer and no parking. If the imdUSD Treasury ever rejects ETH, auction settlement freezes with the bid and token locked, and every drop or 1155 withdraw reverts.\n- **Info, dynamic pricing rounding.** Batch cost truncates to 0 for base prices under roughly 1e4 wei, while the unit price does not.\n\n**Vault panel, by reading only.** Questions 1 to 3 and 9: I traced every order of contribute, convert, claim, delegate and claim-as-delegate through the batch ledger and found it consistent. Contributing settles first, so a benefactor is never in two uncollected batches. Fees before close stay with earlier holders through the close checkpoint. The owed figure in the swap loop covers outstanding fees, protocol fees and the reward on every path, including a rejected reward. Rounding is always down with 1e36 scale, and I found no overflow in the balanced swap at the 1e32 guards. Initialisation is atomic and owner only. Questions 4 to 7 need a real PoolManager and zRouter on a fork, which I could not run. The accepted windfall bound in AUDIT-NOTES is therefore not re-measured. One observation on question 5: the output floor bounds tokens bought, not ETH spent, so the three passes can swap up to roughly 85% of a batch rather than 50% while still clearing the floor. That widens what a sandwich can take if the private mempool leaks. I could not quantify it.\n\n**Deployment.** Config addresses were not re-checked on chain except INFER. The fork test does prove the mainnet treasury accepts ETH, because it asserts the treasury balance grows.\n\n**What is missing.** Running the existing suite, the scratch proofs, and the fork tests requires a working shell sandbox. Restart the session and rerun those before relying on this as a full sweep.","treeHash":null,"usage":{"cachedInputTokens":1497535,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":51850,"runtime":"claude","turns":37,"wallClockMs":740993}}],"verification":[]}