From 1d1057f570ec05d839099de0435f523c8290428d Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Tue, 22 Sep 2026 23:44:03 +0000 Subject: [PATCH] Ignore donations in the vault strategy's share math 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. The strategy now records what it holds, usdc_holdings and asset_holdings, and prices shares and pays withdrawals from those records. deposit, withdraw and rebalance update them with what each transfer actually moved, measured on the vaults. Donated tokens are outside the fund: they cannot move the share price, are never paid out, and rebalance can neither sell nor spend them (InsufficientHoldings). The one-to-one first deposit, the six-decimal share mint and every existing test number are unchanged. A deposit so small that a swap takes its USDC and returns none of the asset now fails with DepositTooSmall. Before, it minted shares against a fund worth nothing, and every later deposit divided by zero. All three ports change (Anchor v2, Anchor v1, Quasar, which stores the per-asset holdings as little-endian u64 bytes). Each runs the attack in a test; the Anchor suites also check rebalance cannot spend donated USDC and that the records match the vault balances after every lifecycle step. The Kani crate proves recorded holdings never exceed vault balances and that a donation cannot dilute the next deposit. The web apps read the recorded holdings, and READMEs, PRODUCT.md, changelogs and the video script follow. Claude-Session: https://claude.ai/code/session_012jbHknFnC6NKkeWvgvziB1 --- CHANGELOG.md | 20 ++ Cargo.lock | 2 + finance/vault-strategy/VIDEO_SCRIPT.md | 2 +- finance/vault-strategy/anchor-v1/CHANGELOG.md | 3 + finance/vault-strategy/anchor-v1/PRODUCT.md | 1 + finance/vault-strategy/anchor-v1/README.md | 13 +- .../anchor-v1/app/scripts/verify-client.mjs | 6 +- .../anchor-v1/app/src/idl/vaultStrategy.ts | 4 + .../anchor-v1/app/src/idl/vault_strategy.json | 31 +++ .../anchor-v1/app/src/preview.tsx | 2 + .../anchor-v1/app/src/solana/strategy.ts | 23 +- .../programs/vault-strategy/src/error.rs | 4 + .../src/instructions/deposit.rs | 38 ++- .../src/instructions/initialize_strategy.rs | 4 +- .../src/instructions/rebalance.rs | 57 +++- .../src/instructions/withdraw.rs | 47 ++-- .../vault-strategy/src/state/strategy.rs | 9 + .../vault-strategy/tests/vault_strategy.rs | 249 ++++++++++++++++++ finance/vault-strategy/anchor/CHANGELOG.md | 3 + finance/vault-strategy/anchor/PRODUCT.md | 1 + finance/vault-strategy/anchor/README.md | 13 +- .../anchor/app/scripts/verify-client.mjs | 6 +- .../anchor/app/src/idl/vaultStrategy.ts | 4 + .../anchor/app/src/idl/vault_strategy.json | 31 +++ .../vault-strategy/anchor/app/src/preview.tsx | 2 + .../anchor/app/src/solana/strategy.ts | 23 +- .../programs/vault-strategy/src/error.rs | 4 + .../src/instructions/deposit.rs | 37 ++- .../src/instructions/initialize_strategy.rs | 4 +- .../src/instructions/rebalance.rs | 52 ++++ .../src/instructions/withdraw.rs | 47 ++-- .../vault-strategy/src/state/strategy.rs | 9 + .../vault-strategy/tests/vault_strategy.rs | 249 ++++++++++++++++++ finance/vault-strategy/kani-proofs/README.md | 6 +- finance/vault-strategy/kani-proofs/src/lib.rs | 106 +++++++- finance/vault-strategy/quasar/CHANGELOG.md | 10 + finance/vault-strategy/quasar/README.md | 11 +- .../quasar/vault-strategy/src/errors.rs | 4 + .../src/instructions/deposit.rs | 47 +++- .../src/instructions/initialize_strategy.rs | 6 +- .../src/instructions/rebalance.rs | 57 +++- .../src/instructions/withdraw.rs | 58 ++-- .../vault-strategy/src/state/strategy.rs | 37 +++ .../quasar/vault-strategy/src/tests.rs | 229 +++++++++++++++- 44 files changed, 1452 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ed894106..d7fbdc8ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 798084cab..724b0bfea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7471,10 +7471,12 @@ dependencies = [ "anchor-v2-testing", "borsh 1.7.0", "mock-swap-router", + "pinocchio 0.11.2", "solana-account 3.4.0", "solana-address 2.6.1", "solana-clock 3.1.1", "solana-kite", + "solana-sysvar 3.1.1", "wincode 0.5.5", ] diff --git a/finance/vault-strategy/VIDEO_SCRIPT.md b/finance/vault-strategy/VIDEO_SCRIPT.md index c998b6717..f1becc104 100644 --- a/finance/vault-strategy/VIDEO_SCRIPT.md +++ b/finance/vault-strategy/VIDEO_SCRIPT.md @@ -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. diff --git a/finance/vault-strategy/anchor-v1/CHANGELOG.md b/finance/vault-strategy/anchor-v1/CHANGELOG.md index 49c9e7da3..9514752e0 100644 --- a/finance/vault-strategy/anchor-v1/CHANGELOG.md +++ b/finance/vault-strategy/anchor-v1/CHANGELOG.md @@ -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 diff --git a/finance/vault-strategy/anchor-v1/PRODUCT.md b/finance/vault-strategy/anchor-v1/PRODUCT.md index 82ef37295..0fa357ccb 100644 --- a/finance/vault-strategy/anchor-v1/PRODUCT.md +++ b/finance/vault-strategy/anchor-v1/PRODUCT.md @@ -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. diff --git a/finance/vault-strategy/anchor-v1/README.md b/finance/vault-strategy/anchor-v1/README.md index c859d3260..e9a3dbfbd 100644 --- a/finance/vault-strategy/anchor-v1/README.md +++ b/finance/vault-strategy/anchor-v1/README.md @@ -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. @@ -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 @@ -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 @@ -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 @@ -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? diff --git a/finance/vault-strategy/anchor-v1/app/scripts/verify-client.mjs b/finance/vault-strategy/anchor-v1/app/scripts/verify-client.mjs index f8f64c961..e4d1f779d 100644 --- a/finance/vault-strategy/anchor-v1/app/scripts/verify-client.mjs +++ b/finance/vault-strategy/anchor-v1/app/scripts/verify-client.mjs @@ -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, @@ -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); diff --git a/finance/vault-strategy/anchor-v1/app/src/idl/vaultStrategy.ts b/finance/vault-strategy/anchor-v1/app/src/idl/vaultStrategy.ts index a55f10902..7af01610c 100644 --- a/finance/vault-strategy/anchor-v1/app/src/idl/vaultStrategy.ts +++ b/finance/vault-strategy/anchor-v1/app/src/idl/vaultStrategy.ts @@ -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; diff --git a/finance/vault-strategy/anchor-v1/app/src/idl/vault_strategy.json b/finance/vault-strategy/anchor-v1/app/src/idl/vault_strategy.json index ef778fb4a..7f6a62b8b 100644 --- a/finance/vault-strategy/anchor-v1/app/src/idl/vault_strategy.json +++ b/finance/vault-strategy/anchor-v1/app/src/idl/vault_strategy.json @@ -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": [ @@ -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" diff --git a/finance/vault-strategy/anchor-v1/app/src/preview.tsx b/finance/vault-strategy/anchor-v1/app/src/preview.tsx index c46e353e7..1c5e53f38 100644 --- a/finance/vault-strategy/anchor-v1/app/src/preview.tsx +++ b/finance/vault-strategy/anchor-v1/app/src/preview.tsx @@ -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, diff --git a/finance/vault-strategy/anchor-v1/app/src/solana/strategy.ts b/finance/vault-strategy/anchor-v1/app/src/solana/strategy.ts index cbd54630f..80b88eec2 100644 --- a/finance/vault-strategy/anchor-v1/app/src/solana/strategy.ts +++ b/finance/vault-strategy/anchor-v1/app/src/solana/strategy.ts @@ -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, @@ -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]; @@ -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; diff --git a/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/error.rs b/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/error.rs index 4f1d48a49..4847feb79 100644 --- a/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/error.rs +++ b/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/error.rs @@ -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, } diff --git a/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/instructions/deposit.rs b/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/instructions/deposit.rs index d78903ee3..b08b53f8a 100644 --- a/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/instructions/deposit.rs +++ b/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/instructions/deposit.rs @@ -98,7 +98,6 @@ pub fn handle_deposit<'info>( VaultError::StrategyNotFullyAllocated ); - let vault_usdc_amount = context.accounts.vault_usdc.amount; let total_shares = context.accounts.strategy.total_shares; let usdc_decimals = context.accounts.usdc_mint.decimals; let strategy_index = context.accounts.strategy.index; @@ -106,6 +105,11 @@ pub fn handle_deposit<'info>( let strategy_key = context.accounts.strategy.key(); let max_slippage_bps = context.accounts.strategy.max_slippage_bps; let asset_count = context.accounts.strategy.asset_count as usize; + // The holdings the program has accounted for, not the vaults' token + // balances: a donation straight into a vault changes a balance and none of + // these, so it cannot move the share price. + let mut usdc_holdings = context.accounts.strategy.usdc_holdings; + let mut asset_holdings = context.accounts.strategy.asset_holdings; let now = Clock::get()?.unix_timestamp; @@ -118,7 +122,7 @@ pub fn handle_deposit<'info>( VaultError::IncompleteAssetAccounts ); - let mut nav: u128 = vault_usdc_amount as u128; + let mut nav: u128 = usdc_holdings as u128; for index in 0..asset_count { let config_account = &remaining[index * 5]; @@ -142,7 +146,7 @@ pub fn handle_deposit<'info>( ); let price = load_price(feed_account, &config.price_feed, now)?; - let amount = read_token_amount(vault_account)?; + let amount = asset_holdings[index]; nav = nav .checked_add(asset_value_in_usdc(amount, price)?) .ok_or(VaultError::MathOverflow)?; @@ -167,6 +171,10 @@ pub fn handle_deposit<'info>( context.accounts.strategy.total_shares = total_shares .checked_add(shares_to_mint) .ok_or(VaultError::MathOverflow)?; + usdc_holdings = usdc_holdings + .checked_add(usdc_amount) + .ok_or(VaultError::MathOverflow)?; + let vault_usdc_info = context.accounts.vault_usdc.to_account_info(); // Pull the depositor's USDC into the strategy's USDC vault. let transfer_accounts = TransferChecked { @@ -229,6 +237,11 @@ pub fn handle_deposit<'info>( .try_into() .map_err(|_| VaultError::MathOverflow)?; + // Record what the swap actually moves, measured on the vaults, rather + // than what was asked for. + let asset_before = read_token_amount(vault_account)?; + let usdc_before = read_token_amount(&vault_usdc_info)?; + let cpi_accounts = RouterSwapAccounts { caller: context.accounts.strategy.to_account_info(), router_config: context.accounts.router_config.to_account_info(), @@ -248,7 +261,26 @@ pub fn handle_deposit<'info>( signer_seeds, ); mock_swap_router::cpi::swap_usdc_for_asset(cpi_ctx, deploy_usdc, minimum_asset_out)?; + + let asset_received = read_token_amount(vault_account)? + .checked_sub(asset_before) + .ok_or(VaultError::MathOverflow)?; + let usdc_spent = usdc_before + .checked_sub(read_token_amount(&vault_usdc_info)?) + .ok_or(VaultError::MathOverflow)?; + // A leg that spends USDC and buys nothing would leave shares minted + // against no recorded value, and every later deposit would divide by a + // zero NAV. Refuse it: the deposit is too small for this basket. + require!(asset_received > 0, VaultError::DepositTooSmall); + asset_holdings[index] = asset_holdings[index] + .checked_add(asset_received) + .ok_or(VaultError::MathOverflow)?; + usdc_holdings = usdc_holdings + .checked_sub(usdc_spent) + .ok_or(VaultError::MathOverflow)?; } + context.accounts.strategy.usdc_holdings = usdc_holdings; + context.accounts.strategy.asset_holdings = asset_holdings; // Mint the shares last, with the strategy PDA signing as the share mint authority. let mint_accounts = MintTo { diff --git a/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/instructions/initialize_strategy.rs b/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/instructions/initialize_strategy.rs index 0cf96dd44..5d62b124f 100644 --- a/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/instructions/initialize_strategy.rs +++ b/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/instructions/initialize_strategy.rs @@ -5,7 +5,7 @@ use anchor_spl::{ }; use crate::error::VaultError; -use crate::state::{Registry, Strategy}; +use crate::state::{Registry, Strategy, MAX_ASSETS}; /// Highest annual management fee a manager may set, in basis points (10%). /// `collect_fees` mints shares to the manager and dilutes every depositor, @@ -91,6 +91,8 @@ pub fn handle_initialize_strategy( fee_bps, max_slippage_bps, total_shares: 0, + usdc_holdings: 0, + asset_holdings: [0; MAX_ASSETS as usize], last_fee_accrual_timestamp: clock.unix_timestamp, asset_count: 0, total_weight_bps: 0, diff --git a/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/instructions/rebalance.rs b/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/instructions/rebalance.rs index 5c9709136..5e3a14fed 100644 --- a/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/instructions/rebalance.rs +++ b/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/instructions/rebalance.rs @@ -9,7 +9,7 @@ use mock_swap_router::{ }; use crate::error::VaultError; -use crate::oracle::{load_price, PYTH_PRICE_PRECISION}; +use crate::oracle::{load_price, read_token_amount, PYTH_PRICE_PRECISION}; use crate::state::{AssetConfig, Strategy}; #[derive(Accounts)] @@ -156,9 +156,24 @@ pub fn handle_rebalance( .try_into() .map_err(|_| VaultError::MathOverflow)?; + // Rebalancing may only trade what the program has accounted for: tokens + // donated into a vault are outside the fund, so they can be neither sold nor + // spent. The legs below record what each swap actually moved. + let sell_index = context.accounts.sell_config.index as usize; + let buy_index = context.accounts.buy_config.index as usize; + let mut usdc_holdings = context.accounts.strategy.usdc_holdings; + let mut asset_holdings = context.accounts.strategy.asset_holdings; + require!( + sell_amount <= asset_holdings[sell_index], + VaultError::InsufficientHoldings + ); + let index_bytes = strategy_index.to_le_bytes(); let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", index_bytes.as_ref(), &[strategy_bump]]]; + let sell_before = read_token_amount(&context.accounts.vault_sell.to_account_info())?; + let usdc_before_sell = read_token_amount(&context.accounts.vault_usdc.to_account_info())?; + // Step 1: sell basket token -> USDC let sell_cpi_accounts = RouterSellAccounts { caller: context.accounts.strategy.to_account_info(), @@ -183,6 +198,27 @@ pub fn handle_rebalance( minimum_usdc_from_sell, )?; + let sold = sell_before + .checked_sub(read_token_amount( + &context.accounts.vault_sell.to_account_info(), + )?) + .ok_or(VaultError::MathOverflow)?; + let usdc_after_sell = read_token_amount(&context.accounts.vault_usdc.to_account_info())?; + let usdc_received = usdc_after_sell + .checked_sub(usdc_before_sell) + .ok_or(VaultError::MathOverflow)?; + asset_holdings[sell_index] = asset_holdings[sell_index] + .checked_sub(sold) + .ok_or(VaultError::InsufficientHoldings)?; + usdc_holdings = usdc_holdings + .checked_add(usdc_received) + .ok_or(VaultError::MathOverflow)?; + require!( + usdc_to_invest <= usdc_holdings, + VaultError::InsufficientHoldings + ); + let buy_before = read_token_amount(&context.accounts.vault_buy.to_account_info())?; + // Step 2: buy basket token with USDC let buy_cpi_accounts = RouterBuyAccounts { caller: context.accounts.strategy.to_account_info(), @@ -207,5 +243,24 @@ pub fn handle_rebalance( minimum_buy_amount, )?; + let bought = read_token_amount(&context.accounts.vault_buy.to_account_info())? + .checked_sub(buy_before) + .ok_or(VaultError::MathOverflow)?; + let usdc_spent = usdc_after_sell + .checked_sub(read_token_amount( + &context.accounts.vault_usdc.to_account_info(), + )?) + .ok_or(VaultError::MathOverflow)?; + asset_holdings[buy_index] = asset_holdings[buy_index] + .checked_add(bought) + .ok_or(VaultError::MathOverflow)?; + usdc_holdings = usdc_holdings + .checked_sub(usdc_spent) + .ok_or(VaultError::InsufficientHoldings)?; + + let strategy = &mut context.accounts.strategy; + strategy.usdc_holdings = usdc_holdings; + strategy.asset_holdings = asset_holdings; + Ok(()) } diff --git a/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/instructions/withdraw.rs b/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/instructions/withdraw.rs index a15235d4d..b44373918 100644 --- a/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/instructions/withdraw.rs +++ b/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/instructions/withdraw.rs @@ -7,8 +7,8 @@ use anchor_spl::{ }; use crate::error::VaultError; -use crate::oracle::{read_mint_decimals, read_token_amount, read_token_mint_and_owner}; -use crate::state::{AssetConfig, Strategy}; +use crate::oracle::{read_mint_decimals, read_token_mint_and_owner}; +use crate::state::{AssetConfig, Strategy, MAX_ASSETS}; #[derive(Accounts)] pub struct WithdrawAccountConstraints<'info> { @@ -75,7 +75,6 @@ pub fn handle_withdraw<'info>( let total_shares = context.accounts.strategy.total_shares; require!(total_shares > 0, VaultError::ZeroTotalShares); - let vault_usdc_amount = context.accounts.vault_usdc.amount; let usdc_decimals = context.accounts.usdc_mint.decimals; let strategy_index = context.accounts.strategy.index; let strategy_bump = context.accounts.strategy.bump; @@ -91,18 +90,37 @@ pub fn handle_withdraw<'info>( let shares_u128 = shares_to_burn as u128; let total_u128 = total_shares as u128; - // USDC leg, floored in the protocol's favour. - let amount_usdc: u64 = (vault_usdc_amount as u128) - .checked_mul(shares_u128) - .ok_or(VaultError::MathOverflow)? - .checked_div(total_u128) - .ok_or(VaultError::MathOverflow)? as u64; + // Every leg is a proportion of the holdings the program has recorded, not of + // the vault's token balance, so tokens donated into a vault are never paid + // out. Floored in the fund's favour. + let proportion = |holding: u64| -> Result { + Ok((holding as u128) + .checked_mul(shares_u128) + .ok_or(VaultError::MathOverflow)? + .checked_div(total_u128) + .ok_or(VaultError::MathOverflow)? as u64) + }; + let amount_usdc = proportion(context.accounts.strategy.usdc_holdings)?; require!(amount_usdc >= min_usdc_out, VaultError::UsdcSlippage); + let mut asset_amounts = [0u64; MAX_ASSETS as usize]; + for (index, amount) in asset_amounts.iter_mut().enumerate().take(asset_count) { + *amount = proportion(context.accounts.strategy.asset_holdings[index])?; + } - // Checks-effects-interactions: shrink supply before any transfer. - context.accounts.strategy.total_shares = total_shares + // Checks-effects-interactions: shrink supply and holdings before any transfer. + let strategy = &mut context.accounts.strategy; + strategy.total_shares = total_shares .checked_sub(shares_to_burn) .ok_or(VaultError::MathOverflow)?; + strategy.usdc_holdings = strategy + .usdc_holdings + .checked_sub(amount_usdc) + .ok_or(VaultError::MathOverflow)?; + for (index, amount) in asset_amounts.iter().enumerate().take(asset_count) { + strategy.asset_holdings[index] = strategy.asset_holdings[index] + .checked_sub(*amount) + .ok_or(VaultError::MathOverflow)?; + } let index_bytes = strategy_index.to_le_bytes(); let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", index_bytes.as_ref(), &[strategy_bump]]]; @@ -171,12 +189,7 @@ pub fn handle_withdraw<'info>( require_keys_eq!(recipient_owner, user_key, VaultError::InvalidRecipient); require_keys_eq!(recipient_mint, config.mint, VaultError::InvalidRecipient); - let vault_balance = read_token_amount(vault_ai)?; - let amount: u64 = (vault_balance as u128) - .checked_mul(shares_u128) - .ok_or(VaultError::MathOverflow)? - .checked_div(total_u128) - .ok_or(VaultError::MathOverflow)? as u64; + let amount = asset_amounts[i]; if amount > 0 { let decimals = read_mint_decimals(mint_ai)?; diff --git a/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/state/strategy.rs b/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/state/strategy.rs index 05dc893a3..adbf067c8 100644 --- a/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/state/strategy.rs +++ b/finance/vault-strategy/anchor-v1/programs/vault-strategy/src/state/strategy.rs @@ -33,6 +33,15 @@ pub struct Strategy { /// the Pyth-implied amount on deposit/rebalance. Bounded by MAX_SLIPPAGE_BPS. pub max_slippage_bps: u16, pub total_shares: u64, + /// 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. + pub usdc_holdings: u64, + /// 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. + pub asset_holdings: [u64; MAX_ASSETS as usize], pub last_fee_accrual_timestamp: i64, /// Assets live at PDAs indexed 0..asset_count, so callers can re-derive the /// complete set and no asset can be silently omitted from a NAV calculation. diff --git a/finance/vault-strategy/anchor-v1/programs/vault-strategy/tests/vault_strategy.rs b/finance/vault-strategy/anchor-v1/programs/vault-strategy/tests/vault_strategy.rs index 60cd1a7da..4400fd00e 100644 --- a/finance/vault-strategy/anchor-v1/programs/vault-strategy/tests/vault_strategy.rs +++ b/finance/vault-strategy/anchor-v1/programs/vault-strategy/tests/vault_strategy.rs @@ -18,6 +18,8 @@ use { solana_signer::Signer, }; +use vault_strategy::error::VaultError; + fn token_program_id() -> Pubkey { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" .parse() @@ -597,6 +599,58 @@ fn read_strategy(ctx: &TestContext) -> vault_strategy::state::Strategy { vault_strategy::state::Strategy::try_deserialize(&mut &account.data[..]).unwrap() } +/// How a failed transaction reports one of the program's errors: Anchor numbers +/// them from 6000 in declaration order. +fn program_error(error: VaultError) -> String { + format!("Custom({})", 6000 + error as u32) +} + +/// Recorded holdings must equal the vaults' token balances whenever nothing has +/// been donated: a mismatch means a handler moved tokens without recording it. +fn assert_holdings_match_vaults(ctx: &TestContext) { + let strategy = read_strategy(ctx); + assert_eq!( + strategy.usdc_holdings, + get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), + "recorded USDC matches the USDC vault" + ); + assert_eq!( + strategy.asset_holdings[0], + get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), + "recorded TSLAx matches the TSLAx vault" + ); + assert_eq!( + strategy.asset_holdings[1], + get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), + "recorded NVDAx matches the NVDAx vault" + ); +} + +/// Transfer USDC straight into the strategy's USDC vault with an ordinary token +/// transfer, never calling the deposit handler. +fn donate_usdc(ctx: &mut TestContext, donor: &Keypair, amount: u64) { + let ix = spl_token::instruction::transfer( + &spl_token::ID, + &derive_ata(&donor.pubkey(), &ctx.usdc_mint), + &ctx.vault_usdc, + &donor.pubkey(), + &[], + amount, + ) + .unwrap(); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[donor], &donor.pubkey()).unwrap(); +} + +/// A holder's position valued in USDC minor units at the test's starting prices +/// (TSLAx $250, NVDAx $180; both assets and USDC have six decimals). +fn value_in_usdc(ctx: &TestContext, owner: &Pubkey) -> u64 { + let balance = + |mint: &Pubkey| get_token_account_balance(&ctx.svm, &derive_ata(owner, mint)).unwrap_or(0); + balance(&ctx.usdc_mint) + + balance(&ctx.tsla_mint) * (TSLA_PRICE as u64 / 100_000_000) + + balance(&ctx.nvda_mint) * (NVDA_PRICE as u64 / 100_000_000) +} + fn read_asset_config(ctx: &TestContext, index: u8) -> vault_strategy::state::AssetConfig { let account = ctx.svm.get_account(&ctx.asset_config(index)).unwrap(); vault_strategy::state::AssetConfig::try_deserialize(&mut &account.data[..]).unwrap() @@ -1403,9 +1457,12 @@ fn test_full_lifecycle() { 3_000_000 ); + assert_holdings_match_vaults(&ctx); + // NVDAx 180 -> 200; basket drifts to 37.5 / 62.5. Rebalance back to 40/60. set_nvda_price(&mut ctx, 20_000_000_000, 200); do_rebalance(&mut ctx, 1, 0, 120_000, 24_000_000); + assert_holdings_match_vaults(&ctx); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), 1_536_000 @@ -1431,6 +1488,8 @@ fn test_full_lifecycle() { 4_320_000 ); + assert_holdings_match_vaults(&ctx); + // A year passes; the manager collects 1% of the 1,350,000,000 supply = 13,500,000. advance_one_year(&mut ctx); let manager_share = do_collect_fees(&mut ctx); @@ -1455,4 +1514,194 @@ fn test_full_lifecycle() { 0 ); assert_eq!(read_strategy(&ctx).total_shares, 463_500_000); + assert_holdings_match_vaults(&ctx); +} + +/// The first-depositor inflation attack: a dust deposit, then a donation straight +/// into the strategy's USDC vault, then a 1,000 USDC deposit with no +/// `minimum_shares` floor. The program prices shares from the holdings it has +/// recorded, so the donation changes the vault's balance and nothing the +/// handler reads: the victim gets exactly the shares they would have got without +/// it, and the donated USDC stays in the vault outside the fund. Modeled on the +/// lending example's `raw_token_donation_does_not_inflate_exchange_rate`. +#[test] +fn test_donation_does_not_inflate_share_price() { + let mut ctx = setup_full(); + standard_strategy(&mut ctx); + + let donation = 1_000_000_000u64; // 1,000 USDC + let victim_deposit = 1_000_000_000u64; // 1,000 USDC + + // The attacker deposits one minor unit. Both 40/60 deploy legs round down to + // zero USDC and are skipped, so the minor unit stays in the USDC vault, + // recorded, and the empty-fund deposit mints one share per minor unit. + let attacker = fund_user(&mut ctx, 1 + donation); + let attacker_share = do_deposit(&mut ctx, &attacker, 1, 0); + assert_eq!( + get_token_account_balance(&ctx.svm, &attacker_share).unwrap(), + 1 + ); + assert_eq!(read_strategy(&ctx).usdc_holdings, 1); + + // The donation lands in the vault without going through the deposit handler. + donate_usdc(&mut ctx, &attacker, donation); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), + 1 + donation + ); + assert_eq!( + read_strategy(&ctx).usdc_holdings, + 1, + "a donation is not recorded" + ); + + // The victim deposits 1,000 USDC with no floor. Priced off the recorded NAV + // of one minor unit against one share: 1,000,000,000 * 1 / 1 shares, the same + // as with no donation at all. Read off the vault balance it would have been + // 1,000,000,000 * 1 / 1,000,000,001 = 0. + let victim = fund_user(&mut ctx, victim_deposit); + let victim_share = do_deposit(&mut ctx, &victim, victim_deposit, 0); + let victim_shares = get_token_account_balance(&ctx.svm, &victim_share).unwrap(); + assert_eq!(victim_shares, victim_deposit); + + // The victim redeems everything in kind: all but rounding dust of the 1,000 + // USDC they put in. + do_withdraw(&mut ctx, &victim, victim_shares, 0); + let victim_value = value_in_usdc(&ctx, &victim.pubkey()); + assert!( + victim_value + 1_000 >= victim_deposit, + "victim got back {victim_value} of {victim_deposit}" + ); + + // The attacker redeems their one share for the recorded holdings left: their + // own minor unit plus the victim's rounding dust, under a tenth of a cent. + // The donation is never paid out. + do_withdraw(&mut ctx, &attacker, 1, 0); + let attacker_value = value_in_usdc(&ctx, &attacker.pubkey()); + assert!( + attacker_value < 1_000, + "attacker got back {attacker_value} minor units" + ); + assert_eq!(read_strategy(&ctx).total_shares, 0); + assert_eq!(read_strategy(&ctx).usdc_holdings, 0); + assert!(get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap() >= donation); +} + +/// A deposit leg that spends USDC and buys none of its asset would mint shares +/// against no recorded value, and every later deposit would then divide by a +/// zero NAV. With TSLAx at 100% and $250, a one-minor-unit deposit swaps its +/// minor unit for zero TSLAx, so the deposit must be refused. +#[test] +fn test_deposit_rejects_leg_that_buys_nothing() { + let mut ctx = setup_full(); + tsla_only_strategy(&mut ctx); + set_weight(&mut ctx, 0, 10_000).unwrap(); + + let attacker = fund_user(&mut ctx, 1); + let ix = deposit_instruction(&ctx, &attacker, 1, 0, deposit_remaining_tsla(&ctx)); + let r = send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&attacker], + &attacker.pubkey(), + ); + let err = format!( + "{:?}", + r.expect_err("a leg that buys nothing must revert the deposit") + ); + assert!( + err.contains(&program_error(VaultError::DepositTooSmall)), + "{err}" + ); + assert_eq!(read_strategy(&ctx).total_shares, 0); + + // A deposit large enough to buy some TSLAx goes through. + let user = fund_user(&mut ctx, 1_000_000); + do_deposit_tsla_only(&mut ctx, &user, 1_000_000, 1); + assert_eq!(read_strategy(&ctx).asset_holdings[0], 4_000); +} + +/// Donated USDC is outside the fund, so a rebalance cannot spend it: the buy leg +/// may spend at most the recorded USDC, which after the sell leg is what the +/// sale brought in. +#[test] +fn test_rebalance_cannot_spend_donated_usdc() { + let mut ctx = setup_full(); + standard_strategy(&mut ctx); + + // Alice's 900 USDC is fully deployed; the recorded USDC is zero. + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); + assert_eq!(read_strategy(&ctx).usdc_holdings, 0); + + let donor = fund_user(&mut ctx, 100_000_000); + donate_usdc(&mut ctx, &donor, 100_000_000); + + // Selling 0.1 NVDAx at $180 brings in 18 USDC; investing 50 would need 32 USDC + // of the donation. + let (sell_mint, sell_config, sell_feed, vault_sell, sell_rate) = asset_accounts(&ctx, 1); + let (buy_mint, buy_config, buy_feed, vault_buy, buy_rate) = asset_accounts(&ctx, 0); + let rebalance = |sell_amount: u64, usdc_to_invest: u64| { + Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Rebalance { + sell_amount, + usdc_to_invest, + } + .data(), + vault_strategy::accounts::RebalanceAccountConstraints { + manager: ctx.manager.pubkey(), + strategy: ctx.strategy_pda, + usdc_mint: ctx.usdc_mint, + sell_mint, + buy_mint, + sell_config, + buy_config, + sell_price_feed: sell_feed, + buy_price_feed: buy_feed, + vault_sell, + vault_buy, + vault_usdc: ctx.vault_usdc, + sell_rate, + buy_rate, + router_config: ctx.router_config_pda, + router_usdc_treasury: ctx.router_usdc_treasury, + swap_router_program: ctx.router_program_id, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ) + }; + let too_much = rebalance(100_000, 50_000_000); + let r = send_transaction_from_instructions( + &mut ctx.svm, + vec![too_much], + &[&ctx.manager], + &ctx.manager.pubkey(), + ); + let err = format!( + "{:?}", + r.expect_err("a rebalance must not spend donated USDC") + ); + assert!( + err.contains(&program_error(VaultError::InsufficientHoldings)), + "{err}" + ); + + // Investing only what the sale brought in goes through, and the donation is + // still outside the recorded holdings. + ctx.svm.expire_blockhash(); + do_rebalance(&mut ctx, 1, 0, 100_000, 18_000_000); + let strategy = read_strategy(&ctx); + assert_eq!(strategy.usdc_holdings, 0); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), + 100_000_000 + ); + assert_eq!( + strategy.asset_holdings[1], + get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap() + ); } diff --git a/finance/vault-strategy/anchor/CHANGELOG.md b/finance/vault-strategy/anchor/CHANGELOG.md index 49c9e7da3..9514752e0 100644 --- a/finance/vault-strategy/anchor/CHANGELOG.md +++ b/finance/vault-strategy/anchor/CHANGELOG.md @@ -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 diff --git a/finance/vault-strategy/anchor/PRODUCT.md b/finance/vault-strategy/anchor/PRODUCT.md index 82ef37295..0fa357ccb 100644 --- a/finance/vault-strategy/anchor/PRODUCT.md +++ b/finance/vault-strategy/anchor/PRODUCT.md @@ -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. diff --git a/finance/vault-strategy/anchor/README.md b/finance/vault-strategy/anchor/README.md index f1a23bee0..74fc95bd3 100644 --- a/finance/vault-strategy/anchor/README.md +++ b/finance/vault-strategy/anchor/README.md @@ -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. @@ -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 @@ -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 @@ -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 @@ -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? diff --git a/finance/vault-strategy/anchor/app/scripts/verify-client.mjs b/finance/vault-strategy/anchor/app/scripts/verify-client.mjs index f8f64c961..e4d1f779d 100644 --- a/finance/vault-strategy/anchor/app/scripts/verify-client.mjs +++ b/finance/vault-strategy/anchor/app/scripts/verify-client.mjs @@ -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, @@ -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); diff --git a/finance/vault-strategy/anchor/app/src/idl/vaultStrategy.ts b/finance/vault-strategy/anchor/app/src/idl/vaultStrategy.ts index a55f10902..7af01610c 100644 --- a/finance/vault-strategy/anchor/app/src/idl/vaultStrategy.ts +++ b/finance/vault-strategy/anchor/app/src/idl/vaultStrategy.ts @@ -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; diff --git a/finance/vault-strategy/anchor/app/src/idl/vault_strategy.json b/finance/vault-strategy/anchor/app/src/idl/vault_strategy.json index ef778fb4a..7f6a62b8b 100644 --- a/finance/vault-strategy/anchor/app/src/idl/vault_strategy.json +++ b/finance/vault-strategy/anchor/app/src/idl/vault_strategy.json @@ -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": [ @@ -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" diff --git a/finance/vault-strategy/anchor/app/src/preview.tsx b/finance/vault-strategy/anchor/app/src/preview.tsx index c46e353e7..1c5e53f38 100644 --- a/finance/vault-strategy/anchor/app/src/preview.tsx +++ b/finance/vault-strategy/anchor/app/src/preview.tsx @@ -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, diff --git a/finance/vault-strategy/anchor/app/src/solana/strategy.ts b/finance/vault-strategy/anchor/app/src/solana/strategy.ts index cbd54630f..80b88eec2 100644 --- a/finance/vault-strategy/anchor/app/src/solana/strategy.ts +++ b/finance/vault-strategy/anchor/app/src/solana/strategy.ts @@ -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, @@ -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]; @@ -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; diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs index 4f1d48a49..4847feb79 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs @@ -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, } diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/deposit.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/deposit.rs index fcf28d50a..3342cdf3c 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/deposit.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/deposit.rs @@ -99,7 +99,6 @@ pub fn handle_deposit( VaultError::StrategyNotFullyAllocated ); - let vault_usdc_amount = context.accounts.vault_usdc.amount(); let total_shares = context.accounts.strategy.total_shares; let usdc_decimals = context.accounts.usdc_mint.decimals(); let strategy_index = context.accounts.strategy.index; @@ -107,6 +106,11 @@ pub fn handle_deposit( let strategy_key = *context.accounts.strategy.address(); let max_slippage_bps = context.accounts.strategy.max_slippage_bps; let asset_count = context.accounts.strategy.asset_count as usize; + // The holdings the program has accounted for, not the vaults' token + // balances: a donation straight into a vault changes a balance and none of + // these, so it cannot move the share price. + let mut usdc_holdings = context.accounts.strategy.usdc_holdings; + let mut asset_holdings = context.accounts.strategy.asset_holdings; let now = Clock::get()?.unix_timestamp; @@ -119,7 +123,7 @@ pub fn handle_deposit( VaultError::IncompleteAssetAccounts ); - let mut nav: u128 = vault_usdc_amount as u128; + let mut nav: u128 = usdc_holdings as u128; for index in 0..asset_count { let config_account = &remaining[index * 5]; @@ -143,7 +147,7 @@ pub fn handle_deposit( ); let price = load_price(feed_account, &config.price_feed, now)?; - let amount = read_token_amount(vault_account)?; + let amount = asset_holdings[index]; nav = nav .checked_add(asset_value_in_usdc(amount, price)?) .ok_or(VaultError::MathOverflow)?; @@ -168,6 +172,9 @@ pub fn handle_deposit( context.accounts.strategy.total_shares = total_shares .checked_add(shares_to_mint) .ok_or(VaultError::MathOverflow)?; + usdc_holdings = usdc_holdings + .checked_add(usdc_amount) + .ok_or(VaultError::MathOverflow)?; // Pull the depositor's USDC into the strategy's USDC vault. let transfer_accounts = TransferChecked { @@ -237,6 +244,11 @@ pub fn handle_deposit( .try_into() .map_err(|_| VaultError::MathOverflow)?; + // Record what the swap actually moves, measured on the vaults, rather + // than what was asked for. + let asset_before = read_token_amount(&vault_account)?; + let usdc_before = context.accounts.vault_usdc.amount(); + let cpi_accounts = RouterSwapAccounts { caller: context.accounts.strategy.to_cpi_handle(), router_config: context.accounts.router_config.cpi_handle(), @@ -256,6 +268,23 @@ pub fn handle_deposit( signer_seeds, ); mock_swap_router::cpi::swap_usdc_for_asset(cpi_ctx, deploy_usdc, minimum_asset_out)?; + + let asset_received = read_token_amount(&vault_account)? + .checked_sub(asset_before) + .ok_or(VaultError::MathOverflow)?; + let usdc_spent = usdc_before + .checked_sub(context.accounts.vault_usdc.amount()) + .ok_or(VaultError::MathOverflow)?; + // A leg that spends USDC and buys nothing would leave shares minted + // against no recorded value, and every later deposit would divide by a + // zero NAV. Refuse it: the deposit is too small for this basket. + require!(asset_received > 0, VaultError::DepositTooSmall); + asset_holdings[index] = asset_holdings[index] + .checked_add(asset_received) + .ok_or(VaultError::MathOverflow)?; + usdc_holdings = usdc_holdings + .checked_sub(usdc_spent) + .ok_or(VaultError::MathOverflow)?; } // Mint the shares last, with the strategy PDA signing as the share mint authority. @@ -272,6 +301,8 @@ pub fn handle_deposit( mint_to(cpi_ctx, shares_to_mint)?; context.accounts.strategy.reacquire_borrow_mut()?; + context.accounts.strategy.usdc_holdings = usdc_holdings; + context.accounts.strategy.asset_holdings = asset_holdings; Ok(()) } diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_strategy.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_strategy.rs index 8bd80099d..e8e43cf0b 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_strategy.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_strategy.rs @@ -11,7 +11,7 @@ use anchor_spl::{ }; use crate::error::VaultError; -use crate::state::{Registry, Strategy}; +use crate::state::{Registry, Strategy, MAX_ASSETS}; /// Highest annual management fee a manager may set, in basis points (10%). /// `collect_fees` mints shares to the manager and dilutes every depositor, @@ -97,6 +97,8 @@ pub fn handle_initialize_strategy( fee_bps, max_slippage_bps, total_shares: 0, + usdc_holdings: 0, + asset_holdings: [0; MAX_ASSETS as usize], last_fee_accrual_timestamp: clock.unix_timestamp, asset_count: 0, total_weight_bps: 0, diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/rebalance.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/rebalance.rs index c3339de3f..4773f109b 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/rebalance.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/rebalance.rs @@ -157,6 +157,18 @@ pub fn handle_rebalance( .try_into() .map_err(|_| VaultError::MathOverflow)?; + // Rebalancing may only trade what the program has accounted for: tokens + // donated into a vault are outside the fund, so they can be neither sold nor + // spent. The legs below record what each swap actually moved. + let sell_index = context.accounts.sell_config.index as usize; + let buy_index = context.accounts.buy_config.index as usize; + let mut usdc_holdings = context.accounts.strategy.usdc_holdings; + let mut asset_holdings = context.accounts.strategy.asset_holdings; + require!( + sell_amount <= asset_holdings[sell_index], + VaultError::InsufficientHoldings + ); + let index_bytes = strategy_index.to_le_bytes(); let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", index_bytes.as_ref(), &[strategy_bump]]]; @@ -165,6 +177,9 @@ pub fn handle_rebalance( // the same account, so hand the borrow back for the duration. context.accounts.strategy.release_borrow()?; + let sell_before = context.accounts.vault_sell.amount(); + let usdc_before_sell = context.accounts.vault_usdc.amount(); + // Step 1: sell basket token -> USDC let sell_cpi_accounts = RouterSellAccounts { caller: context.accounts.strategy.to_cpi_handle(), @@ -189,6 +204,25 @@ pub fn handle_rebalance( minimum_usdc_from_sell, )?; + let sold = sell_before + .checked_sub(context.accounts.vault_sell.amount()) + .ok_or(VaultError::MathOverflow)?; + let usdc_after_sell = context.accounts.vault_usdc.amount(); + let usdc_received = usdc_after_sell + .checked_sub(usdc_before_sell) + .ok_or(VaultError::MathOverflow)?; + asset_holdings[sell_index] = asset_holdings[sell_index] + .checked_sub(sold) + .ok_or(VaultError::InsufficientHoldings)?; + usdc_holdings = usdc_holdings + .checked_add(usdc_received) + .ok_or(VaultError::MathOverflow)?; + require!( + usdc_to_invest <= usdc_holdings, + VaultError::InsufficientHoldings + ); + let buy_before = context.accounts.vault_buy.amount(); + // Step 2: buy basket token with USDC let buy_cpi_accounts = RouterBuyAccounts { caller: context.accounts.strategy.to_cpi_handle(), @@ -213,7 +247,25 @@ pub fn handle_rebalance( minimum_buy_amount, )?; + let bought = context + .accounts + .vault_buy + .amount() + .checked_sub(buy_before) + .ok_or(VaultError::MathOverflow)?; + let usdc_spent = usdc_after_sell + .checked_sub(context.accounts.vault_usdc.amount()) + .ok_or(VaultError::MathOverflow)?; + asset_holdings[buy_index] = asset_holdings[buy_index] + .checked_add(bought) + .ok_or(VaultError::MathOverflow)?; + usdc_holdings = usdc_holdings + .checked_sub(usdc_spent) + .ok_or(VaultError::InsufficientHoldings)?; + context.accounts.strategy.reacquire_borrow_mut()?; + context.accounts.strategy.usdc_holdings = usdc_holdings; + context.accounts.strategy.asset_holdings = asset_holdings; Ok(()) } diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/withdraw.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/withdraw.rs index d6f7d1e77..aed4c172e 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/withdraw.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/withdraw.rs @@ -7,8 +7,8 @@ use anchor_spl::{ }; use crate::error::VaultError; -use crate::oracle::{read_mint_decimals, read_token_amount, read_token_mint_and_owner}; -use crate::state::{AssetConfig, Strategy}; +use crate::oracle::{read_mint_decimals, read_token_mint_and_owner}; +use crate::state::{AssetConfig, Strategy, MAX_ASSETS}; #[derive(Accounts)] pub struct WithdrawAccountConstraints { @@ -75,7 +75,6 @@ pub fn handle_withdraw( let total_shares = context.accounts.strategy.total_shares; require!(total_shares > 0, VaultError::ZeroTotalShares); - let vault_usdc_amount = context.accounts.vault_usdc.amount(); let usdc_decimals = context.accounts.usdc_mint.decimals(); let strategy_index = context.accounts.strategy.index; let strategy_bump = context.accounts.strategy.bump; @@ -91,18 +90,37 @@ pub fn handle_withdraw( let shares_u128 = shares_to_burn as u128; let total_u128 = total_shares as u128; - // USDC leg, floored in the protocol's favour. - let amount_usdc: u64 = (vault_usdc_amount as u128) - .checked_mul(shares_u128) - .ok_or(VaultError::MathOverflow)? - .checked_div(total_u128) - .ok_or(VaultError::MathOverflow)? as u64; + // Every leg is a proportion of the holdings the program has recorded, not of + // the vault's token balance, so tokens donated into a vault are never paid + // out. Floored in the fund's favour. + let proportion = |holding: u64| -> Result { + Ok((holding as u128) + .checked_mul(shares_u128) + .ok_or(VaultError::MathOverflow)? + .checked_div(total_u128) + .ok_or(VaultError::MathOverflow)? as u64) + }; + let amount_usdc = proportion(context.accounts.strategy.usdc_holdings)?; require!(amount_usdc >= min_usdc_out, VaultError::UsdcSlippage); + let mut asset_amounts = [0u64; MAX_ASSETS as usize]; + for (index, amount) in asset_amounts.iter_mut().enumerate().take(asset_count) { + *amount = proportion(context.accounts.strategy.asset_holdings[index])?; + } - // Checks-effects-interactions: shrink supply before any transfer. - context.accounts.strategy.total_shares = total_shares + // Checks-effects-interactions: shrink supply and holdings before any transfer. + let strategy = &mut context.accounts.strategy; + strategy.total_shares = total_shares .checked_sub(shares_to_burn) .ok_or(VaultError::MathOverflow)?; + strategy.usdc_holdings = strategy + .usdc_holdings + .checked_sub(amount_usdc) + .ok_or(VaultError::MathOverflow)?; + for (index, amount) in asset_amounts.iter().enumerate().take(asset_count) { + strategy.asset_holdings[index] = strategy.asset_holdings[index] + .checked_sub(*amount) + .ok_or(VaultError::MathOverflow)?; + } let index_bytes = strategy_index.to_le_bytes(); let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", index_bytes.as_ref(), &[strategy_bump]]]; @@ -178,12 +196,7 @@ pub fn handle_withdraw( require_keys_eq!(recipient_owner, user_key, VaultError::InvalidRecipient); require_keys_eq!(recipient_mint, config.mint, VaultError::InvalidRecipient); - let vault_balance = read_token_amount(&vault_ai)?; - let amount: u64 = (vault_balance as u128) - .checked_mul(shares_u128) - .ok_or(VaultError::MathOverflow)? - .checked_div(total_u128) - .ok_or(VaultError::MathOverflow)? as u64; + let amount = asset_amounts[i]; if amount > 0 { let decimals = read_mint_decimals(&mint_ai)?; diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/state/strategy.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/state/strategy.rs index 06accae27..494c6ef7c 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/state/strategy.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/state/strategy.rs @@ -33,6 +33,15 @@ pub struct Strategy { /// the Pyth-implied amount on deposit/rebalance. Bounded by MAX_SLIPPAGE_BPS. pub max_slippage_bps: u16, pub total_shares: u64, + /// 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. + pub usdc_holdings: u64, + /// 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. + pub asset_holdings: [u64; MAX_ASSETS as usize], pub last_fee_accrual_timestamp: i64, /// Assets live at PDAs indexed 0..asset_count, so callers can re-derive the /// complete set and no asset can be silently omitted from a NAV calculation. diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/tests/vault_strategy.rs b/finance/vault-strategy/anchor/programs/vault-strategy/tests/vault_strategy.rs index 567d31ba2..248a0165e 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/tests/vault_strategy.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/tests/vault_strategy.rs @@ -15,6 +15,8 @@ use { }, }; +use vault_strategy::error::VaultError; + fn token_program_id() -> Address { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" .parse() @@ -594,6 +596,58 @@ fn read_strategy(ctx: &TestContext) -> vault_strategy::state::Strategy { vault_strategy::state::Strategy::try_deserialize(&mut &account.data[..]).unwrap() } +/// How a failed transaction reports one of the program's errors: Anchor numbers +/// them from 6000 in declaration order. +fn program_error(error: VaultError) -> String { + format!("Custom({})", 6000 + error as u32) +} + +/// Recorded holdings must equal the vaults' token balances whenever nothing has +/// been donated: a mismatch means a handler moved tokens without recording it. +fn assert_holdings_match_vaults(ctx: &TestContext) { + let strategy = read_strategy(ctx); + assert_eq!( + strategy.usdc_holdings, + get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), + "recorded USDC matches the USDC vault" + ); + assert_eq!( + strategy.asset_holdings[0], + get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), + "recorded TSLAx matches the TSLAx vault" + ); + assert_eq!( + strategy.asset_holdings[1], + get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), + "recorded NVDAx matches the NVDAx vault" + ); +} + +/// Transfer USDC straight into the strategy's USDC vault with an ordinary token +/// transfer, never calling the deposit handler. +fn donate_usdc(ctx: &mut TestContext, donor: &Keypair, amount: u64) { + let ix = spl_token::instruction::transfer( + &spl_token::ID, + &derive_ata(&donor.pubkey(), &ctx.usdc_mint), + &ctx.vault_usdc, + &donor.pubkey(), + &[], + amount, + ) + .unwrap(); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[donor], &donor.pubkey()).unwrap(); +} + +/// A holder's position valued in USDC minor units at the test's starting prices +/// (TSLAx $250, NVDAx $180; both assets and USDC have six decimals). +fn value_in_usdc(ctx: &TestContext, owner: &Address) -> u64 { + let balance = + |mint: &Address| get_token_account_balance(&ctx.svm, &derive_ata(owner, mint)).unwrap_or(0); + balance(&ctx.usdc_mint) + + balance(&ctx.tsla_mint) * (TSLA_PRICE as u64 / 100_000_000) + + balance(&ctx.nvda_mint) * (NVDA_PRICE as u64 / 100_000_000) +} + fn read_asset_config(ctx: &TestContext, index: u8) -> vault_strategy::state::AssetConfig { let account = ctx.svm.get_account(&ctx.asset_config(index)).unwrap(); vault_strategy::state::AssetConfig::try_deserialize(&mut &account.data[..]).unwrap() @@ -1400,9 +1454,12 @@ fn test_full_lifecycle() { 3_000_000 ); + assert_holdings_match_vaults(&ctx); + // NVDAx 180 -> 200; basket drifts to 37.5 / 62.5. Rebalance back to 40/60. set_nvda_price(&mut ctx, 20_000_000_000, 200); do_rebalance(&mut ctx, 1, 0, 120_000, 24_000_000); + assert_holdings_match_vaults(&ctx); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), 1_536_000 @@ -1428,6 +1485,8 @@ fn test_full_lifecycle() { 4_320_000 ); + assert_holdings_match_vaults(&ctx); + // A year passes; the manager collects 1% of the 1,350,000,000 supply = 13,500,000. advance_one_year(&mut ctx); let manager_share = do_collect_fees(&mut ctx); @@ -1452,4 +1511,194 @@ fn test_full_lifecycle() { 0 ); assert_eq!(read_strategy(&ctx).total_shares, 463_500_000); + assert_holdings_match_vaults(&ctx); +} + +/// The first-depositor inflation attack: a dust deposit, then a donation straight +/// into the strategy's USDC vault, then a 1,000 USDC deposit with no +/// `minimum_shares` floor. The program prices shares from the holdings it has +/// recorded, so the donation changes the vault's balance and nothing the +/// handler reads: the victim gets exactly the shares they would have got without +/// it, and the donated USDC stays in the vault outside the fund. Modeled on the +/// lending example's `raw_token_donation_does_not_inflate_exchange_rate`. +#[test] +fn test_donation_does_not_inflate_share_price() { + let mut ctx = setup_full(); + standard_strategy(&mut ctx); + + let donation = 1_000_000_000u64; // 1,000 USDC + let victim_deposit = 1_000_000_000u64; // 1,000 USDC + + // The attacker deposits one minor unit. Both 40/60 deploy legs round down to + // zero USDC and are skipped, so the minor unit stays in the USDC vault, + // recorded, and the empty-fund deposit mints one share per minor unit. + let attacker = fund_user(&mut ctx, 1 + donation); + let attacker_share = do_deposit(&mut ctx, &attacker, 1, 0); + assert_eq!( + get_token_account_balance(&ctx.svm, &attacker_share).unwrap(), + 1 + ); + assert_eq!(read_strategy(&ctx).usdc_holdings, 1); + + // The donation lands in the vault without going through the deposit handler. + donate_usdc(&mut ctx, &attacker, donation); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), + 1 + donation + ); + assert_eq!( + read_strategy(&ctx).usdc_holdings, + 1, + "a donation is not recorded" + ); + + // The victim deposits 1,000 USDC with no floor. Priced off the recorded NAV + // of one minor unit against one share: 1,000,000,000 * 1 / 1 shares, the same + // as with no donation at all. Read off the vault balance it would have been + // 1,000,000,000 * 1 / 1,000,000,001 = 0. + let victim = fund_user(&mut ctx, victim_deposit); + let victim_share = do_deposit(&mut ctx, &victim, victim_deposit, 0); + let victim_shares = get_token_account_balance(&ctx.svm, &victim_share).unwrap(); + assert_eq!(victim_shares, victim_deposit); + + // The victim redeems everything in kind: all but rounding dust of the 1,000 + // USDC they put in. + do_withdraw(&mut ctx, &victim, victim_shares, 0); + let victim_value = value_in_usdc(&ctx, &victim.pubkey()); + assert!( + victim_value + 1_000 >= victim_deposit, + "victim got back {victim_value} of {victim_deposit}" + ); + + // The attacker redeems their one share for the recorded holdings left: their + // own minor unit plus the victim's rounding dust, under a tenth of a cent. + // The donation is never paid out. + do_withdraw(&mut ctx, &attacker, 1, 0); + let attacker_value = value_in_usdc(&ctx, &attacker.pubkey()); + assert!( + attacker_value < 1_000, + "attacker got back {attacker_value} minor units" + ); + assert_eq!(read_strategy(&ctx).total_shares, 0); + assert_eq!(read_strategy(&ctx).usdc_holdings, 0); + assert!(get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap() >= donation); +} + +/// A deposit leg that spends USDC and buys none of its asset would mint shares +/// against no recorded value, and every later deposit would then divide by a +/// zero NAV. With TSLAx at 100% and $250, a one-minor-unit deposit swaps its +/// minor unit for zero TSLAx, so the deposit must be refused. +#[test] +fn test_deposit_rejects_leg_that_buys_nothing() { + let mut ctx = setup_full(); + tsla_only_strategy(&mut ctx); + set_weight(&mut ctx, 0, 10_000).unwrap(); + + let attacker = fund_user(&mut ctx, 1); + let ix = deposit_instruction(&ctx, &attacker, 1, 0, deposit_remaining_tsla(&ctx)); + let r = send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&attacker], + &attacker.pubkey(), + ); + let err = format!( + "{:?}", + r.expect_err("a leg that buys nothing must revert the deposit") + ); + assert!( + err.contains(&program_error(VaultError::DepositTooSmall)), + "{err}" + ); + assert_eq!(read_strategy(&ctx).total_shares, 0); + + // A deposit large enough to buy some TSLAx goes through. + let user = fund_user(&mut ctx, 1_000_000); + do_deposit_tsla_only(&mut ctx, &user, 1_000_000, 1); + assert_eq!(read_strategy(&ctx).asset_holdings[0], 4_000); +} + +/// Donated USDC is outside the fund, so a rebalance cannot spend it: the buy leg +/// may spend at most the recorded USDC, which after the sell leg is what the +/// sale brought in. +#[test] +fn test_rebalance_cannot_spend_donated_usdc() { + let mut ctx = setup_full(); + standard_strategy(&mut ctx); + + // Alice's 900 USDC is fully deployed; the recorded USDC is zero. + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); + assert_eq!(read_strategy(&ctx).usdc_holdings, 0); + + let donor = fund_user(&mut ctx, 100_000_000); + donate_usdc(&mut ctx, &donor, 100_000_000); + + // Selling 0.1 NVDAx at $180 brings in 18 USDC; investing 50 would need 32 USDC + // of the donation. + let (sell_mint, sell_config, sell_feed, vault_sell, sell_rate) = asset_accounts(&ctx, 1); + let (buy_mint, buy_config, buy_feed, vault_buy, buy_rate) = asset_accounts(&ctx, 0); + let rebalance = |sell_amount: u64, usdc_to_invest: u64| { + Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Rebalance { + sell_amount, + usdc_to_invest, + } + .data(), + vault_strategy::accounts::RebalanceAccountConstraints { + manager: ctx.manager.pubkey(), + strategy: ctx.strategy_pda, + usdc_mint: ctx.usdc_mint, + sell_mint, + buy_mint, + sell_config, + buy_config, + sell_price_feed: sell_feed, + buy_price_feed: buy_feed, + vault_sell, + vault_buy, + vault_usdc: ctx.vault_usdc, + sell_rate, + buy_rate, + router_config: ctx.router_config_pda, + router_usdc_treasury: ctx.router_usdc_treasury, + swap_router_program: ctx.router_program_id, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::ID, + } + .to_account_metas(None), + ) + }; + let too_much = rebalance(100_000, 50_000_000); + let r = send_transaction_from_instructions( + &mut ctx.svm, + vec![too_much], + &[&ctx.manager], + &ctx.manager.pubkey(), + ); + let err = format!( + "{:?}", + r.expect_err("a rebalance must not spend donated USDC") + ); + assert!( + err.contains(&program_error(VaultError::InsufficientHoldings)), + "{err}" + ); + + // Investing only what the sale brought in goes through, and the donation is + // still outside the recorded holdings. + ctx.svm.expire_blockhash(); + do_rebalance(&mut ctx, 1, 0, 100_000, 18_000_000); + let strategy = read_strategy(&ctx); + assert_eq!(strategy.usdc_holdings, 0); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), + 100_000_000 + ); + assert_eq!( + strategy.asset_holdings[1], + get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap() + ); } diff --git a/finance/vault-strategy/kani-proofs/README.md b/finance/vault-strategy/kani-proofs/README.md index ed982322d..e92ca75e8 100644 --- a/finance/vault-strategy/kani-proofs/README.md +++ b/finance/vault-strategy/kani-proofs/README.md @@ -14,16 +14,20 @@ symbolically execute, but the share math is pure integer arithmetic: - `proof_withdraw_within_balance`: **Solvency**: a withdrawal never takes more of any vault balance than it holds (`floor(balance·shares/total) <= balance`, since `shares <= total`); burning the whole supply takes exactly the whole balance. - `proof_deposit_withdraw_cannot_extract`: A deposit→withdraw round-trip never returns more than was deposited, no rounding attack mints shares worth more than they cost. +- `proof_recorded_holdings_never_exceed_balance`: The program prices shares and pays withdrawals from its recorded holdings, not vault balances. Across a deposit, a donation, and a withdrawal, the recorded holding never exceeds the vault's real balance, so every payout is covered however much is donated. +- `proof_donation_cannot_dilute_next_deposit`: The inflation attack modelled directly: after an attacker's first deposit and a donation of any size, the victim's deposit mints exactly one share per minor unit and withdraws in full. - `proof_fee_shares_bounded_by_supply`: The time-based manager fee can never mint more than 100%/year of dilution (`fee_shares <= total_shares` for `elapsed <= 1yr`, `fee_bps <= 10000`). ## Bounded model checking -All three verify nonlinear 128-bit arithmetic with a symbolic divisor (the share +The nonlinear harnesses verify 128-bit arithmetic with a symbolic divisor (the share supply / NAV), so (as percolator does) they bound their symbolic inputs to a representative range; the share identities are scale-invariant. - `proof_withdraw_within_balance`: balances/supply `<= 255`, runs in ~12s - `proof_deposit_withdraw_cannot_extract`: `<= 31`, runs in ~3s +- `proof_recorded_holdings_never_exceed_balance`: balances and supply `<= 255` +- `proof_donation_cannot_dilute_next_deposit`: deposits `<= 31`, donation unbounded - `proof_fee_shares_bounded_by_supply`: `<= 255`, runs in ~4s Run weekly in CI (the `kani.yml` `verify` job), not on every push/PR, because diff --git a/finance/vault-strategy/kani-proofs/src/lib.rs b/finance/vault-strategy/kani-proofs/src/lib.rs index 1f51888e0..139c29544 100644 --- a/finance/vault-strategy/kani-proofs/src/lib.rs +++ b/finance/vault-strategy/kani-proofs/src/lib.rs @@ -11,6 +11,13 @@ //! integer arithmetic. This crate reproduces it faithfully and proves the //! invariants the vault's solvency rests on. //! +//! The program prices shares and pays withdrawals from the holdings it has +//! recorded (`Strategy::usdc_holdings` and `asset_holdings`), never from the +//! vaults' token balances, so tokens donated straight into a vault are outside +//! the fund. The harnesses model a vault as a (recorded, balance) pair and prove +//! what that buys: payouts never exceed the real balance, and a donation cannot +//! dilute the next depositor. +//! //! Nonlinear 128-bit harnesses use bounded model checking (small symbolic //! inputs), as percolator does; the share identities are scale-invariant. @@ -24,17 +31,18 @@ pub fn mul_div_floor(a: u128, b: u128, d: u128) -> Option { a.checked_mul(b)?.checked_div(d) } -/// Proportional withdrawal of one vault balance: `floor(balance * shares / total)` -/// — the formula `handle_withdraw` applies to the USDC leg and to every basket -/// asset. +/// Proportional withdrawal from one vault's recorded holding: +/// `floor(holding * shares / total)` — the formula `handle_withdraw` applies to +/// the USDC leg and to every basket asset. pub fn withdraw_amount(balance: u64, shares_burned: u64, total_shares: u64) -> Option { mul_div_floor(balance as u128, shares_burned as u128, total_shares as u128)? .try_into() .ok() } -/// Shares minted for a deposit: `floor(usdc_amount * total_shares / nav)` -/// (`handle_deposit`; the first deposit, `total_shares == 0`, mints 1:1). +/// Shares minted for a deposit: `floor(usdc_amount * total_shares / nav)`, where +/// `nav` is valued from recorded holdings (`handle_deposit`; the first deposit, +/// `total_shares == 0`, mints 1:1). pub fn deposit_shares(usdc_amount: u64, total_shares: u64, nav: u64) -> Option { if total_shares == 0 { return Some(usdc_amount); @@ -109,7 +117,82 @@ fn proof_deposit_withdraw_cannot_extract() { } // =========================================================================== -// 3. Manager fee dilution is bounded +// 3. Recorded holdings never exceed the vault's balance +// =========================================================================== + +/// One vault as the program sees it (`recorded`) and as the token program does +/// (`balance`). A deposit adds to both, a donation adds to the balance only, and +/// a withdrawal pays `withdraw_amount` of the recorded holding out of both. If +/// `recorded <= balance` holds before each step it holds after it, and every +/// payout is covered by the real balance: the program can never promise tokens +/// the vault does not hold, however much is donated. +#[cfg(kani)] +#[kani::proof] +#[kani::solver(cadical)] +fn proof_recorded_holdings_never_exceed_balance() { + let recorded: u64 = kani::any(); + let balance: u64 = kani::any(); + let deposit: u64 = kani::any(); + let donation: u64 = kani::any(); + let shares_burned: u64 = kani::any(); + let total_shares: u64 = kani::any(); + + kani::assume(recorded <= balance && balance <= 255); + kani::assume(deposit <= 255 && donation <= 255); + kani::assume(total_shares >= 1 && total_shares <= 255); + kani::assume(shares_burned <= total_shares); + + // Deposit: the swap output is recorded and lands in the vault. + let recorded = recorded + deposit; + let balance = balance + deposit; + assert!(recorded <= balance); + + // Donation: tokens land in the vault and nothing is recorded. + let balance = balance + donation; + assert!(recorded <= balance); + + // Withdrawal: paid from the recorded holding, out of the real balance. + let payout = withdraw_amount(recorded, shares_burned, total_shares).expect("computes"); + assert!(payout <= recorded); + assert!(payout <= balance); + assert!(recorded - payout <= balance - payout); +} + +// =========================================================================== +// 4. A donation cannot dilute the next deposit +// =========================================================================== + +/// The inflation attack against recorded holdings, in a USDC-only vault: an +/// attacker's first deposit mints one share per minor unit, a donation of any +/// size lands in the vault, and then the victim deposits. The donation is not in +/// the recorded NAV, so the victim's shares are exactly their deposit, the same +/// as with no donation, and withdrawing them returns every minor unit. +#[cfg(kani)] +#[kani::proof] +#[kani::solver(cadical)] +fn proof_donation_cannot_dilute_next_deposit() { + let attacker_deposit: u64 = kani::any(); + let donation: u64 = kani::any(); + let victim_deposit: u64 = kani::any(); + + kani::assume(attacker_deposit >= 1 && attacker_deposit <= 31); + kani::assume(victim_deposit <= 31); + + let attacker_shares = deposit_shares(attacker_deposit, 0, 0).expect("computes"); + let recorded = attacker_deposit; + let _balance = recorded as u128 + donation as u128; // counted nowhere below + + let victim_shares = + deposit_shares(victim_deposit, attacker_shares, recorded).expect("computes"); + assert_eq!(victim_shares, victim_deposit); + + let total = attacker_shares + victim_shares; + let back = withdraw_amount(recorded + victim_deposit, victim_shares, total).expect("computes"); + assert_eq!(back, victim_deposit); +} + +// =========================================================================== +// 5. Manager fee dilution is bounded // =========================================================================== /// The time-based manager fee mints @@ -158,6 +241,17 @@ mod tests { assert_eq!(deposit_shares(500, 0, 0).unwrap(), 500); } + #[test] + fn donation_does_not_dilute_next_deposit() { + // The attack from the book: one minor unit deposited, 1,000 USDC donated + // (and not recorded), then a 1,000 USDC deposit. + let attacker = deposit_shares(1, 0, 0).unwrap(); + let victim = deposit_shares(1_000_000_000, attacker, 1).unwrap(); + assert_eq!(victim, 1_000_000_000); + let back = withdraw_amount(1 + 1_000_000_000, victim, attacker + victim).unwrap(); + assert_eq!(back, 1_000_000_000); + } + #[test] fn round_trip_not_profitable() { let minted = deposit_shares(100, 200, 150).unwrap(); diff --git a/finance/vault-strategy/quasar/CHANGELOG.md b/finance/vault-strategy/quasar/CHANGELOG.md index 5fc26a73b..abbeea430 100644 --- a/finance/vault-strategy/quasar/CHANGELOG.md +++ b/finance/vault-strategy/quasar/CHANGELOG.md @@ -4,6 +4,16 @@ ### Changed +- Ignore donations. The strategy records what it holds (`usdc_holdings`, and + `asset_holdings` as little-endian u64s) 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 cannot inflate the + share price, and `rebalance` cannot sell or spend them + (`InsufficientHoldings`). Tested by `donation_does_not_inflate_share_price`. +- Reject a deposit leg that buys nothing (`DepositTooSmall`), which would mint + shares against a fund worth nothing. Tested by + `deposit_rejects_leg_that_buys_nothing`. - Reject Pyth prices from before a cluster restart. Under Alpenglow the Clock's `unix_timestamp` may advance by at most twice the slot time elapsed since the parent block, so after a halt it trails real time and the diff --git a/finance/vault-strategy/quasar/README.md b/finance/vault-strategy/quasar/README.md index 064d49501..6dc8a5b53 100644 --- a/finance/vault-strategy/quasar/README.md +++ b/finance/vault-strategy/quasar/README.md @@ -44,12 +44,19 @@ themselves or pair a real mint with a feed they control. USDC vault plus every asset vault valued at its oracle price), mints shares for that fraction of the vault, and deploys the deposit across the basket by swapping a weight-sized slice into each asset through the router. The first - deposit into an empty vault mints shares one-to-one. + deposit into an empty vault mints shares one-to-one. A deposit so small that a + swap returns none of its asset is refused (`DepositTooSmall`). +- The valuation and every payout use the holdings the strategy has recorded + (`usdc_holdings`, and `asset_holdings` stored as little-endian u64s because + zero-copy accounts hold byte arrays only), not the vaults' token balances. + Tokens transferred straight into a vault are outside the fund, so a donation + cannot inflate the share price, the first-depositor attack. - `withdraw` burns shares and pays out a proportional slice of the USDC vault and every asset vault, in kind. - `rebalance` lets the manager sell one asset for USDC and buy another with it, keeping holdings near their targets as prices drift. Both legs are floored to - the oracle price so a bad swap route reverts. + the oracle price so a bad swap route reverts, and neither can sell or spend + more than the recorded holdings (`InsufficientHoldings`). - `collect_fees` accrues the time-based management fee by minting fresh shares to the manager, diluting holders at the configured annual rate. diff --git a/finance/vault-strategy/quasar/vault-strategy/src/errors.rs b/finance/vault-strategy/quasar/vault-strategy/src/errors.rs index 0015f1086..2e7bf1cda 100644 --- a/finance/vault-strategy/quasar/vault-strategy/src/errors.rs +++ b/finance/vault-strategy/quasar/vault-strategy/src/errors.rs @@ -30,4 +30,8 @@ pub enum VaultError { InvalidSwapRouter, FeeTooHigh, PricePredatesRestart, + /// A deployment leg of the deposit would buy none of its asset. + DepositTooSmall, + /// A rebalance would sell or spend more than the recorded holdings. + InsufficientHoldings, } diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/deposit.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/deposit.rs index 8954e649f..50e581288 100644 --- a/finance/vault-strategy/quasar/vault-strategy/src/instructions/deposit.rs +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/deposit.rs @@ -7,7 +7,8 @@ use quasar_spl::prelude::*; use crate::errors::VaultError; use crate::oracle::{asset_value_in_usdc, load_price, read_token_amount, PYTH_PRICE_PRECISION}; use crate::state::{ - load_asset_config, snapshot_strategy, ShareMintPda, Strategy, UsdcVaultPda, STRATEGY_SEED, + load_asset_config, read_asset_holdings, snapshot_strategy, write_asset_holdings, ShareMintPda, + Strategy, UsdcVaultPda, STRATEGY_SEED, }; /// Discriminator of the router's `swap_usdc_for_asset` instruction. @@ -109,9 +110,16 @@ pub fn handle_deposit( let now = i64::from(Clock::get()?.unix_timestamp); + // The holdings the program has accounted for, not the vaults' token + // balances: a donation straight into a vault changes a balance and none of + // these, so it cannot move the share price. + let snapshot = snapshot_strategy(&accounts.strategy); + let mut usdc_holdings = snapshot.usdc_holdings; + let mut asset_holdings = read_asset_holdings(&snapshot.asset_holdings); + // Net asset value over the complete asset set. - let mut nav: u128 = accounts.vault_usdc.amount() as u128; - for index in 0..asset_count { + let mut nav: u128 = usdc_holdings as u128; + for (index, &amount) in asset_holdings.iter().enumerate().take(asset_count) { let config_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET)?; let vault_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET + 1)?; let feed_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET + 4)?; @@ -133,7 +141,6 @@ pub fn handle_deposit( ); let price = load_price(&feed_view, &config.price_feed, now)?; - let amount = read_token_amount(&vault_view)?; nav = nav .checked_add(asset_value_in_usdc(amount, price)?) .ok_or(VaultError::MathOverflow)?; @@ -161,6 +168,9 @@ pub fn handle_deposit( .checked_add(shares_to_mint) .ok_or(VaultError::MathOverflow)?; accounts.strategy.set_inner(strategy); + usdc_holdings = usdc_holdings + .checked_add(usdc_amount) + .ok_or(VaultError::MathOverflow)?; // Pull the depositor's USDC into the strategy's USDC vault. accounts @@ -186,7 +196,7 @@ pub fn handle_deposit( // Deploy the deposit across the basket at its target weights, each leg // swapped through the router under an oracle-anchored slippage floor. The // strategy PDA signs, since the USDC leaves a vault only it controls. - for index in 0..asset_count { + for (index, holding) in asset_holdings.iter_mut().enumerate().take(asset_count) { let config_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET)?; let vault_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET + 1)?; let mint_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET + 2)?; @@ -230,6 +240,11 @@ pub fn handle_deposit( .try_into() .map_err(|_| VaultError::MathOverflow)?; + // Record what the swap actually moves, measured on the vaults, rather + // than what was asked for. + let asset_before = read_token_amount(&vault_view)?; + let usdc_before = accounts.vault_usdc.amount(); + let mut data = [0u8; SWAP_DATA_LEN]; data[0] = ROUTER_SWAP_USDC_FOR_ASSET; data[1..9].copy_from_slice(&deploy_usdc.to_le_bytes()); @@ -251,8 +266,30 @@ pub fn handle_deposit( cpi.push_account(accounts.token_program.to_account_view(), false, false)?; cpi.set_data(&data)?; cpi.invoke_signed(&seeds)?; + + let asset_received = read_token_amount(&vault_view)? + .checked_sub(asset_before) + .ok_or(VaultError::MathOverflow)?; + let usdc_spent = usdc_before + .checked_sub(accounts.vault_usdc.amount()) + .ok_or(VaultError::MathOverflow)?; + // A leg that spends USDC and buys nothing would leave shares minted + // against no recorded value, and every later deposit would divide by a + // zero NAV. Refuse it: the deposit is too small for this basket. + require!(asset_received > 0, VaultError::DepositTooSmall); + *holding = holding + .checked_add(asset_received) + .ok_or(VaultError::MathOverflow)?; + usdc_holdings = usdc_holdings + .checked_sub(usdc_spent) + .ok_or(VaultError::MathOverflow)?; } + let mut strategy = snapshot_strategy(&accounts.strategy); + strategy.usdc_holdings = usdc_holdings; + strategy.asset_holdings = write_asset_holdings(&asset_holdings); + accounts.strategy.set_inner(strategy); + // Mint the shares last, with the strategy PDA signing as the share-mint // authority; the depositor's share account must belong to the depositor. require_keys_eq!( diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/initialize_strategy.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/initialize_strategy.rs index 88c32f4af..7e2981349 100644 --- a/finance/vault-strategy/quasar/vault-strategy/src/instructions/initialize_strategy.rs +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/initialize_strategy.rs @@ -3,7 +3,9 @@ use quasar_lang::sysvars::Sysvar as _; use quasar_spl::prelude::*; use crate::errors::VaultError; -use crate::state::{Registry, ShareMintPda, Strategy, StrategyInner, UsdcVaultPda}; +use crate::state::{ + Registry, ShareMintPda, Strategy, StrategyInner, UsdcVaultPda, ASSET_HOLDINGS_BYTES, +}; /// Highest annual management fee a manager may set (10%). `collect_fees` mints /// shares to the manager and dilutes every depositor, so an uncapped fee would @@ -75,6 +77,8 @@ pub fn handle_initialize_strategy( fee_bps, max_slippage_bps, total_shares: 0, + usdc_holdings: 0, + asset_holdings: [0; ASSET_HOLDINGS_BYTES], last_fee_accrual_timestamp: now, asset_count: 0, total_weight_bps: 0, diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/rebalance.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/rebalance.rs index c1d435695..8716ed246 100644 --- a/finance/vault-strategy/quasar/vault-strategy/src/instructions/rebalance.rs +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/rebalance.rs @@ -4,8 +4,11 @@ use quasar_lang::sysvars::Sysvar as _; use quasar_spl::prelude::*; use crate::errors::VaultError; -use crate::oracle::{load_price, PYTH_PRICE_PRECISION}; -use crate::state::{AssetConfig, Strategy, STRATEGY_SEED}; +use crate::oracle::{load_price, read_token_amount, PYTH_PRICE_PRECISION}; +use crate::state::{ + read_asset_holdings, snapshot_strategy, write_asset_holdings, AssetConfig, Strategy, + STRATEGY_SEED, +}; const ROUTER_SWAP_USDC_FOR_ASSET: u8 = 2; const ROUTER_SWAP_ASSET_FOR_USDC: u8 = 3; @@ -146,6 +149,18 @@ pub fn handle_rebalance( .try_into() .map_err(|_| VaultError::MathOverflow)?; + // Rebalancing may only trade what the program has accounted for: tokens + // donated into a vault are outside the fund, so they can be neither sold nor + // spent. The legs below record what each swap actually moved. + let sell_index = accounts.sell_config.index as usize; + let buy_index = accounts.buy_config.index as usize; + let mut strategy = snapshot_strategy(&accounts.strategy); + let mut asset_holdings = read_asset_holdings(&strategy.asset_holdings); + require!( + sell_amount <= asset_holdings[sell_index], + VaultError::InsufficientHoldings + ); + let index_bytes = strategy_index.to_le_bytes(); let bump = [strategy_bump]; let seeds = [ @@ -173,8 +188,29 @@ pub fn handle_rebalance( sell_cpi.push_account(accounts.router_usdc_treasury.to_account_view(), false, true)?; sell_cpi.push_account(accounts.token_program.to_account_view(), false, false)?; sell_cpi.set_data(&sell_data)?; + let sell_before = read_token_amount(accounts.vault_sell.to_account_view())?; + let usdc_before_sell = read_token_amount(accounts.vault_usdc.to_account_view())?; sell_cpi.invoke_signed(&seeds)?; + let sold = sell_before + .checked_sub(read_token_amount(accounts.vault_sell.to_account_view())?) + .ok_or(VaultError::MathOverflow)?; + let usdc_after_sell = read_token_amount(accounts.vault_usdc.to_account_view())?; + let usdc_received = usdc_after_sell + .checked_sub(usdc_before_sell) + .ok_or(VaultError::MathOverflow)?; + asset_holdings[sell_index] = asset_holdings[sell_index] + .checked_sub(sold) + .ok_or(VaultError::InsufficientHoldings)?; + strategy.usdc_holdings = strategy + .usdc_holdings + .checked_add(usdc_received) + .ok_or(VaultError::MathOverflow)?; + require!( + usdc_to_invest <= strategy.usdc_holdings, + VaultError::InsufficientHoldings + ); + // Step 2: buy the basket token with USDC. Router `swap_usdc_for_asset` // order: caller, router_config, asset_rate, usdc_mint, asset_mint, // caller_usdc_account, caller_asset_account, router_usdc_treasury, @@ -194,7 +230,24 @@ pub fn handle_rebalance( buy_cpi.push_account(accounts.router_usdc_treasury.to_account_view(), false, true)?; buy_cpi.push_account(accounts.token_program.to_account_view(), false, false)?; buy_cpi.set_data(&buy_data)?; + let buy_before = read_token_amount(accounts.vault_buy.to_account_view())?; buy_cpi.invoke_signed(&seeds)?; + let bought = read_token_amount(accounts.vault_buy.to_account_view())? + .checked_sub(buy_before) + .ok_or(VaultError::MathOverflow)?; + let usdc_spent = usdc_after_sell + .checked_sub(read_token_amount(accounts.vault_usdc.to_account_view())?) + .ok_or(VaultError::MathOverflow)?; + asset_holdings[buy_index] = asset_holdings[buy_index] + .checked_add(bought) + .ok_or(VaultError::MathOverflow)?; + strategy.usdc_holdings = strategy + .usdc_holdings + .checked_sub(usdc_spent) + .ok_or(VaultError::InsufficientHoldings)?; + strategy.asset_holdings = write_asset_holdings(&asset_holdings); + accounts.strategy.set_inner(strategy); + Ok(()) } diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/withdraw.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/withdraw.rs index b09dc9cd2..1c3711bfb 100644 --- a/finance/vault-strategy/quasar/vault-strategy/src/instructions/withdraw.rs +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/withdraw.rs @@ -4,10 +4,12 @@ use quasar_lang::remaining::RemainingAccounts; use quasar_spl::prelude::*; use crate::errors::VaultError; -use crate::oracle::{read_mint_decimals, read_token_amount, read_token_mint_and_owner}; +use crate::oracle::{read_mint_decimals, read_token_mint_and_owner}; use crate::state::{ - load_asset_config, snapshot_strategy, ShareMintPda, Strategy, UsdcVaultPda, STRATEGY_SEED, + load_asset_config, snapshot_strategy, ShareMintPda, Strategy, UsdcVaultPda, MAX_ASSETS, + STRATEGY_SEED, }; +use crate::state::{read_asset_holdings, write_asset_holdings}; /// remaining_accounts arrive as, per asset index 0..asset_count: /// [asset_config, vault, mint, user_token_account] @@ -69,7 +71,6 @@ pub fn handle_withdraw( VaultError::IncompleteAssetAccounts ); - let vault_usdc_amount = accounts.vault_usdc.amount(); let usdc_decimals = accounts.usdc_mint.decimals; let strategy_index = u64::from(accounts.strategy.index); let strategy_bump = accounts.strategy.bump; @@ -79,21 +80,41 @@ pub fn handle_withdraw( let shares_u128 = shares_to_burn as u128; let total_u128 = total_shares as u128; - // USDC leg, floored in the protocol's favour. - let amount_usdc: u64 = (vault_usdc_amount as u128) - .checked_mul(shares_u128) - .ok_or(VaultError::MathOverflow)? - .checked_div(total_u128) - .ok_or(VaultError::MathOverflow)? - .try_into() - .map_err(|_| VaultError::MathOverflow)?; + // Every leg is a proportion of the holdings the program has recorded, not of + // the vault's token balance, so tokens donated into a vault are never paid + // out. Floored in the fund's favour. + let proportion = |holding: u64| -> Result { + (holding as u128) + .checked_mul(shares_u128) + .ok_or(VaultError::MathOverflow)? + .checked_div(total_u128) + .ok_or(VaultError::MathOverflow)? + .try_into() + .map_err(|_| VaultError::MathOverflow.into()) + }; + let mut strategy = snapshot_strategy(&accounts.strategy); + let amount_usdc = proportion(strategy.usdc_holdings)?; require!(amount_usdc >= min_usdc_out, VaultError::UsdcSlippage); + let mut asset_holdings = read_asset_holdings(&strategy.asset_holdings); + let mut asset_amounts = [0u64; MAX_ASSETS as usize]; + for (index, amount) in asset_amounts.iter_mut().enumerate().take(asset_count) { + *amount = proportion(asset_holdings[index])?; + } - // Checks-effects-interactions: shrink supply before any transfer. - let mut strategy = snapshot_strategy(&accounts.strategy); + // Checks-effects-interactions: shrink supply and holdings before any transfer. strategy.total_shares = total_shares .checked_sub(shares_to_burn) .ok_or(VaultError::MathOverflow)?; + strategy.usdc_holdings = strategy + .usdc_holdings + .checked_sub(amount_usdc) + .ok_or(VaultError::MathOverflow)?; + for (index, amount) in asset_amounts.iter().enumerate().take(asset_count) { + asset_holdings[index] = asset_holdings[index] + .checked_sub(*amount) + .ok_or(VaultError::MathOverflow)?; + } + strategy.asset_holdings = write_asset_holdings(&asset_holdings); accounts.strategy.set_inner(strategy); let index_bytes = strategy_index.to_le_bytes(); @@ -131,7 +152,7 @@ pub fn handle_withdraw( } // Each basket asset, paid in kind, proportional to shares burned. - for i in 0..asset_count { + for (i, &amount) in asset_amounts.iter().enumerate().take(asset_count) { let config_view = get_view(&remaining, i * ACCOUNTS_PER_ASSET)?; let vault_view = get_view(&remaining, i * ACCOUNTS_PER_ASSET + 1)?; let mint_view = get_view(&remaining, i * ACCOUNTS_PER_ASSET + 2)?; @@ -159,15 +180,6 @@ pub fn handle_withdraw( require_keys_eq!(recipient_owner, user_key, VaultError::InvalidRecipient); require_keys_eq!(recipient_mint, config.mint, VaultError::InvalidRecipient); - let vault_balance = read_token_amount(&vault_view)?; - let amount: u64 = (vault_balance as u128) - .checked_mul(shares_u128) - .ok_or(VaultError::MathOverflow)? - .checked_div(total_u128) - .ok_or(VaultError::MathOverflow)? - .try_into() - .map_err(|_| VaultError::MathOverflow)?; - if amount > 0 { let decimals = read_mint_decimals(&mint_view)?; accounts diff --git a/finance/vault-strategy/quasar/vault-strategy/src/state/strategy.rs b/finance/vault-strategy/quasar/vault-strategy/src/state/strategy.rs index bad6ac6a4..4b234a1c0 100644 --- a/finance/vault-strategy/quasar/vault-strategy/src/state/strategy.rs +++ b/finance/vault-strategy/quasar/vault-strategy/src/state/strategy.rs @@ -11,6 +11,29 @@ pub const ASSET_CONFIG_SEED: &[u8] = b"asset"; /// single transaction. pub const MAX_ASSETS: u8 = 16; +/// Bytes of `Strategy::asset_holdings`: one little-endian u64 per asset index. +pub const ASSET_HOLDINGS_BYTES: usize = MAX_ASSETS as usize * 8; + +/// Decode `Strategy::asset_holdings` into one amount per asset index. +pub fn read_asset_holdings(bytes: &[u8; ASSET_HOLDINGS_BYTES]) -> [u64; MAX_ASSETS as usize] { + let mut holdings = [0u64; MAX_ASSETS as usize]; + for (holding, chunk) in holdings.iter_mut().zip(bytes.chunks_exact(8)) { + let mut word = [0u8; 8]; + word.copy_from_slice(chunk); + *holding = u64::from_le_bytes(word); + } + holdings +} + +/// Encode one amount per asset index into `Strategy::asset_holdings`. +pub fn write_asset_holdings(holdings: &[u64; MAX_ASSETS as usize]) -> [u8; ASSET_HOLDINGS_BYTES] { + let mut bytes = [0u8; ASSET_HOLDINGS_BYTES]; + for (chunk, holding) in bytes.chunks_exact_mut(8).zip(holdings) { + chunk.copy_from_slice(&holding.to_le_bytes()); + } + bytes +} + /// One strategy (basket). PDA `["strategy", index]`, addressed by a /// caller-chosen counter rather than the manager's key. The index is stored so /// every handler can re-derive the PDA to sign for the vaults and share mint. @@ -26,6 +49,18 @@ pub struct Strategy { pub fee_bps: u16, pub max_slippage_bps: u16, pub total_shares: u64, + /// 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. + pub usdc_holdings: u64, + /// 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. + /// Stored as little-endian u64s because zero-copy accounts hold byte arrays + /// only; read and write it with `read_asset_holdings` and + /// `write_asset_holdings`. + pub asset_holdings: [u8; ASSET_HOLDINGS_BYTES], pub last_fee_accrual_timestamp: i64, pub asset_count: u8, pub total_weight_bps: u16, @@ -76,6 +111,8 @@ pub fn snapshot_strategy(strategy: &Account) -> StrategyInner { fee_bps: u16::from(strategy.fee_bps), max_slippage_bps: u16::from(strategy.max_slippage_bps), total_shares: u64::from(strategy.total_shares), + usdc_holdings: u64::from(strategy.usdc_holdings), + asset_holdings: strategy.asset_holdings, last_fee_accrual_timestamp: i64::from(strategy.last_fee_accrual_timestamp), asset_count: strategy.asset_count, total_weight_bps: u16::from(strategy.total_weight_bps), diff --git a/finance/vault-strategy/quasar/vault-strategy/src/tests.rs b/finance/vault-strategy/quasar/vault-strategy/src/tests.rs index 65d0bb62c..04d907c50 100644 --- a/finance/vault-strategy/quasar/vault-strategy/src/tests.rs +++ b/finance/vault-strategy/quasar/vault-strategy/src/tests.rs @@ -5,12 +5,16 @@ //! deposit is priced 1:1 on the first deposit and deployed into the basket //! through the router CPI. `deposit_rejects_price_from_before_a_restart` //! reuses that setup to show a pre-restart price is refused. +//! `donation_does_not_inflate_share_price` shows a donation straight into the +//! USDC vault leaves the share price alone, and +//! `deposit_rejects_leg_that_buys_nothing` shows a deposit too small to buy +//! any of the asset is refused. use { crate::{ cpi::{ AddAssetInstruction, ApproveAssetInstruction, DepositInstruction, - InitializeRegistryInstruction, InitializeStrategyInstruction, + InitializeRegistryInstruction, InitializeStrategyInstruction, WithdrawInstruction, }, errors::VaultError, state::{AssetConfig, AssetVaultPda, Registry, ShareMintPda, Strategy, UsdcVaultPda}, @@ -38,6 +42,14 @@ const PRICE_FEED: Pubkey = Pubkey::new_from_array([6; 32]); const DEPOSITOR_USDC: Pubkey = Pubkey::new_from_array([7; 32]); const DEPOSITOR_SHARE: Pubkey = Pubkey::new_from_array([8; 32]); const FEED_OWNER: Pubkey = Pubkey::new_from_array([9; 32]); +const ATTACKER: Pubkey = Pubkey::new_from_array([11; 32]); +const ATTACKER_USDC: Pubkey = Pubkey::new_from_array([12; 32]); +const ATTACKER_SHARE: Pubkey = Pubkey::new_from_array([13; 32]); +const ATTACKER_ASSET: Pubkey = Pubkey::new_from_array([14; 32]); +const VICTIM: Pubkey = Pubkey::new_from_array([15; 32]); +const VICTIM_USDC: Pubkey = Pubkey::new_from_array([16; 32]); +const VICTIM_SHARE: Pubkey = Pubkey::new_from_array([17; 32]); +const VICTIM_ASSET: Pubkey = Pubkey::new_from_array([18; 32]); fn router_id() -> Pubkey { ROUTER_ID_STR.parse().unwrap() @@ -313,3 +325,218 @@ fn deposit_rejects_price_from_before_a_restart(test: &mut Test) { .succeeds() .has_tokens(DEPOSITOR_SHARE, DEPOSIT); } + +/// A depositor's wallet plus their USDC, share, and asset token accounts (the +/// share account must exist before a deposit; the asset account before an +/// in-kind withdrawal). +fn add_depositor(test: &mut Test, w: &Pdas, owner: Pubkey, accounts: [Pubkey; 3], usdc: u64) { + let [usdc_account, share_account, asset_account] = accounts; + test.add(Wallet::new().at(owner)); + test.add( + TokenAccount::new(USDC_MINT, owner) + .at(usdc_account) + .amount(usdc), + ); + test.add(TokenAccount::new(w.share_mint, owner).at(share_account)); + test.add(TokenAccount::new(ASSET_MINT, owner).at(asset_account)); +} + +/// Deposit: declared accounts, then remaining accounts per basket asset +/// (asset_config, vault_asset, asset_mint, asset_rate, price_feed). +fn deposit_as( + w: &Pdas, + depositor: Pubkey, + usdc_account: Pubkey, + share_account: Pubkey, + usdc_amount: u64, + minimum_shares: u64, +) -> DepositInstruction { + DepositInstruction { + depositor, + strategy_index_seed: STRATEGY_INDEX, + usdc_mint: USDC_MINT, + depositor_usdc_account: usdc_account, + depositor_share_account: share_account, + router_config: router_config_pda(), + router_usdc_treasury: router_treasury_pda(), + swap_router_program: router_id(), + usdc_amount, + minimum_shares, + remaining_accounts: vec![ + AccountMeta::new_readonly(w.asset_config, false), + AccountMeta::new(w.vault_asset, false), + AccountMeta::new(ASSET_MINT, false), + AccountMeta::new_readonly(router_rate_pda(&ASSET_MINT), false), + AccountMeta::new_readonly(PRICE_FEED, false), + ], + } +} + +/// Withdraw in kind: declared accounts, then remaining accounts per basket +/// asset (asset_config, vault_asset, asset_mint, user_asset_account). +fn withdraw( + w: &Pdas, + user: Pubkey, + accounts: [Pubkey; 3], + shares_to_burn: u64, +) -> WithdrawInstruction { + let [usdc_account, share_account, asset_account] = accounts; + WithdrawInstruction { + user, + strategy_index_seed: STRATEGY_INDEX, + usdc_mint: USDC_MINT, + user_share_account: share_account, + user_usdc_account: usdc_account, + shares_to_burn, + min_usdc_out: 0, + remaining_accounts: vec![ + AccountMeta::new_readonly(w.asset_config, false), + AccountMeta::new(w.vault_asset, false), + AccountMeta::new_readonly(ASSET_MINT, false), + AccountMeta::new(asset_account, false), + ], + } +} + +/// A holder's position valued in USDC minor units at the test's price: USDC +/// plus the asset at 250 USDC per token. +fn value_in_usdc(test: &Test, usdc_account: Pubkey, asset_account: Pubkey) -> u64 { + test.tokens(usdc_account) + test.tokens(asset_account) * RATE +} + +/// The first-depositor inflation attack: a small first deposit, then a donation +/// straight into the strategy's USDC vault, then a 1,000 USDC deposit with no +/// `minimum_shares` floor. The program prices shares from the holdings it has +/// recorded, so the donation changes the vault's balance and nothing the +/// handler reads: the victim gets exactly the shares they would have got +/// without it, and the donated USDC stays in the vault outside the fund. +/// Modeled on the lending example's +/// `raw_token_donation_does_not_inflate_exchange_rate`. +#[quasar_test] +fn donation_does_not_inflate_share_price(test: &mut Test) { + let w = setup_deposit(test); + add_pyth_feed(test, PYTH_PRICE, NOW); + + // The smallest deposit that buys any of the asset at 250 USDC minor units + // per asset minor unit. + const ATTACKER_DEPOSIT: u64 = RATE; + const DONATION: u64 = 1_000_000_000; // 1,000 USDC + const VICTIM_DEPOSIT: u64 = 1_000_000_000; // 1,000 USDC + + let attacker_accounts = [ATTACKER_USDC, ATTACKER_SHARE, ATTACKER_ASSET]; + let victim_accounts = [VICTIM_USDC, VICTIM_SHARE, VICTIM_ASSET]; + add_depositor( + test, + &w, + ATTACKER, + attacker_accounts, + ATTACKER_DEPOSIT + DONATION, + ); + add_depositor(test, &w, VICTIM, victim_accounts, VICTIM_DEPOSIT); + + // The empty-fund deposit mints one share per minor unit and buys one minor + // unit of the asset. + test.send(deposit_as( + &w, + ATTACKER, + ATTACKER_USDC, + ATTACKER_SHARE, + ATTACKER_DEPOSIT, + 0, + )) + .succeeds() + .has_tokens(ATTACKER_SHARE, ATTACKER_DEPOSIT) + .has_tokens(w.vault_asset, 1); + + // The attacker sends 1,000 USDC straight to the USDC vault with an ordinary + // token transfer (SPL Token `Transfer`, instruction 3). The deposit handler + // never ran, so the program records none of it. + let mut transfer_data = vec![3u8]; + transfer_data.extend_from_slice(&DONATION.to_le_bytes()); + test.send(Instruction { + program_id: SPL_TOKEN_PROGRAM_ID, + accounts: vec![ + AccountMeta::new(ATTACKER_USDC, false), + AccountMeta::new(w.vault_usdc, false), + AccountMeta::new_readonly(ATTACKER, true), + ], + data: transfer_data, + }) + .succeeds() + .has_tokens(w.vault_usdc, DONATION); + let strategy = test.read::(w.strategy); + assert_eq!( + u64::from(strategy.usdc_holdings), + 0, + "a donation is not recorded" + ); + + // The victim deposits 1,000 USDC with no floor. Priced off the recorded NAV of + // 250 minor units against 250 shares: 1,000,000,000 shares, the same as with + // no donation at all. Read off the vault balance it would have been + // 1,000,000,000 * 250 / 1,000,000,250 = 249. + test.send(deposit_as( + &w, + VICTIM, + VICTIM_USDC, + VICTIM_SHARE, + VICTIM_DEPOSIT, + 0, + )) + .succeeds() + .has_tokens(VICTIM_SHARE, VICTIM_DEPOSIT); + + // The victim redeems everything in kind, all 1,000 USDC of it in the asset. + test.send(withdraw(&w, VICTIM, victim_accounts, VICTIM_DEPOSIT)) + .succeeds() + .has_tokens(VICTIM_SHARE, 0); + assert_eq!( + value_in_usdc(test, VICTIM_USDC, VICTIM_ASSET), + VICTIM_DEPOSIT + ); + + // The attacker redeems their shares for what they deposited through the + // handler. The donation is never paid out and stays in the vault. + test.send(withdraw(&w, ATTACKER, attacker_accounts, ATTACKER_DEPOSIT)) + .succeeds() + .has_tokens(ATTACKER_SHARE, 0) + .has_tokens(w.vault_usdc, DONATION); + assert_eq!( + value_in_usdc(test, ATTACKER_USDC, ATTACKER_ASSET), + ATTACKER_DEPOSIT + ); + + let strategy = test.read::(w.strategy); + assert_eq!(u64::from(strategy.total_shares), 0, "total_shares"); + assert_eq!(u64::from(strategy.usdc_holdings), 0, "usdc_holdings"); +} + +/// A deposit leg that spends USDC and buys none of its asset would mint shares +/// against no recorded value, and every later deposit would then divide by a +/// zero NAV. At 250 USDC minor units per asset minor unit, a one-minor-unit +/// deposit buys nothing, so it must be refused. +#[quasar_test] +fn deposit_rejects_leg_that_buys_nothing(test: &mut Test) { + let w = setup_deposit(test); + add_pyth_feed(test, PYTH_PRICE, NOW); + add_depositor( + test, + &w, + ATTACKER, + [ATTACKER_USDC, ATTACKER_SHARE, ATTACKER_ASSET], + 1, + ); + + test.send(deposit_as( + &w, + ATTACKER, + ATTACKER_USDC, + ATTACKER_SHARE, + 1, + 0, + )) + .fails_with(VaultError::DepositTooSmall); + + let strategy = test.read::(w.strategy); + assert_eq!(u64::from(strategy.total_shares), 0, "total_shares"); +}