# Audit report

> Audit the alignment vaults: src/vaults/uni/ (UniAlignmentVault, UniAlignmentVaultFactory) and src/peripherals/UniswapVaultPriceValidator.sol, at the pinned commit, for a mainnet launch. Read whatever else they depend on, but report on this scope.
>
> miyamints is an NFT launchpad on Ethereum (later Robinhood Chain 4663) built on ms2fun-contracts. MiyaRegistry (src/registry/MiyaRegistry.sol) is a slim, non-upgradeable registry of factories, collections and alignment vaults; it holds the treasury and the fee terms each collection snapshots at launch: a 1% protocol fee to the treasury, a collection-chosen alignment share (minimum 10%) sent to a Uniswap v4 alignment vault, the rest to the artist. Factories: ERC721 fixed-price drops, ERC721 auctions, ERC1155 editions (also used for music drops) with an optional dynamic pricing module. Vaults (src/vaults/uni/) buy the alignment token with collected ETH through zRouter and add full-range v4 liquidity; benefactors claim LP fees pro rata. MiyaPool (src/pools/) is an NFTX-v2-style zero-fee pool: deposit an NFT for 1e18 pool tokens, redeem TARGETED (pick the id) or RANDOM (commit-reveal, ERC721 only), fixed per pool at creation. The deployment is script/DeployMiyamints.s.sol (runbook DEPLOY.md, per-chain config script/config/). An internal audit and its fixes are already in: AUDIT-NOTES.md lists every finding with its status, including M5, which stays unfixed and off at deploy. A finding already listed counts only if its fix is wrong or incomplete.
>
> Answer each numbered question, including the ones where nothing is wrong, and report anything else you find in scope.
>
> 1. Fee accounting was rewritten to an accFeePerShare (1e36) + per-benefactor checkpoint model, settling pending fees before every share change, with LP yield collected before new shares mint. Prove or break: sum of claims <= fees collected; no benefactor's unclaimed fees diluted or erased by another's contribution or by a permissionless drop.withdraw(); dust bounds.
> 2. Reentrancy: receive() skips contribution accounting while a transient 'collecting' flag is set around fee collection and around the keeper's token->ETH swap. Can ETH arrive during that window from anyone but the PoolManager/zRouter and be mis-accounted or lost? Any other reentrancy through the v4 unlock callback or zRouter?
> 3. Conversion caller reward: min(0.0012 ETH, 1% of the batch), paid from the pending ETH before the swap, no gas-price term. Can the keeper (or a builder) extract more, or make the vault insolvent over repeated conversions?
> 4. Keeper model (M2 fixed, see AUDIT-NOTES.md): only registry.conversionKeeper can swap, and claims no longer swap, so token-side fees wait in pendingFeeTokens. Is there any remaining permissionless path that swaps or moves price-sensitive value? Do pending fee tokens always convert before a share change, so H2 still holds? Can pendingFeeTokens diverge from the tokens held, or tokens be stranded? What can a wrong or malicious keeper do beyond the limits AUDIT-NOTES.md states? The validator is a backstop (limits in the notes): can it wrongly block a keeper conversion, including through a dust v3 pool?
> 5. Pool key is set once at deploy and must be hookless; protocolTreasury follows registry.treasury(); depositFees is a permissionless donation that requires shares. Any path for the factory owner or anyone else to redirect liquidity, strand the position, or take the protocol LP-yield cut?
> 6. Vaults are proxies created by the factory via CreateX: initialisation can't be front-run or repeated, and the implementation can't be initialised or destroyed by a third party?

| | |
|---|---|
| Repository | https://github.com/fa11up/miyamints-contracts |
| Commit | `a991e8c9208f3ccad5372db430633b235abc20ad` |
| Job | `a80b18b5-d8d2-4c9d-a1a6-10f3473be4e9` |
| Judged | 2026-11-04 08:32 UTC |
| Findings | 5 high · 1 medium · 2 low · 4 info |

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

## Findings

### 1. High: calculateSwapProportion divides ETH wei by (ETH wei + token units): every conversion after the first swaps the wrong fraction and strands ~30% of each batch (or nearly all of it) in the vault with no

`src/peripherals/UniswapVaultPriceValidator.sol:267`

```
        proportion = (ethAmount * 1e18) / total;
```

_computeProportionFromSqrtPrice (lines 242-270) values 1e18 units of liquidity at the pool price with LiquidityAmounts.getAmountsForLiquidity and returns amount0 / (amount0 + amount1). amount0 is ETH wei and amount1 is alignment-token base units, so for the vault's full-range position the result is 1/(1+P), where P is the raw pool price (token wei per ETH wei), not the share of the batch that must be swapped. A full-range position holds equal VALUE on both sides at any price, so the correct answer is ~50% always; the formula gives 50% only when P is within roughly [0.54, 1.86]. The first conversion is unaffected (lastTickLower == lastTickUpper == 0 returns 5e17 at line 193), but _addToLpPosition stores the full-range ticks, so every later conversion goes through this formula. (a) No v3 TWAP (mainnet INFER: AUDIT-NOTES says INFER has no v2 or v3 pool): the value is clamped to 35% (cheap token, P > 1.86) or 65% (expensive token, P < 0.54) at lines 233-234. The vault swaps 35% of ethToAdd into tokens and offers the other 65% as ETH; LiquidityAmounts.getLiquidityForAmounts in _addToLpPosition (UniAlignmentVault.sol:696) takes min(L0, L1), so the position absorbs the tokens plus ~35% of ethToAdd and ~30% of every batch stays in address(vault).balance. (b) With any v3 WETH/token pool that answers a 30-minute observe (the clamp is skipped), the raw 1/(1+P) is used: for P = 1e4 the vault swaps 0.01% of the batch and ~99.99% stays behind, or the keeper's minOutTarget makes the call revert. The leftover is in no accounting bucket: not totalPendingETH, not accFeePerShare/settledFees, not accumulatedProtocolFees, not pendingFeeTokens; there is no sweep and the factory has no owner passthrough, so it is unrecoverable (AUDIT-NOTES line 108 states unaccounted ETH is never paid out). Shares are still minted for the whole batch (totalEthLocked += ethAdded), so benefactors earn LP yield on ~70% of what they contributed and the alignment token receives ~70% of the intended liquidity. Neither the unit suite (MockVaultPriceValidator returns 5e17) nor test/vaults/UniAlignmentVaultRealV4.t.sol (pool initialised at 1:1, mock validator) exercises this code. Fix: value both sides in ETH before dividing (tokenValueInEth = FullMath.mulDiv(amount1, 2^192, uint256(sqrtPriceX96)^2), proportion = amount0 * 1e18 / (amount0 + tokenValueInEth)), or return 5e17 for the full-range position the vault always uses; additionally have the vault fold any post-add ETH leftover back into totalPendingETH (credited to the batch) and token leftover into pendingFeeTokens so residue is never stranded. Merged from the economics, math, permissions and flow specialists (same root cause, same line).

**Reproduction**

