# Audit report

> Audit src/vaults/uni/ (UniAlignmentVault, PendingLedger, VaultSwaps, UniAlignmentVaultFactory, IVaultConfig): conversion bookkeeping, shares, fees, claims and the keeper's trust boundary, at the pinned commit, for a mainnet launch. Read whatever else in src/ these depend on, but report on this scope.
>
> miyamints is an NFT launchpad on Ethereum mainnet (fork of ms2fun-contracts). Collections (ERC721 drops, ERC721 auctions, ERC1155 editions) sell in any token on the registry allowlist (ETH, IMD, INFER, USDC, imdUSD, cbBTC, WBTC) at a price per token; the artist collects each token exactly as paid; a 1% protocol cut and a collection-chosen alignment share (at least 10%) are taken in the token paid. The alignment share goes to an alignment vault, which books it as pending per benefactor and token; a keeper converts pending tokens to IMD (and ETH) with aggregator calldata it supplies (0x AllowanceHolder, zRouter fallback; both allowlisted by the factory owner, a Safe), the vault balances IMD and INFER and adds permanent full-range liquidity to the hooked INFER/IMD Uniswap v4 pool, and benefactors claim the position's INFER and IMD fees (or take them as ETH, or reinvest). Read docs/IMD-INFER-VAULT.md (design and trust model) and AUDIT-NOTES.md (internal findings, fixes and accepted risks) first. The keeper is trusted for timing and for its floors; the question is what it, or a bug in it, can do beyond selling a batch at its own minimum outputs. BUILD: solc 0.8.28 with via_ir; `forge build --skip test --skip script` builds src in a few minutes; a full build with test/ needs much more memory. `SKIP_FORK_TESTS=true forge test --offline` runs the unit, invariant and integration suites; fork tests need a mainnet RPC and are optional.
>
> Answer each numbered question, including the ones where nothing is wrong:
> 1. PENDING LEDGER (per-token product-and-sum pools, epochs, rescaling at 1e9 with product precision 1e18). Prove or break: a benefactor's pending balance never exceeds what they contributed less what converted; balances never sum to more than pendingByToken; a partial conversion of a of P converts exactly a/P of every balance (rounding toward the vault); a benefactor with several tokens pending gets exactly the shares for what converted and keeps the rest.
> 2. SHARES FOR A BATCH (convertAndAddLiquidity, _batchShares, _sharesFor, _closeBatch). INFER/IMD already free before a batch (leftovers, strays) is added with it but belongs to holders before the batch; the batch is priced once that liquidity is in. Prove or break: no batch of any size or token mix captures earlier holders' value; shares never exceed liquidity units.
> 3. FEES AND CLAIMS (_collectFees, _accPerShare for INFER, IMD and ETH dust, the ledger's fee sums, the 1% protocol cut; claimFees TOKENS, ETH and REINVEST, claimFeesAsDelegate). Lazily collected shares earn only fees after their conversion; claims plus protocol cut never exceed fees collected; no claim pays more than owed or the wrong address; reinvest is priced at the share price before its add.
> 4. AGGREGATOR CALLDATA (SwapCall {target, tokenOut, data, minOut}, ConvertParams). The vault calls only allowlisted targets (0x AllowanceHolder, zRouter), approves exactly the amount in and resets it, requires the input to drop by exactly that amount and the output to rise by at least minOut > 0. Assume a malicious or buggy keeper choosing data, allowlisted targets, AllowanceHolder's operator and token order: prove or break that it cannot move any token beyond each swap's amount in, touch others' pending tokens, fees or ETH, or leave an allowance; and that its worst outcome is selling a batch at its own floors.
> 5. THE ETH LEG AND THE BALANCING SWAP. Non-ETH swaps that output ETH join one ETH -> IMD swap sent all the batch's ETH, attributed pro rata; ETH returning during any swap must not count as a contribution or leg input. The INFER/IMD balancing swap runs through PoolManager.unlock (unlockCallback, settle/take, minSplitOut; pool key: hook flags limited to beforeInitialize, static fee). Order independence; nobody can call or re-enter the callback, settle short, or push the pool to make a conversion, reinvest or ETH-mode claim (IMD -> ETH on the factory's ethPool, minEthOut mandatory) lose beyond its floor.
> 6. SOLVENCY, LIMITS AND OWNER POWERS (_checkSolvent; the 16-token caps; MIN_CONTRIBUTION and minContribution; unowned rounding shares; setSwapTarget, setEthPool, setMinContribution, deployVault). For INFER, IMD, ETH and every pending token held >= fees owed + protocol cut + pending after every external function; can anyone cheaply block contributions, a token's conversion or claims; what can the factory owner do that strands funds, and is any of it irreversible?
> 7. TOKENS. Decimals 6, 8 and 18, tokens returning no bool, USDC/WBTC-style blocklists, fee-on-transfer (must be refused), and INFER and IMD being both payment tokens and fee tokens: any path where these desynchronise the books.
>
> Not findings: anything AUDIT-NOTES.md lists as accepted or not fixed by decision (say so if you find it worse than written), including an issuer blocklist (USDC) on the vault making that token's splits revert; src/pools, src/markets and src/gating unless a question names them; test/ and its mocks; gas optimisations unless they change behaviour.
>
> For every finding: severity by impact; file, line and the lines copied; the call sequence from an external caller; a concrete failing input or state with expected against actual; whether it is reachable with script/config/1.json; the smallest fix; and, where it can be written, a Foundry test that fails on this commit, written out in the finding (you write no files; the judge reproduces it). Reuse the harnesses in test/vaults/VaultTestBase.sol, test/helpers/TestableUniAlignmentVault.sol and test/factories/payments/PaymentMocks.sol. Answer every numbered question, including those where nothing is wrong. Give a coverage row (holds, finding or unreached, with a reason) for every external function of the contracts in scope. Also report every comment or NatSpec that claims a property the code does not have.

