# Audit report

> 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.
>
> miyamints is an NFT launchpad on Ethereum (later Robinhood Chain 4663) built on ms2fun-contracts. MiyaRegistry (src/registry/MiyaRegistry.sol) is a slim, non-upgradeable registry of factories, collections and alignment vaults; it holds the treasury and the fee terms each collection snapshots at launch: a 1% protocol fee to the treasury, a collection-chosen alignment share (minimum 10%) sent to a Uniswap v4 alignment vault, the rest to the artist. Factories: ERC721 fixed-price drops, ERC721 auctions, ERC1155 editions (also used for music drops) with an optional dynamic pricing module. Vaults (src/vaults/uni/) buy the alignment token with collected ETH through zRouter and add full-range v4 liquidity; benefactors claim LP fees pro rata. MiyaPool (src/pools/) is an NFTX-v2-style zero-fee pool: deposit an NFT for 1e18 pool tokens, redeem TARGETED (pick the id) or RANDOM (commit-reveal, ERC721 only), fixed per pool at creation. The deployment is script/DeployMiyamints.s.sol (runbook DEPLOY.md, per-chain config script/config/). 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.
>
> Why 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.
>
> The 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.
>
> 1. 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?
> 2. 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?
> 3. 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?
> 4. 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?
> 5. 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.
> 6. 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?
> 7. 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?
> 8. UniswapVaultPriceValidator: value-based proportion, depth floors, TWAP for each token ordering. Can anyone block a conversion cheaply, and does it wrongly ignore real pools?
> 9. 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.
> 10. 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?
> 11. 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.
> 12. 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?
> 13. 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.
> 14. 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?

| | |
|---|---|
| Repository | https://github.com/fa11up/miyamints-contracts |
| Commit | `b7193cec1ecfd18bc81c390cff0c5bcfd915934b` |
| Job | `6dfbf85f-57ff-475c-912b-d47c64731c93` |
| Judged | 2026-11-04 20:21 UTC |
| Findings | 1 medium · 5 low · 5 info |

Four agents audited the code as it is at `b7193ce`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Medium: 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

`src/pools/MiyaPoolFactory.sol:30`

```
        if (kind == MiyaPool.Kind.ERC1155 && mode == MiyaPool.RedeemMode.RANDOM) revert RandomRequiresERC721();
```

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.

**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.

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

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

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

contract ProofNFT is ERC721 {
    function name() public pure override returns (string memory) { return "P"; }
    function symbol() public pure override returns (string memory) { return "P"; }
    function tokenURI(uint256) public pure override returns (string memory) { return ""; }
    function mint(address to, uint256 id) external { _mint(to, id); }
}

/// AUDIT-NOTES.md accepts that RANDOM pools "must stay off" on Robinhood Chain (4663). Nothing enforces that:
/// MiyaPoolFactory is ownerless and permissionless and the script deploys it unchanged on 4663, so anyone can
/// open a RANDOM pool there. Fails on the current code (the pool is created) and passes once RANDOM pools are
/// refused on chains without secure block hashes (chain-id gate, or a gate on the EIP-2935 history contract).
contract RandomPoolOnArbitrumStackTest is Test {
    function test_randomPoolCannotBeCreatedOnRobinhoodChain() public {
        vm.chainId(4663);
        MiyaPoolFactory f = new MiyaPoolFactory();
        ProofNFT nft = new ProofNFT();
        vm.expectRevert();
        f.createPool(address(nft), MiyaPool.RedeemMode.RANDOM);
    }
}
```

### 2. Low: 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

`src/vaults/uni/UniAlignmentVault.sol:481`

```
            r.liquidityUnitsAdded += uint256(_addToLpPosition(amount0, amount1, tickLower, tickUpper));
```

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.

**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.

### 3. Low: 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

`src/factories/erc721/ERC721AuctionInstance.sol:329`

```
            SafeTransferLib.safeTransferETH(protocolTreasury, s.protocolCut);
```

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.

**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).

### 4. Low: 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

`src/vaults/uni/UniAlignmentVaultFactory.sol:103`

```
        bytes32 guardedSalt = keccak256(abi.encodePacked(uint256(uint160(address(this))), salt));
```

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.

**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.

### 5. Low: 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-

`src/peripherals/UniswapVaultPriceValidator.sol:337`

```
                price = (1e18 * 1e18) / rawPrice;
```

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.

**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.

### 6. Low: 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

`src/factories/erc1155/ERC1155Instance.sol:542`

```
    receive() external payable {}
```

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.

**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.

### 7. Info: 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

`src/vaults/uni/UniAlignmentVault.sol:626`

```
        if (tokenAmount == 0 || minEthOut == 0) return;
```

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.

**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.

### 8. Info: 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

`src/vaults/uni/UniAlignmentVault.sol:483`

```
        if (swapped && r.targetTokenReceived < minOutTarget) revert InsufficientOutput();
```

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.

**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.

### 9. Info: The vault implementation deployed by the factory constructor is left uninitialised, so anyone can call initialize on it and become its owner

`src/vaults/uni/UniAlignmentVaultFactory.sol:52`

```
        vaultImplementation = address(new UniAlignmentVault());
```

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.

**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.

### 10. Info: DynamicPricingModule.calculateBatchCost rounds the whole batch cost to zero for sub-1e4-wei base prices, diverging from calculatePrice

`src/factories/erc1155/DynamicPricingModule.sol:61`

```
        uint256 numerator = basePrice.mulWad(rStart).mulWad(rAmount - 1e18);
```

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.

**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.

### 11. Info: 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

`test/deploy/DeployMiyamints.t.sol:125`

```
        vm.skip(!forked);
```

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.

**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.

---

Judge's submission `773592fa0adef752ffab340724bfac272de6aca78b2a9b5345b46bc70bbe4426`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