Validator level (the attached test): UniswapVaultPriceValidator with v2/v3 factory addresses that have no code (INFER shape), a PoolManager stand-in whose slot0 sqrtPriceX96 = 100 * 2^96 (1 ETH = 10_000 tokens, both 18 decimals), ticks = minUsableTick(200)/maxUsableTick(200). Expected calculateSwapProportion ~5e17. Actual: amount0 = 1e16, amount1 = 1e20, raw proportion = 1e16*1e18/(1e20+1e16) ~ 1e14, clamped to 35e16. With sqrtPriceX96 = 2^96/100 the actual is 65e16. Vault level: second batch of 1 ETH at that price, callerReward 0.0012 ETH, ethToAdd 0.9988 ETH; ethToSwap = 0.35 * 0.9988 = 0.34958 ETH buys ~3,495 tokens; ethRemaining = 0.64922 ETH. getLiquidityForAmounts: liquidity from the token side (~3,495e18 / 100) is smaller than from the ETH side (0.649e18 * 100), so the position takes all tokens plus ~0.3496 ETH; ~0.2996 ETH (30% of the batch) stays in address(vault).balance while totalPendingETH = 0, settledFees and accumulatedProtocolFees unchanged, unreachable by claimFees, withdrawProtocolFees or any other function. Repeats on every batch. Run: forge test --match-path test/scratch/ProportionUnits.t.sol (both tests fail on this code with 35e16 / 65e16 against 5e17).

**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 {UniswapVaultPriceValidator} from "src/peripherals/UniswapVaultPriceValidator.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";

/// @dev Minimal PoolManager stand-in: StateLibrary.getSlot0 reads `extsload(slot)` and takes the low 160 bits
///      as sqrtPriceX96. Every slot returns the configured value, which is all calculateSwapProportion reads.
contract SlotPoolManager {
    bytes32 internal _slot0;

    function setSqrtPrice(uint160 sqrtPriceX96) external {
        _slot0 = bytes32(uint256(sqrtPriceX96));
    }

    function extsload(bytes32) external view returns (bytes32) {
        return _slot0;
    }
}