| | |
|---|---|
| Repository | https://github.com/fa11up/miyamints-contracts.git |
| Commit | `b5bdcc64a7c62487d82047bb9805fda730edd95b` |
| Job | `4884eb75-58db-404a-b2b5-5c788df3c487` |
| Judged | 2026-11-09 18:04 UTC |
| Findings | 1 medium · 4 low · 4 info |

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

## Findings

### 1. Medium: minSplitOut has no direction (and is not read when no swap runs): a pool move that flips the balancing swap clears the keeper's floor and the batch lands 13% below it

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

```
            if (swapOut < minSplitOut) revert InsufficientOutput();
```

_balanceAndAdd picks the balancing direction from the live pool price (VaultSwaps.splitAmount) and compares the summed output, in whichever token came out, with one bare number; when splitAmount returns 0 the floor is not read at all (lines 490-492). ConvertParams carries no expected direction and no floor on liquidity. INFER wei and IMD wei are not comparable: script/config/1.json records 1 IMD = 3,887 INFER, so a floor computed in IMD wei for the quoted INFER -> IMD swap is ~3,887x too loose once the realized direction is IMD -> INFER. Any batch (plus free leftovers) that holds both sides and is INFER-heavy has a price below which the direction flips; just under it a tiny IMD -> INFER swap returns more INFER wei than the IMD-wei floor, the call succeeds, and the whole batch is added full range at the moved price. The batch's benefactors hold permanently fewer liquidity units (for a 3:1 mix ~87% of the quote, 60% for 9:1), and the INFER contributor is additionally valued at the flipped swap's rate (line 362): alice's part of the batch falls from 74.8% to 50.3%. Whoever moved the pool buys back against the new permanent liquidity. The mirror case (IMD-heavy quote, floor in INFER wei) fails safe. Call sequence: benefactors -> receiveContribution(INFER) and receiveContribution(IMD); keeper quotes, sends convertAndAddLiquidity(p) with minSplitOut = 99% of the quoted IMD out; a searcher/builder that sees the call (or a stale quote after a real move, or a keeper bug submitting publicly) sells INFER into the pool first; the keeper's call succeeds instead of reverting InsufficientOutput. Cost of the move is two 1.25% fees on the volume needed (~130 IMD for 3x on 12,220 IMD of virtual depth), so it pays above ~1,000 IMD batches at 3:1. The keeper needs no dishonesty: the floor it supplies does not mean what the docs say ('the keeper floors are the protection', docs/IMD-INFER-VAULT.md line 87). Reachable with script/config/1.json as is. Merged from audit_math; reproduced with test/scratch/SplitFloorDirection.t.sol. Smallest fix: make the floor mean one thing. Add `uint256 minLiquidity` to ConvertParams and `if (liquidity < p.minLiquidity) revert InsufficientLiquidity();` after _balanceAndAdd (also covers the no-swap case), or add the expected direction (`bool splitInferToImd`) and revert when the realized first-pass direction differs. Both change ConvertParams, which the doc calls fixed: a scope decision for the requester.

