# Audit report

> Basket (BASK) is an immutable index vault for Stock Tokens on Robinhood Chain (chain id 4663), deployed at 0x518aa023c1b982a0a64b207b7d3a19bf973796e1 with nothing listed yet. A user deposits one listed Stock Token, priced by its Chainlink feed, and receives BASK; redeem burns BASK for a pro-rata share of every listed token. One owner and one guardian; owner changes wait 7 days and the guardian can veto. Trusted: the owner pairs each token with its true feed. The issuer can pause, block, burn or upgrade the Stock Tokens. This is the second build: after an audit of the first, the owner can retire a closed asset by proposal (closed for good, skipped by every deposit check, 0 in NAV, still paid out by redeem), and lowering NAV_CAP cancels pending raises.
>
> Look hardest at:
>
> 1. Redeem and claim can never be blocked or made to revert: not by the owner or guardian, a paused, blacklisted, reverting, gas-burning or lying Stock Token, a stale or wrong feed, retirement, the deposit hours, the caps or the daily limit. Check the 250,000-gas leg self-call, the 50,000-gas balance reads, the owed and totalOwed accounting, and the 64-asset gas bound.
>
> 2. Nobody can move assets out of the vault except redeem and claim paying the user, and nobody can mint BASK except through deposit (plus the fee shares and the 1e15 dead shares on the first deposit). Look for any path through proposals, executeProposal, closeAsset, proposeRetire, recognizeLoss, setFeeRecipient, finalizeGenesis or reentrancy.
>
> 3. Retire: does close plus retire always unblock deposits when a held asset's token reports oraclePaused, its balance is unreadable or its feed dies; can retire take value beyond the stated dilution (new depositors share the retired asset), block redeem, or skip the 7-day wait or the guardian veto; is a retired asset's loss record kept through deposits.
>
> 4. Deposit pricing and share math: rounding direction, first-deposit and donation attacks, BaskMath, the 0.5% entry and exit fees, managed versus balance, and flagDeficit and recognizeLoss after an issuer burn.
>
> 5. Whether the deposit gate, the 5% per-asset limit, the daily bucket or the NAV cap can be bypassed, and whether a raise proposed before a lowering can still execute.
>
> Accepted by the owner, report only if worse than stated here: tokens the issuer returns after recognizeLoss stay outside managed; an unreadable balance during a shortfall books the leg from managed and claims are paid first come, first served; a complete loss, or retiring every held asset, leaves NAV at 0 and deposits stop; the daily bucket counts deposits, not redemptions; setFeeRecipient (once) and the two-step ownership handover take effect at once; listing does not test oraclePaused; BASK sent to the vault's own address is lost.

| | |
|---|---|
| Repository | https://github.com/identity-md-launches/launch-877-basket.git |
| Commit | `3b1fe81cab5e4d6b148399ce2af5d84ebefeacb4` |
| Job | `b7349cdf-5fec-4b97-a413-1ab9f9dbb5d5` |
| Judged | 2026-10-07 15:25 UTC |
| Findings | 1 medium · 3 low · 4 info |

Four agents audited the code as it is at `3b1fe81`, 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: Issuer-credited tokens (stock split, stock dividend, rebase-up) never enter managed: NAV is understated and the extra tokens are stranded in the vault forever

`src/BaskVault.sol:647`

```
        managed[token] += amount;
```

deposit is the only writer that increases managed[token], and it only adds the exact amount it pulled. NAV (line 590) and every redemption leg (line 674) read managed only, and no entry point ever moves a balance increase that did not arrive through deposit into managed. For an index of Stock Tokens this is not just the documented donation rule: a forward stock split, a stock dividend or a rebasing token upgrade credits the vault with new tokens while the Chainlink per-share feed drops proportionally. The vault then values the position at a fraction of its real worth (half, for a 2-for-1 split), redeemers are paid only the managed-based leg, and the credited tokens stay in the contract with no exit: flagDeficit rejects the state (no shortfall), recognizeLoss cannot run, and there is no sweep. The accepted-risk list covers tokens the issuer returns after recognizeLoss; here no loss ever occurred and existing holders permanently lose the split half of their position. A reverse split (issuer burns) is handled by the loss path after seven days, so the asymmetry is one-directional. Fixing it changes the stated accounting rule, so it needs an owner decision: for example an owner proposal (7-day wait, guardian veto) that recognises surplus of a listed asset into managed, mirroring recognizeLoss, or a split-aware rescale of managed when a feed proposal executes. No proof file is attached because any fix requires a new entry point the test cannot name; the reproduction below is the test/scratch/Judge.t.sol::testStockSplitStrandsHalfOfHoldersPosition run on this tree. Merged from the audit_math report; it reproduces as described.

