# Audit report

> Basket (BASK) is an immutable index vault for Stock Tokens on Robinhood Chain (chain id 4663), deployed at 0xb5878b75d0a329b0edca2b85f04349050b2300af with nothing listed yet. A user deposits one or more listed tokens in one call, each priced by its feed, and receives BASK; redeem burns BASK for a pro-rata share of every held token, paid at once while at most directLimit (25) assets are held, otherwise booked as owed and collected with claim(tokens[], to). A deposit also needs, for every deposited and held token, its Uniswap v3 pool's 30-minute mean price (in quote tokens times the quote feed, with a mean-liquidity floor) within 3% of its feed; a token with no usable pool needs a feed under 26 hours old instead. The pool only blocks; it never sets the price. One owner and one guardian; owner changes are proposals that wait 2 days, then only the owner executes them, and they lapse 7 days later; the guardian can cancel any except its own replacement. Settings change only by proposal within fixed bounds. Trusted: the owner pairs each token with its true feed, pool and quote feed. The issuer can pause, block, burn or upgrade the Stock Tokens; feeds update only on weekdays. This is the fourth build. New since the third: no deposit hours (optional setting, off), no waiting period and no daily limit; any ERC-20 and feed with up to 18 decimals; up to 250 assets; multi-token deposits; booked redemption and batched claims; owner-only execution; the pool check; settings; no fee at all while the fee recipient is unset (set by proposal); removal of a retired empty asset and relisting; a resync proposal.
>
> Look hardest at:
>
> 1. Redeem and claim can never be blocked or made to revert: not by the owner, the guardian, any in-bounds setting or combination of settings (balanceGas, payGas, directLimit, maxAssets), a paused, blacklisted, reverting, gas-burning, lying or upgraded token, a stale or wrong feed, pool or quote feed, retirement or removal. With maxAssets assets in any state a redeem must stay under 28,000,000 gas, on both the direct and the booked path. Check the managed bitmap, the vault-only pay function and the owed and totalOwed accounting.
>
> 2. The pool check: PoolOracle.consult and quote, TickMath, token0/token1 orientation, 6-decimal USDG and 18-decimal WETH quotes, the harmonic-mean liquidity against minLiquidity, poolGas, overflow and rounding. Can a pool, quote feed or feed make deposit, depositStatus, previewDeposit or allAssets revert instead of returning a reason, or change the number of shares minted?
>
> 3. Nobody can move assets out except redeem and claim paying the user, and nobody can mint BASK except deposit (plus the fee shares and the 1e15 dead shares on the first deposit). No fee may be charged while feeRecipient is unset; once set, exactly 0.5% in and 0.5% out. Look at every proposal action, executeProposal, resync (can it count owed tokens into managed?), removeAsset, closeAsset, recognizeLoss and reentrancy.
>
> 4. Proposals: can anyone but the owner execute; can one skip the 2 days or escape the guardian's cancel; can a voided, expired or stale proposal execute after a retire, a removal and relisting, or a NAV cap lowering; can a setting leave its bounds or break the two gas rules (maxAssets x (balanceGas + 60,000) and directLimit x (balanceGas + payGas + 60,000) at most 28,000,000); can the guardian become owner by any sequence.
>
> 5. Deposit share math: rounding direction, first-deposit and donation attacks, managed versus balance, the rule that a deposited token's balance must cover totalOwed, flagDeficit and recognizeLoss after an issuer burn, and the inline assembly under via_ir (Calls, the balance read, the Transfer log, the pendingProposals length rewrite).
>
> 6. Known deviation, please confirm and look for a remedy: the text says a retired asset is skipped by deposit checks and 0 in NAV, but the build stops every deposit while any retired asset has managed above 0 (RetiredBacking), and the permanent 1e15 shares keep it above 0. So a held token that its issuer pauses for good, or whose balance becomes unreadable, would stop all deposits for good. Is there any owner path that resumes deposits in that state?
>
> Accepted by the owner, report only if worse than stated here: no per-asset limit (one stock may be any share of NAV); profit from feed lag within the 3% pool deviation; anyone can stop deposits by moving a thin pool; a held token with no usable pool stops deposits at weekends; no fee while the fee recipient is unset; tokens the issuer credits by raising balances stay outside managed until a resync; an unreadable balance during a shortfall books the leg from managed and claims are paid first come, first served; a complete loss leaves NAV at 0 and deposits stop; the two-step ownership handover takes effect at once; a token upgraded to debit more than the amount strands its claims; the guardian cannot cancel its own replacement; a retired asset that ever held tokens keeps a dust balance, so its slot is in practice not freed; redemption minimums are positional; BASK sent to the vault's own address is lost.