**Reproduction**

TestableUniAlignmentVault at 1 IMD = 3,887 INFER (sqrtPriceX96 = sqrt(2^192/3887), INFER currency0, fee 12500, pool liquidity 7.62e23). alice contributes 583,050e18 INFER, bob 50e18 IMD. Quote (snapshot): SplitSwap(inferToImd=true, out ~49.58e18 IMD), liquidity 6208665207802117471411. Keeper sends minSplitOut = 99% of quoted out = 49088478923276214845. Pool pushed to 33.30% of the INFER price before the call lands. Expected: revert InsufficientOutput. Actual: succeeds; SplitSwap(inferToImd=false, amountIn 25157188119857004 IMD, out 289980977009341203006 INFER) clears the IMD-wei floor; liquidity landed 5399289194284780863833 = 86.96% of the quote. forge test --offline --match-path test/scratch/SplitFloorDirection.t.sol -> FAIL 'landed beyond the keeper's 1% floor: 5399289194284780863833 < 6146578555724096296696'. The test uses the repository's harnesses (test/vaults/VaultTestBase.sol, test/helpers/TestableUniAlignmentVault.sol) as the assignment asks; it passes once the second call reverts.

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

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

import {Vm} from "forge-std/Vm.sol";
import {FixedPointMathLib} from "solady/utils/FixedPointMathLib.sol";
import {VaultTestBase} from "../vaults/VaultTestBase.sol";
import {UniAlignmentVault} from "../../src/vaults/uni/UniAlignmentVault.sol";

