{"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":"a80b18b5-d8d2-4c9d-a1a6-10f3473be4e9","kind":"audit","nodes":[{"acceptedSubmissionHash":"d4ecc1a3fdfaa7822868f89bde593820c17d5546e3de524762c63cb26ce5e7e4","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":"73dbdaa5319fec58cce5a86cf3ee348b25ded8ad9698713a592f065873ab164c","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":"1251c06a77395080f2e426a3d2078abaf48492d0fb28d4f67299f20f91772d43","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":"8b222a0439f6baf41bf2a7622db0a15463bf88fd000e6321b91f73d9f52fd99b","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":"b5c1882d147aefda9c2be0f9ed11f3992f6bccf03e135b58a595ee765a254705","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 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.\n\nmiyamints is an NFT launchpad on Ethereum (later Robinhood Chain 4663) built on ms2fun-contracts. MiyaRegistry (src/registry/MiyaRegistry.sol) is a slim, non-upgradeable registry of factories, collections and alignment vaults; it holds the treasury and the fee terms each collection snapshots at launch: a 1% protocol fee to the treasury, a collection-chosen alignment share (minimum 10%) sent to a Uniswap v4 alignment vault, the rest to the artist. Factories: ERC721 fixed-price drops, ERC721 auctions, ERC1155 editions (also used for music drops) with an optional dynamic pricing module. Vaults (src/vaults/uni/) buy the alignment token with collected ETH through zRouter and add full-range v4 liquidity; benefactors claim LP fees pro rata. MiyaPool (src/pools/) is an NFTX-v2-style zero-fee pool: deposit an NFT for 1e18 pool tokens, redeem TARGETED (pick the id) or RANDOM (commit-reveal, ERC721 only), fixed per pool at creation. The deployment is script/DeployMiyamints.s.sol (runbook DEPLOY.md, per-chain config script/config/). An internal audit and its fixes are already in: AUDIT-NOTES.md lists every finding with its status, including M5, which stays unfixed and off at deploy. A finding already listed counts only if its fix is wrong or incomplete.\n\nAnswer each numbered question, including the ones where nothing is wrong, and report anything else you find in scope.\n\n1. 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.\n2. 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?\n3. 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?\n4. 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?\n5. 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?\n6. 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?","parentJobId":null,"planHash":"b3d8b0f843b8a3959f6afba559a42bedf46d2d9d0c57d6b5351e4c866044db7c","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"a80b18b5-d8d2-4c9d-a1a6-10f3473be4e9","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":"52191","feedbackHash":"4ba848881090c72273f14a968d6bfda83558be26d2ecb983a0daf86404a2ce1b","nodeKey":"audit_economics","submissionHash":"d4ecc1a3fdfaa7822868f89bde593820c17d5546e3de524762c63cb26ce5e7e4","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51451","feedbackHash":"ceb018cc381ff5f4fd60c2a9c1ca1f13bf6a34eb35965eac0d2d149381d93905","nodeKey":"audit_flow","submissionHash":"73dbdaa5319fec58cce5a86cf3ee348b25ded8ad9698713a592f065873ab164c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52682","feedbackHash":"290686c47672e5964e9805113b301caf671c5eaaaed15345898ae259cd0a7a9d","nodeKey":"audit_judge","submissionHash":"1251c06a77395080f2e426a3d2078abaf48492d0fb28d4f67299f20f91772d43","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52366","feedbackHash":"acf5707c13382d8ecde0debde956d6813b1140a337d1b99f1df9be75ab498414","nodeKey":"audit_math","submissionHash":"8b222a0439f6baf41bf2a7622db0a15463bf88fd000e6321b91f73d9f52fd99b","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51874","feedbackHash":"e57e63c105acb828dbb3e7f3381f2ed417882d644ea33fcf554bf26a930094e7","nodeKey":"audit_permissions","submissionHash":"b5c1882d147aefda9c2be0f9ed11f3992f6bccf03e135b58a595ee765a254705","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"94629dd50dd3fe299aedd16bf063b3eb8575a87723f6dfafd973038a09d6b67a","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"a0f4c3f900dedd4a","findings":[{"citation":"resolved","description":"_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).","line":267,"path":"src/peripherals/UniswapVaultPriceValidator.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {UniswapVaultPriceValidator} from \"src/peripherals/UniswapVaultPriceValidator.sol\";\nimport {TickMath} from \"v4-core/libraries/TickMath.sol\";\n\n/// @dev Minimal PoolManager stand-in: StateLibrary.getSlot0 reads `extsload(slot)` and takes the low 160 bits\n///      as sqrtPriceX96. Every slot returns the configured value, which is all calculateSwapProportion reads.\ncontract SlotPoolManager {\n    bytes32 internal _slot0;\n\n    function setSqrtPrice(uint160 sqrtPriceX96) external {\n        _slot0 = bytes32(uint256(sqrtPriceX96));\n    }\n\n    function extsload(bytes32) external view returns (bytes32) {\n        return _slot0;\n    }\n}\n\n/// @notice UniswapVaultPriceValidator.calculateSwapProportion mixes ETH wei and raw token units: for a full-range\n///         position it returns ethUnits / (ethUnits + tokenUnits) = 1 / (1 + P) where P is the raw token/ETH price,\n///         not the value share. A full-range position is always ~50% ETH by value, so the vault must swap ~50% of a\n///         batch. For any token not priced at exactly 1:1 (raw) with ETH the validator returns the clamp edge (35% or\n///         65%), and UniAlignmentVault._addToLpPosition then takes min(liq0, liq1): ~30% of every batch after the\n///         first is left in the vault, unaccounted and unrecoverable.\ncontract ValidatorProportionUnitsTest is Test {\n    uint256 internal constant Q96 = 2 ** 96;\n    address internal constant TOKEN = address(0x1234); // any nonzero address: ETH (address(0)) is currency0\n\n    SlotPoolManager internal pm;\n    UniswapVaultPriceValidator internal validator;\n    int24 internal tickLower;\n    int24 internal tickUpper;\n\n    function setUp() public {\n        pm = new SlotPoolManager();\n        // v2 / v3 factories have no code here -> no TWAP source, the validator takes its clamp path (as for INFER,\n        // which has no v2 or v3 pool per AUDIT-NOTES.md).\n        validator = new UniswapVaultPriceValidator(\n            address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2), address(0x2222), address(0x3333), address(pm), 500, 1800\n        );\n        tickLower = TickMath.minUsableTick(200);\n        tickUpper = TickMath.maxUsableTick(200);\n    }\n\n    function _proportion(uint160 sqrtPriceX96) internal returns (uint256) {\n        pm.setSqrtPrice(sqrtPriceX96);\n        return validator.calculateSwapProportion(TOKEN, tickLower, tickUpper, address(pm), bytes32(uint256(1)));\n    }\n\n    /// 1 ETH = 10_000 tokens (both 18 decimals): sqrtPriceX96 = 100 * 2^96.\n    /// Current code: raw proportion ~1e14 (0.01%), clamped to 35e16. Expected: ~5e17.\n    function test_fullRangeProportionIsHalfWhenTokenIsCheap() public {\n        uint256 p = _proportion(uint160(100 * Q96));\n        assertApproxEqRel(p, 5e17, 0.1e18, \"full-range LP is 50% ETH by value at any price\");\n    }\n\n    /// 1 token = 100 ETH (raw): sqrtPriceX96 = 2^96 / 100.\n    /// Current code: raw proportion ~0.9999, clamped to 65e16. Expected: ~5e17.\n    function test_fullRangeProportionIsHalfWhenTokenIsExpensive() public {\n        uint256 p = _proportion(uint160(Q96 / 100));\n        assertApproxEqRel(p, 5e17, 0.1e18, \"full-range LP is 50% ETH by value at any price\");\n    }\n}","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).","severity":"high","snippet":"        proportion = (ethAmount * 1e18) / total;","title":"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 "},{"citation":"resolved","description":"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.","line":417,"path":"src/vaults/uni/UniAlignmentVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport {Test} 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 {Currency} from \"v4-core/types/Currency.sol\";\nimport {PoolKey} from \"v4-core/types/PoolKey.sol\";\nimport {IHooks} from \"v4-core/interfaces/IHooks.sol\";\n\n/// @dev 18-decimal alignment token.\ncontract AlignToken is ERC20 {\n    constructor() {\n        _mint(msg.sender, 1e30);\n    }\n\n    function name() public pure override returns (string memory) {\n        return \"ALIGN\";\n    }\n\n    function symbol() public pure override returns (string memory) {\n        return \"ALIGN\";\n    }\n}\n\n/// @dev Validator stand-in: no price checks, 50% split (the value the production validator returns for the first\n///      conversion).\ncontract OkValidator is IVaultPriceValidator {\n    function validatePrice(address, uint256) external pure override {}\n\n    function calculateSwapProportion(address, int24, int24, address, bytes32) external pure override returns (uint256) {\n        return 5e17;\n    }\n\n    function quoteEthForTokens(address, uint256) external pure override returns (uint256) {\n        return 0;\n    }\n}\n\n/// @dev Router stand-in reproducing the ETH-in branch of zammdefi zRouter.swapV4 / unlockCallback:\n///      fills 1:1, then `ethRefund = address(this).balance; if (ethRefund != 0 && to != address(this))\n///      _safeTransferETH(payer, ethRefund);` where payer is the swapV4 caller and _safeTransferETH reverts on failure.\n///      The router's `receive()` is open to anyone, so its balance can be non-zero when the vault swaps.\ncontract RefundingRouter {\n    error ETHTransferFailed();\n\n    AlignToken public immutable token;\n    uint256 internal held; // ETH already paid into the pool by earlier swaps\n\n    constructor(AlignToken t) {\n        token = t;\n    }\n\n    receive() external payable {}\n\n    function swapV4(\n        address to,\n        bool,\n        uint24,\n        int24,\n        address tokenIn,\n        address,\n        uint256 swapAmount,\n        uint256 amountLimit,\n        uint256\n    ) external payable returns (uint256 amountIn, uint256 amountOut) {\n        require(tokenIn == address(0) && msg.value == swapAmount, \"eth leg only\");\n        amountIn = swapAmount;\n        amountOut = swapAmount;\n        require(amountOut >= amountLimit, \"limit\");\n        held += swapAmount;\n        token.transfer(to, amountOut);\n        uint256 ethRefund = address(this).balance - held;\n        if (ethRefund != 0 && to != address(this)) {\n            (bool ok,) = payable(msg.sender).call{value: ethRefund}(\"\");\n            if (!ok) revert ETHTransferFailed();\n        }\n    }\n}\n\n/// @notice The ETH->token swap in UniAlignmentVault._doSwapAndLP runs without the transient `collecting` flag.\n///         zRouter refunds its whole ETH balance to the payer after an ETH-in V4 swap, so any ETH parked in the router\n///         (1 wei from anyone) lands in the vault's receive() while convertAndAddLiquidity holds the reentrancy lock:\n///         _receiveExternalContribution reverts Reentrancy(), the router's refund fails, and the whole conversion\n///         reverts. Expected: the conversion completes and the refund is kept by the vault.\ncontract ZRouterRefundRevertsTest is Test {\n    PoolManager pm;\n    AlignToken token;\n    RefundingRouter router;\n    UniAlignmentVault vault;\n    MiyaRegistry reg;\n    PoolKey key;\n    address alice = makeAddr(\"alice\");\n    address griefer = makeAddr(\"griefer\");\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        token = new AlignToken();\n        key = PoolKey(Currency.wrap(address(0)), Currency.wrap(address(token)), 3000, 60, IHooks(address(0)));\n        pm.initialize(key, 79228162514264337593543950336); // 1:1\n\n        reg = new MiyaRegistry(address(this), address(0xFEE), 100, 1000);\n        address[] memory toks = new address[](1);\n        toks[0] = address(token);\n        reg.addTarget(\"t\", toks);\n        reg.setConversionKeeper(address(this));\n\n        router = new RefundingRouter(token);\n        token.transfer(address(router), 1e27);\n\n        vault = UniAlignmentVault(payable(LibClone.clone(address(new UniAlignmentVault()))));\n        vault.initialize(\n            address(this),\n            address(0xBEEF),\n            address(pm),\n            address(token),\n            address(router),\n            3000,\n            60,\n            IVaultPriceValidator(address(new OkValidator())),\n            IMiyaRegistry(address(reg)),\n            0\n        );\n        vault.setV4PoolKey(key);\n        vm.deal(alice, 10 ether);\n        vm.deal(griefer, 1 ether);\n    }\n\n    receive() external payable {}\n\n    function test_oneWeiInRouterDoesNotBlockConversion() public {\n        vm.prank(alice);\n        vault.receiveContribution{value: 1 ether}(Currency.wrap(address(0)), 1 ether, alice);\n\n        // Anyone parks 1 wei in the router (open receive()).\n        vm.prank(griefer);\n        (bool ok,) = payable(address(router)).call{value: 1}(\"\");\n        require(ok, \"park\");\n\n        // Expected: the keeper's conversion succeeds. Actual: Reentrancy() raised in vault.receive() during the\n        // router's refund -> ETHTransferFailed -> convertAndAddLiquidity reverts, and keeps reverting while the\n        // router holds any ETH.\n        vault.convertAndAddLiquidity(1, 1);\n        assertGt(vault.totalLPUnits(), 0, \"liquidity added\");\n        assertEq(vault.totalPendingETH(), 0, \"batch converted\");\n    }\n}","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).","severity":"high","snippet":"        (, r.targetTokenReceived) = IzRouterV4(zRouter).swapV4{value: ethToSwap}(","title":"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"},{"citation":"resolved","description":"_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).","line":414,"path":"src/peripherals/UniswapVaultPriceValidator.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {UniswapVaultPriceValidator} from \"src/peripherals/UniswapVaultPriceValidator.sol\";\n\n/// @dev PoolManager stand-in: every extsload returns the same word, so getSlot0 reads sqrtPriceX96 = 2^96\n///      (1:1) and getLiquidity reads a non-zero liquidity for every pool id (the vault's own v4 pool is healthy).\ncontract SpotPoolManager {\n    function extsload(bytes32) external pure returns (bytes32) {\n        return bytes32(uint256(79228162514264337593543950336)); // 1:1, and liquidity = low 128 bits != 0\n    }\n}\n\n/// @dev A Uniswap V3 pool anyone can create for ~0.001 ETH: 1 wei of in-range liquidity, parked at `tick`,\n///      untouched for 30 minutes so observe() extrapolates its single observation (V3 allows this with\n///      observationCardinality == 1).\ncontract DustV3Pool {\n    int24 public immutable tick;\n    address public immutable token0;\n    address public immutable token1;\n\n    constructor(int24 _tick, address t0, address t1) {\n        tick = _tick;\n        token0 = t0;\n        token1 = t1;\n    }\n\n    function slot0() external pure returns (uint160, int24, uint16, uint16, uint16, uint8, bool) {\n        return (0, 0, 0, 1, 1, 0, true);\n    }\n\n    function liquidity() external pure returns (uint128) {\n        return 1;\n    }\n\n    function observe(uint32[] calldata secondsAgos) external view returns (int56[] memory tc, uint160[] memory sl) {\n        tc = new int56[](2);\n        sl = new uint160[](2);\n        tc[0] = 0;\n        tc[1] = int56(tick) * int56(uint56(secondsAgos[0]));\n    }\n}\n\ncontract DustV3Factory {\n    mapping(uint24 => address) public pools;\n\n    function set(uint24 fee, address pool) external {\n        pools[fee] = pool;\n    }\n\n    function getPool(address, address, uint24 fee) external view returns (address) {\n        return pools[fee];\n    }\n}\n\n/// @notice _queryV3PoolForFee derives price as ((sqrtPriceX96 >> 48)^2 * 1e18) >> 96 in checked arithmetic. For a\n///         pool parked above tick ~694_500 the multiplication exceeds 2^256 and validatePrice reverts with\n///         Panic(0x11). validatePrice is called on every convertAndAddLiquidity, trusts the first V3 pool with\n///         non-zero liquidity (no depth floor), and the validator is immutable per vault: a 1-wei pool freezes every\n///         conversion of every vault for that token. The same expression is used for scanned V4 pools (line 539).\ncontract ExtremeTickOverflowTest is Test {\n    address weth = address(0xBEEF);\n    address token = address(0xCAFE);\n    SpotPoolManager pm;\n    DustV3Factory v3;\n    UniswapVaultPriceValidator validator;\n\n    function setUp() public {\n        pm = new SpotPoolManager();\n        v3 = new DustV3Factory();\n        validator = new UniswapVaultPriceValidator(weth, address(0), address(v3), address(pm), 500, 1800);\n    }\n\n    function test_dustPoolAtExtremeTickDoesNotRevertValidatePrice() public {\n        v3.set(3000, address(new DustV3Pool(800_000, weth, token)));\n        // Expected: a 1-wei pool is not a price reference, or at worst its price is computed without overflow and the\n        // call returns. Actual: arithmetic overflow (Panic 0x11) -> every keeper conversion reverts.\n        validator.validatePrice(token, 1 ether);\n    }\n}","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","severity":"high","snippet":"            uint256 rawPrice = (sqrtScaled * sqrtScaled * 1e18) >> 96;","title":"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"},{"citation":"resolved","description":"_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.","line":226,"path":"src/peripherals/UniswapVaultPriceValidator.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {UniswapVaultPriceValidator} from \"src/peripherals/UniswapVaultPriceValidator.sol\";\nimport {TickMath} from \"v4-core/libraries/TickMath.sol\";\n\n/// @dev PoolManager stand-in: StateLibrary.getSlot0 reads the low 160 bits of `extsload(slot)` as sqrtPriceX96.\ncontract SlotPoolManager {\n    bytes32 internal _slot0;\n\n    function setSqrtPrice(uint160 sqrtPriceX96) external {\n        _slot0 = bytes32(uint256(sqrtPriceX96));\n    }\n\n    function extsload(bytes32) external view returns (bytes32) {\n        return _slot0;\n    }\n}\n\n/// @dev A Uniswap v3 pool anyone can create for a few dollars of gas: 1 wei of liquidity, one observation older than\n///      the 30-minute window, priced at tick 0 (1 token = 1 ETH raw). observe() returns zero cumulatives -> mean tick 0.\ncontract DustV3Pool {\n    function liquidity() external pure returns (uint128) {\n        return 1;\n    }\n\n    function slot0() external pure returns (uint160, int24, uint16, uint16, uint16, uint8, bool) {\n        return (uint160(2 ** 96), 0, 0, 1, 1, 0, true);\n    }\n\n    function token0() external pure returns (address) {\n        return address(0x1234);\n    }\n\n    function token1() external pure returns (address) {\n        return address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);\n    }\n\n    function observe(uint32[] calldata) external pure returns (int56[] memory tickCumulatives, uint160[] memory s) {\n        tickCumulatives = new int56[](2);\n        s = new uint160[](2);\n    }\n}\n\ncontract DustV3Factory {\n    address public immutable pool;\n\n    constructor(address pool_) {\n        pool = pool_;\n    }\n\n    /// Only the 0.3% tier exists: the first tier the validator looks up.\n    function getPool(address, address, uint24 fee) external view returns (address) {\n        return fee == 3000 ? pool : address(0);\n    }\n}\n\n/// @notice A dust v3 pool at an off-market price makes calculateSwapProportion revert with\n///         SwapProportionDeviationTooHigh for every keeper conversion (after the first) of every vault using this\n///         validator. The vault's v4 pool is at 1 ETH = 10_000 tokens; the attacker's v3 pool (1 wei liquidity) says\n///         1:1. spot proportion ~1e14, TWAP proportion 5e17, diff > 5e16 -> revert.\n///         Expected: a pool with 1 wei of liquidity is not a price reference, and the keeper's conversion proceeds.\ncontract ValidatorDustV3BlocksTest is Test {\n    uint256 internal constant Q96 = 2 ** 96;\n    address internal constant TOKEN = address(0x1234);\n\n    SlotPoolManager internal pm;\n    UniswapVaultPriceValidator internal validator;\n    int24 internal tickLower;\n    int24 internal tickUpper;\n\n    function setUp() public {\n        pm = new SlotPoolManager();\n        DustV3Factory v3 = new DustV3Factory(address(new DustV3Pool()));\n        validator = new UniswapVaultPriceValidator(\n            address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2), address(0x2222), address(v3), address(pm), 500, 1800\n        );\n        tickLower = TickMath.minUsableTick(200);\n        tickUpper = TickMath.maxUsableTick(200);\n        pm.setSqrtPrice(uint160(100 * Q96)); // v4 spot: 1 ETH = 10_000 tokens\n    }\n\n    function test_dustV3PoolDoesNotBlockConversion() public {\n        // Current code reverts here with SwapProportionDeviationTooHigh, so convertAndAddLiquidity reverts for the\n        // keeper until the attacker's pool is out-priced, which the attacker undoes with one swap through 1 wei.\n        uint256 p = validator.calculateSwapProportion(TOKEN, tickLower, tickUpper, address(pm), bytes32(uint256(1)));\n        assertGe(p, 35e16, \"proportion within the full-range band\");\n        assertLe(p, 65e16, \"proportion within the full-range band\");\n    }\n}","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","severity":"high","snippet":"                if (diff > 5e16) revert SwapProportionDeviationTooHigh();","title":"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"},{"citation":"resolved","description":"_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).","line":296,"path":"src/peripherals/UniswapVaultPriceValidator.sol","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.","severity":"high","snippet":"                return TickMath.getSqrtPriceAtTick(meanTick);","title":"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"},{"citation":"resolved","description":"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.","line":62,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","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.","severity":"medium","snippet":"    function deployVault(","title":"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"},{"citation":"resolved","description":"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.","line":696,"path":"src/vaults/uni/UniAlignmentVault.sol","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).","severity":"low","snippet":"        uint128 liquidityToAdd = LiquidityAmounts.getLiquidityForAmounts(","title":"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"},{"citation":"resolved","description":"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.","line":335,"path":"src/vaults/uni/UniAlignmentVault.sol","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.","severity":"low","snippet":"            if (conversionParticipants.length >= MAX_CONVERSION_PARTICIPANTS) revert TooManyConversionParticipants();","title":"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"},{"citation":"resolved","description":"_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.","line":147,"path":"src/peripherals/UniswapVaultPriceValidator.sol","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).","severity":"info","snippet":"        if (hasV4Pool && !v2IsReference && !hasV3Pool && priceV4 != 0) {","title":"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"},{"citation":"resolved","description":"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.","line":103,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","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.","severity":"info","snippet":"        bytes32 guardedSalt = keccak256(abi.encodePacked(uint256(uint160(address(this))), salt));","title":"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"},{"citation":"resolved","description":"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.","line":369,"path":"src/vaults/uni/UniAlignmentVault.sol","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).","severity":"info","snippet":"        if (minOutTarget == 0) revert AmountMustBePositive();","title":"Keeper trust statement understates the keeper's power: a self-sandwiched conversion transfers batch principal to the keeper, not merely 'loses value'"},{"citation":"resolved","description":"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.","line":502,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"No failing input for these items; see the other findings for the defects found.","severity":"info","snippet":"        accFeePerShare += FixedPointMathLib.fullMulDiv(amount, ACC_PRECISION, totalShares); // round down: favors vault","title":"Answers to questions 1-5 where nothing is wrong: fee accounting conservation, reentrancy windows, caller reward, keeper limits, pool key and treasury"}],"hash":"1251c06a77395080f2e426a3d2078abaf48492d0fb28d4f67299f20f91772d43","nodeId":"3583e0e6-ecf9-4952-8c2b-b54110879072","outcome":"completed","summary":"The shell sandbox never initialized during this session, so nothing could be executed. All verification was done by reading the code and the upstream zRouter source. The findings file and four proof tests are written.\n\n**Outcome.** `.imd-findings.json` holds 12 findings: 5 high, 1 medium, 2 low, 4 info. Every specialist finding was checked against the code; duplicates across the four specialists were merged into one entry each. The proof tests under `test/scratch/` were written against the contracts and the harness of the existing real-v4 test, but I could not compile or run them here. Please run:\n\n```\nforge test --match-path 'test/scratch/*'\n```\n\n**High findings, all reproduced by reading the code:**\n- **Proportion formula mixes units** at `UniswapVaultPriceValidator.sol:267`. It returns 1/(1+P) instead of the value share, so every conversion after the first swaps 35% or 65% of the batch and about 30% is stranded unaccounted. With a v3 pool present the unclamped value strands nearly everything. Proof attached.\n- **zRouter refund reverts conversions** at `UniAlignmentVault.sol:417`. The upstream router refunds its entire balance to the payer after an ETH-in swap, and the vault's receive() is not in collecting mode there, so one wei parked in the router blocks every conversion. Proof attached. The deployed bytecode was not diffed against the main-branch source.\n- **Overflow in the price formula** at line 414 (also 539 and 152) for any scanned pool above tick ~694,500. A one-wei v3 or v4 dust pool makes validatePrice panic forever. Proof attached.\n- **No depth floor on v3 reference pools** at line 226. A one-wei pool at a wrong tick triggers SwapProportionDeviationTooHigh, or PriceDeviationTooHigh when a v2 reference exists. Proof attached.\n- **TWAP orientation bug** at line 296. The v3 sqrt price is not inverted when the token sorts below WETH, which is INFER's case, so even an honest v3 pool at the true price blocks conversions.\n\n**Other findings:** permissionless deployVault with arbitrary validator plus salt front-run (medium); LP residue left unaccounted every batch (low); 500-participant cap griefing (low); dead v4-vs-TWAP cross-check, Q6 proxy and salt notes, keeper trust understated, and the Q1 to Q5 answers where nothing is wrong (info).\n\n**Dropped or demoted from the specialists:** nothing was dropped outright. The gas figure for the participant cap was kept as an estimate since it was not measured.","treeHash":null,"usage":{"cachedInputTokens":1644371,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":59760,"runtime":"claude","turns":44,"wallClockMs":805730}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"79373c79d1351eba","findings":[{"citation":"resolved","description":"_computeProportionFromSqrtPrice takes getAmountsForLiquidity(sqrtPrice, minTick, maxTick, 1e18) and returns amount0 / (amount0 + amount1). amount0 is ETH wei and amount1 is token wei, so for a full-range position this is 1 / (1 + P) where P is the raw pool price (token wei per ETH wei). But a full-range position always holds equal VALUE on both sides (amount1 / P == amount0), so the fraction of the batch that must be swapped is ~50% at every price. The formula gives 50% only when P == 1 (token worth exactly 1 ETH). For any cheaper token (P > 1.86) the result is < 35% and the clamp at line 233 returns 35%; with a v3 TWAP present (unclamped path, line 237) it is ~0%. The first conversion is unaffected (lastTickLower == lastTickUpper == 0 returns 5e17 at line 193), but _addToLpPosition then stores the full-range ticks, so every later conversion goes through this formula. The vault then swaps 35% of ethToAdd, receives tokens worth 35% of ethToAdd, and getLiquidityForAmounts in _addToLpPosition is token-limited: it consumes those tokens plus ~35% of ethToAdd and leaves ~30% of every batch as ETH in the vault balance. That ETH is not pendingETH, not fees and not protocol fees; no function can ever pay it out (AUDIT-NOTES calls such balance 'unaccounted surplus ... never paid out'). Benefactors receive shares for only ~70% of what they contributed; the loss is permanent and recurs on every batch. On mainnet the alignment token is INFER (price far below 1 ETH per token, 18 decimals, so P >> 1) with no v3 pool, i.e. exactly the 35%-clamp case. The TWAP cross-check at lines 221-226 is also meaningless for realistic prices because both proportions are ~0 or ~1 whatever the relative price. Neither the unit suite (MockVaultPriceValidator returns 5e17) nor UniAlignmentVaultRealV4.t.sol (1:1 price, mock validator) exercises this code.","line":267,"path":"src/peripherals/UniswapVaultPriceValidator.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {UniswapVaultPriceValidator} from \"src/peripherals/UniswapVaultPriceValidator.sol\";\n\n/// @dev Minimal PoolManager stand-in: StateLibrary.getSlot0 reads one storage word through extsload.\ncontract SlotPoolManager {\n    uint160 public sqrtPriceX96;\n\n    constructor(uint160 _sqrtPriceX96) {\n        sqrtPriceX96 = _sqrtPriceX96;\n    }\n\n    function extsload(bytes32) external view returns (bytes32) {\n        return bytes32(uint256(sqrtPriceX96));\n    }\n}\n\n/// @notice UniswapVaultPriceValidator.calculateSwapProportion mixes raw token units: for a full-range position it\n///         returns amount0 / (amount0 + amount1) = 1 / (1 + P) where P is the raw price (token wei per ETH wei).\n///         A full-range position always holds equal VALUE on both sides, so the ETH fraction to swap is ~50% at any\n///         price. For any token not priced at exactly 1 ETH per token the result is ~0% or ~100%, clamped to 35% / 65%\n///         when no v3 TWAP exists (the mainnet INFER case), so from the second conversion on the vault swaps the wrong\n///         amount and strands the unmatched leftover (30% of every batch at the 35% clamp) in its balance forever.\ncontract ValidatorProportionTest is Test {\n    address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;\n    address constant TOKEN = 0x53d0eA0A685220718Dc6a0a6e4617194C6F37921; // INFER (mainnet alignment token)\n    // Full-range ticks for tickSpacing 200 (mainnet zRouterTickSpacing), i.e. what the vault stores after its first LP add.\n    int24 constant TICK_LOWER = -887200;\n    int24 constant TICK_UPPER = 887200;\n\n    function _validator(uint160 sqrtPriceX96) internal returns (UniswapVaultPriceValidator v, address pm) {\n        pm = address(new SlotPoolManager(sqrtPriceX96));\n        // v2/v3 factories without code: no TWAP, the validator takes its \"clamp\" branch.\n        v = new UniswapVaultPriceValidator(WETH, address(0xdead1), address(0xdead2), pm, 500, 1800);\n    }\n\n    /// Token priced at 1e-4 ETH (raw price P = 1e4 token wei per ETH wei): sqrtPriceX96 = 100 * 2^96.\n    function test_cheapToken_fullRangeProportionIsHalf() public {\n        (UniswapVaultPriceValidator v, address pm) = _validator(uint160(100) << 96);\n        uint256 p = v.calculateSwapProportion(TOKEN, TICK_LOWER, TICK_UPPER, pm, bytes32(0));\n        // Expected: ~50% (equal value on both sides of a full-range position). Actual: 35e16 (clamped from ~1e14).\n        assertGe(p, 45e16, \"swap proportion far below the value-weighted 50%: ETH left over after the LP add\");\n        assertLe(p, 55e16, \"swap proportion far above the value-weighted 50%\");\n    }\n\n    /// Token priced at 1e4 ETH (raw price P = 1e-4): sqrtPriceX96 = 2^96 / 100.\n    function test_expensiveToken_fullRangeProportionIsHalf() public {\n        (UniswapVaultPriceValidator v, address pm) = _validator(uint160((uint256(1) << 96) / 100));\n        uint256 p = v.calculateSwapProportion(TOKEN, TICK_LOWER, TICK_UPPER, pm, bytes32(0));\n        // Expected: ~50%. Actual: 65e16 (clamped from ~1e18).\n        assertGe(p, 45e16, \"swap proportion far below the value-weighted 50%\");\n        assertLe(p, 55e16, \"swap proportion far above the value-weighted 50%: tokens left over after the LP add\");\n    }\n}","reproduction":"Validator built with the mainnet config (v3Factory may be any address; here one with no code so the clamp branch runs). v4 pool ETH/INFER at raw price 1e4 token-wei per ETH-wei (sqrtPriceX96 = 100 << 96), vault ticks after the first LP add = -887200 / 887200 (tickSpacing 200). calculateSwapProportion(INFER, -887200, 887200, poolManager, poolId): expected ~5e17 (equal value both sides); actual 35e16. Worked batch: ethToAdd = 1 ETH -> ethToSwap = 0.35 ETH -> ~3500 INFER; LP add is token-limited and takes ~0.35 ETH + 3500 INFER; ~0.30 ETH stays in the vault with no accounting entry and no withdrawal path. Mirror case P = 1e-4 (sqrtPriceX96 = 2^96 / 100) returns 65e16 and strands ~30% of the batch as tokens outside pendingFeeTokens. Fix: compute the proportion on value, i.e. amount0 / (amount0 + amount1 * Q96^2 / sqrtPrice^2) (which is 0.5 for full range), or simply return 5e17 for the full-range position the vault always uses, and recycle any post-add leftover (ETH to totalPendingETH or _distributeFees, tokens to pendingFeeTokens).","severity":"high","snippet":"        proportion = (ethAmount * 1e18) / total;","title":"calculateSwapProportion mixes raw token units: every conversion after the first swaps the wrong ETH fraction and strands the unmatched leftover in the vault forever"},{"citation":"resolved","description":"The deployed zRouter (zammdefi zRouter, 0x000000000000FB114709235f1ccBFfb925F600e4 per script/config/1.json) ends every V4 ETH-in swap in its unlockCallback with `uint256 ethRefund = address(this).balance; if (ethRefund != 0 && to != address(this)) _safeTransferETH(payer, ethRefund);` where payer is the swapV4 caller, i.e. the vault. It refunds its ENTIRE balance, not the unused part of msg.value, and its `receive() external payable {}` is open to anyone. The H1 fix only sets the _COLLECTING_SLOT flag around _claimVaultFees and around the token->ETH swap in _convertPendingFeeTokens; the ETH->token swap in _doSwapAndLP (line 417) runs with the flag clear. So whenever the router holds any ETH when the keeper converts, the refund hits the vault's receive() at line 292, _isCollecting() is false, _receiveExternalContribution() is entered, its nonReentrant guard sees the lock held by convertAndAddLiquidity and reverts Reentrancy(); _safeTransferETH requires success, so the router reverts and the whole conversion reverts. Anyone can keep 1 wei in the router (send to receive(), or selfdestruct) 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: any other user's exactOut swap with to == router leaves ETH there, and the next ETH-in swap (possibly the vault's) gets it. Pending ETH has no other exit while conversions fail, and MAX_CONVERSION_PARTICIPANTS (500) eventually makes new contributions revert (collections then park their cut in pendingVaultCut). Also note the refund is good ETH the vault should keep, not a contribution.","line":417,"path":"src/vaults/uni/UniAlignmentVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {UniAlignmentVault} from \"src/vaults/uni/UniAlignmentVault.sol\";\nimport {MiyaRegistry} from \"src/registry/MiyaRegistry.sol\";\nimport {IMiyaRegistry} from \"src/registry/interfaces/IMiyaRegistry.sol\";\nimport {IVaultPriceValidator} from \"src/interfaces/IVaultPriceValidator.sol\";\nimport {PoolKey} from \"v4-core/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/types/Currency.sol\";\nimport {IHooks} from \"v4-core/interfaces/IHooks.sol\";\n\n/// @dev Vault with the V4 liquidity add stubbed out (the defect is in the swap leg, before any PoolManager call).\ncontract VaultHarness is UniAlignmentVault {\n    function _addToLpPosition(uint256 amount0, uint256 amount1, int24 tickLower, int24 tickUpper)\n        internal\n        override\n        returns (uint128)\n    {\n        lastTickLower = tickLower;\n        lastTickUpper = tickUpper;\n        return uint128((amount0 + amount1) / 2);\n    }\n}\n\ncontract MiniToken {\n    string public name = \"T\";\n    string public symbol = \"T\";\n    uint8 public decimals = 18;\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n        totalSupply += amount;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\ncontract PassValidator is IVaultPriceValidator {\n    function validatePrice(address, uint256) external pure override {}\n\n    function calculateSwapProportion(address, int24, int24, address, bytes32) external pure override returns (uint256) {\n        return 5e17;\n    }\n\n    function quoteEthForTokens(address, uint256) external pure override returns (uint256) {\n        return 0;\n    }\n}\n\n/// @dev Mirrors the deployed zRouter's V4 ETH-in path: after the swap it does\n///      `uint256 ethRefund = address(this).balance; if (ethRefund != 0 && to != address(this)) _safeTransferETH(payer, ethRefund);`\n///      i.e. it forwards its WHOLE ETH balance to the swap payer, and its receive() is open to anyone.\ncontract RefundingRouter {\n    MiniToken public token;\n\n    constructor(MiniToken _token) {\n        token = _token;\n    }\n\n    receive() external payable {}\n\n    function swapV4(\n        address to,\n        bool,\n        uint24,\n        int24,\n        address tokenIn,\n        address,\n        uint256 swapAmount,\n        uint256 amountLimit,\n        uint256\n    ) external payable returns (uint256 amountIn, uint256 amountOut) {\n        amountIn = swapAmount;\n        amountOut = swapAmount; // 1:1 price\n        require(amountOut >= amountLimit, \"slippage\");\n        if (tokenIn == address(0)) {\n            require(msg.value == swapAmount, \"value\");\n            token.transfer(to, amountOut);\n            // the swap input is settled to the PoolManager in the real router; everything else is refunded to payer\n            uint256 ethRefund = address(this).balance - swapAmount;\n            if (ethRefund != 0 && to != address(this)) {\n                (bool ok,) = payable(msg.sender).call{value: ethRefund}(\"\");\n                require(ok, \"zRouter: ETH refund to payer failed\");\n            }\n        } else {\n            token.transferFrom(msg.sender, address(this), swapAmount);\n            (bool ok,) = payable(to).call{value: amountOut}(\"\");\n            require(ok, \"eth out\");\n        }\n    }\n}\n\n/// @notice The vault's receive() only ignores ETH while the `collecting` flag is set, and that flag is NOT set around\n///         the ETH->token swap in _doSwapAndLP. zRouter refunds its entire ETH balance to the payer (the vault) after\n///         every ETH-in swap, so any ETH sitting in the router (1 wei sent by anyone to its open receive(), or another\n///         user's leftover) re-enters receive() -> _receiveExternalContribution (nonReentrant) while\n///         convertAndAddLiquidity holds the lock. The refund reverts, the router reverts, the conversion reverts.\ncontract ZRouterRefundReentrancyTest is Test {\n    receive() external payable {}\n\n    function test_conversionSurvivesRouterEthRefund() public {\n        MiniToken token = new MiniToken();\n        RefundingRouter router = new RefundingRouter(token);\n        token.mint(address(router), 1_000_000e18);\n\n        MiyaRegistry reg = new MiyaRegistry(address(this), address(0xFEE), 100, 1000);\n        address[] memory toks = new address[](1);\n        toks[0] = address(token);\n        uint256 targetId = reg.addTarget(\"t\", toks);\n        reg.setConversionKeeper(address(this));\n\n        VaultHarness vault = new VaultHarness();\n        vault.initialize(\n            address(this),\n            address(0x1111),\n            address(0x2222), // PoolManager stand-in without code: fee collection is skipped, LP add is stubbed\n            address(token),\n            address(router),\n            10000,\n            200,\n            IVaultPriceValidator(address(new PassValidator())),\n            IMiyaRegistry(address(reg)),\n            targetId\n        );\n        vault.setV4PoolKey(\n            PoolKey({\n                currency0: Currency.wrap(address(0)),\n                currency1: Currency.wrap(address(token)),\n                fee: 10000,\n                tickSpacing: 200,\n                hooks: IHooks(address(0))\n            })\n        );\n\n        address alice = address(0xA11CE);\n        vm.deal(alice, 1 ether);\n        vm.prank(alice);\n        vault.receiveContribution{value: 1 ether}(Currency.wrap(address(0)), 1 ether, alice);\n\n        // Anyone parks 1 wei in the router (open receive()). The keeper's conversion must still go through.\n        vm.deal(address(router), 1 wei);\n        vault.convertAndAddLiquidity(1, 1); // currently reverts: Reentrancy() raised inside receive() during the refund\n\n        assertGt(vault.benefactorShares(alice), 0, \"conversion completed and shares issued\");\n        assertEq(vault.totalPendingETH(), 0);\n    }\n}","reproduction":"Vault initialised with the real UniAlignmentVault and a router that behaves like zRouter's ETH-in branch (tokens 1:1 to `to`, then `payer.call{value: address(this).balance - swapAmount}` which must succeed). alice contributes 1 ETH via receiveContribution; anyone sends 1 wei to the router; keeper calls convertAndAddLiquidity(1, 1). Expected: conversion completes, alice gets shares. Actual: revert; the inner frame is ReentrancyGuard.Reentrancy() raised in receive() during the router's refund. Fix: set the collecting flag around the swapV4 call in _doSwapAndLP as well (and treat a refund from zRouter as batch ETH, e.g. credit it with _distributeFees or add it back to totalPendingETH), or make receive() skip accounting when msg.sender == zRouter.","severity":"high","snippet":"        (, r.targetTokenReceived) = IzRouterV4(zRouter).swapV4{value: ethToSwap}(","title":"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 with "},{"citation":"resolved","description":"_getTwapSqrtPriceX96 returns the raw v3 sqrtPriceX96 (sqrt of token1/token0 with v3 ordering, token0 = lower address). calculateSwapProportion passes it to _computeProportionFromSqrtPrice, which assumes v4 ordering with native ETH as currency0 (line 264: `currency0IsNativeETH = address(0) < token`, always true). _queryV3PoolForFee handles this correctly for validatePrice (it inverts when token0 == weth), 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, so the inversion applies. Today INFER has no v3 pool; the moment anyone creates a WETH/INFER v3 pool (0.3%, 0.05% or 1% tier) with any non-zero liquidity and lets 30 minutes pass (cardinality 1 is enough: observe extrapolates from the single observation), every convertAndAddLiquidity reverts from the second batch on (first batch returns 5e17 before the TWAP code). The validator is immutable per vault and the pool key cannot be repointed, so the vault's conversions are dead for good; pending ETH has no other exit and new contributions eventually revert at MAX_CONVERSION_PARTICIPANTS. Creating that pool costs a few dollars and needs no manipulation; an honest pool at the right price triggers it just the same.","line":296,"path":"src/peripherals/UniswapVaultPriceValidator.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {UniswapVaultPriceValidator} from \"src/peripherals/UniswapVaultPriceValidator.sol\";\n\ncontract SlotPoolManager {\n    uint160 public sqrtPriceX96;\n\n    constructor(uint160 _sqrtPriceX96) {\n        sqrtPriceX96 = _sqrtPriceX96;\n    }\n\n    function extsload(bytes32) external view returns (bytes32) {\n        return bytes32(uint256(sqrtPriceX96));\n    }\n}\n\n/// @dev A Uniswap v3 pool with liquidity and a 30-minute TWAP at a fixed tick (v3 token ordering: token0 < token1).\ncontract FixedTickV3Pool {\n    int24 public meanTick;\n    address public token0;\n    address public token1;\n\n    constructor(int24 _meanTick, address _token0, address _token1) {\n        meanTick = _meanTick;\n        token0 = _token0;\n        token1 = _token1;\n    }\n\n    function liquidity() external pure returns (uint128) {\n        return 1e18;\n    }\n\n    function slot0() external pure returns (uint160, int24, uint16, uint16, uint16, uint8, bool) {\n        return (0, 0, 0, 2, 2, 0, true);\n    }\n\n    function observe(uint32[] calldata secondsAgos)\n        external\n        view\n        returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s)\n    {\n        tickCumulatives = new int56[](2);\n        secondsPerLiquidityCumulativeX128s = new uint160[](2);\n        tickCumulatives[0] = 0;\n        tickCumulatives[1] = int56(meanTick) * int56(uint56(secondsAgos[0]));\n    }\n}\n\ncontract FixedV3Factory {\n    address public pool;\n    uint24 public poolFee;\n\n    constructor(address _pool, uint24 _fee) {\n        pool = _pool;\n        poolFee = _fee;\n    }\n\n    function getPool(address, address, uint24 fee) external view returns (address) {\n        return fee == poolFee ? pool : address(0);\n    }\n}\n\n/// @notice _getTwapSqrtPriceX96 returns the v3 pool's sqrt price in v3 token ordering (token0 = lower address), but\n///         calculateSwapProportion feeds it to _computeProportionFromSqrtPrice, which assumes v4 ordering (currency0 =\n///         native ETH). When the alignment token's address is below WETH's (INFER 0x53d0... < WETH 0xC02a...), the v3\n///         price is the reciprocal of the v4 price, the two proportions are mirror images, and the 5% deviation check\n///         reverts on every conversion after the first as soon as ANY WETH/token v3 pool with liquidity exists, even\n///         one quoting exactly the v4 price.\ncontract ValidatorTwapOrientationTest is Test {\n    address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;\n    address constant TOKEN = 0x53d0eA0A685220718Dc6a0a6e4617194C6F37921; // INFER: TOKEN < WETH\n    int24 constant TICK_LOWER = -887200;\n    int24 constant TICK_UPPER = 887200;\n\n    function test_honestV3PoolAtSamePriceDoesNotBlockConversion() public {\n        // v4 pool (currency0 = ETH, currency1 = INFER): 1e4 INFER-wei per ETH-wei, sqrtPriceX96 = 100 * 2^96.\n        address pm = address(new SlotPoolManager(uint160(100) << 96));\n        // v3 pool (token0 = INFER, token1 = WETH): the same price is 1e-4 WETH-wei per INFER-wei, tick ~ -92104.\n        FixedTickV3Pool pool = new FixedTickV3Pool(-92104, TOKEN, WETH);\n        FixedV3Factory v3Factory = new FixedV3Factory(address(pool), 3000);\n\n        UniswapVaultPriceValidator v =\n            new UniswapVaultPriceValidator(WETH, address(0xdead1), address(v3Factory), pm, 500, 1800);\n\n        // Both venues quote the same price, so the cross-check must pass. Actual: SwapProportionDeviationTooHigh().\n        uint256 p = v.calculateSwapProportion(TOKEN, TICK_LOWER, TICK_UPPER, pm, bytes32(0));\n        assertLe(p, 1e18);\n    }\n}","reproduction":"Validator with the mainnet v3 factory semantics (mock factory returning a pool for the 0.3% tier). v4 pool ETH/INFER at raw price 1e4 token-wei per ETH-wei (sqrtPriceX96 = 100 << 96). v3 pool with token0 = INFER, token1 = WETH, liquidity 1e18, 30-minute TWAP tick -92104 (= 1e-4 WETH-wei per INFER-wei, the SAME price). calculateSwapProportion(INFER, -887200, 887200, poolManager, poolId): expected to return (prices agree); actual revert SwapProportionDeviationTooHigh() because the TWAP proportion is computed as ~0.9999e18 against a spot proportion of ~1e14. Fix: in _getTwapSqrtPriceX96 invert the sqrt price (Q96^2 / sqrtPrice, or negate the tick before getSqrtPriceAtTick) when IUniswapV3Pool(pool).token0() != weth, so it is expressed as token per ETH like the v4 slot0; and see the separate finding on dust pools.","severity":"high","snippet":"                return TickMath.getSqrtPriceAtTick(meanTick);","title":"v3 TWAP is read in v3 token ordering but interpreted in v4 ordering: for INFER (address below WETH) any v3 WETH/INFER pool with liquidity blocks every conversion after the first"},{"citation":"resolved","description":"_getTwapSqrtPriceX96 takes the first v3 WETH/token pool (0.3%, then 0.05%, then 1%) whose liquidity() is non-zero and whose observe() succeeds, with no depth threshold (unlike the 10 WETH floor MIN_V2_REFERENCE_WETH applied to v2 pairs). Its TWAP proportion is then compared with the v4 spot proportion and the call REVERTS when they differ by more than 5 points. So the price reference is whatever pool an attacker creates: initialise a v3 pool for the alignment token in the 0.3% tier at an arbitrary tick, mint 1 wei of liquidity, wait 30 minutes (cardinality 1 is enough, v3 extrapolates from the single observation), and every convertAndAddLiquidity on every vault for that token reverts from its second batch on. convertFeeTokens does not consult the validator, but pending ETH can only leave through convertAndAddLiquidity. The validator address is fixed at initialize and the factory has no way to swap it, so recovery means deploying a new vault and abandoning the pending ETH in the old one. AUDIT-NOTES mentions that a dust pool in an earlier tier 'wins' as a reference but not that it can block conversions outright. With the mainnet 1% zRouter tier and no v3 pool for INFER, the attack surface is open today. This is independent of the orientation bug: for a token whose address is above WETH the orderings agree and the attacker simply picks a tick far from the v4 price.","line":226,"path":"src/peripherals/UniswapVaultPriceValidator.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {UniswapVaultPriceValidator} from \"src/peripherals/UniswapVaultPriceValidator.sol\";\n\ncontract SlotPoolManager {\n    uint160 public sqrtPriceX96;\n\n    constructor(uint160 _sqrtPriceX96) {\n        sqrtPriceX96 = _sqrtPriceX96;\n    }\n\n    function extsload(bytes32) external view returns (bytes32) {\n        return bytes32(uint256(sqrtPriceX96));\n    }\n}\n\n/// @dev An attacker-created v3 pool: 1 wei of liquidity, initialised at an arbitrary tick, older than the TWAP window.\ncontract DustV3Pool {\n    int24 public meanTick;\n\n    constructor(int24 _meanTick) {\n        meanTick = _meanTick;\n    }\n\n    function liquidity() external pure returns (uint128) {\n        return 1;\n    }\n\n    function slot0() external pure returns (uint160, int24, uint16, uint16, uint16, uint8, bool) {\n        return (0, 0, 0, 1, 1, 0, true);\n    }\n\n    function observe(uint32[] calldata secondsAgos)\n        external\n        view\n        returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s)\n    {\n        tickCumulatives = new int56[](2);\n        secondsPerLiquidityCumulativeX128s = new uint160[](2);\n        tickCumulatives[0] = 0;\n        tickCumulatives[1] = int56(meanTick) * int56(uint56(secondsAgos[0]));\n    }\n}\n\ncontract FixedV3Factory {\n    address public pool;\n    uint24 public poolFee;\n\n    constructor(address _pool, uint24 _fee) {\n        pool = _pool;\n        poolFee = _fee;\n    }\n\n    function getPool(address, address, uint24 fee) external view returns (address) {\n        return fee == poolFee ? pool : address(0);\n    }\n}\n\n/// @notice calculateSwapProportion reverts whenever the first v3 WETH/token pool with non-zero liquidity (0.3% tier\n///         first) has a 30-minute TWAP whose implied proportion differs from the v4 spot by more than 5 points. The\n///         v3 pool is not checked for depth, so anyone can create one with 1 wei of liquidity at any price and block\n///         every convertAndAddLiquidity on the vault; pending ETH has no other exit.\ncontract ValidatorDustV3DosTest is Test {\n    address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;\n    // Token above WETH so the v3 and v4 orderings agree (this isolates the dust-pool issue from the orientation bug).\n    address constant TOKEN = 0xF000000000000000000000000000000000000001;\n    int24 constant TICK_LOWER = -887200;\n    int24 constant TICK_UPPER = 887200;\n\n    function test_dustV3PoolCannotBlockConversion() public {\n        // Honest v4 pool: 1e4 token-wei per ETH-wei (sqrtPriceX96 = 100 * 2^96).\n        address pm = address(new SlotPoolManager(uint160(100) << 96));\n        // Attacker's v3 pool at tick 0 (price 1), 1 wei of liquidity, in the 0.3% tier the validator checks first.\n        DustV3Pool pool = new DustV3Pool(0);\n        FixedV3Factory v3Factory = new FixedV3Factory(address(pool), 3000);\n\n        UniswapVaultPriceValidator v =\n            new UniswapVaultPriceValidator(WETH, address(0xdead1), address(v3Factory), pm, 500, 1800);\n\n        // Expected: a 1-wei pool is not a price reference, the call returns. Actual: SwapProportionDeviationTooHigh().\n        uint256 p = v.calculateSwapProportion(TOKEN, TICK_LOWER, TICK_UPPER, pm, bytes32(0));\n        assertLe(p, 1e18);\n    }\n}","reproduction":"Token above WETH (e.g. 0xF000...0001) so orderings agree. v4 pool at raw price 1e4 (sqrtPriceX96 = 100 << 96), vault full-range ticks -887200 / 887200. Attacker's v3 pool in the 0.3% tier: liquidity() = 1, 30-minute TWAP tick 0 (price 1). calculateSwapProportion(token, -887200, 887200, poolManager, poolId): expected to return a proportion (no credible reference disagrees); actual revert SwapProportionDeviationTooHigh() (twap proportion 5e17 vs spot ~1e14). Fix: require a minimum depth before a v3 pool is a reference (e.g. WETH balance >= MIN_V2_REFERENCE_WETH, mirroring the v2 rule), and/or clamp into the [35%, 65%] band instead of reverting, so a disagreeing external pool can never stop the keeper.","severity":"medium","snippet":"                if (diff > 5e16) revert SwapProportionDeviationTooHigh();","title":"A 1-wei-liquidity v3 pool at any price is accepted as the TWAP reference and makes calculateSwapProportion revert: anyone can freeze a vault's conversions for the price of a pool creation"},{"citation":"resolved","description":"convertAndAddLiquidity collects accrued LP yield first (line 376) precisely so that the later modifyLiquidity does not net fees off silently (comment at lines 374-376). But between that collection and the add, the vault itself trades on the same pool twice: the fee-token sale in _convertPendingFeeTokens (token-in, so the position earns token fees) and the ETH->token purchase in _doSwapAndLP (ETH-in, so it earns ETH fees: 1% of ethToSwap times the vault's share of pool liquidity at the mainnet tier). modifyLiquidity at line 732 then returns callerDelta = principal + feesAccrued; _settleLPDelta pays only the net, so those fees are neither credited by _creditYield nor added to pendingFeeTokens. They remain as vault ETH balance that no function pays out and as token balance above pendingFeeTokens that no function converts. Separately, the add uses getLiquidityForAmounts, which is limited by one side; the other side's remainder (the price moved during the vault's own swap, and the proportion is computed from the pre-swap spot) is left in the vault the same way. In a 10 ETH / 10-token pool, a 0.5 ETH batch leaves ~4.7% of the tokens bought behind. Neither remainder is recorded, totalEthLocked is overstated, and AUDIT-NOTES only mentions externally sent ETH as unaccounted surplus. For a vault that is the dominant LP of a thin INFER pool, the self-swap fee alone is ~0.5% of every batch, plus the ratio leftover, lost for good.","line":427,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"State: vault holds a full-range position that is 100% of pool liquidity (first conversion done), mainnet fee tier 1%. Keeper converts a batch with ethToSwap = 1 ETH. The swap credits the position 0.01 ETH of fees. _addToLpPosition computes liquidityToAdd from (ethRemaining, tokensReceived); modifyLiquidity returns delta0 = -(ETH principal) + 0.01 ETH; the vault settles 0.01 ETH less than ethRemaining. After the call: vault ETH balance == totalPendingETH (0) + unclaimed fees + accumulatedProtocolFees + 0.01 ETH + ratio leftover, with no path that spends the last two terms; token balance > pendingFeeTokens by the token-side leftover. Expected: those amounts are credited (ETH via _creditYield, tokens via pendingFeeTokens) or re-queued into the next batch. Fix: measure address(this).balance and the token balance before and after the swap+add and route the difference (ETH -> _distributeFees or totalPendingETH; tokens -> pendingFeeTokens), or read feesAccrued from modifyLiquidity's second return value and credit it.","severity":"medium","snippet":"        r.liquidityUnitsAdded = uint256(_addToLpPosition(amount0, amount1, tickLower, tickUpper));","title":"LP fees earned by the vault's own swaps are netted into the liquidity add and, with the ratio leftover of every add, stay in the vault unaccounted: ETH is never claimable and tokens sit outside pendin"},{"citation":"resolved","description":"The factory inherits Ownable and the deploy script hands its ownership to the multisig, but no function is onlyOwner. Anyone can call deployVault with a registry-approved token/target and any priceValidator address; the factory then calls registry.registerVault, so registry.isVault(vault) becomes true and ERC721DropFactory (line 34), ERC721AuctionFactory (line 71) and ERC1155Factory (line 79) accept it as a collection's vault. The validator is read-only and cannot steal, but one that always reverts in validatePrice (or returns 0, which makes _addToLpPosition revert AmountMustBePositive) makes convertAndAddLiquidity impossible for that vault forever, so every collection that is pointed at it (by a misled creator, or by a front end that lists registered vaults) has its alignment share stuck as pendingETH with no exit. A caller can also front-run the official defaultVault salt (same guarded salt for every caller because the factory is the CreateX sender) so the deploy script's address is taken by a vault with the attacker's validator; the script reverts and must change SALT_NAMESPACE.","line":62,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"Any EOA calls UniAlignmentVaultFactory.deployVault(bytes32(0), INFER, 0, IVaultPriceValidator(address(new AlwaysRevertingValidator()))) where the validator's validatePrice reverts. Expected: rejected (owner-only, or validator restricted to defaultPriceValidator / an allowlist). Actual: a vault is created, registered (registry.isVault == true), and a collection created with it cannot ever convert its alignment ETH. Fix: make deployVault onlyOwner, or drop the custom validator parameter and always use defaultPriceValidator (the deploy script already passes address(0)).","severity":"low","snippet":"    function deployVault(","title":"UniAlignmentVaultFactory.deployVault is permissionless and takes an arbitrary priceValidator; the result is registered as a trusted vault that every collection factory accepts"},{"citation":"resolved","description":"CreateX derives the guarded salt from the salt's layout: keccak256(msg.sender, salt) only when the first 20 bytes equal msg.sender and byte 21 is 0x00; keccak256(msg.sender, chainid, salt) when byte 21 is 0x01; keccak256(abi.encode(salt)) for salts that do not start with the sender (and keccak256(chainid, salt) for the zero-address/0x01 form). computeVaultAddress always uses the first formula, so for any other salt it previews an address that is not where deployVault will put the vault. The deploy script builds a factory-prefixed salt (DeployMiyamints.s.sol line 268), so the shipped path is correct; integrators calling deployVault with a plain salt get a wrong preview, and a plain salt is also squattable by anyone through CreateX directly since its guarded salt does not depend on the sender.","line":103,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"salt = keccak256(\"x\") (first 20 bytes != factory). computeVaultAddress(salt) returns CreateX.computeCreate3Address(keccak256(abi.encodePacked(uint256(uint160(factory)), salt)), CREATEX); deployVault(salt, ...) actually deploys at CreateX.computeCreate3Address(keccak256(abi.encode(salt)), CREATEX). Fix: document that salts must be factory-prefixed and revert otherwise (check bytes20(salt) == bytes20(address(this)) and salt[20] == 0x00), or replicate CreateX's _guard logic.","severity":"info","snippet":"        bytes32 guardedSalt = keccak256(abi.encodePacked(uint256(uint160(address(this))), salt));","title":"computeVaultAddress only matches CreateX for salts whose first 20 bytes are the factory address"}],"hash":"73dbdaa5319fec58cce5a86cf3ee348b25ded8ad9698713a592f065873ab164c","nodeId":"bc7e5d1d-49da-401f-9632-fbf962c4aac0","outcome":"completed","summary":"The findings file is complete and structurally clean. Here is the review.\n\n## Outcome\n\nSeven findings are in `.imd-findings.json` (3 high, 2 medium, 1 low, 1 info), four with Foundry proof tests under `test/scratch/`. Bash could not start in this session (sandbox bridge-socket failure on every attempt), so I could not run `forge test` or validate the JSON with a parser. The tests were desk-checked line by line against the validator and vault code paths, and the JSON was checked with regex passes for unescaped quotes, tabs, and non-ASCII. Treat the proofs as unexecuted.\n\n## Findings\n\n- **High: swap proportion mixes raw units.** `_computeProportionFromSqrtPrice` returns amount0 / (amount0 + amount1), which is 1/(1+P) for the raw price P. A full-range position holds equal value on both sides, so the correct fraction is ~50% at any price. For INFER (P far above 1, no v3 TWAP) the clamp returns 35%, so from the second conversion on the LP add is token-limited and ~30% of every batch stays in the vault as ETH nobody can ever withdraw. With a v3 TWAP the unclamped result is ~0%.\n- **High: zRouter refund breaks every conversion.** The deployed zRouter ends each ETH-in swap by sending its entire ETH balance to the payer. The vault's collecting flag is not set around the ETH-to-token swap, so the refund re-enters `receive()` into the nonReentrant path and reverts. One wei sent to the router's open `receive()` blocks all vaults' conversions.\n- **High: v3 TWAP orientation.** The TWAP sqrt price is read in v3 token ordering but interpreted with ETH as currency0. INFER's address is below WETH's, so any WETH/INFER v3 pool with liquidity, even at the correct price, makes every conversion after the first revert.\n- **Medium: dust v3 pool DoS.** A 1-wei-liquidity v3 pool at an arbitrary tick is accepted as the reference and reverts the proportion check. Pending ETH has no other exit and the validator cannot be replaced.\n- **Medium: self-swap fees and add leftovers stranded.** Fees the vault's own swaps earn are netted into the liquidity add after collection ran, and the unmatched side of each add stays behind. Neither is credited or sweepable; tokens sit outside `pendingFeeTokens`.\n- **Low: `deployVault` is permissionless with a caller-chosen validator,** and the result is registered as a trusted vault that all collection factories accept.\n- **Info: `computeVaultAddress`** is only correct for factory-prefixed salts.\n\n## Answers to the numbered questions\n\n1. **Fee accounting holds.** Per-benefactor settlement before every share change, a single round-down per payout, and dust shares kept out of `totalShares` give sum(claims) ≤ fees distributed. Rounding loss is below `totalShares/1e36` wei per distribution. New joiners checkpoint at the current index before receiving shares, so neither contributions nor a permissionless `withdraw()` dilute or erase anyone's fees.\n2. **Reentrancy.** The collecting window only admits ETH from the PoolManager and zRouter, since the pool is hookless. The defect is outside the window: the ETH-to-token swap is unprotected and the real router does send ETH back.\n3. **Caller reward is safe.** It is carved from the batch before the swap, capped at 1%, and the vault balance always covers it. A keeper cannot split batches to earn more.\n4. **Keeper model.** No permissionless path swaps. Fee tokens convert before any share mint, so H2 holds. Tokens can be stranded above `pendingFeeTokens` (medium finding). A malicious keeper can extract nearly a whole batch by self-sandwiching with floors of 1, since the validator imposes no price bound for INFER. That is stronger than the notes' \"cannot take principal\". The validator can wrongly block conversions in two ways, covered above.\n5. **Pool key and treasury.** Set-once, hookless, validated. `protocolTreasury()` follows the registry. `depositFees` cannot strand ETH. The factory owner has no powers over deployed vaults. The only gap is permissionless vault deployment.\n6. **Proxies.**","treeHash":null,"usage":{"cachedInputTokens":1667868,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":78077,"runtime":"claude","turns":50,"wallClockMs":1056401}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1188f01303e45ecd","findings":[{"citation":"resolved","description":"_computeProportionFromSqrtPrice (lines 251-267) computes the ETH fraction as amount0/(amount0+amount1) where amount0 is wei and amount1 is alignment-token base units for 1e18 liquidity. For a full-range position amount1 = P*amount0 (P = token units per wei), so the result is 1/(1+P), which equals 50% only when the pool price is exactly 1:1. The correct answer for a full-range add is always 50% by value. The real-v4 regression test initialises its pool at 1:1 (UniAlignmentVaultRealV4.t.sol:45), which is why this never showed. Consequences in convertAndAddLiquidity (UniAlignmentVault.sol:387-427): the first batch is fine because lastTickLower/Upper are 0 and the validator returns 5e17; from the second batch on, (a) with no V3 TWAP, the mainnet INFER configuration (AUDIT-NOTES: 'INFER has no v2 or v3 pool'), the value is clamped to 35% (P>1.86) or 65% (P<0.54), the vault swaps 35% of the ETH, getLiquidityForAmounts takes min(L0,L1), and ~30% of ethToAdd (or ~30% worth of tokens in the 65% case) is left in the vault; (b) if any V3 pool with liquidity exists (legit or anyone's dust pool at the right price), the unclamped 1/(1+P) is used: for P=1e6 ethToSwap is ~1e-6 of the batch and essentially the whole batch stays behind, or the keeper's minOutTarget makes the tx revert. The leftover is in no accounting bucket: not totalPendingETH, not accFeePerShare/settledFees, not accumulatedProtocolFees, not pendingFeeTokens; there is no sweep/rescue and no owner tunables, so it is unrecoverable (AUDIT-NOTES line 108 confirms unaccounted ETH is never paid out). Also lpPositionValue/LiquidityAdded over-report what was deployed. Fix: for the vault's fixed full-range position return 5e17 unconditionally (or value-weight: ethValueOfAmount1 = FullMath.mulDiv(amount1, Q96*Q96, sqrtP*sqrtP) before summing), and additionally make the vault fold any post-add ETH leftover back into totalPendingETH (credited to the batch's benefactors) and token leftover into pendingFeeTokens so rounding/slippage residue is never stranded.","line":267,"path":"src/peripherals/UniswapVaultPriceValidator.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport { Test } from \"forge-std/Test.sol\";\nimport { UniAlignmentVault } from \"src/vaults/uni/UniAlignmentVault.sol\";\nimport { UniswapVaultPriceValidator } from \"src/peripherals/UniswapVaultPriceValidator.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 { 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 { PoolId, PoolIdLibrary } from \"v4-core/types/PoolId.sol\";\n\n/// @dev 18-decimal alignment token, 1 ETH = 1e6 tokens in the pool below.\ncontract AlignToken is ERC20 {\n    constructor() { _mint(msg.sender, 1e33); }\n    function name() public pure override returns (string memory) { return \"ALIGN\"; }\n    function symbol() public pure override returns (string memory) { return \"ALIGN\"; }\n}\n\n/// @dev zRouter stand-in: fills ETH->token exactly at the pool price (1e6 tokens per ETH), no fee, no slippage.\n///      This is the most favourable fill possible, so any ETH left in the vault is caused by the vault's own\n///      swap proportion, not by the router.\ncontract FixedPriceRouter {\n    AlignToken public immutable token;\n    constructor(AlignToken t) { token = t; }\n    receive() external payable { }\n    function swapV4(address to, bool, uint24, int24, address tokenIn, address, uint256 swapAmount, uint256 amountLimit, uint256)\n        external payable returns (uint256 amountIn, uint256 amountOut)\n    {\n        require(tokenIn == address(0) && msg.value == swapAmount, \"eth leg only\");\n        amountIn = swapAmount;\n        amountOut = swapAmount * 1e6;\n        require(amountOut >= amountLimit, \"limit\");\n        token.transfer(to, amountOut);\n    }\n}\n\n/// @notice UniswapVaultPriceValidator.calculateSwapProportion adds raw amount0 (wei) and raw amount1 (token units)\n///         and takes the ETH share of that sum. For a full-range position that ratio is 1/(1+P) (P = tokens per\n///         wei), not 50%, so for any pool whose price is not 1:1 the vault swaps the wrong fraction and the\n///         unmatched side stays in the vault, unaccounted and unrecoverable. With no V3 TWAP (the mainnet INFER\n///         case) the value is clamped to 35%, which strands ~30% of every batch after the first.\ncontract ProportionStrandsEthTest is Test {\n    using PoolIdLibrary for PoolKey;\n\n    PoolManager pm;\n    PoolModifyLiquidityTest lper;\n    AlignToken token;\n    FixedPriceRouter router;\n    UniswapVaultPriceValidator validator;\n    UniAlignmentVault vault;\n    MiyaRegistry reg;\n    PoolKey key;\n    address alice = makeAddr(\"alice\");\n\n    // sqrt(1e6) * 2^96 : 1 ETH buys 1e6 tokens\n    uint160 constant SQRT_P = 1000 * 79228162514264337593543950336;\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        lper = new PoolModifyLiquidityTest(pm);\n        token = new AlignToken();\n        key = PoolKey(Currency.wrap(address(0)), Currency.wrap(address(token)), 3000, 60, IHooks(address(0)));\n        pm.initialize(key, SQRT_P);\n\n        // Seed 1e21 liquidity: 1 ETH + 1e24 tokens at this price.\n        token.approve(address(lper), type(uint256).max);\n        vm.deal(address(this), 1000 ether);\n        lper.modifyLiquidity{ value: 5 ether }(key, IPoolManager.ModifyLiquidityParams(-887220, 887220, 1e21, 0), \"\");\n\n        reg = new MiyaRegistry(address(this), address(0xFEE), 100, 1000);\n        address[] memory toks = new address[](1);\n        toks[0] = address(token);\n        reg.addTarget(\"t\", toks);\n        reg.setConversionKeeper(address(this));\n\n        router = new FixedPriceRouter(token);\n        token.transfer(address(router), 1e30);\n\n        // Production validator; no v2/v3 factories on this chain, exactly like the INFER deployment.\n        validator = new UniswapVaultPriceValidator(address(0xBEEF), address(0), address(0), address(pm), 500, 1800);\n\n        vault = UniAlignmentVault(payable(LibClone.clone(address(new UniAlignmentVault()))));\n        vault.initialize(address(this), address(0xBEEF), address(pm), address(token), address(router), 3000, 60,\n            IVaultPriceValidator(address(validator)), IMiyaRegistry(address(reg)), 0);\n        vault.setV4PoolKey(key);\n        vm.deal(alice, 10 ether);\n    }\n\n    receive() external payable { }\n\n    function _contribute(uint256 amt) internal {\n        vm.prank(alice);\n        vault.receiveContribution{ value: amt }(Currency.wrap(address(0)), amt, alice);\n    }\n\n    /// @dev ETH the vault holds that no accounting bucket can ever pay out.\n    function _unaccountedEth() internal view returns (uint256) {\n        uint256 owed = vault.totalPendingETH() + (vault.accumulatedFees() - vault.totalFeesClaimed())\n            + vault.accumulatedProtocolFees();\n        return address(vault).balance - owed;\n    }\n\n    function test_secondConversionStrandsThirtyPercentOfBatch() public {\n        // First conversion: lastTick* are still 0 so the validator returns 50% and everything is deployed.\n        _contribute(1 ether);\n        vault.convertAndAddLiquidity(1, 1);\n        assertLt(_unaccountedEth(), 0.001 ether, \"first batch (50% path) is fully deployed\");\n        assertLt(token.balanceOf(address(vault)), 1e18, \"first batch leaves no tokens behind\");\n\n        // Second conversion: calculateSwapProportion now reads the pool and returns 1/(1+1e6) clamped to 35%.\n        uint256 prop = validator.calculateSwapProportion(\n            address(token), vault.lastTickLower(), vault.lastTickUpper(), address(pm), PoolId.unwrap(key.toId())\n        );\n        emit log_named_uint(\"proportion returned (1e18 = 100%)\", prop);\n        assertEq(prop, 35e16, \"1/(1+1e6) clamped to the 35% floor instead of 50%\");\n\n        _contribute(1 ether);\n        uint256 balBefore = address(vault).balance;\n        vault.convertAndAddLiquidity(1, 1);\n\n        uint256 stranded = _unaccountedEth();\n        emit log_named_uint(\"ETH stranded in vault after batch 2 (wei)\", stranded);\n        emit log_named_uint(\"tokens stranded in vault after batch 2\", token.balanceOf(address(vault)));\n\n        // Expected: a full-range add at the pool price uses the whole batch (50/50 by value), so at most dust\n        // (< 1% of the batch) can be left behind. Actual on this code: ~0.30 ETH of the 1 ETH batch.\n        assertLt(stranded, 0.01 ether, \"more than 1% of the batch is stranded, unclaimable by anyone\");\n        assertEq(vault.totalPendingETH(), 0);\n        balBefore; // silence\n    }\n}","reproduction":"Pool ETH/token initialised at sqrtPriceX96 = 1000*2^96 (1 ETH = 1e6 tokens, 18-dec token), production UniswapVaultPriceValidator with v2Factory=v3Factory=address(0) (INFER mainnet shape), zRouter stand-in that fills at exactly the pool price. alice contributes 1 ETH, keeper converts (50% path, ok). alice contributes 1 ETH again, keeper converts: calculateSwapProportion returns 35e16 (expected 5e17); after the call totalPendingETH=0, unclaimed fees=0, protocol fees=0, yet address(vault).balance = 299_640_000_000_000_046 wei (0.2996 ETH of the 0.9988 ETH batch, i.e. 30%), unreachable by any function. Expected: at most dust left. With a V3 pool present (any depth), proportion = 1e18/(1+1e6) ~ 1e12 and ~99.9999% of the batch is stranded or the tx reverts on minOutTarget.","severity":"critical","snippet":"        proportion = (ethAmount * 1e18) / total;","title":"calculateSwapProportion sums raw wei and raw token units, so every conversion after the first strands ~30% of the batch (or nearly all of it) in the vault with no way out"},{"citation":"resolved","description":"_queryV3PoolForFee derives price as ((sqrtPriceX96 >> 48)^2 * 1e18) >> 96 in checked arithmetic. For sqrtPriceX96 > ~9.6e43 (tick > ~694,500; MAX_TICK is 887,272) the multiplication exceeds 2^256 and the call reverts with Panic(0x11). The same expression is used for V4 pools at line 539 and for the TWAP at line 152. validatePrice (line 123) calls _getV3PriceAndLiquidity unconditionally and _getV4PriceAndLiquidity scans six candidate pools (WETH and native, 3000/60 first), accepting the first with liquidity != 0 (no depth floor, unlike MIN_V2_REFERENCE_WETH). Attack: create the Uniswap V3 WETH/INFER 0.3% pool (or a hookless v4 WETH/INFER 3000/60 pool on the same PoolManager, scanned before the vault's own 10000/200 pool) initialised at tick 800,000, mint 1 wei of in-range liquidity, leave it 30 minutes (V3 observe() with cardinality 1 extrapolates once the single observation is older than the window; the v4 path needs no wait). From then on convertAndAddLiquidity reverts for every keeper input; 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. Related, same root cause (trusting a dust pool): see the SwapProportionDeviationTooHigh finding. Fix: compute prices with FullMath.mulDiv on sqrtPriceX96 (no shift-and-square) or wrap each pool query so a failure skips that pool; require a minimum ETH-side depth for V3/V4 reference pools like the V2 floor; never let a reference pool other than the vault's own pool make validatePrice revert, only skip the cross-check.","line":414,"path":"src/peripherals/UniswapVaultPriceValidator.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport { Test } from \"forge-std/Test.sol\";\nimport { UniswapVaultPriceValidator } from \"src/peripherals/UniswapVaultPriceValidator.sol\";\nimport { TickMath } from \"v4-core/libraries/TickMath.sol\";\n\n/// @dev PoolManager stand-in: every extsload returns the same word, so getSlot0 reads sqrtPriceX96 = 2^96\n///      (1:1) and getLiquidity reads a non-zero liquidity for every pool id.\ncontract SpotPoolManager {\n    function extsload(bytes32) external pure returns (bytes32) {\n        return bytes32(uint256(79228162514264337593543950336)); // 1:1, and liquidity = low 128 bits != 0\n    }\n}\n\n/// @dev A Uniswap V3 pool anyone can create for ~0.01 ETH: 1 wei of in-range liquidity, parked at `tick`,\n///      untouched for 30 minutes so observe() extrapolates its single observation (V3 allows this with\n///      observationCardinality == 1).\ncontract DustV3Pool {\n    int24 public immutable tick;\n    address public immutable token0;\n    address public immutable token1;\n    constructor(int24 _tick, address t0, address t1) { tick = _tick; token0 = t0; token1 = t1; }\n    function slot0() external pure returns (uint160, int24, uint16, uint16, uint16, uint8, bool) {\n        return (0, 0, 0, 1, 1, 0, true);\n    }\n    function liquidity() external pure returns (uint128) { return 1; }\n    function observe(uint32[] calldata secondsAgos) external view returns (int56[] memory tc, uint160[] memory sl) {\n        tc = new int56[](2);\n        sl = new uint160[](2);\n        tc[0] = 0;\n        tc[1] = int56(tick) * int56(uint56(secondsAgos[0]));\n    }\n}\n\ncontract DustV3Factory {\n    mapping(uint24 => address) public pools;\n    function set(uint24 fee, address pool) external { pools[fee] = pool; }\n    function getPool(address, address, uint24 fee) external view returns (address) { return pools[fee]; }\n}\n\n/// @notice The validator trusts the first V3 pool with non-zero liquidity, whatever its depth. Anyone can create\n///         a dust V3 pool for the alignment token and park it at an arbitrary tick, after which every keeper\n///         conversion of every vault for that token reverts. The validator and the pool key are immutable per\n///         vault, so there is no admin remedy.\ncontract DustV3PoolBlocksConversionTest is Test {\n    address weth = address(0xBEEF);\n    address token = address(0xCAFE);\n    SpotPoolManager pm;\n    DustV3Factory v3;\n    UniswapVaultPriceValidator validator;\n\n    function setUp() public {\n        pm = new SpotPoolManager();\n        v3 = new DustV3Factory();\n        validator = new UniswapVaultPriceValidator(weth, address(0), address(v3), address(pm), 500, 1800);\n    }\n\n    /// @dev Dust pool parked at tick 800_000 (> ~694_500): (sqrtPriceX96 >> 48)^2 * 1e18 overflows uint256 in\n    ///      _queryV3PoolForFee, which validatePrice calls unconditionally. Every convertAndAddLiquidity reverts\n    ///      with a Panic(0x11), no matter what the keeper passes.\n    function test_dustPoolAtExtremeTickRevertsValidatePrice() public {\n        v3.set(3000, address(new DustV3Pool(800_000, weth, token)));\n        // Expected: a 1-wei pool is not a price reference; validatePrice tolerates it. Actual: arithmetic overflow.\n        validator.validatePrice(token, 1 ether);\n    }\n\n    /// @dev Dust pool parked at tick 300_000: its \"TWAP\" proportion is ~0 while the V4 spot proportion at 1:1 is\n    ///      50%, so calculateSwapProportion reverts SwapProportionDeviationTooHigh on every conversion after the\n    ///      first (lastTickLower/Upper are non-zero from then on).\n    function test_dustPoolAtWrongTickRevertsSwapProportion() public {\n        v3.set(3000, address(new DustV3Pool(300_000, weth, token)));\n        int24 lower = TickMath.minUsableTick(60);\n        int24 upper = TickMath.maxUsableTick(60);\n        // Expected: the dust pool is ignored and the proportion is computed from the vault's own pool.\n        // Actual: revert SwapProportionDeviationTooHigh.\n        uint256 p = validator.calculateSwapProportion(token, lower, upper, address(pm), bytes32(uint256(1)));\n        assertGt(p, 0);\n    }\n}","reproduction":"Validator(weth, v2Factory=0, v3Factory=mockFactory, poolManager=mockPM, 500, 1800). mockFactory.getPool(weth, token, 3000) returns a pool whose slot0() is unlocked, liquidity() = 1, observe([1800,0]) returns tickCumulatives [0, 800000*1800]. validator.validatePrice(token, 1 ether) -> Panic(0x11). Expected: a 1-wei pool is ignored and the call returns. Same with tick 700,000 (still overflows) or with a v4 WETH/token 3000/60 pool at that tick via line 539.","severity":"high","snippet":"            uint256 rawPrice = (sqrtScaled * sqrtScaled * 1e18) >> 96;","title":"validatePrice reverts with an arithmetic overflow for any scanned V3 or V4 pool parked above tick ~694,500; a 1-wei dust pool anyone can create blocks every conversion of every vault for that token, p"},{"citation":"resolved","description":"_getTwapSqrtPriceX96 (line 274) takes the first V3 fee tier whose pool has liquidity() != 0 and a 30-minute observation, with no depth requirement (AUDIT-NOTES acknowledges the tier-order problem only as a missed cross-check, not as a block). Anyone can create WETH/INFER at 0.3% with 1 wei of liquidity parked at, say, tick 300,000 and wait 30 minutes; the TWAP proportion is then ~0 while the V4 spot proportion is ~50% (or 35%/65% clamp band), diff > 5e16, and every convertAndAddLiquidity reverts once lastTickLower/Upper are non-zero (i.e. after the first batch). Keeper floors cannot help: the revert is in a view the keeper does not control. Maintaining the block costs the attacker one dust swap per 30-minute window if anyone pushes the tick back. Combined with the immutable validator this is an indefinite freeze of pending ETH for that token's vaults. Fix: require a minimum ETH-side liquidity depth before a V3 pool is used as a TWAP source (e.g. amount of WETH the in-range liquidity represents >= 10 ether, mirroring MIN_V2_REFERENCE_WETH), and when the cross-check fails fall back to the clamp band instead of reverting, since the keeper's minOutTarget is the real sandwich protection (M2).","line":226,"path":"src/peripherals/UniswapVaultPriceValidator.sol","reproduction":"Validator(weth, 0, mockV3Factory, mockPM(slot0 at 2^96 i.e. 1:1, liquidity != 0), 500, 1800); mockV3Factory.getPool(weth, token, 3000) -> pool with liquidity()=1 and observe returning a mean tick of 300,000. calculateSwapProportion(token, minUsableTick(60), maxUsableTick(60), mockPM, anyId) reverts SwapProportionDeviationTooHigh (test_dustPoolAtWrongTickRevertsSwapProportion in the attached file of the overflow finding). Expected: dust pool ignored, proportion computed from the vault's own pool.","severity":"high","snippet":"                if (diff > 5e16) revert SwapProportionDeviationTooHigh();","title":"A dust V3 pool at a wrong tick makes calculateSwapProportion revert SwapProportionDeviationTooHigh on every conversion after the first (no depth check on the TWAP source, revert instead of fallback)"},{"citation":"resolved","description":"deployVault has no access control and accepts any IVaultPriceValidator; the factory is a registered vault factory, so registry.registerVault succeeds and registry.isVault(vault) becomes true. ERC721DropFactory (line 34), ERC721AuctionFactory (71) and ERC1155Factory (79) accept any isVault address at launch, so the 'approved vault' set the registry is supposed to curate is open to anyone. The vault calls the validator with full trust inside convertAndAddLiquidity (lines 386-390): a hostile validator can revert at will (selective freeze of pending ETH) or return proportion 1e18/0 so _addToLpPosition reverts AmountMustBePositive, freezing all contributions forever (no rescue path), or be an upgradeable proxy that behaves until ETH accumulates. The keeper's floors do not protect against this. Who is harmed: buyers of a collection whose creator (or a phishing site) picked such a vault expecting the alignment share to reach INFER LP. Second effect: because deployVault runs as the factory, any caller's salt whose first 20 bytes are the factory address lands in the factory's CreateX namespace, so the deploy script's deterministic defaultVault salt (DeployMiyamints.s.sol:268-274) can be front-run between the factory tx and the deployVault tx; the squatter's vault then sits at the expected address, registered, with a hostile validator, and the script reverts. Fix: make deployVault onlyOwner (the deploy script already runs as owner), or at least force priceValidator == defaultPriceValidator for non-owner callers; consider recording the validator in the registry so factories can check it.","line":62,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"Attacker calls vaultFactory.deployVault(bytes32(uint256(uint160(address(vaultFactory))) << 96 | 1), INFER, 0, hostileValidator). registry.isVault(returned vault) == true. A creator launches a drop with that vault; after mints, drop.withdraw() routes the alignment cut there. Keeper calls convertAndAddLiquidity: hostileValidator.calculateSwapProportion returns 1e18 -> ethRemaining = 0 -> _addToLpPosition reverts AmountMustBePositive; with validatePrice reverting instead, same result. Expected: only the protocol can create approved vaults, or the validator must be the audited default.","severity":"medium","snippet":"    function deployVault(","title":"deployVault is permissionless and takes an arbitrary priceValidator, so anyone can register a registry-approved vault whose validator they control (and squat the deployer's salt namespace)"},{"citation":"resolved","description":"Even with a correct 50% proportion, _addToLpPosition deposits min(L0, L1) and leaves the other side's excess in the vault. The vault's own ETH->token swap goes through the same pool it LPs in, so part of that swap fee accrues to the vault's position and is netted off inside the subsequent modifyLiquidity (callerDelta = principal - feesAccrued), which lowers what the vault settles and leaves the difference as free ETH/tokens. Price impact of the swap moves the ratio off 50/50 as well. None of this residue is tracked: ETH is not in totalPendingETH/fees/protocol fees and tokens are not in pendingFeeTokens, so it is stranded (AUDIT-NOTES line 108) and pendingFeeTokens diverges from token.balanceOf(vault). Scale: with the vault at 50% of pool depth on a 1% tier, a 0.5 ETH swap leaves ~0.0025 ETH per batch; price impact adds ~(impact) of the swapped half. lpPositionValue = ethToSwap + targetTokenReceived (line 398) adds wei to token units and ignores what was actually deposited. Fix: after modifyLiquidity, credit the ETH leftover back to totalPendingETH (pro rata to the batch) or to accFeePerShare, add token leftover to pendingFeeTokens, and report the settled amounts.","line":696,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"Real PoolManager, pool at 1:1 with 50e18 vault-equivalent liquidity, 1% fee tier; alice contributes 2 ETH, keeper converts through a router that swaps on the same pool. After the call token.balanceOf(vault) - pendingFeeTokens > 0 and address(vault).balance - (totalPendingETH + unclaimed + protocol) > 0 with no function able to move either. Expected: both zero or credited.","severity":"low","snippet":"        uint128 liquidityToAdd = LiquidityAmounts.getLiquidityForAmounts(","title":"Residue from every LP add (own-swap fee accrual, slippage, min-side rounding) stays in the vault unaccounted; lpPositionValue over-reports"},{"citation":"resolved","description":"_trackBenefactorContribution reverts for any benefactor with pendingETH == 0 once 500 distinct addresses have pending ETH. Anyone can fill the array with 500 addresses x MIN_CONTRIBUTION (0.001 ETH); 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, receiveContribution reverts for every collection that has not yet contributed in this batch: Drop/Auction/1155 withdraw() catch the revert and park the cut in pendingVaultCut, so no funds are lost, but alignment delivery stalls until the keeper runs a 500-participant conversion (500 settle+share writes plus 500 pops). Fix: no array at all (iterate off a batch id: credit shares lazily per benefactor from a per-batch sharesPerEth snapshot), or at least a per-address minimum that scales with the array length.","line":335,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"500 EOAs each send 0.001 ETH to the vault (receive()). A new ERC721DropInstance calls withdraw(): vault.receiveContribution reverts TooManyConversionParticipants, VaultContributionFailed is emitted and the cut waits in pendingVaultCut. Expected: a contribution from a legitimate collection is never refused.","severity":"low","snippet":"            if (conversionParticipants.length >= MAX_CONVERSION_PARTICIPANTS) revert TooManyConversionParticipants();","title":"MAX_CONVERSION_PARTICIPANTS (500) lets 0.5 ETH from fresh addresses block every new benefactor's contribution until the keeper converts"},{"citation":"resolved","description":"The branch runs only when no V3 pool was accepted by _queryV3PoolForFee, but _getTwapSqrtPriceX96 uses the same pools with the same liquidity/observe conditions (minus the unlocked check, which cannot differ for an external view call outside a V3 swap). So whenever a TWAP exists hasV3Pool is true and the branch is skipped; when it is false the TWAP is 0. Net effect: for INFER (no v2 reference, no v3) validatePrice checks nothing beyond pool existence, and the comment 'mature pools with V3 history are now protected' does not hold. Documented so the team does not rely on it; the keeper floors are the only protection, as AUDIT-NOTES says.","line":147,"path":"src/peripherals/UniswapVaultPriceValidator.sol","reproduction":"Any token with a V3 pool: _getV3PriceAndLiquidity returns true -> branch skipped. Any token without: _getTwapSqrtPriceX96 returns 0 -> branch skipped. No input reaches _checkPriceDeviation(priceV4, priceV4Twap).","severity":"info","snippet":"        if (hasV4Pool && !v2IsReference && !hasV3Pool && priceV4 != 0) {","title":"validatePrice's V4-vs-V3-TWAP cross-check is unreachable: hasV3Pool == false implies _getTwapSqrtPriceX96 == 0"},{"citation":"resolved","description":"Q6: the implementation created in the constructor is never initialised; anyone may call initialize on it and become its owner. This is harmless: proxies are EIP-1167 clones keeping their own storage, the implementation has no selfdestruct and no delegatecall, and its only owner function (setV4PoolKey) touches only its own storage. Clone initialisation cannot be front-run or repeated: deployCreate3 + initialize + setV4PoolKey run in one transaction and _initialized / PoolKeyAlreadySet guard the rest. Note that the factory inherits Ownable but exposes no owner-only function, and computeVaultAddress (line 103) assumes CreateX's 'msg.sender-prefixed, no redeploy protection' salt format; for any other salt CreateX hashes keccak256(abi.encode(salt)) (permissionless namespace, so a third party can occupy that address first by calling CreateX directly) and the preview is wrong.","line":52,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"Call UniAlignmentVault(factory.vaultImplementation()).initialize(attacker, ...valid args...) -> succeeds, owner()==attacker, no effect on any clone. factory.computeVaultAddress(bytes32(uint256(1))) != address actually produced by deployVault(bytes32(uint256(1)), ...) (CreateX uses keccak256(abi.encode(salt)) for that salt).","severity":"info","snippet":"        vaultImplementation = address(new UniAlignmentVault());","title":"Implementation contract can be initialised by anyone (harmless), factory Ownable has no owner-only function, computeVaultAddress is wrong for salts not prefixed with the factory address"},{"citation":"resolved","description":"Q1 (holds): accFeePerShare += floor(amount*1e36/totalShares) and each payout = floor(shares*(acc-checkpoint)/1e36) with fullMulDiv, so sum of payouts <= totalShares*delta/1e36 <= amount; per-distribution rounding loss < totalShares/1e36 wei. _settleFees runs before every share change (loop in _distributeSharesAndCleanup, dust goes to an already-settled participant with acc 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 rounding: double floor loses at most totalSharesIssued/1e18 + 1 units per participant; accumulatedDustShares can never reach the 1e18 threshold (dust per batch <= ~500 units), so dust liquidity simply earns yield for all holders. Q2 (holds): the collecting flag wraps PoolManager.take(ETH) and the token->ETH zRouter leg; the pool is hookless and the token is registry-approved, so no third-party code runs inside those windows and only PoolManager/zRouter ETH can arrive; 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. Q3 (holds): reward = min(0.0012 ETH, 1% of ethPending) is carved out of the batch before swap/LP, so the vault never pays from fee balances and repeated conversions cannot make it insolvent; a rejected reward is distributed as fees. Q4: no permissionless swap path remains (claims only collect); fee tokens convert before any share mint (H2 holds); pendingFeeTokens can diverge from held tokens only through the LP residue described in the low finding; a bad keeper is bounded as AUDIT-NOTES states, but the validator can be made to block it by anyone (high findings). Q5 (holds): setV4PoolKey is set-once, hookless, validated, 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.","line":502,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"No failing input for these items; see the other findings for the defects found.","severity":"info","snippet":"        accFeePerShare += FixedPointMathLib.fullMulDiv(amount, ACC_PRECISION, totalShares); // round down: favors vault","title":"Answers to questions 1-6 where nothing is wrong: fee accounting conservation, reentrancy window, caller reward, keeper limits, pool key / treasury"}],"hash":"8b222a0439f6baf41bf2a7622db0a15463bf88fd000e6321b91f73d9f52fd99b","nodeId":"0e49ddc4-0256-4f28-8c4b-e3c48fc18ef7","outcome":"completed","summary":"The review is complete and the findings file is written. Here is the recap.\n\n**Outcome.** The vault's fee accounting, reentrancy window, caller reward, keeper gating and pool-key handling all hold up (questions 1, 2, 3, 5, 6 answered as clean, with the argument recorded in an info entry). The defects are in the price validator and the factory. Nine entries are in `.imd-findings.json`, three with Foundry proofs under `test/scratch/` that fail on the current code.\n\n**Critical: swap proportion mixes units.** The validator's `_computeProportionFromSqrtPrice` divides raw wei by raw wei plus raw token units, giving 1/(1+P) instead of 50% for a full-range add. The existing real-v4 test hides it by initialising its pool at 1:1. On the mainnet INFER shape (no V3 pool) the value clamps to 35%, so every conversion after the first leaves about 30% of the batch in the vault with no accounting bucket and no sweep. Proof: pool at 1 ETH = 1e6 tokens, two 1 ETH batches, 0.2996 ETH stranded after the second. If any V3 pool for the token ever exists, the unclamped value strands nearly the whole batch or reverts the keeper.\n\n**High: dust pools block every conversion, with no admin remedy.** The validator accepts the first V3 or V4 pool with nonzero liquidity, no depth floor. A 1-wei pool parked above tick ~694,500 overflows the shift-and-square price math and makes `validatePrice` panic. A dust pool at a merely wrong tick makes `calculateSwapProportion` revert on the deviation check after the first batch. Validator and pool key are immutable per vault, so pending ETH is frozen for as long as the attacker keeps the tick. Both have proofs in the second scratch file.\n\n**Medium: `deployVault` is permissionless with a caller-chosen validator**, so anyone can register a registry-approved vault that collection factories accept, and a hostile validator can freeze that vault's contributions. The deployer's deterministic default-vault salt can also be squatted between the factory and deployVault transactions.\n\n**Low and info.** LP-add residue (own-swap fee accrual, slippage, min-side rounding) is unaccounted and makes `pendingFeeTokens` diverge from held tokens. The 500-participant cap lets 0.5 ETH of dust stall contributions until the keeper converts, though instances catch the revert. The V4-versus-V3-TWAP branch in `validatePrice` is unreachable. The implementation can be initialised by a third party, harmlessly.\n\n**Limits.** No helpers ran and no static analysis tool was available. Passes covered the math, boundary and numerical-gap guides over the three in-scope files, plus the registry, instance payment paths, deploy script and v4 libraries they depend on. CreateX source is not vendored, so the salt-namespace detail in the medium finding rests on CreateX's documented behaviour, which the deploy script's own salt format also reflects.","treeHash":null,"usage":{"cachedInputTokens":1572645,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":52935,"runtime":"claude","turns":32,"wallClockMs":843511}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fb26479062458645","findings":[{"citation":"resolved","description":"_computeProportionFromSqrtPrice values a 1e18-liquidity full-range position with LiquidityAmounts.getAmountsForLiquidity and then divides amount0 (ETH wei) by amount0 + amount1 (ETH wei + token wei). The two sides are in different units, so 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. For a full-range position the correct answer is ~50% at any price (the position holds equal value on both sides); 1/(1+P) only equals ~50% when P is within about [0.54, 1.86] raw. The first conversion is unaffected (lastTickLower == lastTickUpper == 0 returns 5e17), but UniAlignmentVault._addToLpPosition sets lastTickLower/Upper to the full range, so from the second conversion on the vault calls this formula. Consequences: (1) No v3 pool for the token (INFER on mainnet today, DEPLOY.md): the wrong value is clamped to 35% (cheap token) or 65% (expensive token). The vault swaps 35% of ethToAdd into tokens and offers the remaining 65% as ETH; LiquidityAmounts.getLiquidityForAmounts takes the min of the two sides, so the position absorbs ~35% ETH + all tokens and ~30% of the batch is left in the vault as ETH that no accounting entry covers (totalEthLocked += ethAdded still records the full amount). It is never paid as fees, never re-queued, and there is no sweep. Benefactors get ~30% fewer LP shares than their ETH bought. (2) With a v3 WETH/token pool that has a 30-minute TWAP (any third party can create one, see the medium finding), the clamp is skipped: both spot and TWAP proportions are ~1/(1+P), their difference is far below 5e16, and the raw proportion is used. For P = 1e5 the vault swaps 0.001% of the batch, adds a negligible position and leaves 99.99% of the batch stranded; for P large enough that ethToSwap rounds to 0 the vault asks zRouter to swap 0 wei, which either reverts in the router or returns 0 tokens and then reverts in _addToLpPosition (AmountMustBePositive), so conversions are blocked. (3) The 5% spot-vs-TWAP deviation guard is dead code for any token whose raw price is far from 1: a 10x price move changes 1/(1+P) by less than 1e-6 when P ~ 1e6. The validator's own comment ('Full-range positions at unmanipulated prices are naturally in this range') shows the formula was believed to be value-based. Existing tests never see this: the real-v4 tests use MockVaultPriceValidator (fixed 5e17) or a 1:1 pool. Fix: value both sides at the price before dividing (e.g. ethValue = amount0, tokenValueInEth = amount1 * 2^192 / sqrtPriceX96^2 via FullMath, proportion = ethValue*1e18/(ethValue+tokenValueInEth)), or simply return 5e17 for a full-range position since the vault only ever LPs full range; and have the vault credit any ETH/token residue of _addToLpPosition back to totalPendingETH / pendingFeeTokens instead of leaving it unaccounted.","line":267,"path":"src/peripherals/UniswapVaultPriceValidator.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport { Test } from \"forge-std/Test.sol\";\nimport { UniswapVaultPriceValidator } from \"src/peripherals/UniswapVaultPriceValidator.sol\";\nimport { PoolManager } from \"v4-core/PoolManager.sol\";\nimport { PoolKey } from \"v4-core/types/PoolKey.sol\";\nimport { PoolId, PoolIdLibrary } from \"v4-core/types/PoolId.sol\";\nimport { Currency } from \"v4-core/types/Currency.sol\";\nimport { IHooks } from \"v4-core/interfaces/IHooks.sol\";\nimport { TickMath } from \"v4-core/libraries/TickMath.sol\";\n\n/// @notice UniswapVaultPriceValidator.calculateSwapProportion divides raw token-1 units by raw token-0 units\n///         (`ethAmount * 1e18 / (amount0 + amount1)`), so for a full-range position it returns ~1/(1+P) where P is\n///         the raw price (token wei per ETH wei) instead of the value split of the position, which is ~50% at any\n///         price. With no v3 TWAP the result is clamped to [35%, 65%]; the vault then swaps 35% (or 65%) of each\n///         batch and about 30% of the batch cannot be placed in the full-range position and stays in the vault,\n///         unaccounted and unsweepable.\n/// @dev PoolManager pins solc 0.8.26, so this file compiles in that unit (same as test/vaults/UniAlignmentVaultRealV4.t.sol).\ncontract SwapProportionUnitsTest is Test {\n    using PoolIdLibrary for PoolKey;\n\n    PoolManager pm;\n    UniswapVaultPriceValidator validator;\n    address weth = address(0xCAFE);\n    address token = address(0xBEEF); // sorts after native ETH (address(0)); never called by the validator\n    uint24 constant FEE = 10000; // mainnet config: zRouterFee 10000 / tickSpacing 200\n    int24 constant TICK_SPACING = 200;\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        // No v2 factory, no v3 factory (as for INFER on mainnet today): the TWAP cross-check is skipped and the\n        // proportion is clamped to [35%, 65%].\n        validator = new UniswapVaultPriceValidator(weth, address(0), address(0), address(pm), 500, 1800);\n    }\n\n    function _initPool(uint160 sqrtPriceX96) internal returns (bytes32 poolId) {\n        PoolKey memory key = PoolKey(\n            Currency.wrap(address(0)), Currency.wrap(token), FEE, TICK_SPACING, IHooks(address(0))\n        );\n        pm.initialize(key, sqrtPriceX96);\n        poolId = PoolId.unwrap(key.toId());\n    }\n\n    function _proportion(bytes32 poolId) internal view returns (uint256) {\n        return validator.calculateSwapProportion(\n            token,\n            TickMath.minUsableTick(TICK_SPACING),\n            TickMath.maxUsableTick(TICK_SPACING),\n            address(pm),\n            poolId\n        );\n    }\n\n    /// 1 ETH = 1e6 tokens (18 decimals): a full-range position holds equal VALUE on both sides, so the vault must\n    /// swap ~50% of the batch. Actual: 1/(1+1e6) ~ 0 -> clamped to 35%.\n    function test_fullRangeProportionIsValueNeutral_cheapToken() public {\n        // sqrt(1e6) * 2^96\n        bytes32 poolId = _initPool(uint160(1000) << 96);\n        uint256 p = _proportion(poolId);\n        assertApproxEqRel(p, 5e17, 5e16, \"swap proportion must be ~50% for a full-range position\");\n    }\n\n    /// 1 token = 1e6 ETH in raw units (e.g. a low-decimal or very expensive token): same position, same 50% value\n    /// split. Actual: 1/(1+1e-6) ~ 1 -> clamped to 65%; the vault then over-buys tokens and ~30% of the batch is\n    /// stranded as tokens that no path (pendingFeeTokens excluded) ever moves.\n    function test_fullRangeProportionIsValueNeutral_expensiveToken() public {\n        // 2^96 / sqrt(1e6)\n        bytes32 poolId = _initPool(uint160((uint256(1) << 96) / 1000));\n        uint256 p = _proportion(poolId);\n        assertApproxEqRel(p, 5e17, 5e16, \"swap proportion must be ~50% for a full-range position\");\n    }\n}","reproduction":"State: hookless native-ETH/token v4 pool at the vault's tier initialised at 1 ETH = 1e6 tokens (sqrtPriceX96 = 1000 << 96), 18-decimal token, no v2 pair, no v3 pool (mainnet INFER). Vault has already done one conversion (lastTickLower = -887200, lastTickUpper = 887200 at tickSpacing 200). Alice contributes 1 ETH; keeper calls convertAndAddLiquidity(minOut, 1). Expected: priceValidator.calculateSwapProportion returns ~5e17, the vault swaps ~0.5 ETH and essentially all of ethToAdd ends up in the position. Actual: getAmountsForLiquidity(1000<<96, full range, 1e18) gives amount0 ~ 1e15 and amount1 ~ 1e21, proportion = 1e15*1e18/(1e15+1e21) ~ 1e12 (1e-6) -> clamped to 35e16. ethToSwap = 0.35*0.9988 ETH, tokens received ~ 0.35*0.9988*1e6*(1-fee-slippage); _addToLpPosition(amount0 = 0.65*0.9988 ETH, amount1 = tokens) -> liquidity limited by the token side -> position takes ~0.35 ETH + all tokens; ~0.30 ETH stays in address(vault).balance with totalPendingETH = 0, settledFees unchanged, accumulatedProtocolFees unchanged: unreachable by claimFees, withdrawProtocolFees or any other function. Repeat per batch: 30% of all alignment ETH after the first batch is dead. The proof test below calls the validator directly against a real PoolManager and asserts the proportion is within 5% of 50% for a cheap (1e6 tokens/ETH) and an expensive (1e-6 tokens/ETH) full-range pool; on this code it returns 35e16 and 65e16. NOTE: the reviewer's shell sandbox failed to start for the whole session, so this test was written but could not be executed by the reviewer; it follows test/vaults/UniAlignmentVaultRealV4.t.sol for the 0.8.26 PoolManager unit.","severity":"high","snippet":"        proportion = (ethAmount * 1e18) / total;","title":"calculateSwapProportion mixes raw ETH and token units, so every conversion after the first strands ~30% of the batch (or nearly all of it once a v3 pool exists)"},{"citation":"resolved","description":"The v2-side dust problem was fixed (MIN_V2_REFERENCE_WETH), but the v3 side has no depth floor. _queryV3PoolForFee / _getTwapSqrtPriceX96 accept the first fee tier (3000, 500, 10000) whose pool has liquidity() != 0 and whose observe([1800, 0]) succeeds. 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 pool's current tick (one slot, extrapolated with the current tick), i.e. a number the pool creator chooses and can reset with any swap. So for ~0.01 ETH anyone can create WETH/token at 0.3%, mint 1 wei of liquidity across the current tick at an arbitrary price, wait 30 minutes, and from then on the validator's 'v3 TWAP' is that arbitrary price. Effects on a keeper conversion: (a) validatePrice: if the token has a v2 pair holding >= 10 WETH (v2IsReference), priceV2 vs the fake priceV3 exceeds maxPriceDeviationBps (500) -> PriceDeviationTooHigh, every convertAndAddLiquidity reverts. The attacker can also supply the v2 reference themselves (10 WETH + tokens, withdrawable later, so capital is locked not spent). (b) calculateSwapProportion: with a TWAP present the clamp is skipped and the spot proportion is compared to the fake-TWAP proportion; whenever the two 1/(1+P) values differ by more than 5e16 (tokens whose raw price is within roughly [0.05, 20]) -> SwapProportionDeviationTooHigh. In both cases the keeper has no override: minOutTarget does not bypass the validator, the validator address is fixed at initialize, and the factory owner has no setter. Pending ETH (totalPendingETH) and pendingFeeTokens stay locked until the attacker lets the dust pool's tick drift back, and the attacker can re-set it with one swap. AUDIT-NOTES only records that a dust pool 'wins' the tier selection and weakens the check; it does not record that it blocks conversions, and the DEPLOY.md note says dust pairs 'no longer block conversion'. Fix: require a minimum WETH depth for a v3 pool to count (mirror MIN_V2_REFERENCE_WETH by reading WETH.balanceOf(pool) or requiring observationCardinality > 1 and a liquidity floor), and/or let the keeper pass a flag that downgrades a failed cross-check to an event, since the keeper's floors are already the stated safety mechanism.","line":138,"path":"src/peripherals/UniswapVaultPriceValidator.sol","reproduction":"State: alignment token T with a Uniswap v2 WETH/T pair holding 50 WETH (v2IsReference true), no v3 pool, vault at the v4 tier with 2 ETH pending. Attacker: IUniswapV3Factory.createPool(WETH, T, 3000) and initialize at a sqrtPrice 50% below the v2 price, mint() 1 wei liquidity in a tick range containing the current tick (a few wei of WETH and T). Wait 1800 s. Keeper calls convertAndAddLiquidity(minOut, 1). Expected: conversion proceeds (the real market is the v2 pair and the v4 pool, which agree). Actual: _getV3PriceAndLiquidity -> fee tier 3000 -> slot0 unlocked, liquidity() == 1 != 0, observe([1800,0]) succeeds with meanTick = the attacker's tick -> priceV3 = 0.5 * priceV2 -> _checkPriceDeviation: diff = 0.5p, avg = 0.75p, deviation = 6666 bps > 500 -> revert PriceDeviationTooHigh. Repeats for every conversion while the dust pool's tick stays off; the attacker resets it with any swap. Cost to attacker: pool creation + 1 wei mint (~0.01 ETH gas). Same setup without a v2 pair but with T's raw price near 1 (e.g. an 18-decimal token worth ~1 ETH): calculateSwapProportion -> twapProportion (fake tick) vs spotProportion differ by > 5e16 -> SwapProportionDeviationTooHigh.","severity":"medium","snippet":"        if (v2IsReference && hasV3Pool) {\n            (bool isAcceptable, ) = _checkPriceDeviation(priceV2, priceV3);\n            if (!isAcceptable) revert PriceDeviationTooHigh();\n        }","title":"A dust v3 pool with a 30-minute-old observation makes validatePrice / calculateSwapProportion revert and blocks every keeper conversion for that token"},{"citation":"resolved","description":"Independently of the proportion formula, convertAndAddLiquidity buys tokens in the same pool it then LPs into: the swap fee removes ~fee of the token side, and the price impact moves the ratio the full-range position requires (read from slot0 in _addToLpPosition) by roughly twice the average slippage. The two effects never cancel exactly, and getLiquidityForAmounts takes the minimum of the two sides, so one side is always left over (ETH when fee > impact, tokens when impact > fee); _settleLPDelta pays exactly the modifyLiquidity delta and the difference 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 added to pendingFeeTokens, so _convertPendingFeeTokens never sells them. There is no sweep or rescue function and the owner (factory) has no passthrough. Additionally the vault's own swap earns LP fees for its existing position between _claimVaultFees and _addToLpPosition in the same transaction; modifyLiquidity nets those fees into the add delta, so they are also absorbed as unaccounted surplus instead of being credited through _creditYield. AUDIT-NOTES acknowledges only 'ETH sent by anyone other than via contributions/collection (e.g. selfdestruct)' as unaccounted surplus; the vault produces this surplus itself on every batch. Fix: after _addToLpPosition, measure the unused amounts (ethToAdd - ETH actually settled; tokens received - tokens actually settled, from the returned delta) and credit the ETH to totalPendingETH for the next batch (or to _distributeFees) and the tokens to pendingFeeTokens.","line":696,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"State: 1% tier pool (mainnet config) 1:1, 100 ETH and 100 tokens of full-range liquidity, correct 50% proportion (first conversion). Alice contributes 1 ETH (ethToAdd = 0.9988 ETH after the reward). Swap 0.4994 ETH: fee 0.004994 ETH stays out of the reserves, 0.494406 ETH enters, tokens out = 100 - 100*100/100.494406 = 0.49199; new spot = 99.50801/100.494406 = 0.99018 tokens per ETH. A full-range add of 0.4994 ETH at that price needs 0.4994*0.99018 = 0.49450 tokens but only 0.49199 are held -> token side binds -> ETH placed = 0.49199/0.99018 = 0.49687 ETH; leftover = 0.4994 - 0.49687 = 0.00253 ETH (0.25% of the batch) remains in address(vault).balance with no accounting entry (with a larger batch relative to depth the sign flips and tokens are left instead, outside pendingFeeTokens). Expected: the whole of ethToAdd is either in the position or credited back to benefactors; actual: ~0.25% per conversion is dead at the 1% tier and it compounds over the vault's life. With the second-batch 35%/65% clamp (high finding) the same mechanism leaves ~30%.","severity":"low","snippet":"        uint128 liquidityToAdd = LiquidityAmounts.getLiquidityForAmounts(\n            sqrtPriceX96, sqrtPriceAX96, sqrtPriceBX96, amount0, amount1\n        );","title":"LP residue from the swap-then-add sequence is left unaccounted in the vault on every conversion (ETH or tokens), with no path to recover it"},{"citation":"resolved","description":"UniAlignmentVaultFactory.deployVault has no access control and forwards a caller-supplied priceValidator to initialize(); the only requirement is that the token is in an active target. The factory then calls registry.registerVault(vault), so the vault becomes isVault == true, which is the sole check ERC721DropFactory, ERC721AuctionFactory and ERC1155Factory apply to a collection's chosen vault. The vault's validatePrice and calculateSwapProportion are external view calls to that address with no fallback and no setter, so a validator that reverts (or returns a proportion of 0 or 1e18, which makes _addToLpPosition revert with AmountMustBePositive) makes convertAndAddLiquidity impossible for the vault's lifetime. receiveContribution still accepts ETH and there is no withdrawal path for totalPendingETH, so every alignment share any collection routes to that vault is locked permanently. This is not a bypass of an owner power, but the registry's isVault flag (and anything that lists registry vaults, e.g. the app's vault picker) presents such a vault as protocol-vetted while its liveness depends on an unvetted third-party contract. Fix: either restrict deployVault to the factory owner / an allowlist of validators, or drop the custom validator parameter and always use defaultPriceValidator (the deploy script only ever passes address(0)).","line":66,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"Attacker deploys `contract Brick { function validatePrice(address,uint256) external pure { revert(); } function calculateSwapProportion(address,int24,int24,address,bytes32) external pure returns (uint256) { return 5e17; } function quoteEthForTokens(address,uint256) external pure returns (uint256) { return 0; } }` and calls factory.deployVault(salt, INFER, 0, IVaultPriceValidator(brick)). Expected: a stranger cannot mint a protocol-registered vault with arbitrary liveness dependencies. Actual: deployVault succeeds, registry.isVault(vault) == true. An artist launches a drop with vault = that address (passes the factories' isVault check); 10 ETH of sales route 1 ETH of alignment share into the vault via receiveContribution. Keeper calls convertAndAddLiquidity(1, 1) -> priceValidator.validatePrice reverts -> conversion reverts; convertFeeTokens does nothing (no LP); claimFees reverts NoShares. The 1 ETH is held by the vault forever (no function returns pending ETH, no owner passthrough, no validator setter).","severity":"low","snippet":"        IVaultPriceValidator priceValidator","title":"deployVault is permissionless and accepts any priceValidator, so anyone can register a vault in MiyaRegistry whose conversions can never succeed; alignment ETH routed to it is locked forever"},{"citation":"resolved","description":"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 proportion, not the price. A keeper that buys tokens in the pool, calls convertAndAddLiquidity(1, 1) and sells back in the same block receives most of the batch's ETH as the price impact of the vault's buy. AUDIT-NOTES says a bad keeper 'can pass low floors and lose value on conversions ... It cannot take principal'; the first clause is right, the second is not: the value lost is the batch principal and the keeper is the counterparty who receives it. The same applies to pendingFeeTokens via convertFeeTokens. Recommended documentation/operational controls: state it as 'the keeper can extract up to the whole batch', run the keeper from a multisig-controlled or audited bot, and consider an on-chain floor derived from the pool's own recent price (e.g. a v4 TWAP via a lightweight oracle hook is excluded by the hookless requirement, so a registry-set maximum price-impact bps per conversion using the pool's pre- and post-swap sqrtPrice is the simplest in-vault guard).","line":369,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"State: INFER/ETH 1% v4 pool with 50 ETH depth, vault with 10 ETH pending. Keeper (or whoever holds its key) in one bundle: (1) buys 20 ETH of INFER from the pool; (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 paid above fair value; the vault's position is worth several ETH less than the 10 ETH contributed. No on-chain guard fires: validatePrice has no reference pools for INFER and calculateSwapProportion only clamps the split.","severity":"info","snippet":"        if (minOutTarget == 0) revert AmountMustBePositive();","title":"AUDIT-NOTES keeper trust statement understates the keeper's power: a self-sandwiched conversion moves batch principal to the keeper, not just 'loses value'"},{"citation":"resolved","description":"UniAlignmentVault has no constructor and _initialized defaults to false on the implementation, so any address can call initialize() on vaultImplementation and become its owner, then setV4PoolKey on it. Because the proxies are EIP-1167 clones (delegatecall into separate storage) this does not affect any deployed vault, the implementation holds no funds, and it contains no selfdestruct or delegatecall, so it cannot be destroyed or repointed. Reported for completeness of question 6; optionally add a constructor that sets _initialized = true on the implementation.","line":52,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"Anyone calls UniAlignmentVault(factory.vaultImplementation()).initialize(attacker, weth, pm, token, zRouter, fee, ts, validator, registry, 0) -> succeeds, implementation.owner() == attacker. Deployed clones: owner() still == factory, storage unchanged; attacker can do nothing further of consequence.","severity":"info","snippet":"        vaultImplementation = address(new UniAlignmentVault());","title":"Vault implementation contract can be initialised by anyone (harmless to clones)"}],"hash":"b5c1882d147aefda9c2be0f9ed11f3992f6bccf03e135b58a595ee765a254705","nodeId":"3cf5f053-cbc2-4745-8407-4778d48d46e1","outcome":"completed","summary":"Review complete. The findings file holds six entries, one high, one medium, two low, two info. One limitation first: the Bash sandbox refused to start on every attempt for the whole session (\"Failed to create bridge sockets\"), so forge never ran. The proof test exists at test/scratch/SwapProportionUnits.t.sol and is embedded in the high finding, but I could not execute it. It is modelled line by line on the existing real-PoolManager test so it should compile in the same 0.8.26 unit, and the expected failing values (35e16 and 65e16 against an asserted ~5e17) follow directly from the formula.\n\n**Answers to the six questions**\n\n1. **Fee accounting holds.** Each payout is `shares × Δacc / 1e36` rounded down once, settled before every share change, including the dust grant to the largest contributor (already settled in the loop). Sum of claims is bounded by fees distributed, and a re-contribution or a permissionless drop withdraw only adds shares at the new batch rate without touching settled fees. Dust shares never reach the 1e18 threshold in practice, which is harmless. The one accounting gap is not in fees but in principal: see finding 3 below.\n\n2. **Reentrancy is sound within the collecting window.** Only the PoolManager (hookless pool), the alignment token and zRouter execute inside it; no third party can inject a call. The lead I could not close is zRouter itself: any ETH refund from the ETH-to-token leg in `_doSwapAndLP` would hit the unguarded receive path and revert the conversion. zRouter source is not vendored, so this stays unverified.\n\n3. **Reward is bounded.** Keeper extraction is capped at 1% of each batch and cannot cause insolvency; the ETH is carved out before the swap.\n\n4. **Keeper model.** No permissionless path swaps; fee tokens always convert before any share mint. Tokens can be stranded, but not through `pendingFeeTokens` divergence: through LP residue (finding 3) and massively through the proportion bug (finding 1). The validator can wrongly block a conversion via a dust v3 pool (finding 2). The notes' claim that a bad keeper \"cannot take principal\" is wrong: a self-sandwich hands the batch to the keeper (info finding).\n\n5. **Pool key, treasury, depositFees are safe.** Set-once key with hook check, no factory passthrough, treasury follows the registry, donation requires shares. The only soft spot is that `deployVault` is permissionless with a caller-chosen validator and the registry marks the result as a vault (low finding).\n\n6. **Proxies are safe.** Clone and initialise happen in one tx, re-init reverts, the implementation has no selfdestruct. Anyone can initialise the implementation, which is harmless (info finding).\n\n**Findings written**\n\n- **High.** `calculateSwapProportion` divides ETH wei by ETH wei plus token wei, so for a full-range position it returns ~1/(1+P) in raw units instead of ~50%. From the second conversion on, INFER-like tokens hit the 35%/65% clamp and about 30% of every batch stays in the vault with no accounting entry and no sweep. If anyone creates a v3 pool, the clamp is skipped and nearly the whole batch is stranded or the conversion reverts. The 5% TWAP guard is dead for such tokens.\n- **Medium.** A 1-wei-liquidity v3 pool with a 30-minute-old single observation is accepted as the v3 TWAP; combined with a 10+ WETH v2 pair it makes `validatePrice` revert for every conversion, and near a raw price of 1 it trips the proportion deviation check. Cost is pool creation gas.\n- **Low.** Swap-then-add always leaves one side of the LP add unused (about 0.25% of a batch at the 1% tier); the residue is never credited or sweepable.\n- **Low.** Anyone can register a vault with a bricking validator; alignment ETH routed to it is locked forever.\n- **Info.** Keeper trust statement understated; implementation initialisable.\n\n**Not reported as findings.** A 500-participant conversion is roughly 28M gas by hand count, under the mainnet limit and just under Arbitrum-style 32M caps, but I could not measure","treeHash":null,"usage":{"cachedInputTokens":2441923,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":62723,"runtime":"claude","turns":55,"wallClockMs":1038088}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1f9cb1750ebf47ed","findings":[{"citation":"resolved","description":"_computeProportionFromSqrtPrice (lines 242-270) takes the raw amount0/amount1 that 1e18 units of liquidity hold at the pool price and returns ethAmount / (amount0 + amount1). amount0 is ETH wei and amount1 is token base units, so for the vault's full-range position the result is 1/(1+P) where P is the raw token-per-ETH price, not the value share. A full-range position is ~50% ETH by value at every price, so the vault must swap ~50% of a batch regardless of price. The formula only returns ~50% when the token trades at exactly 1:1 with ETH in raw units (which is the price every repo test uses: the real-v4 test initialises the pool at 1:1 and uses MockVaultPriceValidator, so the real validator never ran against the vault). The first conversion is unaffected (lastTickLower == lastTickUpper == 0 returns 5e17, line 192). From the second conversion on, UniAlignmentVault.convertAndAddLiquidity passes the full-range ticks and: (a) with no v3 TWAP (INFER per AUDIT-NOTES.md) the result is clamped to 35e16 if P > 1.857 or 65e16 if P < 0.538 (lines 233-234); (b) with any v3 TWAP it is the unclamped 1/(1+P). UniAlignmentVault._doSwapAndLP then swaps only that share and _addToLpPosition (line 696) takes min(liquidity0, liquidity1) and settles only what that liquidity needs, so the surplus side stays in the vault balance. Nothing accounts for it: it is not pendingETH, not fees, not pendingFeeTokens, and AUDIT-NOTES.md states unaccounted ETH 'is never paid out'. Shares are still minted for the whole batch, so the batch's benefactors earn LP yield on ~70% of what they contributed, the alignment token gets ~70% of the intended liquidity, and the rest is locked forever. Fix: compute the proportion in a common unit (value the token leg at the pool price, i.e. amount1 * price or amount0 * price) or, since the vault only ever opens full-range positions, return 5e17 for full-range ticks; add a non-1:1 price to the real-v4 test.","line":267,"path":"src/peripherals/UniswapVaultPriceValidator.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {UniswapVaultPriceValidator} from \"src/peripherals/UniswapVaultPriceValidator.sol\";\nimport {TickMath} from \"v4-core/libraries/TickMath.sol\";\n\n/// @dev Minimal PoolManager stand-in: StateLibrary.getSlot0 reads `extsload(slot)` and takes the low 160 bits\n///      as sqrtPriceX96. Every slot returns the configured value, which is all calculateSwapProportion reads.\ncontract SlotPoolManager {\n    bytes32 internal _slot0;\n\n    function setSqrtPrice(uint160 sqrtPriceX96) external {\n        _slot0 = bytes32(uint256(sqrtPriceX96));\n    }\n\n    function extsload(bytes32) external view returns (bytes32) {\n        return _slot0;\n    }\n}\n\n/// @notice UniswapVaultPriceValidator.calculateSwapProportion mixes ETH wei and raw token units: for a full-range\n///         position it returns ethUnits / (ethUnits + tokenUnits) = 1 / (1 + P) where P is the raw token/ETH price,\n///         not the value share. A full-range position is always ~50% ETH by value, so the vault must swap ~50% of a\n///         batch. For any token not priced at exactly 1:1 (raw) with ETH the validator returns the clamp edge (35% or\n///         65%), and UniAlignmentVault._addToLpPosition then takes min(liq0, liq1): ~30% of every batch after the\n///         first is left in the vault, unaccounted and unrecoverable.\ncontract ValidatorProportionUnitsTest is Test {\n    uint256 internal constant Q96 = 2 ** 96;\n    address internal constant TOKEN = address(0x1234); // any nonzero address: ETH (address(0)) is currency0\n\n    SlotPoolManager internal pm;\n    UniswapVaultPriceValidator internal validator;\n    int24 internal tickLower;\n    int24 internal tickUpper;\n\n    function setUp() public {\n        pm = new SlotPoolManager();\n        // v2 / v3 factories have no code here -> no TWAP source, the validator takes its clamp path (as for INFER,\n        // which has no v2 or v3 pool per AUDIT-NOTES.md).\n        validator = new UniswapVaultPriceValidator(\n            address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2), address(0x2222), address(0x3333), address(pm), 500, 1800\n        );\n        tickLower = TickMath.minUsableTick(60);\n        tickUpper = TickMath.maxUsableTick(60);\n    }\n\n    function _proportion(uint160 sqrtPriceX96) internal returns (uint256) {\n        pm.setSqrtPrice(sqrtPriceX96);\n        return validator.calculateSwapProportion(TOKEN, tickLower, tickUpper, address(pm), bytes32(uint256(1)));\n    }\n\n    /// 1 ETH = 10_000 tokens (both 18 decimals): sqrtPriceX96 = 100 * 2^96.\n    /// Current code: raw proportion ~1e14 (0.01%), clamped to 35e16. Expected: ~5e17.\n    function test_fullRangeProportionIsHalfWhenTokenIsCheap() public {\n        uint256 p = _proportion(uint160(100 * Q96));\n        assertApproxEqRel(p, 5e17, 0.1e18, \"full-range LP is 50% ETH by value at any price\");\n    }\n\n    /// 1 token = 100 ETH (raw): sqrtPriceX96 = 2^96 / 100.\n    /// Current code: raw proportion ~0.9999, clamped to 65e16. Expected: ~5e17.\n    function test_fullRangeProportionIsHalfWhenTokenIsExpensive() public {\n        uint256 p = _proportion(uint160(Q96 / 100));\n        assertApproxEqRel(p, 5e17, 0.1e18, \"full-range LP is 50% ETH by value at any price\");\n    }\n}","reproduction":"Validator level (proof test): v4 slot0 sqrtPriceX96 = 100 * 2^96 (1 ETH = 10_000 tokens, both 18 decimals), ticks = minUsableTick(60)/maxUsableTick(60), no v3 pool. Expected proportion ~5e17. Actual: amount0 = 1e16, amount1 = 1e20, proportion = 1e16*1e18/(1e20+1e16) ~ 9.999e13, clamped to 35e16. With sqrtPriceX96 = 2^96/100 the actual is 65e16. Vault level: a second batch of 10 ETH at that price, callerReward 0.0012 ETH, ethToAdd 9.9988 ETH; ethToSwap = 0.35 * 9.9988 = 3.4996 ETH buys ~34,891 tokens (0.3% fee); ethRemaining = 6.4992 ETH. getLiquidityForAmounts: liquidity1 (token side) ~ 34,891e18/100 units < liquidity0 ~ 6.4992e18*100 units, so the position takes the tokens plus ~3.489 ETH; ~3.01 ETH (30% of the batch) stays in address(vault).balance with no owner and no path out. With a v3 TWAP present, proportion = ~1e14, ethToSwap = 0.001 ETH, ~99.98% of the batch stays idle.","severity":"high","snippet":"        proportion = (ethAmount * 1e18) / total;","title":"calculateSwapProportion mixes ETH wei with raw token units: every conversion after the first strands ~30% of the batch ETH (or tokens) in the vault, unaccounted and unrecoverable"},{"citation":"resolved","description":"calculateSwapProportion is called on every UniAlignmentVault.convertAndAddLiquidity (line 387) and reverts when the v4 spot proportion differs by more than 5e16 from the proportion derived from a v3 TWAP. _getTwapSqrtPriceX96 (lines 274-301) accepts the first v3 pool (0.3%, then 0.05%, then 1%) whose liquidity() is nonzero and whose observe(1800s) succeeds; there is no minimum liquidity, unlike the 10 WETH floor applied to v2 pairs in validatePrice. A v3 pool with 1 wei of liquidity and one observation older than 30 minutes (cardinality 1 suffices: observe() extrapolates the last observation) satisfies both. Anyone can create it with UniswapV3Factory.createPool(WETH, token, 3000), initialize() at any tick, and mint a minimal position, for roughly 5M gas and no capital. Because both proportions come from the raw-unit formula of the previous finding, an attacker's tick 0 (1:1) TWAP gives 5e17 while a real v4 price of 1 ETH = 10_000 tokens gives ~1e14, so every conversion reverts. validatePrice does not catch it (hasV3Pool is true, so its v4-vs-TWAP branch is skipped). Nothing on the vault can route around it: priceValidator is set once at initialize, v3Factory is immutable in the validator, and the keeper has no parameter that bypasses the check. All pending ETH on every vault using the validator (the factory's defaultPriceValidator is shared) and every later contribution stays unconverted; the only remedy is to move the v3 price back, which the attacker undoes with one swap through 1 wei of liquidity. AUDIT-NOTES.md notes a dust v3 tier can win the price lookup; it does not note that it blocks conversions. Fix: require meaningful v3 liquidity (e.g. the same 10 WETH floor as v2, checked by valuing liquidity at the TWAP) before using a v3 pool as a reference, or drop the v3 cross-check for the keeper-only model since the keeper's floors are the real protection; the raw-unit fix in the previous finding is also needed, otherwise even an honest v3 pool at the true price can differ from the clamped spot value.","line":226,"path":"src/peripherals/UniswapVaultPriceValidator.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {UniswapVaultPriceValidator} from \"src/peripherals/UniswapVaultPriceValidator.sol\";\nimport {TickMath} from \"v4-core/libraries/TickMath.sol\";\n\n/// @dev PoolManager stand-in: StateLibrary.getSlot0 reads the low 160 bits of `extsload(slot)` as sqrtPriceX96.\ncontract SlotPoolManager {\n    bytes32 internal _slot0;\n\n    function setSqrtPrice(uint160 sqrtPriceX96) external {\n        _slot0 = bytes32(uint256(sqrtPriceX96));\n    }\n\n    function extsload(bytes32) external view returns (bytes32) {\n        return _slot0;\n    }\n}\n\n/// @dev A Uniswap v3 pool anyone can create for ~5M gas: 1 wei of liquidity, one observation older than the\n///      30-minute window, priced at tick 0 (1 token = 1 ETH raw). observe() returns zero cumulatives -> mean tick 0.\ncontract DustV3Pool {\n    function liquidity() external pure returns (uint128) {\n        return 1;\n    }\n\n    function slot0() external pure returns (uint160, int24, uint16, uint16, uint16, uint8, bool) {\n        return (uint160(2 ** 96), 0, 0, 1, 1, 0, true);\n    }\n\n    function token0() external pure returns (address) {\n        return address(0x1234);\n    }\n\n    function token1() external pure returns (address) {\n        return address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);\n    }\n\n    function observe(uint32[] calldata) external pure returns (int56[] memory tickCumulatives, uint160[] memory s) {\n        tickCumulatives = new int56[](2);\n        s = new uint160[](2);\n    }\n}\n\ncontract DustV3Factory {\n    address public immutable pool;\n\n    constructor(address pool_) {\n        pool = pool_;\n    }\n\n    /// Only the 0.3% tier exists: the first tier the validator looks up.\n    function getPool(address, address, uint24 fee) external view returns (address) {\n        return fee == 3000 ? pool : address(0);\n    }\n}\n\n/// @notice A dust v3 pool at an off-market price makes calculateSwapProportion revert with\n///         SwapProportionDeviationTooHigh for every keeper conversion of every vault using this validator.\n///         The vault's v4 pool is at 1 ETH = 10_000 tokens; the attacker's v3 pool (1 wei liquidity) says 1:1.\n///         Both proportions come from the raw-unit formula: spot ~1e14, TWAP 5e17, diff > 5e16 -> revert.\n///         Expected: a pool with 1 wei of liquidity is not a price reference, and the keeper's conversion proceeds.\ncontract ValidatorDustV3BlocksTest is Test {\n    uint256 internal constant Q96 = 2 ** 96;\n    address internal constant TOKEN = address(0x1234);\n\n    SlotPoolManager internal pm;\n    UniswapVaultPriceValidator internal validator;\n    int24 internal tickLower;\n    int24 internal tickUpper;\n\n    function setUp() public {\n        pm = new SlotPoolManager();\n        DustV3Factory v3 = new DustV3Factory(address(new DustV3Pool()));\n        validator = new UniswapVaultPriceValidator(\n            address(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2), address(0x2222), address(v3), address(pm), 500, 1800\n        );\n        tickLower = TickMath.minUsableTick(60);\n        tickUpper = TickMath.maxUsableTick(60);\n        pm.setSqrtPrice(uint160(100 * Q96)); // v4 spot: 1 ETH = 10_000 tokens\n    }\n\n    function test_dustV3PoolDoesNotBlockConversion() public {\n        // Current code reverts here with SwapProportionDeviationTooHigh, so convertAndAddLiquidity reverts for the\n        // keeper until the attacker's pool is out-priced, which the attacker undoes for one swap through 1 wei.\n        uint256 p = validator.calculateSwapProportion(TOKEN, tickLower, tickUpper, address(pm), bytes32(uint256(1)));\n        assertGe(p, 35e16, \"proportion within the full-range band\");\n        assertLe(p, 65e16, \"proportion within the full-range band\");\n    }\n}","reproduction":"State: v4 pool at sqrtPriceX96 = 100 * 2^96; attacker's v3 WETH/token 0.3% pool with liquidity() == 1 and observe([1800,0]) returning tickCumulatives [0,0] (mean tick 0, sqrtPrice 2^96), created more than 30 minutes earlier. Keeper calls vault.convertAndAddLiquidity(minOut, minFees). Expected: the validator ignores a pool with 1 wei of liquidity and returns a proportion in [35e16, 65e16]; the conversion proceeds. Actual: spotProportion ~ 9.999e13, twapProportion = 5e17, diff ~ 4.999e17 > 5e16, revert SwapProportionDeviationTooHigh; the whole conversion reverts, and keeps reverting for every later call while the pool holds its 1 wei.","severity":"high","snippet":"                if (diff > 5e16) revert SwapProportionDeviationTooHigh();","title":"Anyone can block every keeper conversion of a token permanently with a 1-wei-liquidity v3 pool: calculateSwapProportion reverts SwapProportionDeviationTooHigh against the dust pool's TWAP"},{"citation":"resolved","description":"UniAlignmentVaultFactory.deployVault has no access control and forwards the caller-supplied priceValidator to UniAlignmentVault.initialize, where it is stored for the life of the vault. It then calls registry.registerVault, so MiyaRegistry.isVault is true for the result, which is the only check ERC721DropFactory (line 34), ERC721AuctionFactory (line 71) and ERC1155Factory (line 79) apply to a collection's vault. The vault itself validates the token and target but not the validator. A collection created against such a vault has it as an immutable (ERC721DropInstance line 55). The validator is view-only, so it cannot take funds, but it fully controls whether the keeper can convert: validatePrice reverting, or calculateSwapProportion returning 1e18 (ethRemaining = 0, _addToLpPosition reverts AmountMustBePositive) or 0 (zero-amount swap), makes convertAndAddLiquidity revert forever. The deployer can switch behaviour later (validator behind a proxy, or keyed on block.timestamp) so the vault looks healthy at launch. Contributions from every collection bound to the vault then stay in pendingETH permanently and never earn LP fees; collections cannot migrate. The audit notes treat registered vaults as protocol-vetted and the deploy script creates exactly one vault through this path, so the open deploy path with a free validator choice is wider than the design needs. Fix: restrict deployVault to the factory owner, or always use defaultPriceValidator (drop the parameter), or have the registry record and expose the validator so factories and frontends can require the default.","line":62,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"1) Attacker deploys contract M implementing IVaultPriceValidator with a storage flag `block` (false at first). 2) Attacker calls vaultFactory.deployVault(salt, INFER, targetId, M): succeeds, vault V is registered, isVault(V) == true. 3) A creator launches an ERC721 drop with vault = V (passes the factory check); minters pay, withdraw() sends the alignment share to V, totalPendingETH grows. 4) Attacker sets M.block = true so validatePrice reverts. 5) Keeper calls V.convertAndAddLiquidity(x, y): expected conversion; actual revert from validatePrice, on every later call too. The ETH in V is never converted and never earns fees, and the collection cannot change its vault.","severity":"medium","snippet":"    function deployVault(","title":"deployVault is permissionless and takes an arbitrary priceValidator, so anyone can register a MiyaRegistry-trusted vault whose conversions they can disable at will"},{"citation":"resolved","description":"convertAndAddLiquidity collects LP fees first (line 376), then swaps on the same v4 pool twice: the token->ETH swap in _convertPendingFeeTokens and the ETH->token swap in _doSwapAndLP, both through zRouter at the vault's own fee tier (the factory sets the pool key to the zRouter tier, so it is the same pool). Those swaps pay LP fees to the vault's existing full-range position in proportion to its share of pool liquidity. The subsequent _addToLpPosition calls modifyLiquidity with a positive delta; PoolManager returns callerDelta = principal + feesAccrued, so _settleLPDelta pays less than the amount0/amount1 the liquidity was sized for. The difference stays in the vault: the ETH part is unaccounted surplus (never distributed, per AUDIT-NOTES.md), the token part is held by the vault but not added to pendingFeeTokens, so _convertPendingFeeTokens never swaps it and nothing else can move it. The fees belonged to the pre-conversion shareholders and are lost to everyone. Magnitude per conversion is fee tier x swapped amount x vault share of pool liquidity, so it is small but permanent and grows with the vault's dominance of the pool. Fix: after the swaps and before the add, run the liquidityDelta = 0 collection again (or collect once after the swaps), or measure balance deltas around the add and credit the leftover ETH via _creditYield and the leftover tokens into pendingFeeTokens.","line":738,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"State after one conversion: vault position holds 90% of the pool's liquidity, pool fee 3000. Second batch of 10 ETH, proportion 50% for simplicity: ETH->token swap of ~5 ETH pays 0.015 ETH of fees, 0.0135 ETH of which accrue to the vault's position; if pendingFeeTokens was also swapped (say 1,000 tokens), 2.7 tokens accrue. The following modifyLiquidity(+L) returns callerDelta with those fees netted, so the vault settles 0.0135 ETH and 2.7 tokens less than LiquidityAmounts sized. Expected: accumulatedFees rises by ~0.0134 ETH (after the 1% protocol cut) and pendingFeeTokens by 2.7. Actual: both stay unchanged; address(vault).balance is 0.0135 ETH above pendingETH + unclaimed + protocol fees, and token.balanceOf(vault) - pendingFeeTokens == 2.7, with no function that can ever pay either out.","severity":"low","snippet":"        _settleLPDelta(delta);","title":"Fees generated by the vault's own swaps in the same conversion are netted into the liquidity add and stranded (ETH as unaccounted surplus, tokens outside pendingFeeTokens)"},{"citation":"resolved","description":"receive() credits msg.sender, so 500 fresh EOAs each sending MIN_CONTRIBUTION (0.001 ETH) fill conversionParticipants between keeper runs. Until the keeper converts, every receiveContribution from a new benefactor reverts; ERC721DropInstance/ERC1155Instance/ERC721AuctionInstance wrap the call in try/catch (ERC721DropInstance line 242), so artist and treasury are still paid and the vault cut parks in pendingVaultCut, but no collection can deliver its alignment share until a conversion clears the list and someone calls flushPendingVaultCut. The only way to clear the list is a full convertAndAddLiquidity over all 500 entries. Per entry, _distributeSharesAndCleanup does ~5 cold SLOADs, two 0->nonzero SSTOREs (feeCheckpoint, benefactorShares) and two warm SSTOREs, roughly 50-55k gas, plus the storage copy in _getActiveBenefactors and 500 pops in _clearConversionParticipants: about 30M gas in total before the swap and LP (estimate; I could not run forge in this session). The keeper's reward is min(0.0012 ETH, 1% of 0.5 ETH) = 0.0012 ETH, while 30M gas costs 0.03 ETH at 1 gwei, so each refill costs the protocol more than it costs the attacker beyond the lock-up of 0.5 ETH on which the 500 addresses earn LP yield like any benefactor. On a chain with a 32M per-transaction gas limit (Arbitrum-style chains such as Robinhood Chain) the conversion may not fit at all, 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 the list in bounded pages (process the first N participants and leave the rest pending), or require a larger MIN_CONTRIBUTION for receive() than for registered instances, or let registered instances bypass the cap.","line":335,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"1) From 500 different addresses: `vault.call{value: 0.001 ether}(\"\")`. conversionParticipants.length == 500, totalPendingETH == 0.5 ETH. 2) A drop calls withdraw(): vault.receiveContribution reverts TooManyConversionParticipants, the cut goes to pendingVaultCut (VaultContributionFailed). Expected: the contribution is credited. Actual: deferred until the keeper runs one conversion that loops 500 entries (measure with forge: ~30M gas), earning 0.0012 ETH. 3) Repeat after the conversion for another 0.5 ETH.","severity":"low","snippet":"            if (conversionParticipants.length >= MAX_CONVERSION_PARTICIPANTS) revert TooManyConversionParticipants();","title":"The 500-slot conversionParticipants cap lets anyone defer every collection's vault cut for 0.5 ETH and force a ~30M-gas conversion whose 0.0012 ETH reward is far below its gas cost"},{"citation":"resolved","description":"_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 and the same liquidity and observe conditions (minus the unlocked check, which only fails mid-swap). So whenever the branch at line 147 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 says mature pools with v3 history are protected against v4 spot manipulation; they are not, and no v2 pair means no cross-check at all (INFER's case). With the keeper-only model this is a documentation issue, not a loss, but the notes should not describe this check as live.","line":147,"path":"src/peripherals/UniswapVaultPriceValidator.sol","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 revert PriceDeviationTooHigh per the comment at lines 143-146; actual: hasV3Pool == true, line 138 is skipped (no v2 reference), line 147 is false, line 167 is false, the call returns without any check.","severity":"info","snippet":"        if (hasV4Pool && !v2IsReference && !hasV3Pool && priceV4 != 0) {","title":"validatePrice's v4-vs-v3-TWAP cross-check is dead code: it runs only when hasV3Pool is false, but every pool that yields a TWAP also sets hasV3Pool"},{"citation":"resolved","description":"CreateX only hashes (msg.sender, salt) when the salt's first 20 bytes equal msg.sender (the factory) and byte 21 is 0x00. For every other salt it uses keccak256(abi.encode(salt)) (or the chain-id form), so computeVaultAddress returns an address that deployVault will not produce, and since that guarded salt is caller-independent, anyone watching the mempool can call CreateX.deployCreate3 with the same salt first and make deployVault revert. The deploy script builds the salt correctly (factory address, 0x00, 11-byte label), so the production path is fine; the finding is about the public helper and any later vault deployed with a free-form salt. Fix: have deployVault require salt[0:20] == address(this) and salt[20] == 0x00, or document the salt format on computeVaultAddress.","line":103,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"salt = keccak256(\"my-vault\"). computeVaultAddress(salt) returns A (sender-guarded formula). deployVault(salt, ...) deploys at CreateX.computeCreate3Address(keccak256(abi.encode(salt))) != A. Expected: equal. If a third party first calls CreateX.deployCreate3(salt, anyInitCode), deployVault(salt, ...) reverts because the CREATE3 address is taken.","severity":"info","snippet":"        bytes32 guardedSalt = keccak256(abi.encodePacked(uint256(uint160(address(this))), salt));","title":"computeVaultAddress assumes the CreateX sender-guarded salt form; for any other salt the prediction is wrong and the address can be squatted by anyone through CreateX"}],"hash":"d4ecc1a3fdfaa7822868f89bde593820c17d5546e3de524762c63cb26ce5e7e4","nodeId":"95a114d7-833e-47d9-8fee-9c16978bb24c","outcome":"completed","summary":"The findings file and two proof tests are written. The shell sandbox never started in this session, so I could not compile or run the proofs; I verified them by hand against the forge-std and v4-core sources in the tree.\n\n## Result\n\nSeven findings are in `.imd-findings.json`. Two are high, with proof tests under `test/scratch/`.\n\n**High: the validator's swap proportion mixes ETH wei with raw token units** (`src/peripherals/UniswapVaultPriceValidator.sol:267`). For a full-range position it returns 1/(1+P), where P is the raw token-per-ETH price, instead of the ~50% value share. The first conversion is unaffected, but every later one gets the clamp edge (35% or 65%) for any token not priced 1:1 with ETH, and the unclamped value when a v3 TWAP exists. The vault then adds liquidity sized by the smaller side, and about 30% of each batch stays in the vault balance with no path out. Every repo test uses a 1:1 price with a mock validator, which hides it.\n\n**High: a 1-wei v3 pool blocks all keeper conversions** (`src/peripherals/UniswapVaultPriceValidator.sol:226`). The TWAP lookup has no liquidity floor, so an attacker's dust pool at an off-market tick makes the 5% deviation check revert on every call. The validator address is fixed per vault and the keeper has no bypass.\n\n**Medium: `deployVault` is permissionless with a caller-chosen validator.** The result is registered as a trusted vault, and its deployer can later make conversions revert forever for any collection bound to it.\n\n**Low:** fees from the vault's own same-transaction swaps are netted into the liquidity add and stranded. **Low:** the 500-participant cap can be filled for 0.5 ETH, deferring every collection's vault cut and forcing a roughly 30M gas conversion for a 0.0012 ETH reward. **Info:** the v4-versus-TWAP check in `validatePrice` is dead code. **Info:** `computeVaultAddress` is only correct for sender-guarded salts.\n\n## Answers to the six questions\n\n1. **Fee accounting holds.** Each distribution and each payout rounds down once, so claims never exceed fees distributed. Contributions and the permissionless `withdraw()` touch only `pendingETH`, never shares, so nothing is diluted or erased. Dust shares are never counted in `totalShares`, so they cost nothing.\n2. **Reentrancy is contained.** No third party can inject a call while the collecting flag is set without a hook or a hooked token, both excluded. One unverified lead: the ETH-to-token swap in `_doSwapAndLP` runs without the flag, so any ETH refund from the real zRouter would hit the contribution path and revert the conversion. I could not check zRouter's source.\n3. **The reward is bounded** at 1% of the batch and paid from the batch, so repeated conversions cannot exceed 1% of contributions or cause insolvency.\n4. **No permissionless path swaps.** Fee tokens convert before every share mint. Tokens can diverge from `pendingFeeTokens` only in the harmless direction, through the stranding above. A malicious keeper can self-sandwich with a floor of 1 and take nearly a whole batch, which is principal, so the note that it \"cannot take principal\" is too strong. The validator can wrongly block, as the second finding shows.\n5. **Pool key, treasury and `depositFees` are safe.** The factory has no owner passthrough, the vault's ownership is locked to the factory, and the LP-yield cut only flows to `registry.treasury()`.\n6. **Initialisation is safe.** Deploy and `initialize` run in one factory transaction, the clone guard blocks repeats, the implementation has no self-destruct and initialising it is harmless. The deploy script uses the correct guarded salt form.\n\nNext step for the author: run the two scratch tests to confirm they fail on this tree before fixing, since I could not execute them here.","treeHash":null,"usage":{"cachedInputTokens":1351662,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":57761,"runtime":"claude","turns":46,"wallClockMs":778268}}],"verification":[]}