| | |
|---|---|
| Repository | https://github.com/identity-md-launches/launch-985-basket.git |
| Commit | `85b8ccb40e91989416d02f44f78ea8437ecad84c` |
| Job | `b0233dd8-81bb-4564-a12d-6b5b02401b81` |
| Judged | 2026-10-08 05:59 UTC |
| Findings | 3 medium · 3 low · 2 info |

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

## Findings

### 1. Medium: In-bounds gas settings let a direct-path redeem exceed 28,000,000 gas (merged: permissions, economics, flow)

`src/BaskVault.sol:456`

```
                || s[15] > 28_000_000 / (s[12] + s[13] + 60_000)
```

The two gas rules in _validateSetting budget 60,000 gas of overhead per funded asset and nothing for fixed costs. On the direct path a leg whose payment fails costs about 59,000 gas on top of the two stipends (cold token account and three cold slots, managed SSTORE, owed and totalOwed zero-to-nonzero at 22,100 each, encode and self-call base, mulDiv), which leaves about 1,000 gas of slack per leg. The fixed costs are not covered: 21,000 intrinsic, calldata for a minimum array sized to the registry (about 45,000 for 350 entries), the fee transfer to a fresh recipient (22,100), the burn, the bitmap scan and the Redeem event, plus about 250 gas for every unfunded registry entry the loop still visits. When the owner picks a combination whose product is exactly 28,000,000 and directLimit is roughly 100 or less, the measured transaction exceeds the limit the brief requires on both paths. Two in-bounds examples both fail: BalanceGas 20,000 / PayGas 480,000 / DirectLimit 50 / MaxAssets 350 measures 28,107,400, and at the default BalanceGas 50,000 with PayGas 450,000 / DirectLimit 50 / MaxAssets 254 it measures 28,039,556 (both including intrinsic and calldata gas; execution gas alone is also above 28,000,000 in the first case). The project's own closest corner (PayGas 500,000 / DirectLimit 48, 160,000 of rule slack) passes at 27,978,671, so the suite does not see this; the booked path (maxAssets rule) cannot be pushed over because its per-leg slack is about 4,000 gas and the registry cannot be small enough for the fixed costs to matter. Impact: under a chain gas limit near 28,000,000 a holder facing 50 upgraded gas-burning Stock Tokens cannot redeem until the owner changes settings two days later, which breaks the brief's first invariant. The two specialists who measured only the project's corner (21,000 and 12,000 of margin) are consistent with this: zero-slack neighbours of that corner go over. Fix (keeps the documented defaults valid): add the fixed and per-entry terms to both rules, for example require s[15] * (s[12] + s[13] + 60_000) + s[14] * 300 + 250_000 <= 28_000_000 and s[14] * (s[12] + 60_000) + 250_000 <= 28_000_000 (defaults give 9.3M and 27.75M), or raise the 60,000 overhead constant to about 70,000. Either change tightens the spec's stated rule slightly and needs the requester's agreement.

**Reproduction**

Proposals accepted by _validateSetting: BalanceGas 20,000; MaxAssets 350; PayGas 480,000; DirectLimit 50 (50 * 560,000 == 28,000,000). Genesis-list 350 tokens with feeds and no pools, fund 50 with 1e18 each in one deposit, set a fresh fee recipient by proposal. Make the 50 funded tokens spin away their balanceOf stipend and hit invalid() in transfer. Cold-call redeem(allShares, self, new uint256[](350), now) with 30,000,000 gas. Expected: total gas (execution + 21,000 + calldata) below 28,000,000. Actual (forge 1.8.3, solc 0.8.26, via_ir, cancun): 28,107,400; every leg is booked, so the call only succeeds because 30M was supplied. Variant at the default BalanceGas: MaxAssets 254, PayGas 450,000, DirectLimit 50, 254 listed / 50 funded: 28,039,556. The attached proof is the first case and fails on this tree.

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