**Reproduction**

State: 3 genesis assets at $100 (feed 100e8, band [25e8, 400e8]); Alice deposits 100 stock0 so managed[stock0] = 100e18 and previewDeposit(stock1, 1e18).nav == 10_000e18. Issuer performs a 2-for-1 split: vault balance of stock0 becomes 200e18 (stock0.mint(vault, 100e18)) and the feed reports 50e8 (inside the band). Expected: holders still own $10,000 of stock0 and a full redemption pays about 200 stock0. Actual on this tree: previewDeposit reports nav == 5_000e18; managed[stock0] stays 100e18; flagDeficit(stock0) reverts InvalidState; Alice redeeming all her shares receives leg 99.49999e18 stock0 while the vault keeps 100.50001e18 stock0 with managed[stock0] == 0.50001e18 and owed == totalOwed == 0. No entry point can ever release the ~100e18 surplus or count it in NAV.

### 2. Low: Retiring an asset never releases its Chainlink feed binding, so a successor token for the same stock can never be listed with its true feed

`src/BaskVault.sol:428`

```
            a.retired = true;
```

_list writes feedAsset[feed] = token (line 476). The only writer that clears it is the Kind.Feed branch of executeProposal (line 411), which goes through _checkReplacement -> _liveAsset and therefore reverts InvalidState for a retired asset. The Retire branch sets a.retired = true and leaves feedAsset[a.feed] pointing at the retired token. _checkFeed (line 457) rejects any listing whose feed is bound to a different token. Retirement is the brief's remedy for a Stock Token that is closed for good, which on this chain includes the issuer reissuing the stock at a new token address (beacon proxies, factory uid -> token mapping). The replacement is priced by the same single Chainlink proxy, so after retirement the owner can neither list it with its true feed (InvalidFeed) nor move the retired entry off the feed (InvalidState). The stock is lost from the vault's universe for the life of the contract unless the owner deploys a wrapper feed, which contradicts the trust assumption that each token is paired with its true feed. The retired asset reads no price anywhere (every deposit check skips it, redeem reads no feed), so the binding is dead state. Minimal fix that preserves the design: delete feedAsset[a.feed] in the Retire branch; the attached proof passes with that one line. Merged from three specialist reports (audit_flow, audit_economics, audit_permissions); all three reproduce identically.

**Reproduction**

State: genesis with stocks[0..2] and feeds[0..2], genesis finalized, 72 hours elapsed. 1) owner: closeAsset(stocks[0]); id = proposeRetire(stocks[0]). 2) warp +7 days; executeProposal(id) -> assets(0).retired == true and feedAsset(feeds[0]) == stocks[0]. 3) Deploy successor MockStock (decimals 18, new uid registered at STOCK_FACTORY). owner: proposeAsset(successor, feeds[0]). Expected: a listing proposal for the migrated stock priced by its true feed. Actual: revert InvalidFeed(feeds[0]) from _checkFeed. 4) owner: proposeFeed(stocks[0], otherFeed) to free the slot. Expected: some path releases the binding. Actual: revert InvalidState from _liveAsset; feedAsset(feeds[0]) == stocks[0] forever. Reproduced in test/scratch/Judge.t.sol::testRetiredAssetLocksFeedForever; the attached proof fails on this tree with InvalidFeed and passes with `delete feedAsset[a.feed]` added to the Retire branch (verified on a patched copy).

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

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

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

contract PFactory {
    mapping(bytes32 => address) public tokenAddress;

    function register(bytes32 id, address token) external {
        tokenAddress[id] = token;
    }
}

