# Audit report

> Basket (BASK) is an immutable index vault for Stock Tokens on Robinhood Chain (chain id 4663), deployed at 0x4e19d7472e650399b06eeaa5ccc29da9b8efbebd 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 (50) assets are held, otherwise booked as owed and collected with claim(tokens[], to). A deposit also needs, for every deposited and every held unretired 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 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 fifth build, written fresh from the text. The fourth was audited; the changes since: a retired asset is skipped by every deposit check and left out of NAV even while it still holds tokens (managed > 0), and accepts only a Resync proposal; the direct gas rule uses 70,000 and directLimit starts at 50; a token with a pool set has no valid price when observe fails or mean liquidity is under minLiquidity (no fallback; the 26-hour rule applies only with no pool); flagDeficit clears the record when the asset is no longer short.
>
> 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 heldCount and the held bitmap (including removeRetired's swap-and-pop), the vault-only pay function and the owed and totalOwed accounting.
>
> 2. The pool check in BaskOracle (pool, price, feed, read) and TickMath: token0/token1 orientation, 6-decimal USDG and 18-decimal WETH quotes, the harmonic-mean liquidity against minLiquidity, poolGas, overflow and rounding. Does a set pool that fails, is drained or is under its floor always block rather than fall back? 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, execute, Resync (can it count owed tokens into managed, or be abused on a retired asset?), removeRetired, close, flagDeficit, 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, a later close (Reopen) or a NAV cap lowering; can a retired asset take any proposal except Resync; can a setting leave its bounds or break the two gas rules (maxAssets x (balanceGas + 60,000) and directLimit x (balanceGas + payGas + 70,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, retired assets left out of NAV and every deposit check, flagDeficit (recording and clearing) and recognizeLoss after an issuer burn or a recovery, and the inline assembly under via_ir (BaskOracle.read and balance, _tokenCall, the Transfer log, the self-calls in redeem and claim).
>
> 6. Anything the code does that the text above does not say, or that the text says and the code does not do.
>
> Accepted by the owner, report only if worse than stated here: profit from feed lag within the 3% pool deviation; an asset with no pool has no 3% bound while its feed is under 26 hours; no per-asset limit (one stock may be any share of NAV); anyone can stop deposits by moving a thin pool; a held asset whose pool fails or is under its floor stops deposits until the pool recovers or a Pool proposal executes; a held token with no pool stops deposits at weekends; depositors after a retire share its tokens; no fee while the fee recipient is unset; tokens the issuer credits by raising balances stay outside managed until a Resync, and depositors during its 2-day wait share them; 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 and it counts toward directLimit; one wei in each of more than directLimit assets sends every redemption to the booked path; a receiver that cannot call claim cannot collect a booked leg; redemption minimums are positional; BASK sent to the vault's own address is lost; a broken quote feed with an absurd answer makes pricing revert; a deposit with close to 250 held assets may not fit one transaction when pools are busy. Operating rules the owner follows: pause deposits before proposing a Resync and never resync before the first deposit; keep each pool's observation cardinality above poolWindow and poolGas at 150,000; fund a replacement before retiring the last held stock; call flagDeficit on an asset whose shortfall has recovered; pause deposits as soon as any asset is short.

| | |
|---|---|
| Repository | https://github.com/identity-md-launches/launch-1020-basket.git |
| Commit | `0a88bde525aed4557b375cf60ee503d707570ac0` |
| Job | `eae3f6bb-aa74-4c4f-96ea-d8802ffef11f` |
| Judged | 2026-10-08 16:13 UTC |
| Findings | 1 medium · 2 low · 3 info |

Four agents audited the code as it is at `0a88bde`, 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: hasPause is probed only at listing; an issuer upgrade that breaks oraclePaused() on a held asset halts all deposits and only Retire (which dilutes existing holders) can clear it

`src/BaskVault.sol:274`

```
        a.hasPause = ok && paused <= 1;
```

_newAsset probes oraclePaused() once and stores the result in Asset.hasPause; line 274 is the only assignment to that field. BaskOracle.price (src/libraries/BaskOracle.sol:93-97) then requires a valid false answer from oraclePaused() on every deposit price check for that asset, and _snapshot prices every unretired asset with managed > 0, not only the input tokens. The brief lists issuer upgrades of the Stock Tokens as a scenario. If an upgrade removes, renames, reverts or widens oraclePaused() on a held token, price() returns OraclePaused for it forever and every deposit, previewDeposit and depositStatus for any input token fails with DepositUnavailable(OraclePaused, token). No proposal re-runs the probe: Feed, Pool, Centre, Reopen and Resync all leave hasPause true (verified by execution of each). The accepted-risk list covers the analogous pool failure because a Pool proposal can clear it; here the only exit is Retire, which permanently drops the asset from NAV while its tokens stay in the vault, so every depositor after Retire shares them at existing holders' expense (reproduced: with 10e18 of token0 and token1 held, retiring token0 and depositing 10e18 of token1 yields a redemption of 5e18 token0 for the new depositor). If the retired asset was the only holding, NAV is zero and deposits stop with ZeroNAV. Redeem and claim are unaffected. The reverse direction also holds: a token listed before it exposed oraclePaused() keeps hasPause false, so a pause added by a later upgrade is ignored. Minimal fix preserving the design: re-run the two-line oraclePaused() probe from _newAsset when a Feed, Pool or Centre proposal executes for the asset (or add a dedicated timelocked action), giving the owner the same lever a Pool proposal gives for pool failures. Merged from audit_permissions (medium) and audit_economics (low).

**Reproduction**

State: three assets listed at genesis with a token exposing oraclePaused()=false, so asset(token0).hasPause == true; alice deposits 10e18 of token0 (managed > 0). Input: the token's oraclePaused() starts reverting (proof: removeProbe(); also verified with vm.etch of code lacking the function). Then depositStatus([token1]) returns (OraclePaused, token0) and deposit([token1],[1e18],alice,0,now) reverts DepositUnavailable(OraclePaused, token0). Owner executes Feed(token0, same feed), Pool(token0, 0,0,0), Centre(token0), close+Reopen(token0) and Resync(token0) after their 2-day waits: asset(token0).hasPause is still true and depositStatus still returns OraclePaused. Expected: an owner proposal re-detects the probe so deposits resume, as a Pool proposal does for a broken pool. Actual: deposits are halted until Retire. Proof test/scratch/Proof_765fb70ae8b5.t.sol fails on this code with 'hasPause must be re-detected by an owner proposal'; test/scratch/Probe.t.sol::testHasPauseStickyAndRetireDilutes and testRetireDilution confirm Reopen/Resync and the post-retire dilution.

**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 {BaskTypes as T} from "src/BaskTypes.sol";

/// @dev Minimal Stock Token with an optional oraclePaused() probe that the issuer can remove by upgrade.
contract StockToken {
    uint8 public constant decimals = 18;
    bool public probeRemoved;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

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

    function removeProbe() external {
        probeRemoved = true;
    }

    function oraclePaused() external view returns (bool) {
        require(!probeRemoved, "function removed by upgrade");
        return false;
    }

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

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

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

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

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

    function set(int256 a, uint256 t) external {
        answer = a;
        updatedAt = t;
    }

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

/// @notice Fails on the current code: once a held Stock Token's oraclePaused() probe stops answering,
/// no owner proposal (Feed, Pool, Centre) re-detects the pause interface, so hasPause stays true and
/// every deposit into the vault reverts with DepositUnavailable(OraclePaused). Passes once executing a
/// Feed, Pool or Centre proposal re-probes oraclePaused() the way listing does.
contract StickyPauseProof is Test {
    BaskVault private vault;
    StockToken[3] private tokens;
    Feed[3] private feeds;
    address private owner = makeAddr("owner");
    address private guardian = makeAddr("guardian");
    address private alice = makeAddr("alice");

    function setUp() public {
        vm.warp(1_800_000_000);
        vault = new BaskVault(owner, guardian);
        for (uint256 i; i < 3; ++i) {
            tokens[i] = new StockToken();
            feeds[i] = new Feed(100e8);
            vm.prank(owner);
            vault.genesisList(address(tokens[i]), address(feeds[i]), address(0), address(0), 0);
            tokens[i].mint(alice, 100e18);
            vm.prank(alice);
            tokens[i].approve(address(vault), type(uint256).max);
        }
        vm.prank(owner);
        vault.finalizeGenesis();
    }

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

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

    function _refresh() private {
        for (uint256 i; i < 3; ++i) {
            feeds[i].set(100e8, block.timestamp);
        }
    }

    function _run(T.Action action, address token, bytes memory data) private {
        vm.prank(owner);
        uint256 id = vault.propose(action, token, data);
        vm.warp(vault.proposal(id).readyAt);
        _refresh();
        vm.prank(owner);
        vault.execute(id);
    }

    function testOwnerCanRecoverDepositsAfterIssuerRemovesPauseProbe() public {
        address t0 = address(tokens[0]);
        vm.prank(alice);
        vault.deposit(_one(t0), _amt(10e18), alice, 0, block.timestamp);
        assertTrue(vault.asset(t0).hasPause);

        // Issuer upgrade: oraclePaused() no longer exists on the held token.
        tokens[0].removeProbe();
        (T.Reason reason, address fault) = vault.depositStatus(_one(address(tokens[1])));
        assertEq(uint256(reason), uint256(T.Reason.OraclePaused));
        assertEq(fault, t0);

        // Every configuration proposal the owner has for a live asset.
        _run(T.Action.Feed, t0, abi.encode(address(feeds[0])));
        _run(T.Action.Pool, t0, abi.encode(address(0), address(0), uint128(0)));
        _run(T.Action.Centre, t0, "");
        _refresh();

        // Expected: the probe is re-detected and deposits of other assets work again.
        // Actual on current code: hasPause is still true and the deposit reverts with OraclePaused.
        assertFalse(vault.asset(t0).hasPause, "hasPause must be re-detected by an owner proposal");
        (reason,) = vault.depositStatus(_one(address(tokens[1])));
        assertEq(uint256(reason), uint256(T.Reason.OK), "deposits must be recoverable without retiring the asset");
        vm.prank(alice);
        uint256 shares = vault.deposit(_one(address(tokens[1])), _amt(1e18), alice, 0, block.timestamp);
        assertGt(shares, 0);
    }
}
```

### 2. Low: Read-only reentrancy: previewRedeem, previewDeposit, depositStatus and allAssets report inconsistent values while redeem or deposit is in flight

`src/BaskVault.sol:794`

```
    function previewRedeem(uint256 shares) external view returns (uint256[] memory amounts, uint256 fee) {
```

Every state-changing entry point is nonReentrant, but the view functions do not check the `entered` flag. redeem() reduces totalSupply (line 652) before the per-asset loop reduces managed[] one asset at a time and makes an external token transfer through pay() for each held asset. A Stock Token whose transfer calls back into previewRedeem (an issuer upgrade can do this) sees the reduced supply against the still-unreduced managed[] of later assets and is quoted roughly twice the true per-share entitlement for a 50% redemption. Symmetrically, during deposit() the token pulls (line 565) increase managed[] for earlier input tokens before shares are minted, so previewDeposit/allAssets quoted from a transferFrom callback see an overstated NAV per share. Vault accounting is not corrupted and the brief's redeem/claim/deposit guarantees hold; the exposure is to any contract that prices BASK through these views (lending collateral, a quoter, a keeper) and can be invoked inside a hooked token transfer. Minimal fix: revert in the views when entered != 1, or apply supply and all managed reductions before any external call. From audit_flow.

**Reproduction**

State: three listed assets; alice deposited 10e18 of asset A (a token whose transfer calls vault.previewRedeem(1e18)) and 10e18 of asset B (plain). previewRedeem(1e18) reports 5e15 of B per 1e18 shares. alice calls redeem(balance/2, alice, [], now). Inside pay() for asset A the hooked transfer calls previewRedeem(1e18): totalSupply is already halved but managed[B] is untouched, so the view returns 9999995000002499 (about 2x) for B. Expected: a view invoked mid-redemption reverts or returns the same 5e15 as before and after the transaction. Actual: about 1e16. Proof test/scratch/Proof_109e6b698e4c.t.sol fails on this code with 'previewRedeem must not report an inflated entitlement while a redemption is in flight'.

**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 {BaskTypes as T} from "src/BaskTypes.sol";

/// @dev A Stock Token whose transfer calls back into a vault view (an issuer upgrade can do this).
contract HookedToken {
    uint8 public constant decimals = 18;
    mapping(address => uint256) public balanceOf;
    BaskVault public vault;
    uint256 public observedLeg1;
    bool public viewReverted;
    bool public armed;

    function setVault(BaskVault v) external {
        vault = v;
    }

    function arm() external {
        armed = true;
    }

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

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        return true;
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        if (armed) {
            armed = false;
            // Mid-redeem view read: supply already reduced, later assets' managed not yet reduced.
            try vault.previewRedeem(1e18) returns (uint256[] memory amounts, uint256) {
                observedLeg1 = amounts[1];
            } catch {
                viewReverted = true;
            }
        }
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        return true;
    }
}

contract PlainToken {
    uint8 public constant decimals = 18;
    mapping(address => uint256) public balanceOf;

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

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        return true;
    }

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

contract Feed {
    uint8 public constant decimals = 8;

    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {
        return (1, 100e8, block.timestamp, block.timestamp, 1);
    }
}

contract ReadOnlyReentrancyTest is Test {
    BaskVault vault;
    HookedToken hooked;
    PlainToken plain;
    PlainToken third;
    address owner = makeAddr("owner");
    address guardian = makeAddr("guardian");
    address alice = makeAddr("alice");

    function setUp() public {
        vm.warp(1_800_000_000);
        vault = new BaskVault(owner, guardian);
        hooked = new HookedToken();
        plain = new PlainToken();
        third = new PlainToken();
        hooked.setVault(vault);
        vm.startPrank(owner);
        vault.genesisList(address(hooked), address(new Feed()), address(0), address(0), 0);
        vault.genesisList(address(plain), address(new Feed()), address(0), address(0), 0);
        vault.genesisList(address(third), address(new Feed()), address(0), address(0), 0);
        vault.finalizeGenesis();
        vm.stopPrank();
        hooked.mint(alice, 10e18);
        plain.mint(alice, 10e18);
        address[] memory ts = new address[](2);
        ts[0] = address(hooked);
        ts[1] = address(plain);
        uint256[] memory am = new uint256[](2);
        am[0] = 10e18;
        am[1] = 10e18;
        vm.prank(alice);
        vault.deposit(ts, am, alice, 0, block.timestamp);
    }

    /// Fails now: the view called from inside redeem returns ~2x the true entitlement for the
    /// not-yet-processed asset. Passes once views are guarded against reentrancy (revert) or
    /// kept consistent mid-flight.
    function testViewIsConsistentOrGuardedDuringRedeem() public {
        (uint256[] memory before,) = vault.previewRedeem(1e18);
        uint256 shares = vault.balanceOf(alice) / 2;
        hooked.arm();
        vm.prank(alice);
        vault.redeem(shares, alice, new uint256[](0), block.timestamp);
        emit log_named_uint("plain leg per 1e18 shares before redeem", before[1]);
        emit log_named_uint("plain leg per 1e18 shares seen mid-redeem", hooked.observedLeg1());
        assertTrue(
            hooked.viewReverted() || hooked.observedLeg1() == before[1],
            "previewRedeem must not report an inflated entitlement while a redemption is in flight"
        );
    }
}
```

### 3. Low: Deposits require a readable balance on idle, never-held, non-input assets, which the brief does not ask for and solvency does not need

`src/BaskVault.sol:498`

```
            (bool readable, uint256 bal) = O.balance(token, config.balanceGas);
```

The brief says a deposit checks 'every deposited and every held unretired token'. _snapshot additionally performs a bounded balanceOf read on every unretired asset, including assets with managed == 0 that are not in the deposit. For such an asset the only consumers of bal are `(wanted[i] && bal < debt)` (false, not wanted) and `available < m` (false, m == 0), so the read cannot change the solvency outcome; it only adds a failure mode. An issuer upgrade that makes balanceOf revert, return fewer than 32 bytes or cost more than balanceGas (50,000 by default) on a listed-but-never-deposited token blocks all deposits of every other asset until the owner retires it (close plus a 2-day Retire proposal) or raises balanceGas by proposal. The accepted list covers a held asset whose pool or balance fails, not an idle one. Existing tests testZeroManagedNonInputMustStillHaveReadableBalance and testDepositChecksIdleUnreadableButSkipsRetired assert the current behaviour, so this is a deliberate choice reported under item 6 (code does what the text does not say). Minimal fix: move the balance read inside `if (m != 0 || wanted[i])` and compute available/debt only there, leaving the retired skip and the idle freshness branch unchanged. From audit_math.

**Reproduction**

Three assets, genesis finalized; alice deposits 1e18 of tokens[0]. tokens[2] (managed == 0, not an input) is upgraded so balanceOf reverts (MockToken.setModes(1, 0)). Expected per the brief: depositStatus([tokens[0]]) == (OK, 0) and a further deposit of tokens[0] succeeds, because tokens[2] is neither deposited nor held. Actual: depositStatus returns (BalanceUnreadable (7), tokens[2]) and deposit([tokens[0]],[1e18],alice,0,now) reverts DepositUnavailable(BalanceUnreadable, tokens[2]); redeem is unaffected. Reproduced in test/scratch/Probe.t.sol::testIdleUnreadableBlocksDeposit.

### 4. Info: Global proposals (Guardian, NavCap, FeeRecipient, Setting) accept any token address and are voided by that token's retire or removal

`src/BaskVault.sol:296`

```
        proposals[id] = T.Proposal(action, token, data, block.timestamp + 2 days, epoch[token], version, false);
```

README.md says the token argument of a global action is zero, but _validate only inspects token for actions up to Resync, and propose stores epoch[token] for whatever address was passed. proposalValid then requires p.epoch == epoch[p.token], so a global proposal created with a listed token's address is silently invalidated when that token is retired or removed; a guardian who cannot cancel its own replacement can still void a Guardian proposal mistakenly keyed to a token the guardian can close and the owner then retires. Owner-only input, no funds at risk, re-proposing fixes it. Minimal fix: in _validate require token == address(0) for actions above Resync (or store epoch 0 for them). Merged from audit_economics and audit_permissions.

**Reproduction**

owner: propose(FeeRecipient, tokens[0], abi.encode(bob)) -> proposalValid(id) == true. guardian: close(tokens[0]); owner: propose(Retire, tokens[0]) and execute it after two days (increments epoch[tokens[0]]). Now proposalValid(id) == false and execute(id) at readyAt reverts InvalidProposal although nothing about the fee recipient changed. Expected: a global proposal is independent of any asset. Actual: bound to an arbitrary token's epoch. Reproduced in test/scratch/Probe.t.sol::testGlobalProposalBoundToTokenEpoch.

### 5. Info: Deposit blockers and immediate owner/guardian actions present in code but absent from the brief: price band around a stored centre, 80-hour feed age on pooled assets, trading hours, freshness count, N

`src/libraries/BaskOracle.sol:88`

```
            answer < a.centre / s.band || (answer / s.band > a.centre)
```

Item 6 asks for anything the code does that the text does not say. All of the following are documented in README.md and only ever block deposits (never redeem or claim), so none is a loss path, but the brief's deposit rules do not state them: (a) every priced asset's feed answer must lie within [centre/band, centre*band] (band 4, settable 2..100) where centre is the answer recorded at listing, Feed change or a Centre proposal, so a genuine move beyond 4x in either direction, including a 5:1 or 10:1 split, blocks every deposit of every token until a Centre proposal executes two days later and the brief's operating rules do not mention recentring before a split; (b) a pooled asset's feed must also be under maxAge (80 hours, src/libraries/BaskOracle.sol:84) independent of the 3% pool check, so a three-day market holiday weekend (about 89.5 hours between the Friday and Tuesday updates) blocks deposits until the feed updates even when the pool check would pass; (c) optional Hours (weekday window) and FreshCount settings can block deposits globally; (d) close(token), pauseDeposits() and lowerNavCap(cap) take effect immediately without the 2-day proposal path, lowerNavCap has no lower bound (0 is accepted and voids pending cap raises) and the guardian, not only the owner, can close any asset and pause deposits, while only the owner can unpause. These are trust assumptions on the owner and guardian rather than defects. Merged from audit_math, audit_economics and audit_permissions.

**Reproduction**

Band: VaultFixture (centre 100e8, band 4); feeds[0].set(25e8 - 1, block.timestamp) or feeds[0].set(400e8 + 1, block.timestamp). Expected per the brief: a valid feed answer under 26 hours with no pool is accepted. Actual: depositStatus([tokens[0]]) returns (Band, tokens[0]) and deposit reverts DepositUnavailable(Band, tokens[0]) (boundary shown by test/Prices.t.sol::testBandBoundariesAndOraclePause). Age: with a healthy pool within 3%, feed updatedAt = now - 80 hours - 1 returns (Feed, token) (boundary shown by testPoolTickZeroPriceLiquidityAndMaxAge). Cap: owner calls lowerNavCap(0) in one transaction; the next deposit of any amount reverts CapExceeded with no delay (mechanism shown by testNavCapLoweringVoidsRaisesAndCapApplies).

### 6. Info: Redemption gas headroom at the tightest in-bounds setting is about 100,000 gas (0.36%) on the booked path

`src/BaskVault.sol:440`

```
            s.maxAssets < assetTokens.length || s.maxAssets > 28_000_000 / (s.balanceGas + 60_000)
```

The booked-path rule budgets 60,000 gas of vault overhead per asset. Measured overhead per held asset on the booked path is roughly 58,000 (two cold owed/totalOwed SSTOREs, managed SLOAD+SSTORE, assetTokens SLOAD, cold token account access, call stipend and the 512-bit mulDiv). At the tightest permitted configuration, maxAssets = 50 with balanceGas = 500,000, the suite's testGasMaximumBalance50Assets records 27,897,644 gas for the call with fee, maximal managed values and gas-burning balanceOf on every asset, which satisfies the requirement but leaves about 102,000 gas; a full 50-entry minAmountsOut array adds roughly 11,000 more. A real transaction also pays the 21,000 intrinsic cost plus calldata, so the end-to-end transaction is about 27,960,000: inside 28,000,000 on a Cancun-schedule EVM but with no margin for a gas-schedule difference on the target chain (for example an L1 data surcharge counted in gasUsed on an Orbit-style rollup). No code defect; recorded so the owner can decide whether to keep balanceGas below 500,000 operationally or widen the booked rule's per-asset allowance to 70,000 like the direct rule. Every direct-path boundary in the suite is lower (maximum 27,331,250 for 26 direct attempts with 500k allowances). From audit_math.

**Reproduction**

Run `forge test --match-test testGasMaximumBalance50Assets -vv`: settings MaxAssets=50, BalanceGas=500000 (and PayGas=500000, DirectLimit=26 or 0 for the booked path), 50 pool-less tokens each held with near-uint256 managed via Resync, fee recipient set, every balanceOf consuming all forwarded gas, redeem(shares/2, receiver, [], now) called with exactly 28,000,000 gas. Expected: success under 28,000,000. Actual: success at 27,897,644 gas (observed on this tree), headroom 102,356.

---

Judge's submission `8e18f67802160bacd6d2a1d859a8ea455ab2300729eba67f4379bd9fc3be46a5`, 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.
