Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ All notable changes to this repository are documented here.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [2026-09-22] - Vault strategy ignores donations

The vault strategy valued itself and paid withdrawals from its vaults' token
balances, and anyone can transfer tokens into a vault. A dust-sized first
deposit followed by a donation could price one share above the next deposit
and round it down to zero shares: the first-depositor inflation attack.

- `finance/vault-strategy` (Anchor v2, Anchor v1 and Quasar) records what the
strategy holds, `usdc_holdings` and `asset_holdings`, updated by `deposit`,
`withdraw` and `rebalance` with what each transfer actually moved, and prices
shares and pays withdrawals from those records. Donated tokens are outside
the fund, and `rebalance` can neither sell nor spend them
(`InsufficientHoldings`), as the lending example already ignores donations.
- A deposit so small that a swap returns none of its asset is rejected with
`DepositTooSmall`; it would otherwise mint shares against a fund worth
nothing and make every later deposit divide by zero.
- Tests in all three ports run the attack, the Kani crate proves recorded
holdings never exceed vault balances and that a donation cannot dilute the
next deposit, and the web apps read the recorded holdings.

## [2026-09-22] - Vault strategy rejects prices from before a cluster restart

The vault strategy checks Pyth freshness in seconds. Under Alpenglow each
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion finance/vault-strategy/VIDEO_SCRIPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ NARRATION:

Alice wants exposure to both stocks without buying and rebalancing them herself, so she calls `deposit` with 900 USDC. `deposit` is permissionless: any user can call it. This is buying into the strategy.

The handler prices her shares against net asset value. It walks the complete asset set, index zero then index one, reading each vault's balance and each Pyth price, and it will not proceed unless every asset's accounts are present, so nothing can be hidden from the valuation. The strategy is empty, so net asset value is zero, and the first deposit is defined as one to one. Alice gets 900 shares. Shares carry six decimals, so under the hood that is 900 million minor units, but think of it as 900 shares worth a dollar each.
The handler prices her shares against net asset value. It walks the complete asset set, index zero then index one, reading the holding the strategy has recorded for each vault and each Pyth price, and it will not proceed unless every asset's accounts are present, so nothing can be hidden from the valuation. The strategy is empty, so net asset value is zero, and the first deposit is defined as one to one. Alice gets 900 shares. Shares carry six decimals, so under the hood that is 900 million minor units, but think of it as 900 shares worth a dollar each.

Checks, effects, interactions: the handler raises `total_shares` first, then pulls her USDC into the USDC vault, then mints her the shares with the strategy PDA signing.

Expand Down
3 changes: 3 additions & 0 deletions finance/vault-strategy/anchor-v1/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## 2026-09-22

- **Donations are ignored.** The strategy records what it holds (`usdc_holdings` and `asset_holdings` on `Strategy`) and prices shares and pays withdrawals from those records, not from the vaults' token balances. `deposit`, `withdraw` and `rebalance` update them with what each transfer actually moved. Tokens transferred straight into a vault are outside the fund: they cannot inflate the share price, are never paid out, and `rebalance` can neither sell nor spend them (new `InsufficientHoldings` error). This closes the first-depositor inflation attack. Tested by `test_donation_does_not_inflate_share_price` and `test_rebalance_cannot_spend_donated_usdc`, and `test_full_lifecycle` checks the records against the vault balances after every step.
- **A deposit leg that buys nothing is rejected.** A deposit so small that a swap spends USDC and returns none of the asset now fails with `DepositTooSmall`; before, it minted shares against a fund worth nothing and every later deposit then divided by zero. Tested by `test_deposit_rejects_leg_that_buys_nothing`.
- The web app reads the recorded holdings for NAV and its allocation view, and its IDL gains the new fields and errors.
- **Prices from before a cluster restart are rejected.** Under Alpenglow each leader sets the Clock's `unix_timestamp`, which may advance by at most twice the slot time elapsed since the parent block, so after a halt the timestamp trails real time and catches up gradually. The 60-second `publish_time` check would therefore accept a Pyth price published just before a multi-hour halt. `load_price` now also reads the update's `posted_slot` (offset 125) and requires it to be after the `LastRestartSlot` sysvar's slot, failing with the new `PricePredatesRestart` error until Pyth posts again. Tested by `test_deposit_rejects_price_from_before_restart`; the web app's IDL gains the error.

## 2026-09-10
Expand Down
1 change: 1 addition & 0 deletions finance/vault-strategy/anchor-v1/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Rules the UI must respect and reflect:

- Deposits are accepted only when target weights sum to **exactly 10,000 bps**; a strategy is either still being configured or fully allocated and live (`StrategyNotFullyAllocated` otherwise).
- Shares: first deposit is 1:1 with USDC minor units; later deposits mint `deposit_usdc × total_shares / NAV`. Share mint is a PDA owned by the strategy PDA.
- NAV and withdrawals use the strategy's recorded holdings (`usdc_holdings`, `asset_holdings`), not vault token balances. Tokens sent straight to a vault are not part of the fund and should not be shown as fund value. A deposit too small to buy any of an asset is rejected (`DepositTooSmall`); a rebalance cannot sell or spend more than the recorded holdings (`InsufficientHoldings`).
- Management fee is charged by minting new shares to the manager (dilution), fixed at creation, capped at `MAX_FEE_BPS` = 1,000 bps (10%), no setter to raise it. `collect_fees` is permissionless.
- Slippage floors are computed on-chain from the Pyth price and `max_slippage_bps` (capped at 1,000 bps); a manager-supplied minimum is not trusted.
- `MAX_ASSETS` = 16. `deposit` re-derives the full `0..asset_count` PDA range and refuses to run if any asset account is missing (`IncompleteAssetAccounts`), so NAV can't be understated.
Expand Down
13 changes: 8 additions & 5 deletions finance/vault-strategy/anchor-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ A note on the word **vault**: by the common standard (ERC-4626) a vault holds a

### Net Asset Value (NAV)