/// @notice minSplitOut is one bare number compared with the balancing swap's output in whichever token came out.
///         Quote: INFER -> IMD, floor in IMD wei. If the pool moves so the batch becomes IMD-heavy, the swap runs
///         IMD -> INFER and its INFER-wei output (3,887 INFER per IMD) clears an IMD-wei floor trivially, so a batch
///         lands 13% under the keeper's 1% floor. Fails on this commit; passes once the floor carries a direction or
///         a minimum liquidity (either makes the second call revert).
contract SplitFloorDirectionTest is VaultTestBase {
    bytes32 constant SPLIT_SIG = keccak256("SplitSwap(bool,uint256,uint256)");

    function _splitOut() internal returns (bool dir, uint256 amtIn, uint256 out) {
        Vm.Log[] memory logs = vm.getRecordedLogs();
        for (uint256 i; i < logs.length; ++i) {
            if (logs[i].topics[0] == SPLIT_SIG && logs[i].emitter == address(vault)) {
                (dir, amtIn, out) = abi.decode(logs[i].data, (bool, uint256, uint256));
            }
        }
    }

    function test_splitFloor_hasNoDirection() public {
        // 1 IMD = 3,887 INFER (script/config/1.json), INFER is currency0, depth of the live pool.
        uint160 sqrtP = uint160(FixedPointMathLib.sqrt((uint256(1) << 192) / 3887));
        vault.setMockPool(sqrtP, 7.62e23);
        _contribute(alice, infer, 583_050e18); // ~150 IMD worth
        _contribute(bob, imd, 50e18);

        // The keeper's quote at the current price: INFER -> IMD, ~49.4 IMD out.
        uint256 snap = vm.snapshotState();
        vm.recordLogs();
        _convert();
        (bool dir,, uint256 quotedOut) = _splitOut();
        uint256 quotedLiquidity = vault.totalLPUnits();
        vm.revertToState(snap);
        assertTrue(dir, "quote direction is INFER -> IMD");
        uint256 floor = quotedOut * 99 / 100; // IMD wei

        // The pool is pushed to 33.30% of the INFER price before the keeper's call lands.
        uint160 sqrtP2 = uint160(FixedPointMathLib.sqrt(((uint256(1) << 192) * 3330 / 10000) / 3887));
        vault.setMockPool(sqrtP2, 7.62e23);

        UniAlignmentVault.ConvertParams memory p = _params(vault);
        p.minSplitOut = floor;
        vm.prank(keeper);
        try vault.convertAndAddLiquidity(p) {
            // If the call lands, the floor must have held: at least 99% of the quoted liquidity.
            assertGe(vault.totalLPUnits(), quotedLiquidity * 99 / 100, "landed beyond the keeper's 1% floor");
        } catch {
            // A floor that binds reverts here (InsufficientOutput): the fixed behaviour.
        }
    }
}
```

### 2. Low: First conversion: ETH dust, leg refunds and the conversion's own pass-2/3 swap fees are booked 100% to the protocol, not kept for the batch as AUDIT-NOTES states

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

```
        if (totalShares == 0) {
            // Nobody to credit (only before the first conversion closes): keep it accounted.
            _protocolFees[i] += amount;
```

_distribute credits the whole amount to _protocolFees whenever totalShares == 0. convertAndAddLiquidity calls it before _closeBatch mints the first shares: at line 367 for every wei of ETH above what is owed (a leg that hands back unspent msg.value, zRouter refunding its whole stray balance, forced ETH, ETH sent through the inherited payable Ownable functions, a rejected keeper reward at line 618), and through _modifyPosition -> _creditYield on LP passes 2 and 3 for the fees the vault's own pass-1 balancing swap paid to the just-added position. On every vault's first batch all of that goes to registry.treasury() via withdrawProtocolFees instead of to the batch that paid it. The comment at line 365 ('dust for current holders') and AUDIT-NOTES.md 'A conversion's own swap fees ... On a first conversion there are no holders, so they stay in the batch' (Final sweep) say otherwise; the treasury takes 100% instead of its 1%. Bounded to one batch per vault and to the leg's refund plus the pass-2/3 fee share, so Low. Reachable with script/config/1.json (zRouter is an allowlisted leg target; IVaultConfig.sol documents its refund-all-ETH behaviour). Merged from audit_permissions, audit_math and audit_flow (three reports of the same mechanism). Smallest fix: in _distribute return without booking when totalShares == 0 (INFER/IMD then stay free and the same call's next pass, or the next batch, adds them), and for ETH skip the line-367 distribution while totalShares == 0 so it is distributed once the first shares exist; or run those distributions after _closeBatch on a first batch.

**Reproduction**

VaultTestBase, fresh vault: alice contributes 1000e18 IMD; vm.deal adds 1 ether of stray ETH to the vault (on mainnet: a leg refund); vault.accrueFees(0, 50e18) stands in for the pass-1 swap fees the pass-2 add collects; keeper runs convertAndAddLiquidity(_params(vault)). Expected (AUDIT-NOTES): feeStats(ETH).protocolFees == 0, feeStats(IMD).protocolFees == 0.5e18 and distributed == 49.5e18 for alice. Actual (test_firstBatch_ethDustToProtocol in test/scratch/Review.t.sol): feeStats(ETH).protocolFees == 1000000000000000000, feeStats(IMD).protocolFees == 50000000000000000000, distributed IMD == 0, claimable(alice) ETH == 0.

### 3. Low: TOKENS-mode claims push ETH dust in the same call: a few wei of dust makes claimFees revert for any benefactor or delegate that cannot receive ETH, locking its INFER and IMD

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

```
            if (!ok) revert TransferFailed();
```

_payout (lines 792-815) sends the ETH dust slot together with INFER and IMD in every non-reinvest claim and reverts the whole claim when the ETH call fails. ETH dust is created for all holders by anyone, for a few wei: any ETH above what is owed at a conversion is distributed (line 367), which includes a wei left in zRouter (it refunds its whole balance on an ETH-in swap), forced ETH, ETH sent with the inherited payable Ownable.requestOwnershipHandover, and a rejected keeper reward. A benefactor is whatever address receiveContribution names, and the payee is its delegate if set; if that address is a contract without a payable receive/fallback, claimFees(TOKENS) and claimFeesAsDelegate revert TransferFailed from then on. REINVEST still works (it leaves ETH claimable) and the benefactor can point delegateBenefactor at an address that takes ETH if its code can make that call; otherwise its INFER and IMD fees are stuck for good. The repository's own collections have receive(), so they are not affected. Reachable with script/config/1.json. From audit_math. Smallest fix: in TOKENS mode do not let the ETH leg fail the claim: on a failed send put the amount back (`_unharvest(b, I_ETH, ethOut, ethOut)` as _reinvest already does) and pay the tokens, or skip the dust slot unless the mode is ETH.

**Reproduction**

VaultTestBase: contract C with no receive() is named benefactor of 150e18 IMD (receiveContribution(IMD, 150e18, C) by alice); bob contributes 100e18 INFER; keeper converts (first batch). vm.deal adds 1000 wei to the vault; bob contributes 10e18 IMD and the keeper converts it, distributing the 1000 wei: claimable(C) = (0, 0, 450). vault.accrueFees(10e18, 10e18); C calls claimFees(ClaimMode.TOKENS, 0). Expected: C is paid its tokens, ETH dust kept claimable. Actual (test_tokensClaim_blockedByEthDust in test/scratch/Review.t.sol): revert TransferFailed(); every later TOKENS claim by C reverts the same way.

### 4. Low: REINVEST mints at totalShares / totalLPUnits and ignores free INFER/IMD leftovers, so a reinvestor takes part of what belongs to earlier holders

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

```
        l = _sharesFor(l, unitsBefore); // shares, at the share price before the add
```

Since the audit-suite fix, free INFER/IMD held before a conversion (leftovers the last add could not pair, and strays) belongs to the holders of that moment: _batchShares prices a batch at totalShares / (unitsBefore + the leftover's liquidity), so the next batch pays for its part of them. _reinvest prices new shares at totalShares / totalLPUnits and leaves the free leftovers out. The leftover is still added at the next conversion with no shares minted for it, and the reinvestor's new shares then share in it. Each reinvest moves (reinvest shares / totalShares) x leftover value from earlier holders to the reinvestor. AUDIT-NOTES claims parity ('Reinvest also mints at the share price'), but the price it uses is not the one _batchShares uses. Bounded by the leftover (AUDIT-NOTES measured up to 1.5% of a batch, more with strays), so Low. Reachable with script/config/1.json: anyone holding shares can claimFees(REINVEST, 0). From audit_permissions. Smallest fix: in _reinvest, after _harvest, value the remaining free INFER/IMD as _batchShares does (subtracting the harvested amounts, which are free at that point) and mint _sharesFor(l, unitsBefore + leftoverLiquidity); or add the leftover to the position first, without minting.

**Reproduction**

VaultTestBase (mock pool 1 IMD per INFER). Two identical vaults: alice contributes 1 ETH and bob 3 ETH, convert; accrueFees(400e18, 400e18); collectFees(). Mint 1000e18 INFER + 1000e18 IMD to one vault only (a leftover that _batchShares would assign to alice and bob). alice calls claimFees(REINVEST, 0) on both. Expected: fewer shares on the vault with the leftover (about 99e18 x 19772.5 / (19772.5 + 1000) = 94.2e18). Actual (test_reinvest_ignoresLeftover in test/scratch/Review.t.sol): 99000000000000000004 shares on both, totalShares == totalLPUnits == 19772510927193754930002 on the leftover vault; the next conversion adds the 1000-unit leftover with no shares minted and alice's new shares take ~5% of it.

### 5. Low: Keeper's minSplitOut also covers earlier holders' free INFER/IMD (leftovers, strays): the balancing swap sells value beyond the batch at the keeper's floor

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

```
            (uint256 freeInfer, uint256 freeImd) = _freeAlignmentBalances();
```

_balanceAndAdd balances and swaps ALL free INFER/IMD (balance minus fees owed minus pending), which includes leftovers from earlier adds and strays that _batchShares attributes to holders before the batch (before[0], before[1]). The only floor on that swap is the keeper's minSplitOut, one total over all passes and over both the batch's and the leftovers' tokens. So the keeper's worst case is not 'selling a batch at its own floors' as AUDIT-NOTES and docs/IMD-INFER-VAULT.md state: with a tiny batch and a large one-sided leftover, almost all of the balancing swap is earlier holders' value, protected only by the keeper floor (and by nothing at all in the direction-flip case above). Call sequence: keeper -> convertAndAddLiquidity(p) -> _collectFees -> before = _freeAlignmentBalances() -> _takePending -> _toImd -> _balanceAndAdd(p.minSplitOut) -> _alignSwap(z, s) with s sized on batch + leftover. Reachable with script/config/1.json (leftovers arise from every multi-pass add; anyone can send IMD/INFER to the vault). Impact bounded by the leftover size, normally small against a deep pool, hence Low. From audit_economics. Smallest fix: size the balancing swap on the batch's own INFER/IMD and add leftovers without swapping, or bound the swap by a vault-computed floor derived from the pool price at the start of the call; at least document that the keeper floor also covers holders' leftovers.

**Reproduction**

VaultTestBase (pool 1 IMD per INFER, fee 12500): alice contributes 10 ETH and the keeper converts (totalShares > 0). 1000e18 IMD is transferred to the vault (stray, owned by alice's shares per _batchShares). bob contributes 1e18 IMD (config minimum). Keeper calls convertAndAddLiquidity(tokens=[IMD], amounts=[1e18], minSplitOut=1). Expected per the stated trust model: at most bob's 1 IMD is exposed to the keeper's floor. Actual (test_keeperFloor_coversLeftover in test/scratch/Review.t.sol): SplitSwap(inferToImd=false, amountIn=504207158530556073737, amountOut=497904569048924122815): ~504 IMD, almost all alice's, is swapped with a 1-wei floor.

### 6. Info: setSwapTarget NatSpec and docs say tokens are refused as swap targets; only INFER and IMD are

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

```
                && (target.code.length == 0 || target == infer || target == imd || target == address(poolManager)
                    || target == address(registry) || target == address(this))
```

The function comment (lines 77-80: 'never a token, the PoolManager, the registry, or an EOA (refused)') and docs/IMD-INFER-VAULT.md ('EOAs, the tokens, the PoolManager and the registry are refused') claim token contracts are refused; AUDIT-NOTES lists only INFER and IMD. The check refuses code-less addresses, INFER, IMD, the PoolManager, the registry and the factory. USDC, imdUSD, cbBTC, WBTC, Permit2, WETH and the vault clones themselves can be allowlisted. With a plain ERC-20 as target the keeper's calldata runs as the vault against that token, but VaultSwaps._exec still requires the input to drop by exactly amountIn and ETH/IMD to rise by minOut > 0 in the same call, which a plain token call cannot satisfy, so no loss was found; a token with a callback (ERC-1363) would widen it. Owner-trust/documentation gap, not a bypass; the 1.json swapTargets are unaffected. Merged from audit_economics, audit_permissions, audit_math and audit_flow. Fix: also refuse registry.isPaymentToken(target) (and registry.isVault(target)), or correct the NatSpec and doc.

**Reproduction**

VaultTestBase: factory.setSwapTarget(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 /* USDC */, true) from the owner. Expected per NatSpec and doc: revert InvalidAddress. Actual (test_setSwapTarget_acceptsUsdc in test/scratch/Review.t.sol): succeeds, isSwapTarget(USDC) == true.

### 7. Info: REINVEST accepts a zero floor (minShares = 0): its balancing swap is the only vault swap without a mandatory floor

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

```
        if (l < minShares) revert InsufficientOutput();
```

claimFees(ETH) refuses minEthOut == 0 (ZeroFloor, line 802), conversions refuse minSplitOut == 0 when a swap happens (line 491) and every SwapCall refuses minOut == 0 (VaultSwaps._exec). claimFees(REINVEST, minShares) runs a vault-side balancing swap in the alignment pool (line 835) with no floor of its own, and line 843 is the only check, so with 0 it accepts any outcome; integrators that pass minEthOut through (the collections' claimVaultFees(mode, minEthOut)) inherit it. Only the claimant's own fees are at stake, so Info. Merged from audit_economics and audit_math. Fix: `if (minShares == 0) revert ZeroFloor();` at the top of _reinvest.

**Reproduction**

VaultTestBase: alice contributes 1 ETH, convert; vault.accrueFees(100e18, 0); alice calls claimFees(ClaimMode.REINVEST, 0). Expected (as in ETH mode): revert ZeroFloor. Actual (test_reinvest_zeroFloorAccepted in test/scratch/Review.t.sol): succeeds, _alignSwap(true, ~50e18) runs with no bound.

### 8. Info: One open token whose held balance drops below its pending blocks every conversion, not just that token's

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

```
            if (_balanceOf(t) < pendingByToken[t]) revert Insolvent(t);
```

After every batch, convertAndAddLiquidity checks balance >= pending for every open token, including tokens not in the batch. If any pending token's balance falls below pendingByToken by means outside the vault (issuer seizure/wipe, a negative rebase, an upgrade), every later batch, including ETH-only or IMD-only ones, reverts Insolvent(t) until that token's pending is gone, and it can't be removed except by converting it, which needs the missing balance. The check exists to catch a swap seizing other pending tokens; it can't distinguish that from a pre-existing shortfall. Not reachable with the plain tokens in script/config/1.json as their public contracts stand, hence Info. From audit_economics. Smallest fix: snapshot each open token's balance before the swaps and require balanceAfter >= min(balanceBefore, pending) for tokens not in the batch.

**Reproduction**

State: pendingByToken[T] = 100e6, balanceOf(vault, T) = 99e6 (issuer removed 1e6; in VaultTestBase use SeizableToken from test/vaults/mocks/VaultMocks.sol as a payment token and seize 1 unit). Keeper calls convertAndAddLiquidity(tokens=[ETH], amounts=[pendingByToken[ETH]], ...). Expected: success (T not in the batch). Actual: revert Insolvent(T) at line 378 for every batch until T's pending is converted.

### 9. Info: Comments claim properties the code lacks: 'everything nonReentrant', 'shares (= liquidity units)', 'keep it accounted', _closeBatch's @dev on _batchShares, and leftover INFER valued at the realized sw

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

```
 *      - receive() ignores ETH the vault pulls in itself (transient flag), everything nonReentrant;
```

Comment/NatSpec mismatches found, none with a loss: (1) line 45 'everything nonReentrant': unlockCallback (guarded by msg.sender == poolManager, sufficient), delegateBenefactor, disableInitializer/initialize and Solady Ownable's transferOwnership, renounceOwnership, requestOwnershipHandover, cancelOwnershipHandover and completeOwnershipHandover, inherited as external payable, carry no guard; requestOwnershipHandover and cancelOwnershipHandover are open to anyone and accept ETH without passing receive(), so ETH enters unbooked at any time and becomes dust at the next conversion (see the first-conversion finding for where it goes then). (2) line 34 'Shares (= liquidity units)': after any batch that adds leftovers (_batchShares issues _sharesFor(ownLiquidity, ...) <= liquidity) or any reinvest, totalShares < totalLPUnits. (3) lines 553-554 are _closeBatch's description left on _batchShares; _closeBatch has no NatSpec. (4) line 655 'keep it accounted' hides that the amount becomes protocol fees. (5) line 567 values pre-batch INFER at the realized swap rate (pool price minus fee and impact) although only the swapped part was sold at it; when the batch brings IMD and free INFER is in excess, the leftover is understated by about (unswapped INFER) x fee/2 and the batch gets ~0.6% of the leftover INFER's value too many shares (mock: 1000 INFER stray, 10 IMD batch: 1.0025% of L against a fair 0.9962%); in the usual direction earlier holders gain. Also docs/IMD-INFER-VAULT.md step 5 says the batch is priced 'at the share price before the add' while the code (correctly) prices it after the leftover's liquidity is in. Merged from audit_economics, audit_math and audit_flow.

**Reproduction**

Any address calls vault.requestOwnershipHandover{value: 1 wei}(): succeeds, address(vault).balance rises by 1, no ContributionReceived, pendingByToken(address(0)) unchanged (test_ownableHandover_acceptsEth in test/scratch/Review.t.sol passes, showing the actual behaviour). For (2): any second batch with free INFER/IMD held before it: BatchConverted.sharesIssued < liquidity added and totalShares() < totalLPUnits().

---

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