contract PFeed {
    uint8 public decimals = 8;
    address public aggregator = address(1);
    int256 public answer = 100e8;
    uint256 public updatedAt;

    constructor() {
        updatedAt = block.timestamp;
    }

    function set(int256 answer_, uint256 time_) external {
        answer = answer_;
        updatedAt = time_;
    }

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

contract PStock {
    bytes32 public uid;
    uint8 public decimals = 18;
    bool public oraclePaused;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    constructor(bytes32 id) {
        uid = id;
    }

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

    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;
        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;
    }
}

/// Fails on the current code: after an asset is retired its Chainlink feed stays bound to the retired
/// token forever, so a successor token for the same stock can never be listed with its true feed.
/// Passes once retirement releases the feed binding (for example `delete feedAsset[a.feed]` in the
/// Retire branch of executeProposal).
contract ProofRetireLocksFeed is Test {
    address internal constant OWNER = 0x30B57ECf51D19ABcED7F6f70974e6fBb6f3b9Da3;
    address internal constant GUARDIAN = 0x5ed39AF86f2C00ad99913B5d727bD68f2A904B68;
    uint256 internal constant MONDAY = 1_728_259_200;
    BaskVault internal vault;
    PFactory internal factory;
    PStock[] internal stocks;
    PFeed[] internal feeds;

    function setUp() public {
        vm.chainId(4663);
        vm.warp(MONDAY + 55800);
        vault = new BaskVault(OWNER, GUARDIAN);
        PFactory template = new PFactory();
        vm.etch(vault.STOCK_FACTORY(), address(template).code);
        factory = PFactory(vault.STOCK_FACTORY());
        for (uint256 i; i < 3; ++i) {
            PStock stock = new PStock(bytes32(i + 1));
            PFeed feed = new PFeed();
            factory.register(stock.uid(), address(stock));
            stocks.push(stock);
            feeds.push(feed);
            vm.prank(OWNER);
            vault.proposeAsset(address(stock), address(feed));
        }
        vm.prank(OWNER);
        vault.finalizeGenesis();
        vm.warp(block.timestamp + 72 hours);
    }

    function testSuccessorTokenCanBeListedWithTheRetiredAssetsFeed() public {
        vm.startPrank(OWNER);
        vault.closeAsset(address(stocks[0]));
        uint256 id = vault.proposeRetire(address(stocks[0]));
        vm.stopPrank();
        vm.warp(block.timestamp + 7 days);
        vault.executeProposal(id);
        (,,, bool retired,,,,) = vault.assets(0);
        assertTrue(retired, "asset 0 retired");

        // the issuer re-issues the same stock at a new token address; its true feed is feeds[0]
        PStock successor = new PStock(bytes32(uint256(0x5e)));
        factory.register(successor.uid(), address(successor));
        feeds[0].set(100e8, block.timestamp);

        vm.prank(OWNER);
        uint256 listing = vault.proposeAsset(address(successor), address(feeds[0]));
        assertGt(listing, 0, "listing proposal created with the stock's true feed");
        vm.warp(block.timestamp + 7 days);
        vault.executeProposal(listing);
        assertEq(vault.assetIndex(address(successor)), 4, "successor listed");
        assertEq(vault.feedAsset(address(feeds[0])), address(successor), "feed now bound to successor");
    }
}
```

### 3. Low: A Stock Token upgraded to debit more than the transfer amount makes every claim for that asset revert forever, stranding the owed tokens with no recovery path

`src/BaskVault.sol:722`

```
        if (!afterOK || beforeBalance < afterBalance || beforeBalance - afterBalance != amount) {
```

payLeg requires the vault balance to fall by exactly amount. On the redeem path that strictness is correct: the 250,000-gas self-call reverts, the token movement rolls back and the leg becomes owed. claim, however, reuses the same payLeg with the same exact-equality postcondition and has no alternative. If the issuer upgrades the token so that an outgoing transfer debits the sender amount plus a fee or burn (even 1 wei), while still crediting the receiver amount, reporting balances truthfully and returning true, then every redeem leg for that asset is booked as owed and every subsequent claim(token, to) reverts TransferFailed, from any caller, to any receiver, forever. The owed balance and the remaining holders' managed share of that token can never leave the vault; there is no sweep, rescue or retirement path that pays it. The README accepts that claims depend on the token moving funds and that a token lying about balances is out of scope; this token is not lying, it is charging a fee, and the vault could deliver amount to the user by tolerating a decrease of at least amount on the claim path. The fix is a design decision because the README states the exact-decrease rule: accept beforeBalance - afterBalance >= amount only in the claim path (the attached proof uses a separate self-only payLegClaim helper), keeping exactness on the redeem path. A hostile token gains nothing new from the looser check since the issuer can already burn vault balances directly. Merged from audit_flow and audit_math; both reproduce identically.

**Reproduction**

State: 3 genesis assets; Alice deposits 10e18 stock0. Set stock0 to MockStock.Mode.ExtraDebit (transfer debits msg.sender amount + 1 wei, credits the receiver amount, returns true). Alice redeems all her shares: stock0 leg is booked as owed (owed[ALICE][stock0] = 9.95e18 - dust, managed reduced by the same, vault still holds 10e18). Alice calls claim(stock0, ALICE), then after warp +365 days claim(stock0, BOB). Expected per the brief: claim is not permanently blocked by a truthful token. Actual: both calls revert TransferFailed(stock0) from line 722-723 because beforeBalance - afterBalance == amount + 1; owed is unchanged and the vault still holds all 10e18. Reproduced in test/scratch/Judge.t.sol::testExtraDebitTokenStrandsClaimsForever; the attached proof fails on this tree with TransferFailed and passes once the claim path tolerates a decrease >= amount (verified on a patched copy).

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

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

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

contract PFactory {
    mapping(bytes32 => address) public tokenAddress;

    function register(bytes32 id, address token) external {
        tokenAddress[id] = token;
    }
}

contract PFeed {
    uint8 public decimals = 8;
    address public aggregator = address(1);
    int256 public answer = 100e8;
    uint256 public updatedAt;

    constructor() {
        updatedAt = block.timestamp;
    }

    function set(int256 answer_, uint256 time_) external {
        answer = answer_;
        updatedAt = time_;
    }

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

/// Stock Token whose issuer upgrade debits the sender one extra wei per outgoing transfer. It reports
/// balances truthfully, credits the receiver the full amount and returns true.
contract PStock {
    bytes32 public uid;
    uint8 public decimals = 18;
    bool public oraclePaused;
    bool public extraDebit;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    constructor(bytes32 id) {
        uid = id;
    }

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

    function setExtraDebit(bool on) external {
        extraDebit = on;
    }

    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;
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        return true;
    }

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

/// Fails on the current code: once a Stock Token debits the vault by more than `amount`, redeem
/// correctly isolates the leg as owed, but every later claim for that token reverts TransferFailed
/// because claim reuses the exact-decrease postcondition, so the owed tokens can never leave the vault.
/// Passes once the claim path accepts a vault balance decrease of at least `amount` (the user still
/// receives exactly `amount`; the overage is the token's own fee), while redeem keeps the exact rule.
contract ProofExtraDebitClaim is Test {
    address internal constant OWNER = 0x30B57ECf51D19ABcED7F6f70974e6fBb6f3b9Da3;
    address internal constant GUARDIAN = 0x5ed39AF86f2C00ad99913B5d727bD68f2A904B68;
    address internal constant ALICE = address(0xA11CE);
    uint256 internal constant MONDAY = 1_728_259_200;
    BaskVault internal vault;
    PFactory internal factory;
    PStock[] internal stocks;
    PFeed[] internal feeds;

    function setUp() public {
        vm.chainId(4663);
        vm.warp(MONDAY + 55800);
        vault = new BaskVault(OWNER, GUARDIAN);
        PFactory template = new PFactory();
        vm.etch(vault.STOCK_FACTORY(), address(template).code);
        factory = PFactory(vault.STOCK_FACTORY());
        for (uint256 i; i < 3; ++i) {
            PStock stock = new PStock(bytes32(i + 1));
            PFeed feed = new PFeed();
            factory.register(stock.uid(), address(stock));
            stocks.push(stock);
            feeds.push(feed);
            stock.mint(ALICE, 1_000e18);
            vm.prank(ALICE);
            stock.approve(address(vault), type(uint256).max);
            vm.prank(OWNER);
            vault.proposeAsset(address(stock), address(feed));
        }
        vm.prank(OWNER);
        vault.finalizeGenesis();
        vm.warp(block.timestamp + 72 hours);
        for (uint256 i; i < 3; ++i) {
            feeds[i].set(100e8, block.timestamp);
        }
    }

    function testOwedLegCanEventuallyBeClaimedFromAnExtraDebitToken() public {
        vm.prank(ALICE);
        vault.deposit(address(stocks[0]), 10e18, ALICE, 0, block.timestamp);
        stocks[0].setExtraDebit(true);

        uint256 shares = vault.balanceOf(ALICE);
        vm.prank(ALICE);
        vault.redeem(shares, new uint256[](0), block.timestamp);
        uint256 owed = vault.owed(ALICE, address(stocks[0]));
        assertGt(owed, 0, "leg isolated as owed");

        uint256 before = stocks[0].balanceOf(ALICE);
        vm.prank(ALICE);
        uint256 paid = vault.claim(address(stocks[0]), ALICE);
        assertEq(paid, owed, "claim pays the owed amount");
        assertEq(stocks[0].balanceOf(ALICE) - before, owed, "user received the owed tokens");
        assertEq(vault.owed(ALICE, address(stocks[0])), 0, "debt cleared");
        assertEq(vault.totalOwed(address(stocks[0])), 0, "total debt cleared");
    }
}
```

### 4. Low: The guardian veto is a 7-day delay, not a block: the owner can replace the guardian with a proposal the guardian cannot cancel and then re-propose the vetoed retirement

`src/BaskVault.sol:391`

```
        if (msg.sender != owner && (msg.sender != guardian || p.kind == Kind.Guardian)) revert Unauthorized();
```

The brief states that owner changes wait 7 days and the guardian can veto, and asks whether retirement can skip the guardian veto. cancelProposal excludes Kind.Guardian from the guardian's cancel right. The owner can therefore propose a retirement and a guardian replacement in the same block; the guardian can cancel the retirement but not its own replacement. After 7 days the replacement executes, the owner re-proposes the retirement and the new guardian does not veto, so the retirement executes on day 14 with the README-documented dilution of existing holders. The same holds for every proposal kind, and for the immediate powers (closeAsset, pauseDeposits) the guardian's only recourse is the same race. The README documents that the guardian cannot cancel guardian replacement, so this is reported as a precise statement of the veto's strength (a delay of at most 14 days from first proposal) rather than an undocumented bypass, and as a trust assumption on the owner key. If a real veto is wanted, the sitting guardian should be able to cancel its own replacement, or the replacement should carry a longer delay than the proposals it can neutralise. No proof attached because the fix changes the documented governance rule.

**Reproduction**

State: Alice deposits 100 stock0. Owner: closeAsset(stock0); id1 = proposeRetire(stock0); id2 = proposeGuardian(0x6A6A). Guardian: cancelProposal(id1) succeeds; cancelProposal(id2) reverts Unauthorized. Warp +7 days: executeProposal(id2) sets guardian == 0x6A6A. Owner: id3 = proposeRetire(stock0); the old guardian's cancelProposal(id3) reverts Unauthorized. Warp +7 days: executeProposal(id3) succeeds and assets(0).retired == true. Expected from the brief: the guardian's veto prevents the retirement. Actual: it postpones it by 7 days. Reproduced in test/scratch/Judge.t.sol::testGuardianVetoOfRetirementIsOnlyADelay.

### 5. Info: The 25,000 USD per-asset floor hard-bounds NAV at 25,000 USD times the asset count until more than 20 assets are listed; the 1,000,000 USD initial NAV_CAP is unreachable with 3 genesis assets

`src/BaskVault.sol:621`

```
        uint256 cap = probation ? M.max(nav2 / 100, 5_000e18) : M.max(M.mulDiv(nav2, 5, 100), 25_000e18);
```

The ordinary per-asset cap is max(5% of post-deposit NAV, 25,000e18). The 5% term only exceeds the floor once NAV2 > 500,000e18, but with N non-probation assets each stuck at the floor NAV cannot exceed 25,000e18 * N, and for N <= 20 the inequality value + delta <= max(0.05 * (NAV + delta), 25,000e18) fails for every delta > 0 once each asset holds 25,000e18. Probation assets add at most 5,000e18 of capacity for 30 days. So the 3-asset genesis basket has a hard ceiling of 75,000e18 regardless of NAV_CAP, and retiring one floor-level asset immediately blocks deposits into every remaining held asset until others are listed. This follows from the stated formula and is not a bypass; it is reported so the owner sizes the genesis basket and the NAV_CAP expectation accordingly. From audit_math; reproduces.

**Reproduction**

State: 3 genesis assets at $100; Alice deposits 250 tokens (25,000e18 USD) into each; NAV = 75,000e18, NAV_CAP = 1,000,000e18. Expected by an operator reading the 5% rule with a 1M cap: further deposits possible. Actual: deposit(stock_i, 1 wei, ...) reverts DepositUnavailable(AssetCap, stock_i) for i in 0..2. Reproduced in test/scratch/Judge.t.sol::testPerAssetFloorBoundsNAVWithThreeAssets.

### 6. Info: Retired assets permanently consume listing slots, so a vault that retires 62 tokens can never regain the three-fresh-feed deposit quorum

`src/BaskVault.sol:444`

```
        if (assets.length >= MAX_ASSETS) revert AssetLimit();
```

Assets are append-only and retirement does not free the slot. _checkListing rejects any listing once assets.length reaches 64, including when most entries are retired. Deposits need at least three unretired assets with a feed updated within 4 hours. Over the life of an immutable vault whose Stock Tokens can be paused, upgraded or migrated by the issuer, each broken token costs one slot forever; after 62 retirements deposits are permanently impossible while redeem and claim keep working. Not a bypass; an operational ceiling the README does not state. Related to, but mechanically distinct from, the feed-binding finding (different storage, different fix: allow a listing to reuse a retired slot, which changes the stated append-only rule). From audit_math; reproduces.

**Reproduction**

State: 64 genesis assets; Alice deposits 1e18 of stocks[63]. Owner: closeAsset + proposeRetire for stocks[0..61]; warp +7 days; executeProposal for all 62. assetCount() == 64. Owner: proposeAsset(newToken, newFeed) -> revert AssetLimit(). depositStatus(stocks[63]) == MarketNotFresh (fresh can never reach 3 with 2 unretired assets). Alice can still redeem all her shares. Reproduced in test/scratch/Judge.t.sol::testRetiredSlotsAreNeverFreedAndQuorumIsLost.

### 7. Info: claim accepts address(0) as the receiving address while deposit rejects it, so a mistaken claim burns the user's owed tokens

`src/BaskVault.sol:741`

```
    function claim(address token, address to) external nonReentrant returns (uint256 amount) {
```

deposit rejects receiver == address(0) and == address(this); claim validates nothing about to. A Stock Token that allows transfers to the zero address (the local mock does; many ERC-20s do) sends the owed tokens to 0x0, the exact-decrease check passes, and the owed record is cleared. Self-harm only, no effect on other users (claim to the vault's own address already reverts cleanly because the balance does not decrease). Reported as an input-validation asymmetry between the two user-facing receiver parameters; the fix is a one-line InvalidAddress check. From audit_math; reproduces.

**Reproduction**

State: Alice deposits 100 stock0; the token blocks Alice; she redeems all shares so owed[ALICE][stock0] = 99.5e18. Alice calls claim(stock0, address(0)). Expected: revert InvalidAddress like deposit. Actual: returns 99.5e18, owed is zero and stock0.balanceOf(address(0)) == 99.5e18. Reproduced in test/scratch/Judge.t.sol::testClaimToZeroAddressBurnsOwedTokens; the attached proof fails on this tree (the call does not revert) and passes with the zero check added to claim (verified on a patched copy).

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

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

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

contract PFactory {
    mapping(bytes32 => address) public tokenAddress;

    function register(bytes32 id, address token) external {
        tokenAddress[id] = token;
    }
}

contract PFeed {
    uint8 public decimals = 8;
    address public aggregator = address(1);
    int256 public answer = 100e8;
    uint256 public updatedAt;

    constructor() {
        updatedAt = block.timestamp;
    }

    function set(int256 answer_, uint256 time_) external {
        answer = answer_;
        updatedAt = time_;
    }

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

/// Stock Token with an issuer blocklist; transfers to address(0) are allowed, as in many ERC-20s.
contract PStock {
    bytes32 public uid;
    uint8 public decimals = 18;
    bool public oraclePaused;
    mapping(address => bool) public blocked;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    constructor(bytes32 id) {
        uid = id;
    }

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

    function blockAddress(address who, bool on) external {
        blocked[who] = on;
    }

    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) {
        require(!blocked[from] && !blocked[to], "blocked");
        allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        return true;
    }

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

/// Fails on the current code: claim(token, address(0)) sends the owed tokens to the zero address and
/// clears the debt, while deposit rejects a zero receiver. Passes once claim rejects to == address(0)
/// with InvalidAddress like deposit does.
contract ProofClaimZeroAddress is Test {
    address internal constant OWNER = 0x30B57ECf51D19ABcED7F6f70974e6fBb6f3b9Da3;
    address internal constant GUARDIAN = 0x5ed39AF86f2C00ad99913B5d727bD68f2A904B68;
    address internal constant ALICE = address(0xA11CE);
    uint256 internal constant MONDAY = 1_728_259_200;
    BaskVault internal vault;
    PFactory internal factory;
    PStock[] internal stocks;
    PFeed[] internal feeds;

    function setUp() public {
        vm.chainId(4663);
        vm.warp(MONDAY + 55800);
        vault = new BaskVault(OWNER, GUARDIAN);
        PFactory template = new PFactory();
        vm.etch(vault.STOCK_FACTORY(), address(template).code);
        factory = PFactory(vault.STOCK_FACTORY());
        for (uint256 i; i < 3; ++i) {
            PStock stock = new PStock(bytes32(i + 1));
            PFeed feed = new PFeed();
            factory.register(stock.uid(), address(stock));
            stocks.push(stock);
            feeds.push(feed);
            stock.mint(ALICE, 1_000e18);
            vm.prank(ALICE);
            stock.approve(address(vault), type(uint256).max);
            vm.prank(OWNER);
            vault.proposeAsset(address(stock), address(feed));
        }
        vm.prank(OWNER);
        vault.finalizeGenesis();
        vm.warp(block.timestamp + 72 hours);
        for (uint256 i; i < 3; ++i) {
            feeds[i].set(100e8, block.timestamp);
        }
    }

    function testClaimRejectsZeroReceiverLikeDeposit() public {
        vm.prank(ALICE);
        vault.deposit(address(stocks[0]), 100e18, ALICE, 0, block.timestamp);
        stocks[0].blockAddress(ALICE, true);
        uint256 shares = vault.balanceOf(ALICE);
        vm.prank(ALICE);
        vault.redeem(shares, new uint256[](0), block.timestamp);
        uint256 owed = vault.owed(ALICE, address(stocks[0]));
        assertGt(owed, 0, "leg owed to the blocked redeemer");

        vm.prank(ALICE);
        vm.expectRevert(BaskVault.InvalidAddress.selector);
        vault.claim(address(stocks[0]), address(0));
        assertEq(vault.owed(ALICE, address(stocks[0])), owed, "debt preserved");
        assertEq(stocks[0].balanceOf(address(0)), 0, "nothing burned");
    }
}
```

### 8. Info: proposalState reports Pending for Reopen, Band and Feed proposals whose asset has since been retired, although they can never execute

`src/BaskVault.sol:382`

```
            (p.kind == Kind.Reopen && p.version != closeVersion[p.token])
```

proposalState is documented as the effective lifecycle state and pendingProposals relies on it. It implements implicit cancellation for a Reopen whose closeVersion moved and a NAV-cap raise whose capVersion moved, but retirement is a third terminal event it does not reflect: after executeProposal(Retire), pending Reopen, Band and Feed proposals on that token still return State.Pending and are listed by pendingProposals, while executeProposal reverts InvalidState from _liveAsset on every attempt until they expire at 14 days. Monitoring that trusts the view (for example a guardian deciding what still needs a veto) sees live-looking proposals that are dead. No funds affected. Fix: in proposalState return State.Cancelled when p.token is a listed asset whose retired flag is set (same pattern as the existing version checks). From audit_permissions; reproduces.

**Reproduction**

State: stocks[0] listed and open. Owner: closeAsset(stocks[0]); r = proposeReopen(stocks[0]); b = proposeBand(stocks[0]); f = proposeFeed(stocks[0], otherFeed); t = proposeRetire(stocks[0]). Warp +7 days; executeProposal(t). Expected: proposalState(r), (b), (f) are non-pending and pendingProposals(1, 10) omits them. Actual: all three return State.Pending (1), pendingProposals lists 3 ids, and executeProposal on each reverts InvalidState. Reproduced in test/scratch/Judge.t.sol::testProposalStateStaysPendingForDeadProposalsAfterRetire; the attached proof fails on this tree and passes with the retired-asset check added to proposalState (verified on a patched copy).

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

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

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

contract PFactory {
    mapping(bytes32 => address) public tokenAddress;

    function register(bytes32 id, address token) external {
        tokenAddress[id] = token;
    }
}

contract PFeed {
    uint8 public decimals = 8;
    address public aggregator = address(1);
    int256 public answer = 100e8;
    uint256 public updatedAt;

    constructor() {
        updatedAt = block.timestamp;
    }

    function set(int256 answer_, uint256 time_) external {
        answer = answer_;
        updatedAt = time_;
    }

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

contract PStock {
    bytes32 public uid;
    uint8 public decimals = 18;
    bool public oraclePaused;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    constructor(bytes32 id) {
        uid = id;
    }

    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;
        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;
    }
}

/// Fails on the current code: after an asset is retired, pending Reopen, Band and Feed proposals on it
/// still report State.Pending from proposalState and are listed by pendingProposals, although
/// executeProposal reverts InvalidState on every one of them. Passes once proposalState reports a
/// non-pending state (Cancelled) for asset-scoped proposals whose asset is retired.
contract ProofProposalStateAfterRetire is Test {
    address internal constant OWNER = 0x30B57ECf51D19ABcED7F6f70974e6fBb6f3b9Da3;
    address internal constant GUARDIAN = 0x5ed39AF86f2C00ad99913B5d727bD68f2A904B68;
    uint256 internal constant MONDAY = 1_728_259_200;
    BaskVault internal vault;
    PFactory internal factory;
    PStock[] internal stocks;
    PFeed[] internal feeds;

    function setUp() public {
        vm.chainId(4663);
        vm.warp(MONDAY + 55800);
        vault = new BaskVault(OWNER, GUARDIAN);
        PFactory template = new PFactory();
        vm.etch(vault.STOCK_FACTORY(), address(template).code);
        factory = PFactory(vault.STOCK_FACTORY());
        for (uint256 i; i < 3; ++i) {
            PStock stock = new PStock(bytes32(i + 1));
            PFeed feed = new PFeed();
            factory.register(stock.uid(), address(stock));
            stocks.push(stock);
            feeds.push(feed);
            vm.prank(OWNER);
            vault.proposeAsset(address(stock), address(feed));
        }
        vm.prank(OWNER);
        vault.finalizeGenesis();
        vm.warp(block.timestamp + 72 hours);
    }

    function testRetirementMakesAssetScopedProposalsNonPending() public {
        PFeed other = new PFeed();
        vm.startPrank(OWNER);
        vault.closeAsset(address(stocks[0]));
        uint256 r = vault.proposeReopen(address(stocks[0]));
        uint256 b = vault.proposeBand(address(stocks[0]));
        uint256 f = vault.proposeFeed(address(stocks[0]), address(other));
        uint256 t = vault.proposeRetire(address(stocks[0]));
        vm.stopPrank();
        vm.warp(block.timestamp + 7 days);
        vault.executeProposal(t);

        // each of these can never execute again (InvalidState today; InvalidProposal once reported as cancelled)
        vm.expectRevert();
        vault.executeProposal(r);
        vm.expectRevert();
        vault.executeProposal(b);
        vm.expectRevert();
        vault.executeProposal(f);

        assertTrue(vault.proposalState(r) != BaskVault.State.Pending, "reopen on retired asset is not pending");
        assertTrue(vault.proposalState(b) != BaskVault.State.Pending, "band on retired asset is not pending");
        assertTrue(vault.proposalState(f) != BaskVault.State.Pending, "feed on retired asset is not pending");
        (uint256[] memory ids,) = vault.pendingProposals(1, 10);
        assertEq(ids.length, 0, "no dead proposals listed as pending");
    }
}
```

---

Judge's submission `417f0ead74c2c093ccadf4b59f0a122d9ffd363a163377d6c9850519d38c3a9e`, 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.