/// @notice UniswapVaultPriceValidator.calculateSwapProportion mixes ETH wei and raw token units: for a full-range
///         position it returns ethUnits / (ethUnits + tokenUnits) = 1 / (1 + P) where P is the raw token/ETH price,
///         not the value share. A full-range position is always ~50% ETH by value, so the vault must swap ~50% of a
///         batch. For any token not priced at exactly 1:1 (raw) with ETH the validator returns the clamp edge (35% or
///         65%), and UniAlignmentVault._addToLpPosition then takes min(liq0, liq1): ~30% of every batch after the
///         first is left in the vault, unaccounted and unrecoverable.
contract ValidatorProportionUnitsTest is Test {
    uint256 internal constant Q96 = 2 ** 96;
    address internal constant TOKEN = address(0x1234); // any nonzero address: ETH (address(0)) is currency0

    SlotPoolManager internal pm;
    UniswapVaultPriceValidator internal validator;
    int24 internal tickLower;
    int24 internal tickUpper;

    function setUp() public {
        pm = new SlotPoolManager();
        // v2 / v3 factories have no code here -> no TWAP source, the validator takes its clamp path (as for INFER,
        // which has no v2 or v3 pool per AUDIT-NOTES.md).
        validator = new UniswapVaultPriceValidator(
            address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2), address(0x2222), address(0x3333), address(pm), 500, 1800
        );
        tickLower = TickMath.minUsableTick(200);
        tickUpper = TickMath.maxUsableTick(200);
    }

    function _proportion(uint160 sqrtPriceX96) internal returns (uint256) {
        pm.setSqrtPrice(sqrtPriceX96);
        return validator.calculateSwapProportion(TOKEN, tickLower, tickUpper, address(pm), bytes32(uint256(1)));
    }

    /// 1 ETH = 10_000 tokens (both 18 decimals): sqrtPriceX96 = 100 * 2^96.
    /// Current code: raw proportion ~1e14 (0.01%), clamped to 35e16. Expected: ~5e17.
    function test_fullRangeProportionIsHalfWhenTokenIsCheap() public {
        uint256 p = _proportion(uint160(100 * Q96));
        assertApproxEqRel(p, 5e17, 0.1e18, "full-range LP is 50% ETH by value at any price");
    }

    /// 1 token = 100 ETH (raw): sqrtPriceX96 = 2^96 / 100.
    /// Current code: raw proportion ~0.9999, clamped to 65e16. Expected: ~5e17.
    function test_fullRangeProportionIsHalfWhenTokenIsExpensive() public {
        uint256 p = _proportion(uint160(Q96 / 100));
        assertApproxEqRel(p, 5e17, 0.1e18, "full-range LP is 50% ETH by value at any price");
    }
}
```

### 2. High: zRouter refunds its whole ETH balance to the vault after the ETH->token swap; receive() is not in 'collecting' mode there, so 1 wei parked in the router makes every convertAndAddLiquidity revert

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

```
        (, r.targetTokenReceived) = IzRouterV4(zRouter).swapV4{value: ethToSwap}(
```

The H1 fix sets _COLLECTING_SLOT only around _claimVaultFees (line 615) and around the token->ETH swap in _convertPendingFeeTokens (line 584). The ETH->token swap in _doSwapAndLP runs with the flag clear. zammdefi zRouter (script/config/1.json: 0x000000000000FB114709235f1ccBFfb925F600e4; source checked at github.com/zammdefi/zRouter main, src/zRouter.sol) ends every ETH-in V4 swap in its unlockCallback with `if (ethIn) { uint256 ethRefund = address(this).balance; if (ethRefund != 0 && to != address(this)) _safeTransferETH(payer, ethRefund); }`, where payer is the swapV4 msg.sender (the vault), `to` is the vault, and _safeTransferETH reverts with ETHTransferFailed on failure. It refunds the router's ENTIRE balance, not the unused part of msg.value, and its `receive() external payable {}` is open to anyone. So whenever the router holds any ETH when the keeper converts, the refund hits the vault's receive() (line 292): _isCollecting() is false, _receiveExternalContribution() is entered, its nonReentrant guard sees the lock held by convertAndAddLiquidity and reverts Reentrancy() (and even without the lock, a refund below MIN_CONTRIBUTION would revert ContributionBelowMinimum); the router's refund fails, the router reverts, and the whole conversion reverts. Anyone can keep 1 wei in the router (send to receive(), or selfdestruct into it) and block every vault's conversions; the keeper's private mempool does not help because the dust is already on chain. It also fires with no attacker whenever ETH is left in the router by other users (e.g. an ETH-in swap with to == router skips the refund and leaves the ETH there). Pending ETH has no other exit while conversions fail, and once 500 distinct benefactors are pending new contributions revert (collections park their cut in pendingVaultCut). The deployed bytecode at the mainnet address was not diffed against the main-branch source in this review (no shell); the vault-side behaviour (any ETH arriving in receive() during _doSwapAndLP reverts the conversion) is fully determined by the vault code. Fix: set the collecting flag around the swapV4 call in _doSwapAndLP as well (and account a refund from zRouter as batch ETH, e.g. add it back to totalPendingETH or credit it via _distributeFees), or make receive() skip accounting when msg.sender == zRouter. Reported by the flow specialist; reproduced against the vault code and the router source.

**Reproduction**

The attached test uses the real v4 PoolManager, a 1:1 pool, a validator stand-in returning 50%, and a router stand-in that reproduces zRouter's ETH-in branch (fills 1:1, then refunds address(this).balance minus what earlier swaps paid in, to msg.sender, reverting on failure). alice contributes 1 ETH via receiveContribution; a griefer sends 1 wei to the router; the keeper calls vault.convertAndAddLiquidity(1, 1). Expected: conversion completes, totalLPUnits > 0, totalPendingETH == 0. Actual: the router's refund call into vault.receive() reverts with Reentrancy(), the router reverts ETHTransferFailed, convertAndAddLiquidity reverts; it keeps reverting on every later call while the router holds any ETH. Run: forge test --match-path test/scratch/ZRouterRefundReverts.t.sol (compiles as a 0.8.26 unit like test/vaults/UniAlignmentVaultRealV4.t.sol).

**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 {UniAlignmentVault} from "src/vaults/uni/UniAlignmentVault.sol";
import {MiyaRegistry} from "src/registry/MiyaRegistry.sol";
import {IVaultPriceValidator} from "src/interfaces/IVaultPriceValidator.sol";
import {IMiyaRegistry} from "src/registry/interfaces/IMiyaRegistry.sol";
import {LibClone} from "solady/utils/LibClone.sol";
import {ERC20} from "solady/tokens/ERC20.sol";
import {PoolManager} from "v4-core/PoolManager.sol";
import {Currency} from "v4-core/types/Currency.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {IHooks} from "v4-core/interfaces/IHooks.sol";

/// @dev 18-decimal alignment token.
contract AlignToken is ERC20 {
    constructor() {
        _mint(msg.sender, 1e30);
    }

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

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

/// @dev Validator stand-in: no price checks, 50% split (the value the production validator returns for the first
///      conversion).
contract OkValidator is IVaultPriceValidator {
    function validatePrice(address, uint256) external pure override {}

    function calculateSwapProportion(address, int24, int24, address, bytes32) external pure override returns (uint256) {
        return 5e17;
    }

    function quoteEthForTokens(address, uint256) external pure override returns (uint256) {
        return 0;
    }
}

/// @dev Router stand-in reproducing the ETH-in branch of zammdefi zRouter.swapV4 / unlockCallback:
///      fills 1:1, then `ethRefund = address(this).balance; if (ethRefund != 0 && to != address(this))
///      _safeTransferETH(payer, ethRefund);` where payer is the swapV4 caller and _safeTransferETH reverts on failure.
///      The router's `receive()` is open to anyone, so its balance can be non-zero when the vault swaps.
contract RefundingRouter {
    error ETHTransferFailed();

    AlignToken public immutable token;
    uint256 internal held; // ETH already paid into the pool by earlier swaps

    constructor(AlignToken t) {
        token = t;
    }

    receive() external payable {}

    function swapV4(
        address to,
        bool,
        uint24,
        int24,
        address tokenIn,
        address,
        uint256 swapAmount,
        uint256 amountLimit,
        uint256
    ) external payable returns (uint256 amountIn, uint256 amountOut) {
        require(tokenIn == address(0) && msg.value == swapAmount, "eth leg only");
        amountIn = swapAmount;
        amountOut = swapAmount;
        require(amountOut >= amountLimit, "limit");
        held += swapAmount;
        token.transfer(to, amountOut);
        uint256 ethRefund = address(this).balance - held;
        if (ethRefund != 0 && to != address(this)) {
            (bool ok,) = payable(msg.sender).call{value: ethRefund}("");
            if (!ok) revert ETHTransferFailed();
        }
    }
}

/// @notice The ETH->token swap in UniAlignmentVault._doSwapAndLP runs without the transient `collecting` flag.
///         zRouter refunds its whole ETH balance to the payer after an ETH-in V4 swap, so any ETH parked in the router
///         (1 wei from anyone) lands in the vault's receive() while convertAndAddLiquidity holds the reentrancy lock:
///         _receiveExternalContribution reverts Reentrancy(), the router's refund fails, and the whole conversion
///         reverts. Expected: the conversion completes and the refund is kept by the vault.
contract ZRouterRefundRevertsTest is Test {
    PoolManager pm;
    AlignToken token;
    RefundingRouter router;
    UniAlignmentVault vault;
    MiyaRegistry reg;
    PoolKey key;
    address alice = makeAddr("alice");
    address griefer = makeAddr("griefer");

    function setUp() public {
        pm = new PoolManager(address(this));
        token = new AlignToken();
        key = PoolKey(Currency.wrap(address(0)), Currency.wrap(address(token)), 3000, 60, IHooks(address(0)));
        pm.initialize(key, 79228162514264337593543950336); // 1:1

        reg = new MiyaRegistry(address(this), address(0xFEE), 100, 1000);
        address[] memory toks = new address[](1);
        toks[0] = address(token);
        reg.addTarget("t", toks);
        reg.setConversionKeeper(address(this));

        router = new RefundingRouter(token);
        token.transfer(address(router), 1e27);

        vault = UniAlignmentVault(payable(LibClone.clone(address(new UniAlignmentVault()))));
        vault.initialize(
            address(this),
            address(0xBEEF),
            address(pm),
            address(token),
            address(router),
            3000,
            60,
            IVaultPriceValidator(address(new OkValidator())),
            IMiyaRegistry(address(reg)),
            0
        );
        vault.setV4PoolKey(key);
        vm.deal(alice, 10 ether);
        vm.deal(griefer, 1 ether);
    }

    receive() external payable {}

    function test_oneWeiInRouterDoesNotBlockConversion() public {
        vm.prank(alice);
        vault.receiveContribution{value: 1 ether}(Currency.wrap(address(0)), 1 ether, alice);

        // Anyone parks 1 wei in the router (open receive()).
        vm.prank(griefer);
        (bool ok,) = payable(address(router)).call{value: 1}("");
        require(ok, "park");

        // Expected: the keeper's conversion succeeds. Actual: Reentrancy() raised in vault.receive() during the
        // router's refund -> ETHTransferFailed -> convertAndAddLiquidity reverts, and keeps reverting while the
        // router holds any ETH.
        vault.convertAndAddLiquidity(1, 1);
        assertGt(vault.totalLPUnits(), 0, "liquidity added");
        assertEq(vault.totalPendingETH(), 0, "batch converted");
    }
}
```

### 3. High: Price formula ((sqrtPriceX96 >> 48)^2 * 1e18) overflows for any scanned v3 or v4 pool above tick ~694,500: a 1-wei dust pool anyone can create makes validatePrice revert with Panic(0x11) on every conv

`src/peripherals/UniswapVaultPriceValidator.sol:414`

```
            uint256 rawPrice = (sqrtScaled * sqrtScaled * 1e18) >> 96;
```

_queryV3PoolForFee derives price as ((sqrtPriceX96 >> 48)^2 * 1e18) >> 96 in checked arithmetic. For sqrtPriceX96 > ~9.6e43 (tick > ~694,500; MAX_TICK is 887,272) sqrtScaled^2 * 1e18 exceeds 2^256 and the call reverts with Panic(0x11). The identical expression is used for v4 pools at line 539 and for the TWAP at line 152. validatePrice (line 118) is called on every convertAndAddLiquidity (UniAlignmentVault.sol:386); it calls _getV3PriceAndLiquidity unconditionally (accepting the first 0.3%/0.05%/1% pool whose liquidity() != 0 and whose observe(1800s) succeeds, no depth floor unlike MIN_V2_REFERENCE_WETH) and _getV4PriceAndLiquidity, which scans six candidate hookless pools in the order (WETH,token,3000/60), (ETH,token,3000/60), (WETH,token,500/10), (ETH,token,500/10), (WETH,token,10000/200), (ETH,token,10000/200) and accepts the first with non-zero liquidity, so with the mainnet 10000/200 vault pool an attacker-created 3000/60 pool is read first. Attack (v4 variant, no waiting): initialize the hookless ETH/INFER 3000/60 pool on the same PoolManager at tick 800,000 and add 1 wei of in-range liquidity (a narrow range costs ~0.0007 token-units of INFER and ~1 wei of ETH); from then on _queryV4PoolForTokenPair reverts at line 539 for every keeper input. v3 variant: create the Uniswap v3 WETH/INFER 0.3% pool, initialize at tick 800,000, mint 1 wei of in-range liquidity, wait 30 minutes (observe() with cardinality 1 extrapolates the single observation once it is older than the window); _queryV3PoolForFee then reverts at line 414. Pending ETH can only leave the vault through a conversion, so it is frozen; the validator address and pool key are immutable per vault and the registry cannot replace them, so there is no admin remedy short of redeploying vaults and relaunching collections (collections snapshot their vault). Cost: pool creation gas plus dust. The keeper's floors cannot help: the revert is in a view the keeper does not control. Fix: compute prices with FullMath.mulDiv on sqrtPriceX96 (no shift-and-square) or wrap each reference-pool query so a failure skips that pool; require a minimum ETH-side depth for v3/v4 reference pools as for v2; never let a reference pool other than the vault's own pool make validatePrice revert. Reported by the math specialist; reproduced against the code (tick 800,000: sqrtPriceX96 ~ 1.86e46, >>48 ~ 6.6e31, squared ~4.4e63, * 1e18 ~ 4.4e81 > 2^256 ~ 1.16e77).

**Reproduction**

Attached test: Validator(weth, v2Factory = 0, v3Factory = mock factory, poolManager = mock whose extsload returns 2^96 for every slot, so the vault's own v4 pool is healthy at 1:1 with non-zero liquidity, 500, 1800). mockFactory.getPool(weth, token, 3000) returns a pool whose slot0() is unlocked, liquidity() = 1, and observe([1800, 0]) returns tickCumulatives [0, 800000 * 1800] (mean tick 800,000). validator.validatePrice(token, 1 ether). Expected: a 1-wei pool is ignored, or at worst priced without overflow, and the call returns. Actual: revert Panic(0x11) (arithmetic overflow) at line 414, so every convertAndAddLiquidity for that token reverts. Same result with a tick of 700,000, and with a 1-wei hookless v4 (ETH, token, 3000, 60) pool at that tick through line 539 (no 30-minute wait). Run: forge test --match-path test/scratch/ExtremeTickOverflow.t.sol

**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 {UniswapVaultPriceValidator} from "src/peripherals/UniswapVaultPriceValidator.sol";

/// @dev PoolManager stand-in: every extsload returns the same word, so getSlot0 reads sqrtPriceX96 = 2^96
///      (1:1) and getLiquidity reads a non-zero liquidity for every pool id (the vault's own v4 pool is healthy).
contract SpotPoolManager {
    function extsload(bytes32) external pure returns (bytes32) {
        return bytes32(uint256(79228162514264337593543950336)); // 1:1, and liquidity = low 128 bits != 0
    }
}

/// @dev A Uniswap V3 pool anyone can create for ~0.001 ETH: 1 wei of in-range liquidity, parked at `tick`,
///      untouched for 30 minutes so observe() extrapolates its single observation (V3 allows this with
///      observationCardinality == 1).
contract DustV3Pool {
    int24 public immutable tick;
    address public immutable token0;
    address public immutable token1;

    constructor(int24 _tick, address t0, address t1) {
        tick = _tick;
        token0 = t0;
        token1 = t1;
    }

    function slot0() external pure returns (uint160, int24, uint16, uint16, uint16, uint8, bool) {
        return (0, 0, 0, 1, 1, 0, true);
    }

    function liquidity() external pure returns (uint128) {
        return 1;
    }

    function observe(uint32[] calldata secondsAgos) external view returns (int56[] memory tc, uint160[] memory sl) {
        tc = new int56[](2);
        sl = new uint160[](2);
        tc[0] = 0;
        tc[1] = int56(tick) * int56(uint56(secondsAgos[0]));
    }
}

contract DustV3Factory {
    mapping(uint24 => address) public pools;

    function set(uint24 fee, address pool) external {
        pools[fee] = pool;
    }

    function getPool(address, address, uint24 fee) external view returns (address) {
        return pools[fee];
    }
}

/// @notice _queryV3PoolForFee derives price as ((sqrtPriceX96 >> 48)^2 * 1e18) >> 96 in checked arithmetic. For a
///         pool parked above tick ~694_500 the multiplication exceeds 2^256 and validatePrice reverts with
///         Panic(0x11). validatePrice is called on every convertAndAddLiquidity, trusts the first V3 pool with
///         non-zero liquidity (no depth floor), and the validator is immutable per vault: a 1-wei pool freezes every
///         conversion of every vault for that token. The same expression is used for scanned V4 pools (line 539).
contract ExtremeTickOverflowTest is Test {
    address weth = address(0xBEEF);
    address token = address(0xCAFE);
    SpotPoolManager pm;
    DustV3Factory v3;
    UniswapVaultPriceValidator validator;

    function setUp() public {
        pm = new SpotPoolManager();
        v3 = new DustV3Factory();
        validator = new UniswapVaultPriceValidator(weth, address(0), address(v3), address(pm), 500, 1800);
    }

    function test_dustPoolAtExtremeTickDoesNotRevertValidatePrice() public {
        v3.set(3000, address(new DustV3Pool(800_000, weth, token)));
        // Expected: a 1-wei pool is not a price reference, or at worst its price is computed without overflow and the
        // call returns. Actual: arithmetic overflow (Panic 0x11) -> every keeper conversion reverts.
        validator.validatePrice(token, 1 ether);
    }
}
```

### 4. High: A v3 pool with 1 wei of liquidity is accepted as the TWAP reference with no depth floor, and a disagreement reverts instead of falling back: anyone can freeze every keeper conversion of a token for th

`src/peripherals/UniswapVaultPriceValidator.sol:226`

```
                if (diff > 5e16) revert SwapProportionDeviationTooHigh();
```

_getTwapSqrtPriceX96 (lines 274-301) takes the first v3 WETH/token pool (0.3%, then 0.05%, then 1%) whose liquidity() is non-zero and whose observe([1800, 0]) succeeds, with no depth requirement (the v2 side got MIN_V2_REFERENCE_WETH = 10 WETH after the internal audit; the v3 side did not). A v3 pool with observation cardinality 1 answers observe(1800) as soon as its single observation is 1800 s old, and the returned mean tick is exactly the tick the pool creator chose (and can reset with any swap through 1 wei of liquidity). calculateSwapProportion is called on every convertAndAddLiquidity (UniAlignmentVault.sol:387) once lastTickLower/Upper are non-zero (every conversion after the first) and REVERTS when the v4 spot proportion differs from the v3 'TWAP' proportion by more than 5e16. So for ~0.01 ETH of gas anyone creates WETH/INFER at 0.3%, initializes it at an arbitrary tick, mints 1 wei of in-range liquidity, waits 30 minutes, and from then on every convertAndAddLiquidity on every vault for that token reverts SwapProportionDeviationTooHigh. A second mechanism in validatePrice: if the token has a v2 pair holding >= 10 WETH (v2IsReference), the fake priceV3 vs priceV2 exceeds maxPriceDeviationBps (500) at line 138-140 and reverts PriceDeviationTooHigh. The keeper has no override (minOutTarget does not bypass the validator), the validator is fixed at initialize, the factory has no setter, and pending ETH can only leave through convertAndAddLiquidity, so the vault's conversions are frozen until the attacker lets go; convertFeeTokens does not consult the validator but cannot move pending ETH. AUDIT-NOTES (line 97-99) records only that a dust pool in an earlier tier 'wins' the tier lookup and weakens the cross-check, and the 1.json note says dust pairs 'no longer block conversion'; neither covers this block. With the mainnet 1% zRouter tier and no v3 pool for INFER, the surface is open today. Note the raw-unit proportion formula (separate finding) makes the band compare 1/(1+P) values, so the 5e16 threshold is crossed for any real price far from 1:1; fixing the formula alone (full-range -> always 5e17) would neutralise the SwapProportion mechanism but not the PriceDeviationTooHigh mechanism. Fix: require meaningful v3 depth before a pool is a reference (e.g. value the in-range liquidity in WETH at the TWAP and require >= MIN_V2_REFERENCE_WETH, or require observationCardinality > 1 plus a liquidity floor), and on a failed cross-check fall back to the [35%, 65%] clamp (or emit) instead of reverting, since the keeper's floors are the stated protection. Merged from the economics, math, permissions and flow specialists.

**Reproduction**

Attached test: validator with weth = mainnet WETH, v2Factory = 0x2222 (no code), v3Factory = mock returning one pool for the 0.3% tier, poolManager = stand-in whose slot0 sqrtPriceX96 = 100 * 2^96 (vault's v4 pool at 1 ETH = 10_000 tokens). The mock v3 pool: liquidity() = 1, slot0 unlocked, observe() returns tickCumulatives [0, 0] (mean tick 0, i.e. 1:1), created more than 30 minutes earlier. validator.calculateSwapProportion(token, minUsableTick(200), maxUsableTick(200), pm, poolId). Expected: a pool with 1 wei of liquidity is not a reference and a proportion in [35e16, 65e16] is returned so the keeper's conversion proceeds. Actual: spotProportion ~ 1e14, twapProportion = 5e17, diff ~ 5e17 > 5e16 -> revert SwapProportionDeviationTooHigh; every later convertAndAddLiquidity reverts while the pool holds its 1 wei. Second mechanism: token T with a v2 WETH/T pair holding 50 WETH, attacker's 1-wei v3 pool at a price 50% below: priceV3 = 0.5 * priceV2 -> _checkPriceDeviation diff/avg = 6666 bps > 500 -> revert PriceDeviationTooHigh. Run: forge test --match-path test/scratch/DustV3BlocksProportion.t.sol

**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 {UniswapVaultPriceValidator} from "src/peripherals/UniswapVaultPriceValidator.sol";
import {TickMath} from "v4-core/libraries/TickMath.sol";

/// @dev PoolManager stand-in: StateLibrary.getSlot0 reads the low 160 bits of `extsload(slot)` as sqrtPriceX96.
contract SlotPoolManager {
    bytes32 internal _slot0;

    function setSqrtPrice(uint160 sqrtPriceX96) external {
        _slot0 = bytes32(uint256(sqrtPriceX96));
    }

    function extsload(bytes32) external view returns (bytes32) {
        return _slot0;
    }
}

/// @dev A Uniswap v3 pool anyone can create for a few dollars of gas: 1 wei of liquidity, one observation older than
///      the 30-minute window, priced at tick 0 (1 token = 1 ETH raw). observe() returns zero cumulatives -> mean tick 0.
contract DustV3Pool {
    function liquidity() external pure returns (uint128) {
        return 1;
    }

    function slot0() external pure returns (uint160, int24, uint16, uint16, uint16, uint8, bool) {
        return (uint160(2 ** 96), 0, 0, 1, 1, 0, true);
    }

    function token0() external pure returns (address) {
        return address(0x1234);
    }

    function token1() external pure returns (address) {
        return address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
    }

    function observe(uint32[] calldata) external pure returns (int56[] memory tickCumulatives, uint160[] memory s) {
        tickCumulatives = new int56[](2);
        s = new uint160[](2);
    }
}

contract DustV3Factory {
    address public immutable pool;

    constructor(address pool_) {
        pool = pool_;
    }

    /// Only the 0.3% tier exists: the first tier the validator looks up.
    function getPool(address, address, uint24 fee) external view returns (address) {
        return fee == 3000 ? pool : address(0);
    }
}

/// @notice A dust v3 pool at an off-market price makes calculateSwapProportion revert with
///         SwapProportionDeviationTooHigh for every keeper conversion (after the first) of every vault using this
///         validator. The vault's v4 pool is at 1 ETH = 10_000 tokens; the attacker's v3 pool (1 wei liquidity) says
///         1:1. spot proportion ~1e14, TWAP proportion 5e17, diff > 5e16 -> revert.
///         Expected: a pool with 1 wei of liquidity is not a price reference, and the keeper's conversion proceeds.
contract ValidatorDustV3BlocksTest is Test {
    uint256 internal constant Q96 = 2 ** 96;
    address internal constant TOKEN = address(0x1234);

    SlotPoolManager internal pm;
    UniswapVaultPriceValidator internal validator;
    int24 internal tickLower;
    int24 internal tickUpper;

    function setUp() public {
        pm = new SlotPoolManager();
        DustV3Factory v3 = new DustV3Factory(address(new DustV3Pool()));
        validator = new UniswapVaultPriceValidator(
            address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2), address(0x2222), address(v3), address(pm), 500, 1800
        );
        tickLower = TickMath.minUsableTick(200);
        tickUpper = TickMath.maxUsableTick(200);
        pm.setSqrtPrice(uint160(100 * Q96)); // v4 spot: 1 ETH = 10_000 tokens
    }

    function test_dustV3PoolDoesNotBlockConversion() public {
        // Current code reverts here with SwapProportionDeviationTooHigh, so convertAndAddLiquidity reverts for the
        // keeper until the attacker's pool is out-priced, which the attacker undoes with one swap through 1 wei.
        uint256 p = validator.calculateSwapProportion(TOKEN, tickLower, tickUpper, address(pm), bytes32(uint256(1)));
        assertGe(p, 35e16, "proportion within the full-range band");
        assertLe(p, 65e16, "proportion within the full-range band");
    }
}
```

### 5. High: v3 TWAP sqrt price is read in v3 token ordering but interpreted in v4 ordering: for INFER (address below WETH) any v3 WETH/INFER pool at the TRUE price makes calculateSwapProportion revert on every co

`src/peripherals/UniswapVaultPriceValidator.sol:296`

```
                return TickMath.getSqrtPriceAtTick(meanTick);
```

_getTwapSqrtPriceX96 returns the raw v3 sqrtPriceX96 (sqrt of token1/token0 with v3 ordering, token0 = lower address) without looking at the pool's token0. calculateSwapProportion passes it to _computeProportionFromSqrtPrice, which assumes v4 ordering with native ETH as currency0 (line 264: currency0IsNativeETH = address(0) < token, always true), i.e. a sqrt of token/ETH. _queryV3PoolForFee handles the orientation for validatePrice (it inverts when token0 == weth, lines 416-422) but the TWAP helper used by calculateSwapProportion does not. When the alignment token's address is below WETH's, v3 token0 is the token and the v3 sqrt price is the reciprocal of the v4 one, so the TWAP proportion is ~1 - spotProportion. For any price other than ~1 raw the difference exceeds 5e16 and line 226 reverts SwapProportionDeviationTooHigh. Mainnet: INFER = 0x53d0eA0A685220718Dc6a0a6e4617194C6F37921 < WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 (script/config/1.json), so the inversion applies. INFER has no v3 pool today; the moment anyone creates a WETH/INFER v3 pool (0.3%, 0.05% or 1%) with any non-zero liquidity and 30 minutes pass, every convertAndAddLiquidity reverts from the second batch on (the first batch returns 5e17 before the TWAP code). An honest, deep pool at the right price triggers it just the same, so this is not only an attack: ordinary market growth bricks the vault. The validator is immutable per vault and the pool key cannot be repointed, so recovery means new vaults and abandoning the pending ETH. Distinct from the dust-pool finding (which needs a wrong tick) and from the raw-unit formula finding (which this compounds): the fix is to orient the TWAP. Fix: in _getTwapSqrtPriceX96, when IUniswapV3Pool(pool).token0() != weth, invert the price (negate meanTick before getSqrtPriceAtTick, or sqrt = 2^192 / sqrtPriceX96) so it is expressed as token per ETH like the v4 slot0; the same orientation bug exists in validatePrice's dead branch at lines 154-157, which always inverts rawTwap regardless of the v3 ordering. Reported by the flow specialist; reproduced against the code by hand (no proof slot left; the attached dust-pool test's harness can be adapted by returning tickCumulatives [0, -92104 * 1800] and token0 = TOKEN).

**Reproduction**

State: v4 pool ETH/INFER at raw price 1e4 token-wei per ETH-wei (sqrtPriceX96 = 100 << 96); vault ticks after the first LP add = minUsableTick(200)/maxUsableTick(200). v3 pool with token0 = INFER, token1 = WETH, liquidity 1e18 (any depth), 30-minute mean tick -92104 (= 1e-4 WETH-wei per INFER-wei, the SAME price as v4). calculateSwapProportion(INFER, -887200, 887200, poolManager, poolId). Expected: the two sources agree, so a proportion is returned and the keeper converts. Actual: spot proportion from 100<<96: amount0 = 1e16, amount1 = 1e20 -> ~1e14. TWAP sqrt from tick -92104 is 2^96/100 (uninverted): amount0 = 1e20, amount1 = 1e16 -> ~0.9999e18. diff ~ 1e18 > 5e16 -> revert SwapProportionDeviationTooHigh() for every conversion after the first, for as long as the v3 pool exists.

### 6. Medium: deployVault is permissionless and takes an arbitrary priceValidator, so anyone can register a MiyaRegistry-trusted vault whose conversions they can disable, and can front-run the deploy script's vault

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

```
    function deployVault(
```

UniAlignmentVaultFactory inherits Ownable (the deploy script hands ownership to the multisig) but exposes no owner-only function: deployVault has no access control and forwards the caller-supplied priceValidator to UniAlignmentVault.initialize, where it is stored for the life of the vault (no setter, factory has no passthrough). The factory then calls registry.registerVault (MiyaRegistry.sol:156 only checks msg.sender is a registered vault factory), so registry.isVault(vault) becomes true, which is the sole check ERC721DropFactory (line 34), ERC721AuctionFactory (line 71) and ERC1155Factory (line 79) apply to a collection's vault; collections snapshot the vault as an immutable. The vault trusts the validator fully inside convertAndAddLiquidity (lines 386-390): a validator that reverts, or returns a proportion of 1e18 (ethRemaining = 0) or 0 (zero-amount swap), makes _addToLpPosition revert AmountMustBePositive, so the vault can never convert; it can be a proxy or time-keyed so it behaves until ETH accumulates. The validator is view-only and cannot take funds, but every alignment share routed to such a vault stays in totalPendingETH forever (no withdrawal path), and the registry presents the vault as protocol-vetted to the app's vault picker. Second effect: deployVault runs with the factory as CreateX msg.sender, so a salt whose first 20 bytes are the factory address lands in the factory's guarded namespace for every caller; the deploy script's deterministic defaultVault salt (DeployMiyamints.s.sol:268-274) can be front-run between the factory deployment and the script's deployVault call, after which the expected address holds a vault with the attacker's validator and the script reverts. The deploy script only ever passes address(0) for the validator. Fix: make deployVault onlyOwner (the script already runs as owner), or at least force priceValidator == defaultPriceValidator for non-owner callers; consider recording the validator in the registry so factories and the front end can check it. Merged from the economics, math, permissions and flow specialists.

**Reproduction**

1) Attacker deploys contract B with validatePrice(address,uint256) that reverts (or calculateSwapProportion returning 1e18) and quoteEthForTokens returning 0. 2) Attacker calls vaultFactory.deployVault(bytes32(uint256(uint160(address(vaultFactory))) << 96 | 1), INFER, 0, IVaultPriceValidator(B)). Expected: rejected (owner-only or validator restricted). Actual: succeeds, registry.isVault(V) == true. 3) A creator launches an ERC721 drop with vault = V (passes the isVault check); minters pay, drop.withdraw() sends the alignment share to V, totalPendingETH grows. 4) Keeper calls V.convertAndAddLiquidity(x, y): revert from B.validatePrice (or AmountMustBePositive in _addToLpPosition), on every later call too; claimFees reverts NoShares; no function returns the pending ETH. 5) Front-run variant: after the factory is deployed and before the script's deployVault, call deployVault with the script's exact salt and a hostile validator; the script's deployVault reverts because the CREATE3 address is taken.

### 7. Low: Residue from every LP add (own-swap fee accrual netted into modifyLiquidity, min-side rounding, price impact) stays in the vault unaccounted: ETH is never claimable and tokens sit outside pendingFeeTo

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

```
        uint128 liquidityToAdd = LiquidityAmounts.getLiquidityForAmounts(
```

Even with a correct 50% proportion, convertAndAddLiquidity collects accrued LP yield first (line 376) and then trades on the same pool twice before adding: the fee-token sale in _convertPendingFeeTokens (token-in, so the vault's own position earns token fees) and the ETH->token purchase in _doSwapAndLP (ETH-in, so it earns ETH fees: fee tier x ethToSwap x the vault's share of pool liquidity). modifyLiquidity at line 732 returns callerDelta = principal + feesAccrued, so _settleLPDelta pays less than the amount0/amount1 the liquidity was sized for; the difference is neither credited by _creditYield nor added to pendingFeeTokens. Separately, getLiquidityForAmounts takes min(L0, L1) and the price moved during the vault's own swap while the proportion was computed from the pre-swap spot, so one side is always left over; _settleLPDelta pays exactly the modifyLiquidity delta and the remainder stays in the vault. Leftover ETH is counted nowhere (totalEthLocked += ethAdded records the full amount, totalPendingETH is zeroed, accFeePerShare is not increased) and leftover tokens are not in pendingFeeTokens, so _convertPendingFeeTokens never sells them; there is no sweep and the owner (factory) has no passthrough. AUDIT-NOTES acknowledges only externally sent ETH as unaccounted surplus; the vault produces this surplus itself on every batch, and pendingFeeTokens diverges from token.balanceOf(vault) (question 4). Magnitude per conversion: fee tier x swapped amount x vault share of pool liquidity, plus the ratio leftover (~0.25% of the batch at the 1% tier for a batch small relative to depth; larger with price impact). Small per batch, but permanent and compounding, and large for a vault that dominates a thin INFER pool. Fix: after _addToLpPosition measure ETH and token balance deltas (or read feesAccrued from modifyLiquidity's second return value) and route the ETH to _creditYield or back into totalPendingETH and the tokens into pendingFeeTokens. Merged from the economics, math, permissions and flow specialists.

**Reproduction**

State: vault's full-range position is 100% of pool liquidity (first conversion done), fee tier 1% (mainnet), correct 50% proportion. Keeper converts a batch with ethToSwap = 1 ETH through zRouter on the same pool: the swap credits the position 0.01 ETH of fees. _addToLpPosition sizes liquidityToAdd from (ethRemaining, tokensReceived); modifyLiquidity returns delta0 = -(ETH principal) + 0.01 ETH, so the vault settles 0.01 ETH less than ethRemaining. Expected after the call: accumulatedFees rises by ~0.0099 ETH (after the 1% protocol cut) or the ETH is re-queued; actual: address(vault).balance - (totalPendingETH + accumulatedFees - totalFeesClaimed + accumulatedProtocolFees) == 0.01 ETH + ratio leftover, and token.balanceOf(vault) - pendingFeeTokens > 0, with no function able to move either. Worked ratio example at 1% tier, 100 ETH/100 token pool at 1:1, 0.4994 ETH swapped: tokens out 0.49199, new spot 0.99018, token side binds, ETH placed 0.49687, leftover 0.00253 ETH (0.25% of the batch).

### 8. Low: The 500-slot conversionParticipants cap lets anyone defer every collection's vault cut for 0.5 ETH of recoverable capital and force the keeper into a 500-entry conversion rewarded with 0.0012 ETH

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

```
            if (conversionParticipants.length >= MAX_CONVERSION_PARTICIPANTS) revert TooManyConversionParticipants();
```

receive() credits msg.sender, so 500 fresh addresses each sending MIN_CONTRIBUTION (0.001 ETH) fill conversionParticipants between keeper runs; the ETH is not lost to the attacker (it becomes their LP shares at the next conversion, minus at most the 1% reward share), so the cost is gas only and it can be repeated after every conversion. While full, _trackBenefactorContribution reverts for every benefactor with pendingETH == 0, i.e. every collection that has not yet contributed in this batch: ERC721DropInstance.withdraw (line 244), and the 1155/Auction equivalents, catch the revert and park the cut in pendingVaultCut (VaultContributionFailed), so no funds are lost, but alignment delivery stalls until the keeper runs a conversion over all 500 entries (500 x _settleFees + two 0->nonzero SSTOREs + pendingETH clear, plus the storage array copy in _getActiveBenefactors and 500 pops in _clearConversionParticipants; several tens of millions of gas by estimate, not measured in this review) for a reward of min(0.0012 ETH, 1% of 0.5 ETH) = 0.0012 ETH, far below the gas cost. On a chain with a ~32M per-transaction gas limit (Robinhood Chain 4663 is Arbitrum-style) the conversion may not fit, in which case the list can never be cleared and the vault is bricked; that needs measurement on the target chain. Fix: let the keeper convert in bounded pages, or require a larger MIN_CONTRIBUTION for receive() than for registered instances, or let registered collection instances bypass the cap. Merged from the economics and math specialists.

**Reproduction**

1) From 500 different addresses: vault.call{value: 0.001 ether}(""). conversionParticipants.length == 500, totalPendingETH == 0.5 ETH. 2) A drop whose instance has pendingETH == 0 calls withdraw(): vault.receiveContribution reverts TooManyConversionParticipants, the drop emits VaultContributionFailed and parks the cut in pendingVaultCut. Expected: a contribution from a legitimate collection is credited. Actual: deferred until the keeper runs one 500-entry conversion earning 0.0012 ETH. 3) Repeat after the conversion for another 0.5 ETH of locked (not lost) capital.

### 9. Info: validatePrice's v4-vs-v3-TWAP cross-check is dead code (runs only when no v3 pool was found, but the TWAP comes from the same pools), and its orientation logic is also wrong

`src/peripherals/UniswapVaultPriceValidator.sol:147`

```
        if (hasV4Pool && !v2IsReference && !hasV3Pool && priceV4 != 0) {
```

_getV3PriceAndLiquidity returns hasV3Pool = true for the first fee tier whose pool is unlocked, has nonzero liquidity and answers observe(twapSecondsAgo). _getTwapSqrtPriceX96 uses the same tiers in the same order with the same liquidity and observe conditions (minus the unlocked check, which cannot differ for an external view call outside a v3 swap). So whenever the branch would find a TWAP, hasV3Pool is true and the branch is skipped; when hasV3Pool is false, _getTwapSqrtPriceX96 returns 0 and the inner block does nothing. The comment at lines 143-146 ('mature pools with V3 history are now protected') does not hold, and for INFER (no v2 reference, no v3) validatePrice checks nothing beyond pool existence; safety rests on the keeper's floors as AUDIT-NOTES says. If the branch is ever made reachable, note that lines 154-157 always invert rawTwap (currency0Addr is always address(0)) regardless of the v3 pool's token0, which is wrong when token0 == weth (see the TWAP orientation finding). With the keeper-only model this is a documentation issue, not a loss. Merged from the economics and math specialists.

**Reproduction**

Token with a v3 0.3% pool that has 30 minutes of observations and no v2 pair; v4 spot moved 50% within the block. validatePrice(token, x): expected (per the comment) revert PriceDeviationTooHigh; actual: hasV3Pool == true, line 138 is skipped (no v2 reference), line 147 is false, line 167 is false, the call returns without any check. Token with no v3 pool: hasV3Pool == false, _getTwapSqrtPriceX96 returns 0, branch body skipped. No input reaches _checkPriceDeviation(priceV4, priceV4Twap).

### 10. Info: Question 6: proxy initialisation is safe; the implementation can be initialised by anyone (harmless); computeVaultAddress is only correct for factory-prefixed salts, and other salts are squattable thr

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

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

Clone initialisation cannot be front-run or repeated: deployCreate3, initialize and setV4PoolKey run in one transaction from the factory, _initialized guards initialize and PoolKeyAlreadySet guards the key. The implementation created in the constructor (line 52) is never initialised, so anyone may call initialize on it and become its owner; this is harmless because proxies are EIP-1167 clones with their own storage, the implementation holds no funds, and it has no selfdestruct or delegatecall, so it cannot be destroyed or repointed (optionally set _initialized in a constructor). computeVaultAddress assumes CreateX's 'msg.sender-prefixed, no redeploy protection' salt form (first 20 bytes == factory, byte 21 == 0x00 -> keccak256(abi.encode(msg.sender, salt))). For any other salt CreateX uses keccak256(abi.encode(salt)) (or the chain-id form), so the preview is wrong, and since that guarded salt does not depend on the sender, anyone can occupy the address first by calling CreateX.deployCreate3 directly, making deployVault revert. The deploy script builds a factory-prefixed salt (DeployMiyamints.s.sol:268), so the shipped path is correct. Fix: have deployVault require bytes20(salt) == bytes20(address(this)) and salt[20] == 0x00, or document the format. Merged from the economics, math, permissions and flow specialists.

**Reproduction**

UniAlignmentVault(factory.vaultImplementation()).initialize(attacker, weth, pm, token, zRouter, fee, ts, validator, registry, 0) succeeds and owner() == attacker on the implementation; every clone's owner() is still the factory and its storage is unchanged. salt = keccak256("x"): factory.computeVaultAddress(salt) returns CreateX.computeCreate3Address(keccak256(abi.encodePacked(uint256(uint160(factory)), salt)), CREATEX), while deployVault(salt, ...) deploys at CreateX.computeCreate3Address(keccak256(abi.encode(salt)), CREATEX); if a third party first calls CreateX.deployCreate3(salt, anyInitCode), deployVault(salt, ...) reverts.

### 11. Info: Keeper trust statement understates the keeper's power: a self-sandwiched conversion transfers batch principal to the keeper, not merely 'loses value'

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

```
        if (minOutTarget == 0) revert AmountMustBePositive();
```

Trust assumption, not a bypass. convertAndAddLiquidity and convertFeeTokens take their only price protection from the keeper-supplied floors (minOutTarget >= 1, minEthOutForFees >= 1); validatePrice is a no-op for INFER (no v2/v3 pools) and calculateSwapProportion's clamp bounds the split, not the price. A keeper (or whoever holds its key) that buys INFER in the pool, calls convertAndAddLiquidity(1, 1) and sells back in the same bundle receives most of the price impact of the vault's buy, i.e. batch principal. AUDIT-NOTES line 94-95 says a bad keeper 'can pass low floors and lose value on conversions ... It cannot take principal or claims'; the first clause is right, the second is not for principal: the value lost is the batch and the keeper is the counterparty. Claims (settled fees) are indeed untouchable. Recommended: state it as 'the keeper can extract up to the whole batch', run the keeper from a controlled bot with a live quote, and consider an in-vault maximum price-impact check using the pool's pre- and post-swap sqrtPrice. Reported by the permissions specialist; consistent with the code.

**Reproduction**

State: INFER/ETH 1% v4 pool with 50 ETH depth, vault with 10 ETH pending. Keeper in one bundle: (1) buys 20 ETH of INFER; (2) calls vault.convertAndAddLiquidity(1, 1): the vault swaps ~5 ETH at the inflated price and LPs the rest; (3) sells the INFER back. Expected (per AUDIT-NOTES): the keeper cannot take principal. Actual: step (3) returns the keeper's 20 ETH plus the ETH the vault overpaid; no on-chain guard fires (no reference pools for INFER; the proportion clamp does not bound price).

### 12. Info: Answers to questions 1-5 where nothing is wrong: fee accounting conservation, reentrancy windows, caller reward, keeper limits, pool key and treasury

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

```
        accFeePerShare += FixedPointMathLib.fullMulDiv(amount, ACC_PRECISION, totalShares); // round down: favors vault
```

Q1 (holds): accFeePerShare += floor(amount * 1e36 / totalShares) and each payout = floor(shares * (acc - checkpoint) / 1e36) with fullMulDiv, so sum(payouts) <= totalShares * delta / 1e36 <= amount; per-distribution rounding loss < totalShares / 1e36 wei. _settleFees runs before every share change (loop in _distributeSharesAndCleanup; the dust recipient is an active, already-settled participant and accFeePerShare is unchanged), LP yield is force-collected and pending fee tokens converted before new shares mint, so a later contribution or a permissionless drop.withdraw() can neither dilute nor erase earlier unclaimed fees; re-contribution only touches pendingETH. Share dust: the double floor loses at most totalSharesIssued/1e18 + 1 units per participant; accumulatedDustShares will in practice never reach 1e18, so dust liquidity simply earns yield that is shared by all holders. Q2 (holds for the covered windows): the collecting flag wraps PoolManager.take(ETH) and the token->ETH zRouter leg; the pool is hookless and the token is registry-approved, so only PoolManager/zRouter ETH can arrive there; unlockCallback is PoolManager-only and uses the stored key; _payCallerReward runs after all state updates and the keeper can only re-enter guard-free views or withdrawProtocolFees. The uncovered window is the ETH->token swap (separate high finding). Q3 (holds): reward = min(0.0012 ETH, 1% of ethPending), carved out of the batch before swap/LP, never from fee balances; a rejected reward is distributed as fees; repeated conversions cannot make the vault insolvent. Q4: no permissionless swap path remains (claims only collect); fee tokens convert before any share mint (H2 holds); pendingFeeTokens diverges from tokens held only through the LP residue (low finding) and direct transfers; a bad keeper is bounded as AUDIT-NOTES states except that it can extract principal by self-sandwiching (info finding); the validator can be made to block the keeper by anyone (high findings). Q5 (holds): setV4PoolKey is set-once, hookless, validated and called by the factory in the deploy tx; nothing can repoint liquidity; protocolTreasury follows registry.treasury() and withdrawProtocolFees pays only accumulatedProtocolFees; depositFees requires shares and only increases accFeePerShare; the factory owner has no power over deployed vaults. Coverage note: this review ran without a shell (the sandbox failed to start), so the attached proofs were written against the code and the existing real-v4 test's harness but could not be executed here; the specialists' proofs were read, not run.

**Reproduction**

No failing input for these items; see the other findings for the defects found.

---

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