[NAV](https://www.investopedia.com/terms/n/nav.asp) is the total value of everything the strategy holds: the USDC vault balance plus each asset vault balance valued at its Pyth price. It prices new deposits fairly, so every depositor pays the same per-share price regardless of when they join.
[NAV](https://www.investopedia.com/terms/n/nav.asp) is the total value of everything the strategy holds: its USDC plus each asset valued at its Pyth price. It prices new deposits fairly, so every depositor pays the same per-share price regardless of when they join.

The amounts come from the strategy's own records, `usdc_holdings` and `asset_holdings`, not from the vaults' token balances. Deposits, swaps and withdrawals update them with what each transfer actually moved, so they always equal what the fund owns. Anyone can transfer tokens straight into a vault, and those tokens (a donation) are outside the fund: they change a vault's balance and nothing the program reads. That is the defense against the first-depositor inflation attack, where a dust-sized first deposit followed by a donation would otherwise price one share above the next deposit and round it down to zero shares. Donated tokens are never paid out, and `rebalance` can neither sell nor spend them (`InsufficientHoldings`).

Because the asset set is dynamic, `deposit` must value *every* asset. The assets live at PDAs indexed `0..asset_count`, and `deposit` re-derives that complete range from the accounts it is given, refusing to run if any asset is missing (`IncompleteAssetAccounts`). This makes it structurally impossible to omit an asset and understate NAV.

Expand All @@ -38,7 +40,8 @@ Prices come from [Pyth Network](https://pyth.network/) `PriceUpdateV2` accounts.
A [share](https://www.investopedia.com/terms/s/shares.asp) represents a fraction of the whole strategy. Hold 1% of shares and you own 1% of every vault.

- **First deposit**: shares are issued 1:1 with USDC minor units (initial price of 1 USDC per share).
- **Later deposits**: `shares_to_mint = deposit_usdc × total_shares / NAV`.
- **Later deposits**: `shares_to_mint = deposit_usdc × total_shares / NAV`, with NAV valued from the recorded holdings.
- **A deposit leg must buy something.** A deposit so small that one of its swaps spends USDC and returns none of the asset is refused (`DepositTooSmall`). Otherwise it would mint shares against no recorded value, and every later deposit would divide by a zero NAV.
- Shares are [SPL tokens](https://solana.com/docs/terminology#token); the share mint's address is a [PDA](https://solana.com/docs/terminology#program-derived-address-pda), so it is deterministic and the strategy PDA is its mint authority.

### Management Fee
Expand Down Expand Up @@ -92,7 +95,7 @@ An [in-kind distribution](https://www.investopedia.com/terms/i/in-kind.asp) retu

### Alice deposits, and her money is deployed at once

`deposit(usdc_amount, minimum_shares)`, with each asset's `[asset_config, vault, mint, rate, price_feed]` passed as remaining accounts, plus the router accounts. The handler requires the strategy to be fully allocated, values every asset for NAV (first deposit is 1:1), mints shares to Alice, then deploys her USDC across the basket at its target weights through the router, each leg under an oracle slippage floor. With the weights at 40/60, a 900 USDC deposit lands as 1.44 TSLAx and 3.0 NVDAx with no idle USDC.
`deposit(usdc_amount, minimum_shares)`, with each asset's `[asset_config, vault, mint, rate, price_feed]` passed as remaining accounts, plus the router accounts. The handler requires the strategy to be fully allocated, values every asset's recorded holding for NAV (first deposit is 1:1), mints shares to Alice, then deploys her USDC across the basket at its target weights through the router, each leg under an oracle slippage floor. With the weights at 40/60, a 900 USDC deposit lands as 1.44 TSLAx and 3.0 NVDAx with no idle USDC.

### Bob deposits at the current share price

Expand Down Expand Up @@ -157,7 +160,7 @@ cargo build-sbf --manifest-path programs/vault-strategy/Cargo.toml
cargo test --manifest-path programs/vault-strategy/Cargo.toml
```

Tests live in `programs/vault-strategy/tests/vault_strategy.rs` and use [LiteSVM](https://github.com/LiteSVM/litesvm). Both `.so` files are loaded from `target/deploy/`, so build before testing. The suite covers the full lifecycle end to end (deposit with auto-deployment, a price move, rebalance back to target, a second depositor priced at the new NAV, a year's fee, in-kind withdrawal), retiring an asset with `set_weight` and reallocating to reopen deposits, and the rejection paths: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage, an under-allocated strategy, non-manager `set_weight`, unregistered router, and incomplete asset accounts on deposit.
Tests live in `programs/vault-strategy/tests/vault_strategy.rs` and use [LiteSVM](https://github.com/LiteSVM/litesvm). Both `.so` files are loaded from `target/deploy/`, so build before testing. The suite covers the full lifecycle end to end (deposit with auto-deployment, a price move, rebalance back to target, a second depositor priced at the new NAV, a year's fee, in-kind withdrawal), retiring an asset with `set_weight` and reallocating to reopen deposits, and the rejection paths: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage, an under-allocated strategy, non-manager `set_weight`, unregistered router, and incomplete asset accounts on deposit. `test_full_lifecycle` checks after every step that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the first-depositor attack (a one-minor-unit deposit, a 1,000 USDC transfer straight into the USDC vault, then a 1,000 USDC deposit with no `minimum_shares` floor) and checks the victim gets exactly the shares they would have got without the donation. `test_deposit_rejects_leg_that_buys_nothing` and `test_rebalance_cannot_spend_donated_usdc` pin the other two guards.

## FAQ

Expand All @@ -167,7 +170,7 @@ A manager creates a strategy with `initialize_strategy`, registers curator-appro

### How are share prices calculated?

Shares are priced at the strategy's net asset value: the total value of the vault balances at current prices divided by shares outstanding. A later depositor pays the current share price rather than diluting earlier ones.
Shares are priced at the strategy's net asset value: the total value of its recorded holdings at current prices divided by shares outstanding. A later depositor pays the current share price rather than diluting earlier ones. Tokens transferred straight into a vault are not part of the recorded holdings, so they cannot move the share price.

### How does the manager operate the fund?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ if (program) {
feeBps: 100,
maxSlippageBps: 250,
totalShares: new BN("1350000000"),
usdcHoldings: new BN("1000"),
assetHoldings: Array.from({ length: 16 }, (_, i) => new BN(i === 1 ? 2880000 : 0)),
lastFeeAccrualTimestamp: new BN("1700000000"),
assetCount: 2,
totalWeightBps: 10000,
Expand All @@ -112,7 +114,9 @@ if (program) {
decoded.maxSlippageBps === 250 &&
decoded.assetCount === 2 &&
decoded.totalWeightBps === 10000 &&
decoded.totalShares.toString() === "1350000000";
decoded.totalShares.toString() === "1350000000" &&
decoded.usdcHoldings.toString() === "1000" &&
decoded.assetHoldings[1].toString() === "2880000";
good ? ok("Strategy encode/decode round-trip") : fail("Strategy round-trip", JSON.stringify(decoded));
} catch (e) {
fail("Strategy round-trip", e.message);
Expand Down
4 changes: 4 additions & 0 deletions finance/vault-strategy/anchor-v1/app/src/idl/vaultStrategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export interface StrategyAccount {
feeBps: number;
maxSlippageBps: number;
totalShares: BN;
/** USDC the program has recorded in the USDC vault; excludes donations. */
usdcHoldings: BN;
/** Each asset's recorded holding, indexed by asset index; excludes donations. */
assetHoldings: BN[];
lastFeeAccrualTimestamp: BN;
assetCount: number;
totalWeightBps: number;
Expand Down
31 changes: 31 additions & 0 deletions finance/vault-strategy/anchor-v1/app/src/idl/vault_strategy.json
Original file line number Diff line number Diff line change
Expand Up @@ -1089,6 +1089,16 @@
"code": 6026,
"name": "PricePredatesRestart",
"msg": "Price feed is stale: it predates the last cluster restart"
},
{
"code": 6027,
"name": "DepositTooSmall",
"msg": "Deposit is too small: a deployment leg would buy none of its asset"
},
{
"code": 6028,
"name": "InsufficientHoldings",
"msg": "Rebalance spends more than the strategy's recorded holdings"
}
],
"types": [
Expand Down Expand Up @@ -1285,6 +1295,27 @@
"name": "total_shares",
"type": "u64"
},
{
"name": "usdc_holdings",
"docs": [
"USDC the program has accounted for in the USDC vault: deposits in, swap",
"spending and withdrawals out. Share prices and payouts use this, never the",
"vault's token balance, so USDC transferred straight into the vault",
"(a donation) is ignored rather than counted as fund value."
],
"type": "u64"
},
{
"name": "asset_holdings",
"docs": [
"Each asset's accounted-for amount, indexed by asset index: swap output in,",
"swap input and withdrawals out. Like `usdc_holdings`, it ignores tokens",
"transferred straight into a vault. Always <= that vault's token balance."
],
"type": {
"array": ["u64", 16]
}
},
{
"name": "last_fee_accrual_timestamp",
"type": "i64"
Expand Down
2 changes: 2 additions & 0 deletions finance/vault-strategy/anchor-v1/app/src/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const account: StrategyAccount = {
feeBps: 100,
maxSlippageBps: 100,
totalShares: new BN("12600000000"),
usdcHoldings: new BN(0),
assetHoldings: [new BN("20545200"), new BN("42802500"), ...Array.from({ length: 14 }, () => new BN(0))],
lastFeeAccrualTimestamp: new BN("1900000000"),
assetCount: 2,
totalWeightBps: 10_000,
Expand Down
23 changes: 12 additions & 11 deletions finance/vault-strategy/anchor-v1/app/src/solana/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,12 @@ export async function loadStrategyAccount(
}

/**
* Load everything the UI needs about a strategy: config, assets, vault balances, and
* freshly parsed oracle prices, then derive NAV exactly as the program does
* (value = amount * price / 1e8, all in USDC minor units).
* Load everything the UI needs about a strategy: config, assets, the holdings the
* program has recorded, and freshly parsed oracle prices, then derive NAV exactly as
* the program does (value = amount * price / 1e8, all in USDC minor units). The
* program prices shares from its recorded holdings, not the vaults' token balances,
* so tokens donated straight into a vault are not part of the fund; neither are they
* here.
*/
export async function loadStrategyView(
connection: Connection,
Expand Down Expand Up @@ -98,20 +101,19 @@ export async function loadStrategyView(
const configPdas = Array.from({ length: assetCount }, (_, i) => assetConfigPda(strategy, i));
const configs = (await program.account.assetConfig.fetchMultiple(configPdas)) as (AssetConfigAccount | null)[];

// One RPC round-trip for the USDC vault + every asset vault + every price feed.
const raw: PublicKey[] = [usdcVault];
// One RPC round-trip for every price feed.
const raw: PublicKey[] = [];
configs.forEach((c) => {
if (c) raw.push(c.vault, c.priceFeed);
if (c) raw.push(c.priceFeed);
});
const infos = await connection.getMultipleAccountsInfo(raw);

const usdcInfo = infos[0];
const usdcAmount = usdcInfo ? readTokenAmount(usdcInfo.data) : 0n;
const usdcAmount = toBig(account.usdcHoldings);

const now = nowSeconds();
let navMinor = usdcAmount;
let navComplete = true;
let cursor = 1;
let cursor = 0;

const assets: AssetView[] = configs.map((c, i) => {
const config = configPdas[i];
Expand All @@ -132,9 +134,8 @@ export async function loadStrategyView(
actualWeight: null,
};
}
const vaultInfo = infos[cursor++];
const feedInfo = infos[cursor++];
const vaultAmount = vaultInfo ? readTokenAmount(vaultInfo.data) : 0n;
const vaultAmount = toBig(account.assetHoldings[c.index]);

let price: bigint | null = null;
let publishTime: number | null = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,8 @@ pub enum VaultError {
FeeTooHigh,
#[msg("Price feed is stale: it predates the last cluster restart")]
PricePredatesRestart,
#[msg("Deposit is too small: a deployment leg would buy none of its asset")]
DepositTooSmall,
#[msg("Rebalance spends more than the strategy's recorded holdings")]
InsufficientHoldings,
}
Loading
Loading