```solidity
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {BaskVault} from "src/BaskVault.sol";

/// @dev Hostile Stock Token: balanceOf burns its whole stipend, transfer burns its whole stipend.
contract HostileToken {
    uint8 public constant decimals = 18;
    mapping(address => uint256) internal _balances;
    mapping(address => mapping(address => uint256)) public allowance;
    bool public hostile;

    function mint(address to, uint256 value) external {
        _balances[to] += value;
    }

    function setHostile(bool h) external {
        hostile = h;
    }

    function balanceOf(address account) external view returns (uint256 value) {
        value = _balances[account];
        if (hostile) {
            assembly {
                for {} gt(gas(), 800) {} {}
            }
        }
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        allowance[from][msg.sender] -= amount;
        _balances[from] -= amount;
        _balances[to] += amount;
        return true;
    }

    function transfer(address, uint256) external view returns (bool) {
        if (hostile) {
            assembly {
                invalid()
            }
        }
        return true;
    }
}

contract SimpleFeed {
    uint8 public constant decimals = 8;
    int256 internal answer;
    uint256 internal updatedAt;

    constructor(int256 a) {
        answer = a;
        updatedAt = block.timestamp;
    }

    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {
        return (1, answer, updatedAt, updatedAt, 1);
    }
}

interface VmCold {
    function cool(address target) external;
}

/// @notice Settings that satisfy both gas rules exactly (50 * (20_000 + 480_000 + 60_000) == 28_000_000)
/// still let a direct-path redemption over 350 listed / 50 funded hostile assets exceed 28,000,000 gas.
/// Fails on the current code. Passes once either the settings are rejected or the redemption fits.
contract RedeemGasBoundProof is Test {
    address internal constant OWNER = 0x30B57ECf51D19ABcED7F6f70974e6fBb6f3b9Da3;
    address internal constant GUARDIAN = 0x5ed39AF86f2C00ad99913B5d727bD68f2A904B68;
    BaskVault internal vault;
    address[] internal tokens;

    function setUp() public {
        vm.warp(10 days);
        vault = new BaskVault(OWNER, GUARDIAN);
    }

    function _proposeAndExecute(BaskVault.ProposalData memory d) internal returns (bool accepted) {
        vm.prank(OWNER);
        try vault.propose(d) returns (uint256 id) {
            vm.warp(vm.getBlockTimestamp() + 2 days);
            vm.prank(OWNER);
            vault.executeProposal(id);
            accepted = true;
        } catch {
            accepted = false;
        }
    }

    function _setting(BaskVault.Setting key, uint256 value) internal returns (bool) {
        BaskVault.ProposalData memory d;
        d.action = BaskVault.Action.SettingChange;
        d.setting = key;
        d.value = value;
        return _proposeAndExecute(d);
    }

    function testDirectPathRedeemStaysUnder28MAtInBoundsSettings() public {
        // In-bounds per _validateSetting: 350 * 80_000 == 28_000_000 and 50 * 560_000 == 28_000_000.
        bool ok = _setting(BaskVault.Setting.BalanceGas, 20_000);
        ok = ok && _setting(BaskVault.Setting.MaxAssets, 350);
        ok = ok && _setting(BaskVault.Setting.PayGas, 480_000);
        ok = ok && _setting(BaskVault.Setting.DirectLimit, 50);
        if (!ok) return; // A fix that rejects this combination also satisfies the requirement.

        uint256 funded = 50;
        address[] memory selected = new address[](funded);
        uint256[] memory amounts = new uint256[](funded);
        for (uint256 i; i < 350; ++i) {
            HostileToken token = new HostileToken();
            SimpleFeed feed = new SimpleFeed(1e8);
            tokens.push(address(token));
            vm.prank(OWNER);
            vault.genesisList(address(token), address(feed), address(0), address(0), 0);
            if (i < funded) {
                token.mint(address(this), 1e18);
                token.approve(address(vault), 1e18);
                selected[i] = address(token);
                amounts[i] = 1e18;
            }
        }
        vm.prank(OWNER);
        vault.finalizeGenesis();
        vault.deposit(selected, amounts, address(this), 0, vm.getBlockTimestamp());

        BaskVault.ProposalData memory fee;
        fee.action = BaskVault.Action.FeeRecipient;
        fee.target = makeAddr("fee-recipient");
        assertTrue(_proposeAndExecute(fee));

        for (uint256 i; i < tokens.length; ++i) {
            if (i < funded) HostileToken(tokens[i]).setHostile(true);
            VmCold(address(vm)).cool(tokens[i]);
        }
        uint256 shares = vault.balanceOf(address(this));
        bytes memory callData =
            abi.encodeCall(vault.redeem, (shares, address(this), new uint256[](tokens.length), vm.getBlockTimestamp()));
        VmCold(address(vm)).cool(address(vault));
        uint256 start = gasleft();
        (bool success,) = address(vault).call{gas: 30_000_000}(callData);
        uint256 used = start - gasleft();
        used += 21_000;
        for (uint256 i; i < callData.length; ++i) {
            used += callData[i] == 0 ? 4 : 16;
        }
        emit log_named_uint("direct-path redeem gas incl. intrinsic", used);
        assertTrue(success, "redeem reverted");
        assertGt(vault.owed(address(this), tokens[0]), 0, "hostile legs must be booked, not paid");
        assertLt(used, 28_000_000, "redeem exceeded 28,000,000 gas at in-bounds settings");
    }
}
```

