{"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":"4884eb75-58db-404a-b2b5-5c788df3c487","kind":"audit","nodes":[{"acceptedSubmissionHash":"510326e9dd75756f4eadc4e34a808bc8098f05246d2138040864b48f4ec11842","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":"e73e76232b3bcd3594e227c1ab8511ae93b0939e3df275f87b2c3fab1b723787","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":"bbfdb26c09bfccb722f23c11a3b0ea18c70ca5e2ebd8e4635c77274653743cc8","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":"1374d86e595257708f64af8cc777c8134c6c702941e2ce2c192115cffdeae6d7","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":"fb85147b34e5a696480e69bdfd8a4ff889010b2207e2ea80b24c82cd5de8cf7a","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 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.\n\nmiyamints 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.\n\nAnswer each numbered question, including the ones where nothing is wrong:\n1. 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.\n2. 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.\n3. 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.\n4. 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.\n5. 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.\n6. 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?\n7. 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.\n\nNot 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.\n\nFor 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.","parentJobId":null,"planHash":"a6718f6719692b4da98ba74130c9e1d1394aa68446e7c902b94072d701e2afeb","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"4884eb75-58db-404a-b2b5-5c788df3c487","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":"52606","feedbackHash":"22b2bd457601952796a59a635c90e47195cd0036184ff93234272d2f41cb7444","nodeKey":"audit_economics","submissionHash":"510326e9dd75756f4eadc4e34a808bc8098f05246d2138040864b48f4ec11842","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50986","feedbackHash":"bfc28e625c04befb931b4a457d4d693ca3f1ef5bdb5efa2d766c0cfa6025da9d","nodeKey":"audit_flow","submissionHash":"e73e76232b3bcd3594e227c1ab8511ae93b0939e3df275f87b2c3fab1b723787","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51465","feedbackHash":"aa124f3638deb8d4b8091426a7d42329f9e85dc801cbe7eae45da84cc90e62fb","nodeKey":"audit_judge","submissionHash":"bbfdb26c09bfccb722f23c11a3b0ea18c70ca5e2ebd8e4635c77274653743cc8","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52251","feedbackHash":"4c2d4f24f7a87898ad7f2a7ee78a53cf3e7084921d1fd449e306046045a93df9","nodeKey":"audit_math","submissionHash":"1374d86e595257708f64af8cc777c8134c6c702941e2ce2c192115cffdeae6d7","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52608","feedbackHash":"c35c7d31f9eb101ed4f6838ea6889a39ac271c4f5ed428859cb3b97d0d2fff38","nodeKey":"audit_permissions","submissionHash":"fb85147b34e5a696480e69bdfd8a4ff889010b2207e2ea80b24c82cd5de8cf7a","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"bfce7c5d45b42092c1e476a468138299004b6ea00de47ef151e7a228eaaf16c7","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1731fbfe0c4574fb","findings":[{"citation":"resolved","description":"_balanceAndAdd picks the balancing direction itself from the live pool price (VaultSwaps.splitAmount) and then compares the summed output, in whichever token came out, with one bare number (lines 490-494: `if (swapIn != 0) { if (minSplitOut == 0) revert ZeroFloor(); if (swapOut < minSplitOut) revert InsufficientOutput(); ...`). ConvertParams carries no expected direction and no floor on the liquidity added, and when splitAmount returns 0 the floor is not read at all. INFER and IMD wei are not comparable: script/config/1.json records 1 IMD = 3,887 INFER. A floor the keeper computed in IMD wei for an INFER -> IMD swap is therefore ~3,887x too loose once the direction is IMD -> INFER. Any batch (plus free leftovers) that holds both sides and is INFER-heavy has a price k* = imdSide / inferSideValue below which the direction flips; just under it a tiny IMD -> INFER swap returns more INFER wei than the IMD-wei floor, so the call succeeds and the whole batch is added full range at the moved price. Liquidity units fall to 2*sqrt(k)/(1+k)-style: for a 3:1 batch 86.6% of the quote, for 9:1 60%. Whoever moved the pool buys INFER back against the new, permanent liquidity and keeps the difference; the batch's benefactors hold correspondingly fewer shares for good. INFER in the batch is also valued at that swap's rate (line 362), so the INFER contributor's part of the batch falls from 74.8% to 50.3% in the test. This is the gap between quote and landing that the floors exist for (docs: 'the keeper floors are the protection'; AUDIT-NOTES only accepts a move made BEFORE the quote). The mirror case (IMD-heavy quote, floor in INFER wei) fails safe because the flipped output is far below the floor, so this is one-sided at the mainnet price. Call sequence: benefactors -> receiveContribution(INFER), receiveContribution(IMD or ETH/USDC...); keeper simulates, sends convertAndAddLiquidity(p) with minSplitOut = 99% of the quoted IMD out; a searcher/builder that sees the call (or a keeper bug that submits publicly, or a stale quote after a real 3x move) sells INFER into the pool first; the keeper's call succeeds; the searcher buys back. The keeper needs no dishonesty. Cost to the mover is two 1.25% fees on the volume needed to move the pool (about 5,170 IMD of volume, ~130 IMD of fees, for 3x on today's 12,220 IMD of virtual depth), so it pays for batches above roughly 1,000 IMD (~1.1 ETH) at 3:1 and sooner for more lopsided mixes. Reachable with script/config/1.json as is (INFER, IMD and ETH are payment tokens; pool fee 12500). Smallest fix: make the floor mean one thing. Either add the expected direction to ConvertParams and revert when the realized first-pass direction differs (`bool splitInferToImd`), or, better, add `uint256 minLiquidity` and `if (liquidity < p.minLiquidity) revert InsufficientLiquidity();` after _balanceAndAdd, which also covers the no-swap case. Both add a field to ConvertParams (the interface the doc calls fixed), so that is a scope decision; the test below builds the struct field by field and must set the new field from its quote.","line":492,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"Pool at the config's price (sqrtPriceX96 for 1 IMD = 3,887 INFER, INFER currency0, fee 12500), empty vault. alice contributes 583,050e18 INFER (150 IMD worth), bob 50e18 IMD. Keeper quote: INFER -> IMD, about 49.4e18 IMD out, liquidity 6210162370196709362434; alice 74.76% of the batch. Keeper sends minSplitOut = 99% of the quoted out (about 48.9e18). Pool moved to 33.30% of the fair INFER price before the call lands. Expected: revert InsufficientOutput (the pool is 3x off the quote). Actual: succeeds, liquidity 5399289195416874595696 (86.9% of the quote, 13.1% under a 1% floor), alice 50.28% of the batch. Run: forge test --offline --match-path test/scratch/SplitFloorDirection.t.sol -vv -> FAIL 'landed beyond the keeper's floor: 5399289195416874595696 < 6023857499090808081560'.","severity":"medium","snippet":"if (swapOut < minSplitOut) revert InsufficientOutput();","title":"minSplitOut has no direction (and is skipped when no swap runs): a pool move that flips the balancing swap clears the keeper's floor and the batch lands with 13% less liquidity"},{"citation":"resolved","description":"_distribute (lines 652-663) credits `_protocolFees[i]` with the whole amount 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, ETH forced in or sent through the inherited payable Ownable functions), and through _modifyPosition -> _creditYield in passes 2 and 3 for the fees the vault's own balancing swaps paid to its just-added liquidity. On a vault's first batch all of that goes to registry.treasury() via withdrawProtocolFees instead of to the batch that paid it. The in-code comment says 'keep it accounted'; AUDIT-NOTES ('A conversion's own swap fees', Final sweep) says 'On a first conversion there are no holders, so they stay in the batch', and the docs say ETH dust carries 'no cut'. Every vault deployed by deployVault has a first batch, so this is reachable with script/config/1.json; the amount is the leg's unspent ETH (bounded only by the leg's minOut) plus the pass-2/3 fee share. Smallest fix: when totalShares == 0 leave the amount unbooked (INFER/IMD then stay free and the same call's next pass, or the next batch, adds them; for ETH skip the distribution at line 367 while totalShares == 0 so it is distributed after the first shares exist), or call _distribute for ETH after _closeBatch on a first batch.","line":656,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"Fresh vault. alice contributes 583,050e18 INFER, bob 150e18 IMD. 0.01 ETH sits in the vault outside any contribution (vm.deal; on mainnet: a leg refund or selfdestruct). Keeper converts both tokens. Expected: the 0.01 ETH is dust for the first holders (or stays unbooked). Actual: feeStats(address(0)).protocolFees == 10000000000000000 and claimable ETH of alice and bob is 0. Test test_firstConversion_dustGoesToProtocol fails with 'stray ETH on the first batch went to the protocol: 10000000000000000 != 0'.","severity":"low","snippet":"_protocolFees[i] += amount;","title":"First conversion: ETH dust, leg refunds and the conversion's own swap fees are booked 100% to the protocol, not kept in the batch as AUDIT-NOTES states"},{"citation":"resolved","description":"_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), and that 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 (line 618). 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 (a token-only splitter, a contract written for the default TOKENS mode), 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. 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.","line":813,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"Benefactor C is a contract with no receive(). C contributes 150e18 IMD, alice 583,050e18 INFER; keeper converts (first batch). 1000 wei of ETH reaches the vault outside a contribution; bob contributes 10e18 IMD and the keeper converts it, which distributes the 1000 wei: claimable(C) = (0, 0, 450). C calls claimFees(ClaimMode.TOKENS, 0). Expected: C is paid its tokens (ETH dust kept claimable). Actual: revert TransferFailed(); the same revert hits every later claim, whatever INFER/IMD C has earned. Test test_tokensClaim_blockedByEthDust fails with TransferFailed().","severity":"low","snippet":"if (!ok) revert TransferFailed();","title":"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"},{"citation":"resolved","description":"claimFees(ClaimMode.REINVEST, 0) reaches _reinvest with minShares = 0. The INFER/IMD balancing swap at line 835 has no floor of its own and line 843 is the only check, so with 0 it accepts any outcome. The conversion's balancing swap (ZeroFloor at line 491), ETH-mode claims (line 802) and every keeper swap (VaultSwaps._exec) all refuse a zero floor; this path is the exception, and integrators that pass minEthOut through (the collections' claimVaultFees(mode, minEthOut)) inherit it. Only the claimant's own fees are at stake. Smallest fix: `if (minShares == 0) revert ZeroFloor();` at the top of _reinvest.","line":843,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"A benefactor with 1,000e18 INFER of claimable fees and shares calls claimFees(ClaimMode.REINVEST, 0) after the pool was pushed to half the INFER price: half of the INFER is sold at the pushed price and the add mints about 0.707x the shares of an unmoved pool. Expected (as for mode ETH, which reverts ZeroFloor on 0): revert. Actual: succeeds with no bound.","severity":"info","snippet":"if (l < minShares) revert InsufficientOutput();","title":"REINVEST accepts a zero floor: its balancing swap runs unprotected when minEthOut is 0, unlike every other swap in the vault"},{"citation":"resolved","description":"Not guarded: unlockCallback (line 926, guarded by msg.sender == poolManager instead, which holds because PoolManager only calls back the address that called unlock and the PoolManager cannot be a swap target), delegateBenefactor (line 1041, callable from inside a keeper swap: it only changes the caller's own delegate), disableInitializer/initialize, receive() while the collecting flag is set, and Solady Ownable's transferOwnership, renounceOwnership, requestOwnershipHandover, cancelOwnershipHandover and completeOwnershipHandover, which the vault inherits as external payable functions. requestOwnershipHandover and cancelOwnershipHandover are open to anyone and accept ETH without passing receive(), so ETH can enter the vault unbooked at any time, including with the collecting flag clear (it becomes dust at the next conversion; see the first-conversion finding for where it goes then). No loss found from any of these; the comment overstates the guard. Related comment defects: the first @dev above _batchShares (lines 553-554, 'Split the batch's liquidity across its tokens by realized IMD value; each token's pool pays its part...') describes _closeBatch, which has no NatSpec; the _distribute comment 'keep it accounted' (line 655) hides that the amount becomes protocol fees.","line":45,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"Any address calls vault.requestOwnershipHandover{value: 1 wei}() on a deployed vault: it succeeds, address(vault).balance rises by 1 wei, no ContributionReceived event, pendingByToken(address(0)) unchanged. Expected from the header comment: every entry point is nonReentrant and ETH enters only through receive()/receiveContribution. Actual: five inherited payable functions and two external functions carry no guard.","severity":"info","snippet":"*      - receive() ignores ETH the vault pulls in itself (transient flag), everything nonReentrant;","title":"NatSpec: \"everything nonReentrant\" is not true of unlockCallback, delegateBenefactor and the inherited payable Ownable entry points"},{"citation":"resolved","description":"The function comment (lines 77-80) says a target must never be 'a token, the PoolManager, the registry, or an EOA (refused)' and docs/IMD-INFER-VAULT.md says 'EOAs, the tokens, the PoolManager and the registry are refused'. The check refuses INFER, IMD, the PoolManager, the registry, the factory and code-less addresses only. The other payment tokens in script/config/1.json (USDC, imdUSD, cbBTC, WBTC), Permit2, WETH and the vault clones themselves are accepted. I found no way for the keeper to profit from a token as target under the current guards (the input must drop by exactly amountIn and ETH or IMD must rise in the same call, which a plain ERC-20 call cannot do), so this is a trust-assumption gap, not a loss: the allowlist is the one place the Safe can undo the calldata guards, and the on-chain check is narrower than written. Owner powers, for the record: setSwapTarget(x, false) for every target stops all conversions that need a swap or the ETH leg (reversible); setEthPool to a pool that does not exist stops ETH-mode claims only (reversible); setMinContribution(token, huge) makes that token's cuts park in the collections (reversible); the factory's inherited renounceOwnership freezes all three forever (irreversible); each vault's zRouter is fixed at initialize and ETH-mode claims approve it without consulting the allowlist, so the Safe cannot retire it for claims. Smallest fix: refuse registry.isPaymentToken(target) and registry.isVault(target) in setSwapTarget, or correct the comments.","line":84,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"factory.setSwapTarget(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 /* USDC */, true) from the owner. Expected per the NatSpec and the design doc: revert InvalidAddress. Actual: succeeds, isSwapTarget(USDC) == true and the vault will approve and call the USDC contract with keeper calldata.","severity":"info","snippet":"&& (target.code.length == 0 || target == infer || target == imd || target == address(poolManager)","title":"NatSpec and docs say swap targets that are tokens are refused; setSwapTarget only refuses INFER and IMD"}],"hash":"1374d86e595257708f64af8cc777c8134c6c702941e2ce2c192115cffdeae6d7","nodeId":"efe6fcf6-34ac-4bba-8a4e-acebf3553663","outcome":"completed","summary":"The review is written to `.imd-findings.json`: six findings (1 medium, 2 low, 3 info), no critical or high. The three with a proof each fail on this commit when run standalone under `test/scratch/`; the three info findings were found by reading and have no test. No source files were changed.\n\nWhat ran: the three scratch proofs on a simulated pool. The repository's own unit, invariant and fork suites were not run, and nothing was checked against the real PoolManager, zRouter or AllowanceHolder. Q1–Q4 and Q7 below are argued from the code, not from new tests. Only the math pass (Math Precision, Boundary, Numerical Gap) was done; the access-control, economics and control-flow passes were not.\n\n## Findings\n\n1. **Medium — `minSplitOut` has no direction** (`UniAlignmentVault.sol:492`). The floor is one bare number compared with whichever token the balancing swap outputs, and is not read at all when no swap runs.\n   - **Trigger:** an INFER-heavy mixed batch quoted as INFER → IMD, then the pool pushed far enough to flip the direction. A tiny IMD → INFER swap returns INFER wei (1 IMD = 3,887 INFER), which clears a floor meant in IMD wei.\n   - **Measured:** 150 IMD-worth of INFER plus 50 IMD, floor 1% under the quote, pool moved to 33.3% of the INFER price. The call succeeds with 86.9% of the quoted liquidity, and the INFER contributor's part of the batch falls from 74.76% to 50.28%.\n   - **Reach:** needs someone to move the pool between quote and landing (a builder, a leaked transaction, a stale quote); the keeper need not be dishonest. Reachable with `script/config/1.json`. The mirror case (IMD-heavy quote) fails safe.\n   - **Fix:** add an expected direction or, better, a `minLiquidity` floor to `ConvertParams`. Either adds a field to an interface the design doc calls fixed, so it is your decision; the proof test must then set that field.\n\n2. **Low — a first conversion books dust to the protocol** (`:656`). With no shares yet, `_distribute` credits 100% to `_protocolFees`: stray ETH, leg refunds, and the conversion's own pass-2/3 swap fees. AUDIT-NOTES says these \"stay in the batch\". Measured: 0.01 ETH stray on a first batch ends as `protocolFees == 0.01 ETH`.\n\n3. **Low — ETH dust can lock TOKENS-mode claims** (`:813`). `_payout` pushes the ETH dust slot with INFER and IMD and reverts the whole claim if the send fails. Anyone can create dust for a few wei, so a benefactor or delegate contract without a payable `receive` can no longer claim. Measured: `claimable = (0, 0, 450)`, then `TransferFailed()`. The repository's collections have `receive()` and are unaffected.\n\n4. **Info — REINVEST accepts a zero floor** (`:843`), unlike every other swap in the vault.\n\n5. **Info — \"everything nonReentrant\" overstates** (`:45`). `unlockCallback`, `delegateBenefactor` and the inherited payable Solady Ownable functions are unguarded; `requestOwnershipHandover{value}` lets ETH in unbooked. No loss found.\n\n6. **Info — `setSwapTarget` refuses only INFER and IMD** (`UniAlignmentVaultFactory.sol:84`), while the NatSpec and design doc say tokens are refused. USDC, WBTC, cbBTC, imdUSD, Permit2 and vault clones are accepted. No keeper profit path found under the current guards.\n\n## Answers\n\n1. **Pending ledger — holds.**\n   - Balances only round down: each conversion sets `p' = floor(p·rem/pending)` and adds are exact, so the sum of balances never exceeds `pendingByToken`.\n   - A partial conversion of a of P scales every balance by at most rem/P, exact to 1 part in 1e9.\n   - Shares are `amount·Δs/p0` with `ds = floor(shares·p/pending)`, so their sum never exceeds the token's shares. Each token has its own record, so several pending tokens settle independently.\n   - Dropped dust is under 1e-9 of a balance or of pending, as documented.\n   - Not in the notes: fees on not-yet-collected shares round to zero when `ds·Δacc/1e36 < 1` (low `p`, cheap 18-decimal token). This favours the vault, is tiny, and is unowned.\n\n2. **Shares for a batch — holds for share arith","treeHash":null,"usage":{"cachedInputTokens":1797789,"cost":{"source":"runtime","usdMicros":8123467},"inputTokens":28,"model":"claude-fable-5-1","outputTokens":80446,"runtime":"claude","turns":21,"wallClockMs":894080}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"829d84438fb3fdc8","findings":[{"citation":"resolved","description":"_balanceAndAdd balances and swaps ALL free INFER/IMD (_freeAlignmentBalances: balance - fees owed - 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, a single total over all passes and over both the batch's and the leftovers' tokens. So the keeper's worst case is not bounded by '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. Call sequence: keeper -> convertAndAddLiquidity(p) -> _collectFees -> before = _freeAlignmentBalances() -> _takePending -> _toImd -> _balanceAndAdd(p.minSplitOut) -> _alignSwap(z, s) where s is sized on (batch + leftover). Reachable with script/config/1.json (keeper set in the registry; leftovers arise from every multi-pass add, and anyone can send IMD/INFER to the vault). Impact is bounded by the size of leftovers, which are normally small against a deep pool, hence Low. Smallest fix: size the balancing swap on the batch's own INFER/IMD only and add leftovers without swapping (or with a vault-computed bound such as minSplitOut >= quote-derived floor scaled to the total swapped), or document that the keeper floor also covers holders' leftovers.","line":492,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"State (VaultTestBase, pool 1 IMD per INFER, fee 12500): alice contributes 10 ETH and the keeper converts (totalShares > 0). Anyone transfers 1000e18 IMD to the vault (stray/leftover; free, owned by alice's shares per _batchShares). bob contributes 1e18 IMD (config minimum). Keeper calls convertAndAddLiquidity with tokens=[IMD], amounts=[1e18], minSplitOut=1. Expected (per the stated trust model): the only value exposed to the keeper's floor is bob's 1 IMD batch. Actual: SplitSwap(inferToImd=false, amountIn ~= 503e18, ...) — about 500 IMD of alice's leftover is swapped IMD->INFER with a 1-wei floor; on mainnet a keeper (or its key holder) sandwiching that swap takes up to ~500 IMD of earlier holders' value while bob's batch is ~0.5 IMD of it.","severity":"low","snippet":"if (swapOut < minSplitOut) revert InsufficientOutput();","title":"Keeper's minSplitOut also covers earlier holders' free INFER/IMD (leftovers, strays): a keeper can sell value beyond the batch at its own floor"},{"citation":"resolved","description":"The NatSpec (and docs/IMD-INFER-VAULT.md: 'EOAs, the tokens, the PoolManager and the registry are refused') claim token contracts are refused. The check at line 84 refuses only code-less addresses, infer, imd, poolManager, registry and the factory. Other payment tokens (USDC, WBTC, cbBTC, imdUSD) and any other ERC-20 can be allowlisted. Not directly exploitable as I traced it, because VaultSwaps._exec requires the input balance to drop by exactly amountIn AND the output (ETH/IMD) to rise by minOut > 0 within one call to the target, which a plain token call cannot satisfy; but it is a guard the docs promise and the code lacks, and an owner mistake would rely solely on those balance checks.","line":80,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"factory.setSwapTarget(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 /* USDC, has code */, true) called by the owner: expected revert InvalidAddress per the NatSpec; actual succeeds and isSwapTarget(USDC) == true. Fix: also refuse `IMiyaRegistry(registry).isPaymentToken(target)` (and fix the comment).","severity":"info","snippet":"///         belongs here: never a token, the PoolManager, the registry, or an EOA (refused).","title":"setSwapTarget NatSpec says tokens are refused as swap targets; only INFER and IMD are"},{"citation":"resolved","description":"After every batch, convertAndAddLiquidity checks balance >= pending for every open token, including tokens not in the batch. If any pending token's balance at the vault falls below its pendingByToken by any means outside the vault (issuer seizure/wipe, a negative rebase, a token upgraded to charge on balance), every later batch — including ETH-only or IMD-only batches — reverts Insolvent(t) until that token's pending is gone, and that token's pending 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 (none of IMD, INFER, USDC, imdUSD, cbBTC, WBTC can lower the vault's balance outside its own transfers, as far as their public contracts go), hence Info.","line":378,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"State: pendingByToken[T] = 100e6, balanceOf(vault, T) = 99e6 (T's issuer removed 1e6). Keeper calls convertAndAddLiquidity(tokens=[ETH], amounts=[pendingByToken[ETH]], ...): expected success (T is not touched by the batch); actual revert Insolvent(T). Smallest fix: snapshot each open token's balance before the swaps and require balanceAfter >= min(balanceBefore, pending) for tokens not in the batch (still catches any swap seizing them).","severity":"info","snippet":"if (_balanceOf(t) < pendingByToken[t]) revert Insolvent(t);","title":"One open token whose held balance drops below its pending blocks every conversion, not just that token's"},{"citation":"resolved","description":"claimFees(ETH) refuses minEthOut == 0 (ZeroFloor), conversions refuse minSplitOut == 0 when a swap happens and every SwapCall refuses minOut == 0. claimFees(REINVEST, minShares) runs a vault-side balancing swap in the alignment pool (_alignSwap) but accepts minShares == 0, so a reinvest submitted with 0 has its swap unprotected against a sandwich. Self-chosen by the claimant (a collection's claimVaultFees passes the owner's value), so Info; it contradicts the pattern stated in the header ('the keeper's floors as the price protection', 'minOut ... must be non-zero').","line":843,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"Benefactor with 100e18 INFER and 0 IMD claimable calls claimFees(ClaimMode.REINVEST, 0): expected ZeroFloor (as in ETH mode); actual executes _alignSwap(true, ~50e18) with no bound; a sandwich moving the INFER/IMD price before it lowers the shares minted with no revert. Fix: `if (minShares == 0) revert ZeroFloor();` in _reinvest.","severity":"info","snippet":"if (l < minShares) revert InsufficientOutput();","title":"REINVEST accepts a zero floor (minShares = 0), unlike every other swap path"},{"citation":"resolved","description":"_batchShares issues _sharesFor(ownLiquidity, before[2] + leftoverLiquidity) <= liquidity, and _reinvest issues l * totalShares / totalLPUnits; so after any batch that adds leftovers, totalShares < totalLPUnits and a share is worth more than one unit. The header (line 34) states shares = liquidity units. Also lines 553-554 are _closeBatch's @dev left on _batchShares, and 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.","line":34,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"Second batch with any free leftover (before[0] or before[1] > 0): BatchConverted.sharesIssued < liquidity added, totalShares() < totalLPUnits(). Fix: reword the comments.","severity":"info","snippet":" *         Shares (= liquidity units) go to each token's benefactors in proportion to the IMD value the converted amount","title":"NatSpec claims shares equal liquidity units; after leftovers or a reinvest they do not"}],"hash":"510326e9dd75756f4eadc4e34a808bc8098f05246d2138040864b48f4ec11842","nodeId":"2ce042af-faeb-4ffd-91ab-9f51545b88ab","outcome":"completed","summary":"I found no critical, high or medium defects in `src/vaults/uni/`. There is one Low finding and four Info items, all written to `.imd-findings.json`. Every result comes from reading the code: I did not compile or run anything, because a full build with the tests takes several minutes and a lot of memory, so I put the time into reading. None of the five findings has a Foundry proof (proofs are only required for critical and high findings). Each one gives the exact failing state and what should happen against what does.\n\n## Findings\n\n| # | Sev | Where | Issue |\n|---|---|---|---|\n| 1 | Low | `UniAlignmentVault.sol:492` | `minSplitOut` is the only floor on the balancing swap, and that swap also sells earlier holders' free INFER/IMD (leftovers, strays). Example: holders already have 1000 IMD free and a new 1 IMD batch is converted with `minSplitOut=1`. About 503 IMD is swapped IMD→INFER, roughly 500 of it the earlier holders', protected only by the keeper's 1-wei floor. So the keeper can sell more than \"a batch at its own floors\", up to the size of the leftovers (normally small). |\n| 2 | Info | `UniAlignmentVaultFactory.sol:80` | The NatSpec and the design doc say tokens are refused as swap targets. The code only refuses INFER and IMD: `setSwapTarget(USDC,true)` succeeds. I found no way to exploit it, because the balance checks in `_exec` stop it. |\n| 3 | Info | `UniAlignmentVault.sol:378` | If any open token's held balance falls below its pending (issuer wipe, rebase), every batch reverts `Insolvent(t)`, even an ETH-only one. No token in `1.json` can do this. |\n| 4 | Info | `UniAlignmentVault.sol:843` | REINVEST accepts `minShares=0`. Every other swap path in the vault refuses a zero floor. |\n| 5 | Info | `UniAlignmentVault.sol:34` | Comments that don't match the code (listed at the end). |\n\n## Answers to the questions\n\n**1. Pending ledger: holds.**\n- **Balances never exceed what was contributed less what converted.** Each conversion multiplies every balance by at most remaining/pending, a re-snapshot stores the rounded-down balance, and the rescale path keeps the same bound. So the balances add up to no more than `pendingByToken`.\n- **Partial conversions.** Shares per conversion are `amount·ds/p0` with `ds = shares·p/pending` rounded down, so a partial conversion of a of P takes exactly a/P of each balance, rounded toward the vault.\n- **Fees on lazily collected shares** use `ds·acc/ACC − Σ⌈ds_k·acc_k/ACC⌉`, so they only include fees after each conversion.\n- **Several tokens per benefactor.** Each token has its own pool, so a benefactor gets exactly the shares for what converted and keeps the rest pending.\n- **Dust.** A remainder below 1/p, or anything past a second rescale, is dropped. The documentation's bounds are correct (P_MIN = SCALE makes its two thresholds coincide).\n- **Early return in `settle`.** It is safe: any amount above 0 lowers p by at least 1, and epochs and scales only move forward.\n\n**2. Shares for a batch: holds for the batch.**\n- `own` is the batch's value in IMD and `left` is the earlier free INFER/IMD valued at the same realized rate. The batch is priced at `totalShares/(units + leftover liquidity)`.\n- I worked through both directions (an IMD-heavy batch with INFER leftovers, and the reverse). The batch gets the same value it would have alone; the fees saved by pairing with leftovers go to the earlier holders.\n- Shares never exceed liquidity units: `_sharesFor` is at most its input because `totalShares ≤ totalLPUnits`.\n- Accepted edge: leftovers that the add can't use carry into the next batch's `before`.\n- The keeper-floor exposure is finding 1.\n\n**3. Fees and claims: holds.**\n- Fees are booked before every change to shares: at the start of a conversion, inside `_modifyPosition` during the balancing adds, and in reinvest. Batch shares start earning from `acc` as it stands at close.\n- Held amounts always equal distributed − claimed + protocol cut. `_unharvest` keeps that true, including when the swap output e","treeHash":null,"usage":{"cachedInputTokens":1036360,"cost":{"source":"runtime","usdMicros":2405571},"inputTokens":23,"model":"claude-opus-5-5","outputTokens":55067,"runtime":"claude","turns":16,"wallClockMs":560237}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"f0c4ae5b8ada215c","findings":[{"citation":"resolved","description":"_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.","line":492,"path":"src/vaults/uni/UniAlignmentVault.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.24;\n\nimport {Vm} from \"forge-std/Vm.sol\";\nimport {FixedPointMathLib} from \"solady/utils/FixedPointMathLib.sol\";\nimport {VaultTestBase} from \"../vaults/VaultTestBase.sol\";\nimport {UniAlignmentVault} from \"../../src/vaults/uni/UniAlignmentVault.sol\";\n\n/// @notice minSplitOut is one bare number compared with the balancing swap's output in whichever token came out.\n///         Quote: INFER -> IMD, floor in IMD wei. If the pool moves so the batch becomes IMD-heavy, the swap runs\n///         IMD -> INFER and its INFER-wei output (3,887 INFER per IMD) clears an IMD-wei floor trivially, so a batch\n///         lands 13% under the keeper's 1% floor. Fails on this commit; passes once the floor carries a direction or\n///         a minimum liquidity (either makes the second call revert).\ncontract SplitFloorDirectionTest is VaultTestBase {\n    bytes32 constant SPLIT_SIG = keccak256(\"SplitSwap(bool,uint256,uint256)\");\n\n    function _splitOut() internal returns (bool dir, uint256 amtIn, uint256 out) {\n        Vm.Log[] memory logs = vm.getRecordedLogs();\n        for (uint256 i; i < logs.length; ++i) {\n            if (logs[i].topics[0] == SPLIT_SIG && logs[i].emitter == address(vault)) {\n                (dir, amtIn, out) = abi.decode(logs[i].data, (bool, uint256, uint256));\n            }\n        }\n    }\n\n    function test_splitFloor_hasNoDirection() public {\n        // 1 IMD = 3,887 INFER (script/config/1.json), INFER is currency0, depth of the live pool.\n        uint160 sqrtP = uint160(FixedPointMathLib.sqrt((uint256(1) << 192) / 3887));\n        vault.setMockPool(sqrtP, 7.62e23);\n        _contribute(alice, infer, 583_050e18); // ~150 IMD worth\n        _contribute(bob, imd, 50e18);\n\n        // The keeper's quote at the current price: INFER -> IMD, ~49.4 IMD out.\n        uint256 snap = vm.snapshotState();\n        vm.recordLogs();\n        _convert();\n        (bool dir,, uint256 quotedOut) = _splitOut();\n        uint256 quotedLiquidity = vault.totalLPUnits();\n        vm.revertToState(snap);\n        assertTrue(dir, \"quote direction is INFER -> IMD\");\n        uint256 floor = quotedOut * 99 / 100; // IMD wei\n\n        // The pool is pushed to 33.30% of the INFER price before the keeper's call lands.\n        uint160 sqrtP2 = uint160(FixedPointMathLib.sqrt(((uint256(1) << 192) * 3330 / 10000) / 3887));\n        vault.setMockPool(sqrtP2, 7.62e23);\n\n        UniAlignmentVault.ConvertParams memory p = _params(vault);\n        p.minSplitOut = floor;\n        vm.prank(keeper);\n        try vault.convertAndAddLiquidity(p) {\n            // If the call lands, the floor must have held: at least 99% of the quoted liquidity.\n            assertGe(vault.totalLPUnits(), quotedLiquidity * 99 / 100, \"landed beyond the keeper's 1% floor\");\n        } catch {\n            // A floor that binds reverts here (InsufficientOutput): the fixed behaviour.\n        }\n    }\n}","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.","severity":"medium","snippet":"            if (swapOut < minSplitOut) revert InsufficientOutput();","title":"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"},{"citation":"resolved","description":"_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.","line":654,"path":"src/vaults/uni/UniAlignmentVault.sol","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.","severity":"low","snippet":"        if (totalShares == 0) {\n            // Nobody to credit (only before the first conversion closes): keep it accounted.\n            _protocolFees[i] += amount;","title":"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"},{"citation":"resolved","description":"_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.","line":813,"path":"src/vaults/uni/UniAlignmentVault.sol","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.","severity":"low","snippet":"            if (!ok) revert TransferFailed();","title":"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"},{"citation":"resolved","description":"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.","line":841,"path":"src/vaults/uni/UniAlignmentVault.sol","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.","severity":"low","snippet":"        l = _sharesFor(l, unitsBefore); // shares, at the share price before the add","title":"REINVEST mints at totalShares / totalLPUnits and ignores free INFER/IMD leftovers, so a reinvestor takes part of what belongs to earlier holders"},{"citation":"resolved","description":"_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.","line":472,"path":"src/vaults/uni/UniAlignmentVault.sol","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.","severity":"low","snippet":"            (uint256 freeInfer, uint256 freeImd) = _freeAlignmentBalances();","title":"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"},{"citation":"resolved","description":"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.","line":84,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","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.","severity":"info","snippet":"                && (target.code.length == 0 || target == infer || target == imd || target == address(poolManager)\n                    || target == address(registry) || target == address(this))","title":"setSwapTarget NatSpec and docs say tokens are refused as swap targets; only INFER and IMD are"},{"citation":"resolved","description":"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.","line":843,"path":"src/vaults/uni/UniAlignmentVault.sol","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.","severity":"info","snippet":"        if (l < minShares) revert InsufficientOutput();","title":"REINVEST accepts a zero floor (minShares = 0): its balancing swap is the only vault swap without a mandatory floor"},{"citation":"resolved","description":"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.","line":378,"path":"src/vaults/uni/UniAlignmentVault.sol","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.","severity":"info","snippet":"            if (_balanceOf(t) < pendingByToken[t]) revert Insolvent(t);","title":"One open token whose held balance drops below its pending blocks every conversion, not just that token's"},{"citation":"resolved","description":"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.","line":45,"path":"src/vaults/uni/UniAlignmentVault.sol","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().","severity":"info","snippet":" *      - receive() ignores ETH the vault pulls in itself (transient flag), everything nonReentrant;","title":"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"}],"hash":"bbfdb26c09bfccb722f23c11a3b0ea18c70ca5e2ebd8e4635c77274653743cc8","nodeId":"526a29e3-0133-4596-8cf5-79f65217fa21","outcome":"completed","summary":"The add-rounding check holds across realistic prices and amounts, so that is not a finding. The findings file is complete and verified. Here is the review.\n\n## Outcome\n\n`.imd-findings.json` holds 9 findings: 1 medium, 4 low, 4 info. Every specialist claim reproduced against the code; none was dropped. Three reports of the first-conversion protocol booking were merged into one, four reports of the `setSwapTarget` comment mismatch into one, two of the reinvest zero floor into one, and the comment defects into one. The medium carries a proof (`test/scratch/SplitFloorDirection.t.sol`), which fails on this commit with the stated reason. All reproductions ran in `test/scratch/Review.t.sol` using the repository's harnesses, offline, in 45 seconds of compile.\n\n| Sev | Finding | Reproduced |\n|---|---|---|\n| medium | `minSplitOut` has no direction and is skipped when no swap runs: a pool push that flips the balancing swap clears the floor and the batch lands at 87% of the quoted liquidity | yes, proof attached |\n| low | First conversion books ETH dust, leg refunds and pass-2/3 swap fees 100% to the protocol, contrary to AUDIT-NOTES | yes |\n| low | TOKENS-mode claim pushes ETH dust in the same call: a payee without `receive()` is locked out of INFER/IMD fees once dust exists | yes |\n| low | REINVEST prices at `totalShares / totalLPUnits`, ignoring free leftovers that `_batchShares` assigns to earlier holders | yes |\n| low | The keeper's one floor also covers earlier holders' free INFER/IMD, so its worst case is not bounded by the batch | yes |\n| info | `setSwapTarget` refuses only INFER and IMD among tokens; NatSpec and docs say tokens are refused | yes |\n| info | REINVEST accepts a zero floor, unlike every other vault swap | yes |\n| info | One open token with balance below pending blocks every batch, not just its own | by code reading |\n| info | Comment and NatSpec claims the code lacks (listed below) | partly by test |\n\n## Answers to the numbered questions\n\n**1. Pending ledger.** Holds. Balances are `amount * p / p0` rounded down, so each is at most the contributed fraction remaining and their sum never exceeds `pendingByToken`, which is reduced by exactly the converted amount. A conversion of a out of P multiplies p by (P-a)/P, so every balance converts the same fraction, rounded toward the vault. Shares use `ds = shares * p / pending`, so a benefactor gets shares times its fraction of the pool, per token; fees use `sa` rounded up and clamp at zero. Rescaling at 1e9 and the two-scale window behave as documented. No finding.\n\n**2. Shares for a batch.** Holds up to a second-order term. `_batchShares` prices the batch after the leftover's liquidity is in and issues at most `liquidity` shares, so totalShares never exceeds LP units. The leftover INFER is valued at the realized swap rate although only part of it was swapped, which gives a batch about 0.6% of a large INFER stray's value too much (info, listed in the comments finding). The real gap is on the other side: the batch's shares are protected, but the leftover's tokens are swapped under the keeper's floor (low).\n\n**3. Fees and claims.** Holds. New shares earn from the `acc` snapshot taken in `_closeBatch`, so lazily collected shares earn only after their conversion. Claims plus protocol cut never exceed fees distributed; `_harvest` moves settled amounts to claimed and the payee is the benefactor or its delegate only. Reinvest prices at `unitsBefore` as the comment says, but that price ignores leftovers (low), and TOKENS claims can be blocked by dust (low). The first conversion sends its own fees and dust to the protocol instead of the batch (low).\n\n**4. Aggregator calldata.** Holds for everything the vault approves. The target is checked live, approval is exactly the amount in and reset, the input must drop by exactly that amount and the output rise by at least a non-zero floor. INFER and IMD are never approved during a batch, so free leftovers and fees cannot be pulled; ETH cannot ","treeHash":null,"usage":{"cachedInputTokens":1531714,"cost":{"source":"runtime","usdMicros":4885862},"inputTokens":418,"model":"claude-fable-5-1","outputTokens":39152,"runtime":"claude","turns":23,"wallClockMs":592349}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ef5038c1bdac3372","findings":[{"citation":"resolved","description":"_distribute() books the whole amount as _protocolFees when totalShares == 0. During the first convertAndAddLiquidity, totalShares is still 0 until _closeBatch runs, yet three things are distributed before that: (1) the position fees _modifyPosition collects on LP passes 2 and 3 (the pass-1 balancing swap in the hooked INFER/IMD pool pays 1.25% to LPs, and after pass 1 the vault's own position is one of them), (2) ETH beyond what is owed (line 366-367: a leg refund or a stray, which the comment calls 'dust for current holders'), and (3) a rejected keeper reward. All of it is counted in _feeOwed, so _free() excludes it from the batch, and the batch's benefactors, the only holders, get none of it. AUDIT-NOTES.md ('A conversion's own swap fees', final sweep) states: 'On a first conversion there are no holders, so they stay in the batch.' The code does the opposite: the treasury takes 100% instead of its 1% cut. Bounded: one batch per vault (the first), amounts are the vault's share of the pass-1 swap fee plus strays; with 1.json this is reachable on every new vault's first batch. Smallest fix: in _distribute, when totalShares == 0 return without booking (the tokens then stay free and are added with the batch, which is what the notes promise), or defer the three distributions until after _closeBatch so the new shares receive them.","line":654,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"VaultTestBase harness: alice contributes 1000 IMD; vault.accrueFees(0, 50e18) so the first add collects 50 IMD of position fees (the mock stands in for pass-2 collection of pass-1 swap fees); vm.deal(vault, 1 ether) stray; keeper runs convertAndAddLiquidity(_params(vault)). Expected (AUDIT-NOTES): feeStats(IMD).protocolFees == 0.5e18 (1%), distributed == 49.5e18, alice claimable 49.5 IMD; the 1 ETH stray is holders' dust. Actual: feeStats(IMD).protocolFees == 50e18, distributed == 0, claimable(alice) == (0,0,0); feeStats(ETH).protocolFees == 1e18. Scratch test test/scratch/FirstBatchYield.t.sol asserted the actual values and passed on this commit.","severity":"low","snippet":"        if (totalShares == 0) {\n            // Nobody to credit (only before the first conversion closes): keep it accounted.\n            _protocolFees[i] += amount;","title":"Yield and stray ETH booked while the first batch is open go 100% to the protocol, not to the batch or holders"},{"citation":"resolved","description":"_batchShares values pre-batch INFER (before[0]) at rateImd/rateInfer, the realized rate of the balancing swap (swapOut/swapIn when INFER is in excess). That rate is the pool price minus the 1.25% fee and impact, but only the swapped part of the leftover INFER was sold at it; the unswapped part (about half) pairs into the position at the spot price. So when the batch brings IMD and the free INFER is in excess, 'left' is understated by about (unswapped INFER) x fee/2 of its spot value and ownLiquidity = liquidity x own/(own+left) is overstated by that much. In the opposite direction (IMD in excess, the usual case) the leftover is overvalued and earlier holders gain. Bounded by ~0.6% of the leftover INFER's value times the batch's fraction; leftovers are normally dust, so this matters only when a large INFER stray sits in the vault. Not reachable to a profit in practice (nobody donates INFER to create it), so informational: the claim 'no batch captures earlier holders' value' holds only up to this second-order term. Fix if wanted: value leftovers and the batch's INFER at the pool price before the batch (sqrtP0), and charge the swap cost to the side that caused the excess.","line":567,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"Mock pool at 1 IMD/INFER, fee 12500, no impact. State: earlier holders own the position; 1000 INFER stray in the vault (before[0]=1000e18, before[1]=0); batch = 10 IMD. splitAmount: INFER excess, s ~= 497.5 INFER -> out ~= 491.3 IMD; rate = 491.3/497.5 = 0.9875. left = 987.5e18, own = 10e18, ownLiquidity = L x 10/997.5 = 1.0025% of L. By what actually entered the position (502.5 INFER at 1.0 + 491.3 IMD = 993.8 value vs batch 10) the batch's fair share is 10/1003.8 = 0.9962% of L. Actual exceeds expected by 0.0063% of L, i.e. the batch gets 0.63% more shares than its value bought, taken from earlier holders.","severity":"info","snippet":"        uint256 left = before[1] + FixedPointMathLib.fullMulDiv(before[0], rateImd, rateInfer);","title":"Leftover INFER is priced at the batch's realized swap rate, so a batch that forces an INFER->IMD balance takes a fee-sized slice of earlier holders' leftover"},{"citation":"resolved","description":"docs/IMD-INFER-VAULT.md line 73 ('EOAs, the tokens, the PoolManager and the registry are refused'), the setSwapTarget NatSpec (line 79-80: 'never a token') and AUDIT-NOTES.md ('refuses EOAs, INFER, IMD, the PoolManager, the registry, the factory') disagree with each other; the code refuses only INFER and IMD, not USDC, imdUSD, cbBTC or WBTC, which can hold pending balances. Owner-only, so a trust assumption rather than a bypass, and VaultSwaps' guards still make a token target useless to a keeper: a transfer(keeper, amountIn) call on a token target drops the input by exactly amountIn but cannot raise the vault's ETH or IMD by minOut >= 1, so the swap reverts InsufficientOutput. Report as a documentation mismatch; the cheapest alignment is to also refuse registry.isPaymentToken(target) in setSwapTarget.","line":84,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"factory.setSwapTarget(USDC, true) from the owner: expected (docs) revert InvalidAddress; actual: succeeds, isSwapTarget(USDC) == true. A keeper swap with target USDC then reverts on the output guard, so no funds move.","severity":"info","snippet":"                && (target.code.length == 0 || target == infer || target == imd || target == address(poolManager)\n                    || target == address(registry) || target == address(this))","title":"setSwapTarget refuses only INFER and IMD as targets; the docs and NatSpec say 'the tokens' / 'never a token' are refused"}],"hash":"e73e76232b3bcd3594e227c1ab8511ae93b0939e3df275f87b2c3fab1b723787","nodeId":"5fa1534a-00b1-4c79-8290-f6b45c330f5b","outcome":"completed","summary":"The review is complete. The findings file holds three substantiated items, all low or informational. No critical, high or medium defect was found in the five in-scope contracts at this commit.\n\n## Verification done\n\n- Read all of `src/vaults/uni/`, the settler library, the vault interface, the registry's keeper and allowlist hooks, `script/config/1.json`, both design documents, and the test harnesses.\n- Ran the full vault suite offline: 106 unit, swap-guard and invariant tests passed, including the 256-run, 128k-call invariant campaign.\n- Wrote and ran one scratch test to confirm the first finding, then removed it. Fork tests were not run (no RPC).\n\n## Findings (written to `.imd-findings.json`)\n\n1. **Low. First-batch yield goes entirely to the protocol.** `UniAlignmentVault.sol:654`. `_distribute` books everything as protocol fee while `totalShares == 0`. During the first conversion that covers pass-2 and pass-3 fee collection (the vault's own pass-1 split swap pays 1.25% to LPs including itself), the \"dust for current holders\" ETH at line 366, and a rejected keeper reward. AUDIT-NOTES says these \"stay in the batch\". Confirmed: 50 IMD of fees accrued before the first add ended as `protocolFees == 50e18`, `distributed == 0`, `claimable(alice) == 0`. Reachable with `1.json` on every new vault. Fix: return early in `_distribute` when there are no shares so the tokens stay free and are added with the batch.\n2. **Info. Leftover INFER valued at the realized swap rate.** `UniAlignmentVault.sol:567`. When the batch forces an INFER-to-IMD balance, pre-batch INFER is priced below spot by roughly half the pool fee, so the batch over-captures up to about 0.6% of the leftover's value times its fraction. Needs a large INFER stray; nobody profits by creating one. The opposite direction favours earlier holders.\n3. **Info. `setSwapTarget` refuses only INFER and IMD, not the other payment tokens**, while the design doc and NatSpec say \"the tokens\" or \"never a token\". Owner-only, and the output guard makes a token target unusable anyway.\n\n## Answers to the numbered questions\n\n1. **Pending ledger: holds.** Balances use `floor(amount·p/p0)` with `p` strictly decreasing per conversion, so a benefactor's balance never exceeds contributed less converted and the sum never exceeds `pendingByToken`. Shares per token are `floor(shares·p/pending)` then `floor(amount·ds/p0)`, so sums stay under what was issued. Each token has its own pool, so converting token A leaves a benefactor's record in B untouched. Accepted imprecision as documented: 1 part in 1e9 per conversion near `P_MIN`, unowned dust after an epoch restart (bounded by 1/p of pending, up to 1e-9 not 1e-18 when p sits near `P_MIN`), and a balance two scales behind is dropped.\n2. **Batch shares: holds up to finding 2.** `before` is measured after fee collection and before `_takePending`, so leftovers are exactly the pre-batch free INFER/IMD. The batch's shares are `ownLiquidity · totalShares / (unitsBefore + leftoverLiquidity)` with both divisions rounding down, so shares never exceed liquidity units and leftover liquidity accrues to earlier holders. Σ `values` ≤ measured output, which also favours earlier holders.\n3. **Fees and claims: hold.** `acc` is snapshotted in `_closeBatch` after every distribution of the call, and `sa` rounds up while fees round down, so lazy shares earn only post-conversion growth. `_distribute` adds `amount` to `_distributed` while per-holder claims floor, so claims plus cut never exceed collection. `_unharvest` was checked both directions and keeps `distributed − claimed + protocol` equal to the tokens held. Reinvest reads `totalLPUnits` and `totalShares` before its add. Payees are the caller, its delegate, or the delegate as `msg.sender`.\n4. **Aggregator calldata: holds.** Approval is exactly `amountIn` to the target and reset; the input must drop by exactly `amountIn`; `tokenOut` must rise by `minOut ≥ 1`. The AllowanceHolder ephemeral allowance can only be spent thr","treeHash":null,"usage":{"cachedInputTokens":1136624,"cost":{"source":"runtime","usdMicros":5965888},"inputTokens":290,"model":"claude-fable-5-1","outputTokens":54378,"runtime":"claude","turns":18,"wallClockMs":977425}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3c9b660ee1938912","findings":[{"citation":"resolved","description":"convertAndAddLiquidity distributes ETH beyond what is owed as 'dust for current holders, before the new shares' (line 365-367: `if (address(this).balance > ethOwed) _distribute(I_ETH, address(this).balance - ethOwed);`) and books the position's fees from the vault's own balancing swaps through _modifyPosition -> _creditYield -> _distribute. Both run before _closeBatch adds the batch's shares. On a vault's first conversion totalShares == 0, so _distribute books the whole amount as _protocolFees, and anyone can send it to the treasury with withdrawProtocolFees(ETH). That ETH is the first batch's own unspent leg input: zRouter refunds its whole ETH balance after an ETH-in swap, and a 0x quote 'for the expected amount' can leave msg.value unspent. The same goes for the fees from the second- and third-pass balancing swaps, which are paid on the pass-1 position. AUDIT-NOTES ('A conversion's own swap fees ... On a first conversion there are no holders, so they stay in the batch') and the comment at line 365 both say otherwise. Impact: the first batch's benefactors lose the refunded ETH and the multi-pass swap-fee rebate to the treasury. The amount is bounded by the leg's refund, so this is low. Reachable with script/config/1.json: zRouter is an allowlisted leg target, and its refund-all-ETH behaviour is documented in IVaultConfig.sol.","line":654,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"Using test/vaults/VaultTestBase.sol on a fresh vault: _contributeEth(alice, 1 ether); router.setRefundExtra(0.3 ether) (the mock zRouter refunds 0.3 ETH after the ETH-in leg, as the real one refunds strays); _convert(). Expected: the 0.3 ETH stays with the batch (alice, the only holder), as AUDIT-NOTES says. Actual (ran as test/scratch/FirstBatch.t.sol): vault.claimable(alice) ETH = 0, feeStats(ETH).protocolFees = 0.3e18, and withdrawProtocolFees(address(0)) pays the treasury 0.3 ETH. Smallest fix: in convertAndAddLiquidity, when totalShares == 0, skip the ETH-dust distribution (leave it in the vault for the next batch). For the first batch's swap fees, hold them in a transient 'first-batch' bucket that _closeBatch distributes after totalShares += sharesIssued, or treat the first batch's fees as free INFER/IMD that the next pass adds.","severity":"low","snippet":"        if (totalShares == 0) {\n            // Nobody to credit (only before the first conversion closes): keep it accounted.\n            _protocolFees[i] += amount;","title":"First conversion: ETH the leg hands back (and pass-2/3 swap fees) go to the protocol treasury, not the batch or holders"},{"citation":"resolved","description":"Since the audit-suite fix, free INFER/IMD held before a conversion (leftovers the last add could not pair, and strays) belong 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 with batches ('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).","line":841,"path":"src/vaults/uni/UniAlignmentVault.sol","reproduction":"VaultTestBase (mock pool at 1 IMD per INFER). alice contributes 1 ETH and bob 3 ETH, then _convert(). vault.accrueFees(400e18, 400e18); vault.collectFees(). Mint 1000 INFER + 1000 IMD to the vault: a 1000e18-unit leftover that _batchShares assigns to alice and bob. alice has 4918.377e18 shares, bob 14755.133e18, and totalShares = totalLPUnits = 19673.51e18. alice calls claimFees(REINVEST, 0). Expected (priced like _batchShares): 99e18 x 19673.51 / (19673.51 + 1000) = 94.21e18 shares. Actual (ran in test/scratch): 99.0e18 shares, identical to a twin vault with no leftover. That is 4.79e18 shares (~5%) too many, and bob's claim on the leftover falls from 75.00% to 74.62% (about 3.8e18 units). The next _convert() adds the leftover to the position and mints no shares for it. Smallest fix: in _reinvest, after _harvest (the harvested INFER/IMD are then 'free'; subtract them), value the remaining free INFER/IMD as _batchShares does and mint _sharesFor(l, unitsBefore + leftoverLiquidity). Alternatively, add the leftover to the position first, without minting.","severity":"low","snippet":"        l = _sharesFor(l, unitsBefore); // shares, at the share price before the add","title":"REINVEST mints at totalShares / totalLPUnits and ignores free INFER/IMD leftovers, so a reinvestor takes part of what belongs to earlier holders"},{"citation":"resolved","description":"The NatSpec (lines 79-80: 'only a router that can't be told to spend the vault's other balances belongs here: never a token, the PoolManager, the registry, or an EOA (refused)') and docs/IMD-INFER-VAULT.md line 73 ('EOAs, the tokens, the PoolManager and the registry are refused') say token targets are refused. The code refuses only INFER and IMD. Every other payment token (USDC, WBTC, cbBTC, imdUSD) and any other ERC-20 can be allowlisted. With a token as target, the keeper's calldata runs as the vault against that token, for example approve(keeper, max) on a pending token. Today the swap guards stop such a call from passing alone (the input must drop by exactly amountIn and IMD/ETH must rise by minOut > 0). A token with a callback path (ERC-1363 approveAndCall or transferAndCall) would let a keeper-controlled spender satisfy both and keep a standing allowance on a pending token. That is an owner-trust and misconfiguration gap, not a bypass, hence info. The 1.json swapTargets (AllowanceHolder, zRouter) are not affected.","line":82,"path":"src/vaults/uni/UniAlignmentVaultFactory.sol","reproduction":"In VaultTestBase: factory.setSwapTarget(USDC_ADDR, true) succeeds, and factory.isSwapTarget(USDC_ADDR) == true. The doc says it must revert. Fix: refuse every registry.isPaymentToken(target), or require target to be one of a fixed set (AllowanceHolder, zRouter); or correct the NatSpec and doc to say only INFER/IMD are refused.","severity":"info","snippet":"        if (\n            allowed\n                && (target.code.length == 0 || target == infer || target == imd || target == address(poolManager)\n                    || target == address(registry) || target == address(this))\n        ) revert InvalidAddress();","title":"setSwapTarget refuses only INFER and IMD among tokens, but its NatSpec and the design doc say tokens are refused"}],"hash":"fb85147b34e5a696480e69bdfd8a4ff889010b2207e2ea80b24c82cd5de8cf7a","nodeId":"5244a192-6748-4593-b318-99d2e5026d4b","outcome":"completed","summary":"I found no critical, high or medium issues in `src/vaults/uni/`. There are two low findings and one info finding, all in `.imd-findings.json` with exact lines and snippets. I reproduced the two lows in throwaway tests under `test/scratch/` (since deleted) and wrote no other files. The src build and those tests passed; I did not run the full `SKIP_FORK_TESTS` suite or the fork tests, and no proof tests were needed since nothing is high or above.\n\n## Findings\n1. **Low: the first conversion's ETH refund and some swap fees go to the treasury** (`UniAlignmentVault.sol:654`). On a vault's first batch there are no shares yet, so `_distribute` books the leg's ETH refund and the fees from the second and third balancing passes as protocol fees. Ran: a 1 ETH first batch with a 0.3 ETH router refund gave alice 0 and the treasury 0.3 ETH. AUDIT-NOTES and the comment at line 365 say this value stays with the batch.\n2. **Low: reinvest ignores free INFER/IMD leftovers** (`:841`). Reinvest prices shares at `totalShares / totalLPUnits`, while `_batchShares` also counts the leftover's liquidity. Ran: with a 1000e18-unit leftover, alice's reinvest minted 99e18 shares instead of about 94.2e18, and bob's claim on the leftover fell from 75.00% to 74.62%.\n3. **Info: `setSwapTarget` refuses only INFER and IMD** (`UniAlignmentVaultFactory.sol:82`). Its NatSpec and the design doc say every token is refused. USDC, WBTC, cbBTC and imdUSD can be allowlisted.\n\n## Answers\n1. **Pending ledger: holds.**\n   - A balance is `amount·p/p0`, and every new `p` is rounded down, so by induction each balance is at most what was contributed less what converted, and all balances sum to at most `pendingByToken`.\n   - A partial conversion converts exactly a/P of every balance, to within 1/p (at most 1e-9) and rounded toward the vault.\n   - Dropping a too-small remainder (when `np == 0`) is equivalent to the rescale branch, because SCALE equals P_MIN.\n   - Shares are `amount·Δs/p0`, and fees only count index growth after each conversion. Each token's record is settled on its own, so a benefactor with several tokens gets exactly the shares for what converted and keeps the rest.\n   - The \"unchanged\" shortcut is sound because every conversion strictly lowers `p` or changes the scale or epoch.\n   - With 1.json's magnitudes, Δs per conversion is at least about 8e6 (INFER at p = 1e9), so share precision loss is negligible.\n2. **Batch shares: holds for batches** (finding 2 covers reinvest).\n   - `before` is read after `_collectFees` and before `_takePending`, so it covers only leftovers and strays.\n   - The batch gets `L·own/(own+left)`, priced at `totalShares/(unitsBefore + leftL)`.\n   - This keeps shares at or below liquidity units.\n   - INFER is valued at the balancing swap's realized rate. This can favour or penalise INFER contributors within one batch by up to the swap's price impact and fee. I treat that as a design choice, not a defect.\n3. **Fees and claims: hold**, apart from findings 1 and 2.\n   - Claims plus the protocol cut never exceed what was collected, because each payout rounds down once and `sa` rounds up.\n   - The `_unharvest` bookkeeping balances exactly.\n   - TOKENS and ETH claims pay the delegate, or the benefactor if none is set.\n   - `claimFeesAsDelegate` requires `benefactorDelegate[b] == msg.sender`, and a duplicate entry harvests 0.\n   - Reinvest mints only after `_collectFees`, and settles before minting.\n4. **Aggregator calldata: holds.**\n   - Targets are checked live against the allowlist.\n   - Only `tokenIn` is approved, for exactly the amount in, and reset to 0 after.\n   - The ERC-20 input must drop by exactly the amount in, and the output is measured by the vault's own balance.\n   - Every state-changing entry point that touches funds is `nonReentrant` (only `delegateBenefactor` is not, and it only sets the caller's own delegate). `unlockCallback` checks the PoolManager as caller.\n   - An AllowanceHolder ephemeral allowance on any other token finds a ","treeHash":null,"usage":{"cachedInputTokens":1830570,"cost":{"source":"runtime","usdMicros":2749213},"inputTokens":32,"model":"claude-opus-5-5","outputTokens":61457,"runtime":"claude","turns":22,"wallClockMs":649053}}],"verification":[]}