### 2. Medium: Confirmed deviation 6: retiring a funded asset that is frozen for good stops every deposit permanently and no owner path resumes them (merged: four specialists)

`src/BaskVault.sol:663`

```
                if (managed[token] != 0) return (Reason.RetiredBacking, token, 0, prices);
```

The brief says a retired asset is skipped by deposit checks and counts 0 in NAV. The build returns RetiredBacking from _depositContext whenever any retired asset has managed > 0, which blocks deposit, previewDeposit and depositStatus. Once an asset has been funded, managed can never return to 0: redeem legs are floor(managed * net / supply) with net < supply because the 1e15 permanent shares never redeem, so at least 1 wei stays (observed 333,333,333,333,334 after every circulating share is redeemed); recognizeLoss writes off only a readable shortfall, which a paused-but-intact balance does not have, and flagDeficit reverts BalanceUnreadable when balanceOf cannot be read; removeAsset needs managed == 0; Feed, Recentre, Reopen, Pool and Resync proposals reject a retired asset in _validateProposal and List rejects a still-registered token. The same token blocks deposits before retirement too (OraclePaused at line 601 because hasPause is fixed at listing and no proposal clears it, or Unreadable at line 667), so Retire is the only owner tool for that state and it converts the block into a permanent one. A Stock Token paused for good by its issuer, or upgraded so that its balance is unreadable, is an ordinary lifecycle event for one of up to 250 constituents; one such event turns the immutable vault into a redeem-only product forever while redemption and claims keep working. This is worse than the accepted 'retired asset keeps a dust slot', because it is every deposit, and the trigger is a third party (the issuer) rather than an owner choice. The owner can mitigate it only by not retiring: a paused token without a pause interface does not block deposits at all. Remedies, both a scope decision because they change the sentence 'retired assets are 0 in NAV': (a) value retired backing instead of excluding it: in _depositContext replace the RetiredBacking return with nav += _value(managed[token], a.centre, a.tokenDecimals, a.feedDecimals) (or a frozen price carried in the Retire proposal's value field, timelocked and guardian-cancellable), skipping the live balance, pause, pool and feed checks for retired assets; new depositors then pay for the residual at a fixed price and existing holders are not diluted. The attached proof passes under that change (verified against a scratch copy of the vault). (b) A timed, guardian-cancellable write-off action valid only for retired assets that sets managed to 0 and leaves owed untouched; the proof also passes under that remedy. Following the text literally (skip retired, 0 NAV) re-opens the dilution the implementer blocked (a 300e18 deposit paid out 360e18 in the earlier revision) and would fail the proof's second assertion.

**Reproduction**

Three 18-decimal tokens listed with 8-decimal feeds at 1e8 and no pools, Alice deposits 100e18 of each (supply 300e18). Issuer pauses token0 (oraclePaused() true, transfers revert, balanceOf intact): depositStatus([token1]) = (OraclePaused, token0). Owner closes token0, proposes Retire, executes after 2 days: depositStatus([token1]) = (RetiredBacking, token0). Alice redeems all 299.999e18 shares: totalSupply = 1e15, managed[token0] = 333,333,333,333,334, her token0 leg is booked as owed and cannot be claimed while paused. Then flagDeficit(token0) reverts NoDeficit; removeAsset(token0) reverts InvalidAsset; propose(Feed|Recentre|Reopen|Pool|Resync|List, token0) each revert InvalidAsset; a Resync of token1 after a donation leaves the status unchanged. Expected per brief: deposits of token1/token2 continue with token0 at 0 NAV. Actual: every deposit reverts DepositUnavailable(16, token0) for the life of the contract. Same end state when token0.balanceOf reverts instead (flagDeficit reverts BalanceUnreadable). The attached proof fails on this tree at the depositStatus assertion (16 != 0) and passes once retired backing is priced into NAV or written off by a timed action.

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

```solidity
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {BaskVault} from "src/BaskVault.sol";
import {MockToken, MockFeed} from "test/mocks/Mocks.sol";

/// Fails on the current build: after a funded asset whose issuer paused it for good is closed and
/// retired, every deposit reverts DepositUnavailable(RetiredBacking) and no owner, guardian or
/// permissionless call can lift it (no shortfall to recognize, removal needs managed == 0, every
/// asset proposal rejects a retired asset, and the 1e15 permanent shares keep managed above zero).
/// Passes once deposits resume after such a retirement without letting the new depositor acquire
/// backing they did not pay for (retired backing priced into NAV, or written off by a timed action).
contract RetiredBackingDeadlockProof is Test {
    address constant OWNER = 0x30B57ECf51D19ABcED7F6f70974e6fBb6f3b9Da3;
    address constant GUARDIAN = 0x5ed39AF86f2C00ad99913B5d727bD68f2A904B68;
    BaskVault vault;
    MockToken[3] tokens;
    MockFeed[3] feeds;
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    function setUp() public {
        vm.warp(10 days);
        vault = new BaskVault(OWNER, GUARDIAN);
        for (uint256 i; i < 3; ++i) {
            tokens[i] = new MockToken(18);
            feeds[i] = new MockFeed(8, 1e8);
            vm.prank(OWNER);
            vault.genesisList(address(tokens[i]), address(feeds[i]), address(0), address(0), 0);
            tokens[i].mint(alice, 1_000e18);
            tokens[i].mint(bob, 1_000e18);
            vm.prank(alice);
            tokens[i].approve(address(vault), type(uint256).max);
            vm.prank(bob);
            tokens[i].approve(address(vault), type(uint256).max);
        }
        vm.prank(OWNER);
        vault.finalizeGenesis();
        address[] memory ts = new address[](3);
        uint256[] memory amts = new uint256[](3);
        for (uint256 i; i < 3; ++i) {
            ts[i] = address(tokens[i]);
            amts[i] = 100e18;
        }
        vm.prank(alice);
        vault.deposit(ts, amts, alice, 0, vm.getBlockTimestamp());
    }

    function _one(address t) internal pure returns (address[] memory a) {
        a = new address[](1);
        a[0] = t;
    }

    function _amt(uint256 x) internal pure returns (uint256[] memory a) {
        a = new uint256[](1);
        a[0] = x;
    }

    function _refresh() internal {
        for (uint256 i; i < 3; ++i) feeds[i].set(1e8, vm.getBlockTimestamp());
    }

    function testRetiredFrozenAssetMustNotStopDepositsForever() public {
        // Issuer pauses token 0 for good: oraclePaused() == true and transfers revert, balance intact.
        tokens[0].setPause(true);
        (BaskVault.Reason before,) = vault.depositStatus(_one(address(tokens[1])));
        assertEq(uint256(before), uint256(BaskVault.Reason.OraclePaused));

        // The only owner tool for this state is close + Retire.
        vm.prank(OWNER);
        vault.closeAsset(address(tokens[0]));
        BaskVault.ProposalData memory d;
        d.action = BaskVault.Action.Retire;
        d.token = address(tokens[0]);
        vm.prank(OWNER);
        uint256 id = vault.propose(d);
        vm.warp(vm.getBlockTimestamp() + 2 days);
        _refresh();
        vm.prank(OWNER);
        vault.executeProposal(id);
        assertTrue(vault.asset(address(tokens[0])).retired);
        assertEq(vault.managed(address(tokens[0])), 100e18);

        // Even after every circulating share is redeemed, the permanent shares leave backing behind.
        uint256 all = vault.balanceOf(alice);
        vm.prank(alice);
        vault.redeem(all, alice, new uint256[](0), vm.getBlockTimestamp());
        assertEq(vault.totalSupply(), 1e15);
        assertGt(vault.managed(address(tokens[0])), 0);
        // Alice's token0 leg is booked and unpayable while the token stays paused.
        assertGt(vault.owed(alice, address(tokens[0])), 0);

        // Alice refunds the vault so NAV is positive again (deposits need nav > 0 while supply > 0).
        address[] memory ts = new address[](2);
        uint256[] memory amts = new uint256[](2);
        ts[0] = address(tokens[1]);
        ts[1] = address(tokens[2]);
        amts[0] = 100e18;
        amts[1] = 100e18;
        (BaskVault.Reason reason, address fault) = vault.depositStatus(ts);
        emit log_named_uint("depositStatus reason (16 == RetiredBacking)", uint256(reason));
        emit log_named_address("fault", fault);
        assertEq(uint256(reason), uint256(BaskVault.Reason.OK), "deposits are blocked for good");

        vm.prank(alice);
        uint256 aliceShares = vault.deposit(ts, amts, alice, 0, vm.getBlockTimestamp());
        assertGt(aliceShares, 0);

        // A later depositor must be able to enter, and must not acquire backing for free:
        // the basket value of Bob's legs (healthy tokens at their $1 feeds, the retired token at its
        // $1 centre) may not exceed the $100 he paid.
        vm.prank(bob);
        uint256 shares = vault.deposit(_one(address(tokens[1])), _amt(100e18), bob, 0, vm.getBlockTimestamp());
        vm.prank(bob);
        uint256[] memory legs = vault.redeem(shares, bob, new uint256[](0), vm.getBlockTimestamp());
        uint256 out = legs[0] + legs[1] + legs[2];
        emit log_named_uint("bob basket value out (USD 1e18)", out);
        assertLe(out, 100e18, "new shares acquired backing omitted from their price");
    }
}
```

### 3. Medium: A pool below the liquidity floor, or one whose observation fails, switches the 3% check off instead of blocking, so moving a thin pool unlocks feed-lag deposits rather than stopping them

`src/BaskVault.sol:607`

```
            if (ok && liquidity >= a.minLiquidity) {
```

_price only applies the 3% deviation test when consult succeeds and the harmonic-mean liquidity is at least minLiquidity; otherwise it falls through to line 624 and accepts the feed at face value if it is under NoPoolAge (26 hours). The brief accepts that 'anyone can stop deposits by moving a thin pool' and 'profit from feed lag within the 3% pool deviation'. The actual consequence is the opposite of a stop: in a thin pool anyone can make the pool check disappear for a whole 30-minute window and then deposit against a feed that lags the market by any amount inside the 4x band. Two routes. (1) Liquidity collapse: a Uniswap v3 pool accumulates secondsPerLiquidityCumulative as delta << 128 / max(liquidity, 1), so one second with zero active liquidity in the window (a swap that pushes the price past every position's range for one block, or the dominant LP pulling its position for one block) makes the harmonic mean about 1,800 regardless of the other 1,799 seconds (computed from the v3 formula with 1e12 liquidity otherwise: 1,799), which is below any sensible minLiquidity. (2) Observation failure: observe(1800) reverts 'OLD' when the oldest stored observation is younger than 30 minutes; with observation cardinality C, C swaps in C consecutive blocks (sub-second blocks on an Orbit chain) push the oldest observation inside the window, and consult returns ok = false. Either way the depositor buys the token on the market after an after-hours move, deposits it at the stale weekday feed price (fresh enough for the 26-hour rule), and redeems in kind for the whole basket; the gain is the full feed-market gap on an unlimited deposit, paid by existing holders. The pool check is the only market cross-check the design has, and the 'thin pool' acceptance assumed its failure mode is a blocked deposit. Remedy (changes specified behaviour, needs a decision): for a configured pool, treat a failed observation or liquidity under the floor as a blocking reason (for example a new Reason.PoolLiquidity) and reserve the age-only fallback for pool == 0; the owner can still remove a dead pool by proposal. A lighter mitigation is operational: set observation cardinality near the maximum on each pool and prefer pools with full-range liquidity, and keep NoPoolAge short.

**Reproduction**

test/scratch/Judge.t.sol testLowLiquidityDisablesDeviationCheck and testObserveRevertDisablesDeviationCheck (both pass on this tree as confirmations). Three tokens at $1 with pools, minLiquidity 100, Alice deposits 100e18 of each. Pool0 mean tick set to -2231 (market $0.80) with harmonic liquidity 1e12: assetPrice(token0) = PoolDeviation and previewDeposit reverts. Same tick with harmonic liquidity 50 (below the floor of 100): assetPrice(token0) = OK; previewDeposit([token0],[100e18]) credits 100e18 USD for tokens worth 80e18 on the market. Bob deposits 100e18 token0 (bought for $80), receives 100e18 shares, redeems: legs 50e18 token0, 25e18 token1, 25e18 token2 = $90 at market, a $10 gain taken from Alice with no fee. Expected: a divergent pool blocks, as the accepted-risk list assumes. With pool0.observe reverting instead, assetPrice(token0) = OK as well.

### 4. Low: Issuer balance credits waiting for a Resync are captured by whoever deposits in the two-day window (merged: permissions, math, flow)

`src/BaskVault.sol:396`

```
            uint256 extra = available > managed[d.token] ? available - managed[d.token] : 0;
```

Deposit NAV counts only managed at the feed price (line 678) while balance credits from the issuer (a split executed as a balance raise, a dividend paid in tokens, a reissue) enter managed only when the owner executes a Resync at least two days after proposing it. In that window, which is public, deposits price BASK below the real backing, and with no waiting period or deposit limit any depositor can enter before executeProposal(Resync) and redeem right after, taking a pro-rata share of the credit from the holders it was meant for; with the fee recipient unset the round trip is free. A split that also halves the feed (passing the band and, once the pool converges, the 3% check) understates NAV by the asset's weight times one half for the whole window. The accepted-risk list says such credits 'stay outside managed until a resync'; it does not say new depositors are priced against the understated NAV. Resync itself is correct: it excludes owed tokens and never lowers managed. Remedy without a code change: pause deposits (immediate for owner or guardian) before any announced credit and keep them paused until the Resync executes. Code remedy that preserves the design: require depositsPaused in executeProposal for Resync, or credit min(surplus at proposal, surplus at execution). Blocking deposits automatically whenever balance exceeds managed is not advisable because a 1-wei donation would then stop deposits.

**Reproduction**

test/scratch/Judge.t.sol testResyncSandwich (passes on this tree as a confirmation). Alice deposits 100e18 token0 (managed 100e18, supply 100e18). Issuer mints 10e18 to the vault. Owner proposes Resync(token0); after 2 days previewDeposit still reports nav 100e18. Bob deposits 100e18 token0 and receives 100e18 shares. Owner executes: managed = 210e18. Bob redeems 100e18 shares and receives 105e18 token0. Expected: Alice, the only holder when the credit arrived, receives the 10e18; actual: Bob leaves with 5e18 of it. Split variant from the specialists: mint 100e18 to the vault, feed 1e8 to 0.5e8, pool tick near -6932; previewDeposit reports nav 250e18 against a true 300e18 and a 300e18 deposit of token1 mints 360e18 shares.

### 5. Low: One wei per asset forces every redemption onto the booked path for good once more than directLimit assets are listed

`src/BaskVault.sol:817`

```
        bool direct = count <= _settings[15];
```

The direct-or-booked decision counts assets with nonzero managed (bits in the bitmap), not legs that will pay anything. With more than directLimit assets listed, anyone can deposit 1 wei into each other asset (each mints a few shares, so it succeeds) and push the count above directLimit permanently: a 1-wei managed amount gives a leg of floor(1 * net / supply) = 0 on every redemption, so the asset is never un-funded, and recognizeLoss cannot apply because there is no shortfall. From then on every redeem, including a single-token redemption of a healthy asset, books all legs as owed and pays nothing, and only the receiver address can collect through claim. A redemption sent to an address that cannot call claim (an exchange deposit address, a contract without a claim path) leaves the tokens in the vault indefinitely because nobody can push the payment. The brief frames booking as the consequence of genuinely holding more than directLimit assets; here an unprivileged party switches the mode for a few wei, and at the default limit of 25 any 26 funded entries do so. The owner can raise directLimit only to 77 at the default gas settings, so with 78 or more listed assets the grief cannot be undone. Minimal fix that keeps the gas rules: a permissionless claimFor(address creditor, address[] tokens) that pays owed[creditor][token] to the creditor only (same pay sandbox, same min(owed, balance) rule), so a custodial receiver's debt can be pushed to it. A fuller fix decides the direct path per nonzero leg with an attempted-payment counter capped at directLimit, which needs the direct rule restated as maxAssets * (balanceGas + 60,000) + directLimit * payGas <= 28,000,000.

**Reproduction**

test/scratch/Judge.t.sol DustGriefTest.testDustFundingForcesBookedRedemptions (passes on this tree as a confirmation). 26 assets listed with $100 feeds and no pools, defaults (directLimit 25). Alice deposits 100e18 of token0. Griefer deposits 1 wei of each of tokens 1..25 in one call and receives 2,500 shares. Alice calls redeem(shares, custodial, [], deadline): legs[0] = 99,999,989,999,999,999,975 but token0.balanceOf(custodial) == 0 and owed[custodial][token0] == legs[0]; no Payment. The griefer then redeems all its shares and managed[token1] is still 1. Expected: a healthy single-token redemption is paid directly as before the 25 wei. Actual: booked, and collectable only by the custodial address itself.

### 6. Low: An absurd positive quote-feed answer makes _price revert with MathOverflow, so deposit, depositStatus, previewDeposit, assetPrice and allAssets revert instead of returning QuoteFeed

`src/BaskVault.sol:615`

```
                    FullMath.mulDiv(quoteAmount, quoteAnswer, 10 ** (uint256(a.quoteDecimals) + a.quoteFeedDecimals));
```

The primary feed is bounded by the band before any multiplication, but the quote feed only has to be positive and fresh. quoteAmount carries 18 extra decimals (1e26 for a $100 token against a 6-decimal quote, up to about 3.4e74 at the extreme tick), so FullMath.mulDiv reverts with MathOverflow when quoteAmount * quoteAnswer / 10^(qDec + qfDec) does not fit in 256 bits: a quote feed answering about 1.2e65 or more at a normal tick, or about 3.4e16 at the minimum tick. The pool itself cannot cause this (quoteAmount is bounded by TickMath and a realistic quote answer keeps the product far below 2^256), and the primary feed cannot either. Chainlink-style int192 answers cannot reach these values, so it needs a broken or custom quote feed; the impact is loss of the reason codes the brief's item 2 asks for (allAssets reverting hides every asset's status, monitoring sees a bare revert) until a Pool proposal replaces the quote feed two days later. Redeem and claim are unaffected. The README's general note that extreme oracle values can make views revert covers this; it is recorded because item 2 asks for it. Fix: bound the quote answer before multiplying (for example compute the high word with mulmod as FullMath does and return (Reason.QuoteFeed, token) when it would not fit, or reject quoteAnswer above 10^qfDec * 1e6).

**Reproduction**

test/scratch/Judge.t.sol testQuoteFeedOverflowReverts (passes on this tree as a confirmation). List an 18-decimal token with an 8-decimal feed at 100e8, pool token/6-decimal quote at mean tick -230271 (pool price 99.99e18), 8-decimal quote feed at 1e8: assetPrice returns OK. Set the quote feed to type(int256).max with a fresh timestamp. Expected: Reason.QuoteFeed or PoolDeviation with the token as fault. Actual: assetPrice, allAssets and depositStatus all revert with FullMath.MathOverflow() (selector 0x9bd9c5bf). An answer of 1e52 does not trigger it (returns PoolDeviation).

### 7. Info: Assets without a usable pool have no 3% bound at all: feed lag up to the 4x band is depositable while the feed is under 26 hours old

`src/BaskVault.sol:624`

```
        if (block.timestamp - updatedAt > _settings[2]) return (Reason.NoPoolAge, answer, updatedAt, 0);
```

This is the specified behaviour for pool == 0 ('a token with no usable pool needs a feed under 26 hours old instead'), recorded so the owner weighs it when listing without a pool. Stock feeds update only on weekdays while the tokens trade continuously, so on a weekday evening or Monday morning (feed age under 26 hours) a depositor can buy the token at market, deposit it at the stale feed valuation and redeem the whole basket in kind; the only limits are the band (4x of centre) and the NAV cap, not 3%. The accepted-risk sentence about feed lag mentions the 3% deviation, which does not exist for these assets. Mitigations are listing choices: require a pool for every post-genesis listing, or set NoPoolAge well under the overnight gap, or accept and document it.

**Reproduction**

test/scratch/Judge.t.sol testNoPoolFeedLagUnbounded (passes on this tree as a confirmation). Remove token0's pool by a Pool proposal with all-zero fields, fund the basket, set feed0 to 1e8 and warp 17 hours while refreshing the other feeds. assetPrice(token0) returns OK and previewDeposit([token0],[100e18]) values the deposit at 100e18 USD whatever the token's market price is at that moment.

### 8. Info: Privileged-power inventory: deposit-side powers are immediate, redeem and claim have none, the guardian cannot escalate

`src/BaskVault.sol:294`

```
    function setDepositsPaused(bool paused) external onlyRole nonReentrant {
```

Documentation of trust assumptions, no defect. Immediate owner powers: genesisList, finalizeGenesis, transferOwnership (two-step, immediate on accept, cannot target the guardian at either step), setDepositsPaused(false), lowerNAVCap (down to 0, which stops deposits), closeAsset, propose, cancelProposal, executeProposal (owner only; a non-owner gets Unauthorized). Guardian: setDepositsPaused(true) only, closeAsset on any asset (also voids pending Reopen proposals), cancelProposal on everything except Action.Guardian. Permissionless: deposit, redeem, claim, removeAsset (retired, empty and debt-free only), flagDeficit, recognizeLoss after 7 days, the ERC-20 functions; pay is vault-only. Guardian-to-owner escalation was traced and is closed: transferOwnership and acceptOwnership both reject the guardian, Guardian proposals reject the owner and the pending owner at proposal and at execution, so neither ordering of Guardian(X) and transferOwnership(X) completes. A rogue guardian can hold deposits paused or every asset closed only until the owner's Guardian replacement executes (2 days, not cancellable by the guardian). Redeem and claim read no feeds, pools or deposit settings other than the gas stipends and directLimit, and no role can pause, retire or remove them out of existence; the managed bitmap and the owed/totalOwed accounting stayed consistent in every sequence traced (deposit, direct and booked redeem, claim, recognizeLoss, removeAsset swap, relisting). Minting happens only in deposit (dead shares, fee, receiver) and tokens leave only through pay from redeem and claim; the fee is 0 while feeRecipient is unset and ceil(0.5%) in and out once set. Reentrancy from token callbacks into any state-changing entry point reverts Reentrant, and pay rejects any caller but the vault.

**Reproduction**

Guardian calls setDepositsPaused(true): depositStatus returns Paused; only the owner can unpause. Owner proposes Guardian(new); guardian's cancelProposal(id) reverts Unauthorized; after 2 days the owner executes. Non-owner executeProposal reverts Unauthorized. transferOwnership(guardian) reverts InvalidAddress; a pending owner that has since become guardian cannot acceptOwnership. Exercised by the existing suite (test/Governance.t.sol, test/Adversarial.t.sol, test/RegistryExit.t.sol) and re-read for this review.

---

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