diff --git a/finance/vault-strategy/anchor/CHANGELOG.md b/finance/vault-strategy/anchor/CHANGELOG.md index 46cfa360..4ec33328 100644 --- a/finance/vault-strategy/anchor/CHANGELOG.md +++ b/finance/vault-strategy/anchor/CHANGELOG.md @@ -6,13 +6,16 @@ - **Curated asset registry.** A `Registry` plus per-mint `WhitelistEntry` accounts, maintained by a protocol authority separate from strategy managers. Each entry binds an approved mint to its official Pyth price feed. New instructions: `initialize_registry`, `whitelist_asset`. - **Dynamic assets.** A strategy now grows its portfolio with `add_asset`, which registers a whitelisted mint at the next index as an `AssetConfig` PDA (`["asset", strategy, index]`) and creates its vault. Assets occupy the contiguous range `0..asset_count`, up to `MAX_ASSETS` (8). Replaces the previous fixed two-asset layout. -- **Oracle-bounded slippage.** `invest` and `rebalance` now compute each swap's minimum output from the Pyth price and a strategy-level `max_slippage_bps` (capped at `MAX_SLIPPAGE_BPS` = 10%), instead of trusting a caller-supplied minimum. Set at creation via `initialize_strategy`. +- **Oracle-bounded slippage.** `deposit` and `rebalance` compute each swap's minimum output from the Pyth price and a strategy-level `max_slippage_bps` (capped at `MAX_SLIPPAGE_BPS` = 10%), instead of trusting a caller-supplied minimum. Set at creation via `initialize_strategy`. +- **Full-allocation invariant with immediate deployment.** A strategy accepts deposits only once its weights sum to exactly 10,000 bps (`deposit` reverts with `StrategyNotFullyAllocated` otherwise). `deposit` then swaps each depositor's USDC into the basket at its target weights through the registered router in the same transaction, so every deposit is fully invested (bar sub-cent rounding dust) and the USDC vault holds no idle cash. +- **Retirable assets.** `set_weight(weight_bps)` changes an asset's target weight after creation, including setting it to zero to retire it (reassign that weight to another asset to reach 100% and reopen deposits; `rebalance` liquidates the retired holdings). The asset's index is preserved, so the `0..asset_count` range the valuation handlers depend on stays contiguous. ### Changed -- `initialize_strategy` now takes `(fee_bps, max_slippage_bps, swap_router)` and binds the strategy to a registry; weights and price feeds move to `add_asset`. -- `deposit` and `withdraw` take each asset's accounts as remaining accounts and validate the complete `0..asset_count` set, so NAV and in-kind payouts always cover every asset. -- `invest` takes `(usdc_amount)` and `rebalance` takes `(sell_amount, usdc_to_invest)`; per-call minimums are gone. +- `initialize_strategy` now takes `(index, fee_bps, max_slippage_bps, swap_router)` and binds the strategy to a registry; the strategy PDA is seeded by a caller-chosen index (`["strategy", index]`) rather than the manager's key, with the manager kept as a stored field. Weights and price feeds move to `add_asset`. +- `deposit` takes each asset's `[asset_config, vault, mint, rate, price_feed]` plus the router accounts, validates the complete `0..asset_count` set for NAV, requires the strategy to be fully allocated, and deploys the deposit at the target weights. +- `withdraw` takes each asset's `[asset_config, vault, mint, user_token_account]` and pays out every asset in kind over the complete `0..asset_count` set. +- `rebalance` takes `(sell_amount, usdc_to_invest)`; per-call minimums are gone. ### Fixed diff --git a/finance/vault-strategy/anchor/README.md b/finance/vault-strategy/anchor/README.md index a15c6db3..ed91092e 100644 --- a/finance/vault-strategy/anchor/README.md +++ b/finance/vault-strategy/anchor/README.md @@ -1,6 +1,6 @@ # Vault Strategy -A manager-run investment vault on Solana. Users deposit [USDC](https://www.investopedia.com/terms/u/usd-coin-usdc.asp) and receive shares representing proportional ownership of a portfolio of assets. The manager adds assets from a curated whitelist, deploys deposited USDC into them, earns a fee, and depositors withdraw their proportional slice in kind when they choose. +A manager-run investment vault on Solana. Users deposit [USDC](https://www.investopedia.com/terms/u/usd-coin-usdc.asp) and receive shares representing proportional ownership of a portfolio of assets. The manager adds assets from a curated whitelist and sets their target weights; each deposit is deployed across those assets at its weights in the same transaction. The manager rebalances as prices drift, earns a fee, and depositors withdraw their proportional slice in kind when they choose. The example uses two stocks as the portfolio assets: **TSLAx** (Tesla) and **NVDAx** (NVIDIA) - [xStocks](https://backed.fi/xstocks) issued on Solana by Backed Finance. In tests these are mock [tokens](https://solana.com/docs/terminology#token). @@ -25,6 +25,8 @@ A note on the word **vault**: by the common standard (ERC-4626) a vault holds a 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. +Referencing every asset has a transaction-size cost: `deposit` pulls in `14 + 5N` accounts and `withdraw` `10 + 4N`, where `N` is the asset count. That stays within Solana's 128-account transaction lock limit at the `MAX_ASSETS` cap of 16 (94 accounts for `deposit`), but a basket beyond roughly three assets no longer fits a legacy transaction's 1232-byte limit, so the client must send a v0 transaction with an [Address Lookup Table](https://docs.anza.xyz/proposals/versioned-transactions). + Prices come from [Pyth Network](https://pyth.network/) `PriceUpdateV2` accounts. A 60-second staleness window is enforced; zero or negative prices are rejected. ### Shares @@ -47,11 +49,13 @@ fee_shares = total_shares × fee_bps × elapsed_seconds / (10_000 × 31_536_000) ### Weights and Rebalancing -Each asset carries a target **weight** in basis points (e.g. 40% TSLAx, 60% NVDAx); the running sum is kept at or below 10,000. Weights are advisory targets the manager maintains with `invest` and `rebalance`; the program does not force an allocation on deposit. [Rebalancing](https://www.investopedia.com/terms/r/rebalancing.asp) sells an over-weight asset and buys an under-weight one in a single atomic instruction. +Each asset carries a target **weight** in basis points (e.g. 40% TSLAx, 60% NVDAx). A strategy accepts deposits only once its weights sum to exactly 10,000 (`add_asset` and `set_weight` keep the running sum at or below 10,000; `deposit` requires it to equal 10,000, else `StrategyNotFullyAllocated`). So a strategy is either still being configured or fully allocated and live, and `deposit` deploys each depositor's USDC straight into the basket at those weights, fully invested bar sub-cent rounding dust. There is no idle-cash mode. + +[Rebalancing](https://www.investopedia.com/terms/r/rebalancing.asp) handles the drift that prices create after a deposit: `rebalance` sells an over-weight asset for USDC and buys an under-weight one in a single atomic instruction. `set_weight` changes a target after creation, including setting it to zero to **retire** an asset: deposits stop allocating to it, the manager sells its holdings out with `rebalance`, and the now-empty vault keeps its index so the contiguous `0..asset_count` range stays intact (the index is never reused). ### Slippage, bounded by the oracle -[Slippage](https://www.investopedia.com/terms/s/slippage.asp) is the gap between the expected and the realized amount of a swap. Rather than trust a manager-supplied minimum, `invest` and `rebalance` compute the floor themselves from the Pyth price and the strategy's `max_slippage_bps`: a swap whose output falls more than that tolerance below the oracle-implied amount reverts. `max_slippage_bps` is set at creation and capped at `MAX_SLIPPAGE_BPS` (1,000 bps = 10%). +[Slippage](https://www.investopedia.com/terms/s/slippage.asp) is the gap between the expected and the realized amount of a swap. Rather than trust a manager-supplied minimum, `deposit` and `rebalance` compute the floor themselves from the Pyth price and the strategy's `max_slippage_bps`: a swap whose output falls more than that tolerance below the oracle-implied amount reverts. `max_slippage_bps` is set at creation and capped at `MAX_SLIPPAGE_BPS` (1,000 bps = 10%). ### In-Kind Withdrawal @@ -63,69 +67,47 @@ An [in-kind distribution](https://www.investopedia.com/terms/i/in-kind.asp) retu ### Participants -| Person | Role | Motivation | -|--------|------|-----------| -| **Victor** | Registry authority | Curate which assets (and which official Pyth feed) are safe to hold; a protocol role, not a manager | -| **Maria** | Strategy manager | Earn a 1% annual fee; run a basket she has a thesis on | -| **Alice** | Early depositor | Diversified TSLAx + NVDAx exposure without managing positions | -| **Bob** | Later depositor | Join the same strategy after it has been running | +- **Victor**, the registry authority: curates which assets, and which official Pyth feed, are safe to hold. A protocol role, not a manager. +- **Maria**, the strategy manager: earns a 1% annual fee running a basket she has a thesis on. +- **Alice**, the early depositor: wants diversified TSLAx and NVDAx exposure without managing positions. +- **Bob**, the later depositor: joins the same strategy after it has been running. `Maria` and `Victor` are stored as plain `Pubkey`s and may each be a [Squads](https://squads.so/) multisig; the program only checks the signature. -### Step 1 - Victor creates the registry and whitelists assets +### Victor creates the registry and whitelists assets `initialize_registry()` creates a `Registry` PDA (`["registry", victor]`) owned by Victor. `whitelist_asset(price_feed)` then creates one `WhitelistEntry` PDA (`["whitelist", registry, mint]`) per approved mint, binding it to its official Pyth feed. Only Victor can do this. This separation is the anti-fraud core: a manager can only ever add assets Victor approved, and the feed comes from the registry, so a manager cannot list a token they mint themselves or pair a real mint with a feed they control. -### Step 2 - Maria initializes the strategy - -`initialize_strategy(fee_bps=100, max_slippage_bps=100, swap_router)` creates the `Strategy` PDA (`["strategy", maria]`), the share mint, and the USDC vault, binding the strategy to Victor's registry. No assets yet. - -### Step 3 - Maria adds assets +### Maria initializes the strategy -`add_asset(weight_bps)`, once per asset, creates an `AssetConfig` at `["asset", strategy, index]` (index = current `asset_count`), copies the official feed from the whitelist entry, and creates that asset's vault. TSLAx at index 0 (4000 bps), NVDAx at index 1 (6000 bps). Rejected if the mint is not whitelisted, if the weights would exceed 10,000 bps, or once `MAX_ASSETS` (8) is reached. +`initialize_strategy(index=0, fee_bps=100, max_slippage_bps=100, swap_router)` creates the `Strategy` PDA (`["strategy", 0]`), the share mint, and the USDC vault, binding the strategy to Victor's registry. The strategy is addressed by a caller-chosen index (`"strategy" + 0`, `"strategy" + 1`, …) rather than the manager's key. No assets yet. -### Step 4 - Alice deposits +### Maria adds assets -`deposit(usdc_amount, minimum_shares)`, with each asset's `[asset_config, vault, price_feed]` passed as remaining accounts. First deposit is 1:1. USDC moves into the USDC vault; shares are minted to Alice. +`add_asset(weight_bps)`, once per asset, creates an `AssetConfig` at `["asset", strategy, index]` (index = current `asset_count`), copies the official feed from the whitelist entry, and creates that asset's vault. TSLAx at index 0 (4000 bps), NVDAx at index 1 (6000 bps). Rejected if the mint is not whitelisted, if the weights would exceed 10,000 bps, or once `MAX_ASSETS` (8) is reached. Deposits stay closed until the weights sum to exactly 10,000. -### Step 5 - Maria invests +### Alice deposits, and her money is deployed at once -`invest(usdc_amount)` for one registered asset, passing its `asset_config` and `price_feed`. The handler reads the Pyth price, computes the minimum acceptable output, and CPIs the router; a fill worse than the bound reverts. +`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. -### Step 6 - Bob deposits at the current share price +### Bob deposits at the current share price -Same as step 4. Because shares are priced at NAV, Bob pays the current per-share value and does not dilute Alice's gain. +Same as Alice's deposit. Because shares are priced at NAV, Bob pays the current per-share value and does not dilute Alice's gain; his USDC is deployed at the target weights too. -### Step 7 - Maria rebalances +### Maria rebalances -`rebalance(sell_amount, usdc_to_invest)` sells one asset for USDC and buys another, both legs bounded against their Pyth prices, in one atomic instruction. +A price move pushes the basket off target. `rebalance(sell_amount, usdc_to_invest)` sells the over-weight asset for USDC and buys the under-weight one, both legs bounded against their Pyth prices, in one atomic instruction. `set_weight(weight_bps)` changes a target between rebalances, or retires an asset by setting it to zero (then reassign that weight to another asset to reach 100% again, and `rebalance` liquidates the retired holdings). -### Step 8 - Fees accrue +### Fees accrue `collect_fees()` mints time-and-rate-proportional fee shares to Maria, diluting all holders by the fee. -### Step 9 - Alice withdraws in kind +### Alice withdraws in kind `withdraw(shares_to_burn, min_usdc_out)`, with each asset's `[asset_config, vault, mint, user_token_account]` as remaining accounts. Alice's shares burn and she receives her proportional slice of USDC and every asset. Amounts floor in the protocol's favour. --- -## Instruction Reference - -| Instruction | Signer | Notes | -|------------|--------|-------| -| `initialize_registry` | registry authority | Creates the whitelist | -| `whitelist_asset` | registry authority | Approves a mint, binds it to its Pyth feed | -| `initialize_strategy` | manager | Sets fee and slippage caps, binds to a registry | -| `add_asset` | manager | Adds a whitelisted asset at the next index, creates its vault | -| `deposit` | depositor | NAV over all assets (remaining accounts); mints shares | -| `invest` | manager | USDC → asset, slippage floor computed from Pyth | -| `rebalance` | manager | asset → USDC → asset, both legs Pyth-bounded | -| `collect_fees` | anyone | Mints fee shares to the manager | -| `withdraw` | user | Burns shares, pays out USDC + every asset in kind (remaining accounts) | - ---- - ## Oracle Integration (Pyth) `PriceUpdateV2` price (i64) is read at byte offset 73 and `publish_time` at 93, directly from account bytes to avoid borsh version incompatibility with Anchor. Pyth USD pairs use exponent −8; with USDC and the basket tokens all at 6 decimals, value in USDC minor units is `amount × price / 10⁸`. Each asset's feed pubkey is fixed in its `AssetConfig` (copied from the registry), and validated on every read. In tests, mock `PriceUpdateV2` accounts are injected into LiteSVM (TSLAx $250, NVDAx $180). @@ -134,7 +116,7 @@ Same as step 4. Because shares are priced at NAV, Bob pays the current per-share ## Mock Swap Router vs Production -The `mock-swap-router` exists only for testing: it stores a `usdc_per_token` rate per asset, holds the basket mints' authority, and mints/burns to simulate swaps. The `Strategy` stores the router program pubkey at creation, and `invest`/`rebalance` require the router account to match it (`InvalidSwapRouter`). In production, replace the router CPIs with [Jupiter](https://jup.ag); the strategy PDA still signs. +The `mock-swap-router` exists only for testing: it stores a `usdc_per_token` rate per asset, holds the basket mints' authority, and mints/burns to simulate swaps. The `Strategy` stores the router program pubkey at creation, and `deposit` and `rebalance` require the router account to match it (`InvalidSwapRouter`). In production, replace the router CPIs with [Jupiter](https://jup.ag); the strategy PDA still signs. --- @@ -171,4 +153,4 @@ 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 (registry, whitelist, strategy, add-asset, deposit, invest, rebalance, fees, in-kind withdraw) and the rejection paths: non-whitelisted asset, weight overflow, over-cap fee and slippage, oracle-bounded swap slippage, 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: non-whitelisted 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. 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 34407779..306afada 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs @@ -20,6 +20,8 @@ pub enum VaultError { DuplicateAsset, #[msg("Total target weight would exceed 10000 basis points")] WeightOverflow, + #[msg("Strategy weights must sum to 100% before it can accept deposits")] + StrategyNotFullyAllocated, #[msg("Wrong number of asset accounts supplied for the strategy's assets")] IncompleteAssetAccounts, #[msg("An asset account does not match the strategy's registered asset")] diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/add_asset.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/add_asset.rs index ca03a9dd..bc1e3575 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/add_asset.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/add_asset.rs @@ -16,7 +16,7 @@ pub struct AddAssetAccountConstraints<'info> { mut, has_one = manager, has_one = registry @ VaultError::InvalidRegistry, - seeds = [b"strategy", strategy.manager.as_ref()], + seeds = [b"strategy", strategy.index.to_le_bytes().as_ref()], bump = strategy.bump )] pub strategy: Box>, diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/collect_fees.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/collect_fees.rs index 086decf8..04e964d0 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/collect_fees.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/collect_fees.rs @@ -17,7 +17,7 @@ pub struct CollectFeesAccountConstraints<'info> { #[account( mut, has_one = manager, - seeds = [b"strategy", strategy.manager.as_ref()], + seeds = [b"strategy", strategy.index.to_le_bytes().as_ref()], bump = strategy.bump )] pub strategy: Account<'info, Strategy>, @@ -57,7 +57,7 @@ pub fn handle_collect_fees(context: Context) -> R let elapsed_seconds = (current_ts - last_ts) as u64; let total_shares = context.accounts.strategy.total_shares; let fee_bps = context.accounts.strategy.fee_bps; - let manager_key = context.accounts.strategy.manager; + let strategy_index = context.accounts.strategy.index; let strategy_bump = context.accounts.strategy.bump; // fee_shares = total_shares * fee_bps * elapsed / (10_000 * SECONDS_PER_YEAR) @@ -86,7 +86,8 @@ pub fn handle_collect_fees(context: Context) -> R .ok_or(VaultError::MathOverflow)?; // Mint fee shares to manager - strategy PDA signs - let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", manager_key.as_ref(), &[strategy_bump]]]; + let index_bytes = strategy_index.to_le_bytes(); + let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", index_bytes.as_ref(), &[strategy_bump]]]; let mint_accounts = MintTo { mint: context.accounts.share_mint.to_account_info(), 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 f4a040c3..1a31f6c5 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 @@ -5,9 +5,10 @@ use anchor_spl::{ mint_to, transfer_checked, Mint, MintTo, TokenAccount, TokenInterface, TransferChecked, }, }; +use mock_swap_router::cpi::accounts::SwapUsdcForAssetAccountConstraints as RouterSwapAccounts; use crate::error::VaultError; -use crate::oracle::{asset_value_in_usdc, load_price, read_token_amount}; +use crate::oracle::{asset_value_in_usdc, load_price, read_token_amount, PYTH_PRICE_PRECISION}; use crate::state::{AssetConfig, Strategy}; #[derive(Accounts)] @@ -18,7 +19,7 @@ pub struct DepositAccountConstraints<'info> { #[account( mut, has_one = usdc_mint @ VaultError::InvalidUsdcMint, - seeds = [b"strategy", strategy.manager.as_ref()], + seeds = [b"strategy", strategy.index.to_le_bytes().as_ref()], bump = strategy.bump )] pub strategy: Box>, @@ -57,61 +58,94 @@ pub struct DepositAccountConstraints<'info> { )] pub vault_usdc: Box>, + /// CHECK: Router config PDA from the mock-swap-router program + #[account(mut)] + pub router_config: UncheckedAccount<'info>, + + /// CHECK: Router USDC treasury ATA + #[account(mut)] + pub router_usdc_treasury: UncheckedAccount<'info>, + + /// CHECK: Router authority PDA from the mock-swap-router program + #[account(mut)] + pub router_authority: UncheckedAccount<'info>, + + #[account( + constraint = swap_router_program.key() == strategy.swap_router @ VaultError::InvalidSwapRouter + )] + pub swap_router_program: Program<'info, mock_swap_router::program::MockSwapRouter>, + pub associated_token_program: Program<'info, AssociatedToken>, pub token_program: Interface<'info, TokenInterface>, pub system_program: Program<'info, System>, // remaining_accounts: for each asset index 0..asset_count, in order: - // [asset_config, vault, price_feed] + // [asset_config, vault, asset_mint, asset_rate, price_feed] } +/// Deposit USDC, receive shares priced at net asset value, and immediately deploy +/// the deposit into the basket at its target weights. The strategy must be fully +/// allocated first: the weights sum to exactly 10000, so every deposit is fully +/// invested. For each asset the handler swaps `usdc_amount * weight_bps / 10000` +/// through the registered router, so a depositor's money is invested in the same +/// transaction they put it in (only sub-cent rounding dust can remain as USDC). pub fn handle_deposit<'info>( context: Context<'info, DepositAccountConstraints<'info>>, usdc_amount: u64, minimum_shares: u64, ) -> Result<()> { require!(usdc_amount > 0, VaultError::ZeroDeposit); + // A strategy accepts deposits only once its weights sum to 100%, so a deposit is + // always fully invested. A half-configured or under-allocated basket is closed. + require!( + context.accounts.strategy.total_weight_bps == 10_000, + 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 manager_key = context.accounts.strategy.manager; + let strategy_index = context.accounts.strategy.index; let strategy_bump = context.accounts.strategy.bump; 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; let now = Clock::get()?.unix_timestamp; // Net asset value over the complete asset set. The assets are exactly indices - // 0..asset_count, so requiring three accounts per index, in order, each with a + // 0..asset_count, so requiring five accounts per index, in order, each with a // matching index, makes it impossible to omit an asset and understate NAV. let remaining = context.remaining_accounts; require!( - remaining.len() == asset_count * 3, + remaining.len() == asset_count * 5, VaultError::IncompleteAssetAccounts ); let mut nav: u128 = vault_usdc_amount as u128; - for i in 0..asset_count { - let config_ai = &remaining[i * 3]; - let vault_ai = &remaining[i * 3 + 1]; - let feed_ai = &remaining[i * 3 + 2]; + for index in 0..asset_count { + let config_account = &remaining[index * 5]; + let vault_account = &remaining[index * 5 + 1]; + let feed_account = &remaining[index * 5 + 4]; - let config = AssetConfig::load_checked(config_ai)?; + let config = AssetConfig::load_checked(config_account)?; require_keys_eq!( config.strategy, strategy_key, VaultError::InvalidAssetAccount ); - require!(config.index as usize == i, VaultError::InvalidAssetAccount); + require!( + config.index as usize == index, + VaultError::InvalidAssetAccount + ); require_keys_eq!( - vault_ai.key(), + vault_account.key(), config.vault, VaultError::InvalidAssetAccount ); - let price = load_price(feed_ai, &config.price_feed, now)?; - let amount = read_token_amount(vault_ai)?; + let price = load_price(feed_account, &config.price_feed, now)?; + let amount = read_token_amount(vault_account)?; nav = nav .checked_add(asset_value_in_usdc(amount, price)?) .ok_or(VaultError::MathOverflow)?; @@ -137,6 +171,7 @@ pub fn handle_deposit<'info>( .checked_add(shares_to_mint) .ok_or(VaultError::MathOverflow)?; + // Pull the depositor's USDC into the strategy's USDC vault. let transfer_accounts = TransferChecked { from: context.accounts.depositor_usdc_account.to_account_info(), mint: context.accounts.usdc_mint.to_account_info(), @@ -146,7 +181,80 @@ pub fn handle_deposit<'info>( let cpi_ctx = CpiContext::new(context.accounts.token_program.key(), transfer_accounts); transfer_checked(cpi_ctx, usdc_amount, usdc_decimals)?; - let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", manager_key.as_ref(), &[strategy_bump]]]; + let index_bytes = strategy_index.to_le_bytes(); + let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", index_bytes.as_ref(), &[strategy_bump]]]; + + // Deploy the deposit across the basket at its target weights. Each leg swaps a + // weight-sized slice of the deposit through the router, under an oracle-computed + // slippage floor. The strategy PDA signs, since the USDC leaves a vault only it + // controls. + for index in 0..asset_count { + let config_account = &remaining[index * 5]; + let vault_account = &remaining[index * 5 + 1]; + let mint_account = &remaining[index * 5 + 2]; + let rate_account = &remaining[index * 5 + 3]; + let feed_account = &remaining[index * 5 + 4]; + + let config = AssetConfig::load_checked(config_account)?; + require_keys_eq!( + mint_account.key(), + config.mint, + VaultError::InvalidAssetAccount + ); + + if config.weight_bps == 0 { + continue; + } + + let deploy_usdc: u64 = (usdc_amount as u128) + .checked_mul(config.weight_bps as u128) + .ok_or(VaultError::MathOverflow)? + .checked_div(10_000) + .ok_or(VaultError::MathOverflow)? as u64; + + if deploy_usdc == 0 { + continue; + } + + // Slippage floor anchored to the oracle: expected_out = deploy_usdc * 10^8 / + // price, allowed to fall short by at most max_slippage_bps. + let price = load_price(feed_account, &config.price_feed, now)?; + let expected_out = (deploy_usdc as u128) + .checked_mul(PYTH_PRICE_PRECISION) + .ok_or(VaultError::MathOverflow)? + .checked_div(price) + .ok_or(VaultError::MathOverflow)?; + let minimum_asset_out: u64 = expected_out + .checked_mul((10_000 - max_slippage_bps) as u128) + .ok_or(VaultError::MathOverflow)? + .checked_div(10_000) + .ok_or(VaultError::MathOverflow)? + .try_into() + .map_err(|_| VaultError::MathOverflow)?; + + let cpi_accounts = RouterSwapAccounts { + caller: context.accounts.strategy.to_account_info(), + router_config: context.accounts.router_config.to_account_info(), + asset_rate: rate_account.clone(), + usdc_mint: context.accounts.usdc_mint.to_account_info(), + asset_mint: mint_account.clone(), + caller_usdc_account: context.accounts.vault_usdc.to_account_info(), + caller_asset_account: vault_account.clone(), + router_usdc_treasury: context.accounts.router_usdc_treasury.to_account_info(), + router_authority: context.accounts.router_authority.to_account_info(), + associated_token_program: context.accounts.associated_token_program.to_account_info(), + token_program: context.accounts.token_program.to_account_info(), + system_program: context.accounts.system_program.to_account_info(), + }; + let cpi_ctx = CpiContext::new_with_signer( + context.accounts.swap_router_program.key(), + cpi_accounts, + signer_seeds, + ); + mock_swap_router::cpi::swap_usdc_for_asset(cpi_ctx, deploy_usdc, minimum_asset_out)?; + } + + // Mint the shares last, with the strategy PDA signing as the share mint authority. let mint_accounts = MintTo { mint: context.accounts.share_mint.to_account_info(), to: context.accounts.depositor_share_account.to_account_info(), 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 3ebd45a9..7e9f665f 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 @@ -14,12 +14,13 @@ use crate::state::{Registry, Strategy}; pub const MAX_FEE_BPS: u16 = 1_000; /// Highest slippage tolerance a manager may set, in basis points (10%). -/// invest/rebalance reject a swap whose output deviates from the Pyth price by +/// deposit/rebalance reject a swap whose output deviates from the Pyth price by /// more than this; capping it stops a manager from setting a tolerance so loose /// that the bound is meaningless. pub const MAX_SLIPPAGE_BPS: u16 = 1_000; #[derive(Accounts)] +#[instruction(index: u64)] pub struct InitializeStrategyAccountConstraints<'info> { #[account(mut)] pub manager: Signer<'info>, @@ -33,7 +34,7 @@ pub struct InitializeStrategyAccountConstraints<'info> { init, payer = manager, space = Strategy::DISCRIMINATOR.len() + Strategy::INIT_SPACE, - seeds = [b"strategy", manager.key().as_ref()], + seeds = [b"strategy", index.to_le_bytes().as_ref()], bump )] pub strategy: Box>, @@ -67,6 +68,7 @@ pub struct InitializeStrategyAccountConstraints<'info> { pub fn handle_initialize_strategy( context: Context, + index: u64, fee_bps: u16, max_slippage_bps: u16, swap_router: Pubkey, @@ -80,6 +82,7 @@ pub fn handle_initialize_strategy( let clock = Clock::get()?; context.accounts.strategy.set_inner(Strategy { + index, manager: context.accounts.manager.key(), registry: context.accounts.registry.key(), share_mint: context.accounts.share_mint.key(), diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/invest.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/invest.rs deleted file mode 100644 index cab4add6..00000000 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/invest.rs +++ /dev/null @@ -1,142 +0,0 @@ -use anchor_lang::prelude::*; -use anchor_spl::{ - associated_token::AssociatedToken, - token_interface::{Mint, TokenAccount, TokenInterface}, -}; -use mock_swap_router::{ - cpi::accounts::SwapUsdcForAssetAccountConstraints as RouterSwapAccounts, state::AssetRate, -}; - -use crate::error::VaultError; -use crate::oracle::{load_price, PYTH_PRICE_PRECISION}; -use crate::state::{AssetConfig, Strategy}; - -#[derive(Accounts)] -pub struct InvestAccountConstraints<'info> { - /// Only the manager may call invest - pub manager: Signer<'info>, - - #[account( - mut, - has_one = manager, - has_one = usdc_mint @ VaultError::InvalidUsdcMint, - seeds = [b"strategy", strategy.manager.as_ref()], - bump = strategy.bump - )] - pub strategy: Box>, - - /// The asset to buy. Validated against its registered config below. - #[account( - constraint = asset_config.strategy == strategy.key() @ VaultError::InvalidAssetAccount, - constraint = asset_config.mint == asset_mint.key() @ VaultError::AssetNotFound, - constraint = asset_config.vault == vault_asset.key() @ VaultError::InvalidAssetAccount, - )] - pub asset_config: Box>, - - pub usdc_mint: Box>, - - #[account(mut)] - pub asset_mint: Box>, - - /// CHECK: Pyth feed - validated against the asset's registered feed - #[account( - constraint = price_feed.key() == asset_config.price_feed @ VaultError::InvalidPriceFeed - )] - pub price_feed: UncheckedAccount<'info>, - - #[account( - mut, - associated_token::mint = usdc_mint, - associated_token::authority = strategy, - associated_token::token_program = token_program - )] - pub vault_usdc: Box>, - - #[account( - mut, - associated_token::mint = asset_mint, - associated_token::authority = strategy, - associated_token::token_program = token_program - )] - pub vault_asset: Box>, - - pub asset_rate: Account<'info, AssetRate>, - - /// CHECK: Router config PDA from the mock-swap-router program - #[account(mut)] - pub router_config: UncheckedAccount<'info>, - - /// CHECK: Router USDC treasury ATA - #[account(mut)] - pub router_usdc_treasury: UncheckedAccount<'info>, - - /// CHECK: Router authority PDA from the mock-swap-router program - #[account(mut)] - pub router_authority: UncheckedAccount<'info>, - - #[account( - constraint = swap_router_program.key() == strategy.swap_router @ VaultError::InvalidSwapRouter - )] - pub swap_router_program: Program<'info, mock_swap_router::program::MockSwapRouter>, - - pub associated_token_program: Program<'info, AssociatedToken>, - pub token_program: Interface<'info, TokenInterface>, - pub system_program: Program<'info, System>, -} - -pub fn handle_invest(context: Context, usdc_amount: u64) -> Result<()> { - let strategy = &context.accounts.strategy; - let manager_key = strategy.manager; - let strategy_bump = strategy.bump; - let max_slippage_bps = strategy.max_slippage_bps; - - // Slippage floor anchored to the oracle, not to a manager-supplied number: - // expected_out = usdc_amount * 10^8 / price, then allow it to fall short by at - // most max_slippage_bps. The router rejects any fill below this. - let now = Clock::get()?.unix_timestamp; - let price = load_price( - &context.accounts.price_feed, - &context.accounts.asset_config.price_feed, - now, - )?; - - let expected_out = (usdc_amount as u128) - .checked_mul(PYTH_PRICE_PRECISION) - .ok_or(VaultError::MathOverflow)? - .checked_div(price) - .ok_or(VaultError::MathOverflow)?; - let minimum_asset_out: u64 = expected_out - .checked_mul((10_000 - max_slippage_bps) as u128) - .ok_or(VaultError::MathOverflow)? - .checked_div(10_000) - .ok_or(VaultError::MathOverflow)? - .try_into() - .map_err(|_| VaultError::MathOverflow)?; - - let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", manager_key.as_ref(), &[strategy_bump]]]; - - let cpi_accounts = RouterSwapAccounts { - caller: context.accounts.strategy.to_account_info(), - router_config: context.accounts.router_config.to_account_info(), - asset_rate: context.accounts.asset_rate.to_account_info(), - usdc_mint: context.accounts.usdc_mint.to_account_info(), - asset_mint: context.accounts.asset_mint.to_account_info(), - caller_usdc_account: context.accounts.vault_usdc.to_account_info(), - caller_asset_account: context.accounts.vault_asset.to_account_info(), - router_usdc_treasury: context.accounts.router_usdc_treasury.to_account_info(), - router_authority: context.accounts.router_authority.to_account_info(), - associated_token_program: context.accounts.associated_token_program.to_account_info(), - token_program: context.accounts.token_program.to_account_info(), - system_program: context.accounts.system_program.to_account_info(), - }; - - let cpi_ctx = CpiContext::new_with_signer( - context.accounts.swap_router_program.key(), - cpi_accounts, - signer_seeds, - ); - - mock_swap_router::cpi::swap_usdc_for_asset(cpi_ctx, usdc_amount, minimum_asset_out)?; - - Ok(()) -} diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/mod.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/mod.rs index 7def5baa..66e9eb18 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/mod.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/mod.rs @@ -3,8 +3,8 @@ pub mod collect_fees; pub mod deposit; pub mod initialize_registry; pub mod initialize_strategy; -pub mod invest; pub mod rebalance; +pub mod set_weight; pub mod whitelist_asset; pub mod withdraw; @@ -13,7 +13,7 @@ pub use collect_fees::*; pub use deposit::*; pub use initialize_registry::*; pub use initialize_strategy::*; -pub use invest::*; pub use rebalance::*; +pub use set_weight::*; pub use whitelist_asset::*; pub use withdraw::*; 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 b94d6eaa..b75e1b1a 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 @@ -20,7 +20,7 @@ pub struct RebalanceAccountConstraints<'info> { mut, has_one = manager, has_one = usdc_mint @ VaultError::InvalidUsdcMint, - seeds = [b"strategy", strategy.manager.as_ref()], + seeds = [b"strategy", strategy.index.to_le_bytes().as_ref()], bump = strategy.bump )] pub strategy: Box>, @@ -116,7 +116,7 @@ pub fn handle_rebalance( ); let strategy = &context.accounts.strategy; - let manager_key = strategy.manager; + let strategy_index = strategy.index; let strategy_bump = strategy.bump; let slip = (10_000 - strategy.max_slippage_bps) as u128; @@ -160,7 +160,8 @@ pub fn handle_rebalance( .try_into() .map_err(|_| VaultError::MathOverflow)?; - let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", manager_key.as_ref(), &[strategy_bump]]]; + let index_bytes = strategy_index.to_le_bytes(); + let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", index_bytes.as_ref(), &[strategy_bump]]]; // Step 1: sell basket token -> USDC let sell_cpi_accounts = RouterSellAccounts { diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/set_weight.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/set_weight.rs new file mode 100644 index 00000000..d0cbd6b0 --- /dev/null +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/set_weight.rs @@ -0,0 +1,50 @@ +use anchor_lang::prelude::*; + +use crate::error::VaultError; +use crate::state::{AssetConfig, Strategy}; + +#[derive(Accounts)] +pub struct SetWeightAccountConstraints<'info> { + pub manager: Signer<'info>, + + #[account( + mut, + has_one = manager, + seeds = [b"strategy", strategy.index.to_le_bytes().as_ref()], + bump = strategy.bump + )] + pub strategy: Box>, + + #[account( + mut, + constraint = asset_config.strategy == strategy.key() @ VaultError::InvalidAssetAccount, + )] + pub asset_config: Box>, +} + +/// Change an asset's target weight. Setting it to zero retires the asset: deposits +/// stop allocating to it, and the manager sells its holdings out with `rebalance`, +/// leaving an empty vault at the asset's index. The index is never reused, so the +/// contiguous 0..asset_count range the valuation handlers depend on stays intact. +/// Funds do not move here; this only edits the target the manager trades toward. +pub fn handle_set_weight( + context: Context, + weight_bps: u16, +) -> Result<()> { + let strategy = &mut context.accounts.strategy; + let asset_config = &mut context.accounts.asset_config; + + // total_weight_bps = total_weight_bps - old_weight + new_weight, kept <= 10000. + let new_total = strategy + .total_weight_bps + .checked_sub(asset_config.weight_bps) + .ok_or(VaultError::MathOverflow)? + .checked_add(weight_bps) + .ok_or(VaultError::MathOverflow)?; + require!(new_total <= 10_000, VaultError::WeightOverflow); + + asset_config.weight_bps = weight_bps; + strategy.total_weight_bps = new_total; + + 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 30b75642..a15235d4 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 @@ -18,7 +18,7 @@ pub struct WithdrawAccountConstraints<'info> { #[account( mut, has_one = usdc_mint @ VaultError::InvalidUsdcMint, - seeds = [b"strategy", strategy.manager.as_ref()], + seeds = [b"strategy", strategy.index.to_le_bytes().as_ref()], bump = strategy.bump )] pub strategy: Box>, @@ -77,7 +77,7 @@ pub fn handle_withdraw<'info>( let vault_usdc_amount = context.accounts.vault_usdc.amount; let usdc_decimals = context.accounts.usdc_mint.decimals; - let manager_key = context.accounts.strategy.manager; + let strategy_index = context.accounts.strategy.index; let strategy_bump = context.accounts.strategy.bump; let strategy_key = context.accounts.strategy.key(); let user_key = context.accounts.user.key(); @@ -104,7 +104,8 @@ pub fn handle_withdraw<'info>( .checked_sub(shares_to_burn) .ok_or(VaultError::MathOverflow)?; - let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", manager_key.as_ref(), &[strategy_bump]]]; + let index_bytes = strategy_index.to_le_bytes(); + let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", index_bytes.as_ref(), &[strategy_bump]]]; // Hoist owned account-info handles for every CPI up front, so the asset loop // can borrow remaining_accounts without also re-borrowing `context.accounts` diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/lib.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/lib.rs index 21eec1a1..53b11305 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/lib.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/lib.rs @@ -29,14 +29,18 @@ pub mod vault_strategy { instructions::whitelist_asset::handle_whitelist_asset(context, price_feed) } + /// Open a strategy at a caller-chosen index, e.g. index 0 derives the PDA + /// from seeds `"strategy" + 0`. Manager pays and becomes the strategy's manager. pub fn initialize_strategy( context: Context, + index: u64, fee_bps: u16, max_slippage_bps: u16, swap_router: Pubkey, ) -> Result<()> { instructions::initialize_strategy::handle_initialize_strategy( context, + index, fee_bps, max_slippage_bps, swap_router, @@ -48,6 +52,14 @@ pub mod vault_strategy { instructions::add_asset::handle_add_asset(context, weight_bps) } + /// Change an asset's target weight, or set it to zero to retire it. Manager only. + pub fn set_weight( + context: Context, + weight_bps: u16, + ) -> Result<()> { + instructions::set_weight::handle_set_weight(context, weight_bps) + } + pub fn deposit<'info>( context: Context<'info, DepositAccountConstraints<'info>>, usdc_amount: u64, @@ -56,10 +68,6 @@ pub mod vault_strategy { instructions::deposit::handle_deposit(context, usdc_amount, minimum_shares) } - pub fn invest(context: Context, usdc_amount: u64) -> Result<()> { - instructions::invest::handle_invest(context, usdc_amount) - } - pub fn collect_fees(context: Context) -> Result<()> { instructions::collect_fees::handle_collect_fees(context) } 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 ec2db339..62eae6bd 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 @@ -1,14 +1,25 @@ use anchor_lang::prelude::*; /// Largest number of basket assets one strategy can hold. Not a storage limit -/// (each asset is its own account); the cap keeps deposit and withdraw, which -/// must reference every asset at once, within transaction account limits. USDC -/// is the base currency, held separately, and does not count against this. -pub const MAX_ASSETS: u8 = 8; +/// (each asset is its own account); the cap bounds how many accounts deposit and +/// withdraw, which must reference every asset at once, pull into a single +/// instruction: deposit uses 14 + 5*N accounts and withdraw 10 + 4*N, where N is the +/// asset count. At the cap of 16 that is 94 accounts for deposit (74 for withdraw), +/// within Solana's 128-account transaction lock limit but past the 1232-byte legacy +/// transaction size (which fits only ~3 assets), so a client depositing into a large +/// basket must send a v0 transaction with an Address Lookup Table. USDC is the base +/// currency, held separately, and does not count against this. +pub const MAX_ASSETS: u8 = 16; +/// One strategy (basket). Its address is a PDA seeded by a caller-chosen index, +/// e.g. seeds `"strategy" + 0`, so strategies are addressed by a simple counter +/// rather than by the manager's key. The index is stored here so every handler +/// can re-derive the PDA to sign for the vaults and share mint. #[account] #[derive(InitSpace)] pub struct Strategy { + /// Index used as the PDA seed, e.g. 0 for the first strategy. + pub index: u64, pub manager: Pubkey, /// Whitelist this strategy draws assets from. add_asset only accepts mints /// approved in this registry. @@ -19,7 +30,7 @@ pub struct Strategy { /// Annual management fee in basis points (e.g. 100 = 1%). pub fee_bps: u16, /// Maximum tolerated deviation, in basis points, between a swap's output and - /// the Pyth-implied amount on invest/rebalance. Bounded by MAX_SLIPPAGE_BPS. + /// the Pyth-implied amount on deposit/rebalance. Bounded by MAX_SLIPPAGE_BPS. pub max_slippage_bps: u16, pub total_shares: u64, pub last_fee_accrual_timestamp: i64, @@ -46,8 +57,9 @@ pub struct AssetConfig { pub price_feed: Pubkey, /// Strategy-owned associated token account holding this asset. pub vault: Pubkey, - /// Target share of the strategy's value in basis points. Advisory: the - /// manager maintains it with invest/rebalance; no handler enforces it on deposit. + /// Target share of the strategy's value in basis points. deposit deploys at these + /// weights (the sum across assets must reach 10000 before deposits open), and the + /// manager maintains them against price drift with rebalance. pub weight_bps: u16, pub bump: u8, } 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 ef9c9c22..19eb86d1 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 @@ -91,6 +91,7 @@ const NVDA_RATE: u64 = 180; const FEE_BPS: u16 = 100; // 1% const SLIPPAGE_BPS: u16 = 100; // 1% +const STRATEGY_INDEX: u64 = 0; // strategy PDA seed: "strategy" + 0 struct TestContext { svm: LiteSVM, @@ -178,8 +179,10 @@ fn setup_full() -> TestContext { send_transaction_from_instructions(&mut svm, vec![ix], &[&payer], &payer.pubkey()).unwrap(); } - let (strategy_pda, _) = - Pubkey::find_program_address(&[b"strategy", manager.pubkey().as_ref()], &vault_program_id); + let (strategy_pda, _) = Pubkey::find_program_address( + &[b"strategy", STRATEGY_INDEX.to_le_bytes().as_ref()], + &vault_program_id, + ); let (share_mint_pda, _) = Pubkey::find_program_address(&[b"share_mint", strategy_pda.as_ref()], &vault_program_id); let (registry_pda, _) = @@ -332,6 +335,7 @@ fn init_strategy(ctx: &mut TestContext, fee_bps: u16, slippage_bps: u16, router: let ix = Instruction::new_with_bytes( ctx.vault_program_id, &vault_strategy::instruction::InitializeStrategy { + index: STRATEGY_INDEX, fee_bps, max_slippage_bps: slippage_bps, swap_router: router, @@ -403,43 +407,79 @@ fn standard_strategy(ctx: &mut TestContext) { add_asset(ctx, 1, nm, wn, vn, 6000).unwrap(); } -/// remaining_accounts for deposit: [asset_config, vault, price_feed] per asset. -fn deposit_remaining(ctx: &TestContext) -> Vec { +/// One asset's deposit remaining_accounts, in the order the handler reads: +/// [asset_config, vault, mint, rate, price_feed]. Deposit deploys into the asset, +/// so vault and mint must be writable. +fn asset_deposit_metas( + config: Pubkey, + vault: Pubkey, + mint: Pubkey, + rate: Pubkey, + feed: Pubkey, +) -> Vec { vec![ - AccountMeta::new_readonly(ctx.asset_config(0), false), - AccountMeta::new_readonly(ctx.vault_tsla, false), - AccountMeta::new_readonly(ctx.price_feed_tsla, false), - AccountMeta::new_readonly(ctx.asset_config(1), false), - AccountMeta::new_readonly(ctx.vault_nvda, false), - AccountMeta::new_readonly(ctx.price_feed_nvda, false), + AccountMeta::new_readonly(config, false), + AccountMeta::new(vault, false), + AccountMeta::new(mint, false), + AccountMeta::new_readonly(rate, false), + AccountMeta::new_readonly(feed, false), ] } -fn do_deposit( - ctx: &mut TestContext, - user: &Keypair, - usdc_amount: u64, - minimum_shares: u64, -) -> Pubkey { - let user_usdc = derive_ata(&user.pubkey(), &ctx.usdc_mint); - let user_share = derive_ata(&user.pubkey(), &ctx.share_mint_pda); +fn deposit_remaining_tsla(ctx: &TestContext) -> Vec { + asset_deposit_metas( + ctx.asset_config(0), + ctx.vault_tsla, + ctx.tsla_mint, + ctx.tsla_rate_pda, + ctx.price_feed_tsla, + ) +} - let mut metas = vault_strategy::accounts::DepositAccountConstraints { +/// remaining_accounts for a deposit into the two-asset standard strategy. +fn deposit_remaining(ctx: &TestContext) -> Vec { + let mut metas = deposit_remaining_tsla(ctx); + metas.extend(asset_deposit_metas( + ctx.asset_config(1), + ctx.vault_nvda, + ctx.nvda_mint, + ctx.nvda_rate_pda, + ctx.price_feed_nvda, + )); + metas +} + +/// Named accounts for a deposit (everything except per-asset remaining_accounts). +fn deposit_named_metas(ctx: &TestContext, user: &Keypair) -> Vec { + vault_strategy::accounts::DepositAccountConstraints { depositor: user.pubkey(), strategy: ctx.strategy_pda, share_mint: ctx.share_mint_pda, usdc_mint: ctx.usdc_mint, - depositor_usdc_account: user_usdc, - depositor_share_account: user_share, + depositor_usdc_account: derive_ata(&user.pubkey(), &ctx.usdc_mint), + depositor_share_account: derive_ata(&user.pubkey(), &ctx.share_mint_pda), vault_usdc: ctx.vault_usdc, + router_config: ctx.router_config_pda, + router_usdc_treasury: ctx.router_usdc_treasury, + router_authority: ctx.router_authority_pda, + 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); - metas.extend(deposit_remaining(ctx)); + .to_account_metas(None) +} - let ix = Instruction::new_with_bytes( +fn deposit_instruction( + ctx: &TestContext, + user: &Keypair, + usdc_amount: u64, + minimum_shares: u64, + remaining: Vec, +) -> Instruction { + let mut metas = deposit_named_metas(ctx, user); + metas.extend(remaining); + Instruction::new_with_bytes( ctx.vault_program_id, &vault_strategy::instruction::Deposit { usdc_amount, @@ -447,33 +487,167 @@ fn do_deposit( } .data(), metas, - ); + ) +} + +/// Deposit into the two-asset standard strategy, auto-deploying at the target weights. +fn do_deposit( + ctx: &mut TestContext, + user: &Keypair, + usdc_amount: u64, + minimum_shares: u64, +) -> Pubkey { + let remaining = deposit_remaining(ctx); + let ix = deposit_instruction(ctx, user, usdc_amount, minimum_shares, remaining); send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[user], &user.pubkey()).unwrap(); - user_share + derive_ata(&user.pubkey(), &ctx.share_mint_pda) } -fn invest_ix( - ctx: &TestContext, - mint: Pubkey, - config: Pubkey, - feed: Pubkey, - vault: Pubkey, - rate: Pubkey, +/// Deposit into a TSLAx-only strategy. +fn do_deposit_tsla_only( + ctx: &mut TestContext, + user: &Keypair, usdc_amount: u64, -) -> Instruction { - Instruction::new_with_bytes( + minimum_shares: u64, +) -> Pubkey { + let remaining = deposit_remaining_tsla(ctx); + let ix = deposit_instruction(ctx, user, usdc_amount, minimum_shares, remaining); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[user], &user.pubkey()).unwrap(); + derive_ata(&user.pubkey(), &ctx.share_mint_pda) +} + +/// Update the router's exchange rate for a mint (and its Pyth feed stays the caller's +/// job). Used to keep the router quote in step with a price move. +fn set_router_rate(ctx: &mut TestContext, mint: Pubkey, rate: u64, rate_pda: Pubkey) { + let ix = Instruction::new_with_bytes( + ctx.router_program_id, + &mock_swap_router::instruction::SetRate { + mint, + usdc_per_token: rate, + } + .data(), + mock_swap_router::accounts::SetRateAccountConstraints { + authority: ctx.payer.pubkey(), + router_config: ctx.router_config_pda, + asset_mint: mint, + usdc_mint: ctx.usdc_mint, + asset_rate: rate_pda, + router_authority: ctx.router_authority_pda, + router_usdc_treasury: ctx.router_usdc_treasury, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&ctx.payer], &ctx.payer.pubkey()) + .unwrap(); +} + +/// Move NVDAx's price: rewrite its Pyth feed and update the router rate to match. +fn set_nvda_price(ctx: &mut TestContext, price: i64, rate: u64) { + set_price_feed(&mut ctx.svm, ctx.price_feed_nvda, price); + let nvda_mint = ctx.nvda_mint; + let nvda_rate_pda = ctx.nvda_rate_pda; + set_router_rate(ctx, nvda_mint, rate, nvda_rate_pda); +} + +fn set_weight( + ctx: &mut TestContext, + index: u8, + weight_bps: u16, +) -> Result<(), solana_kite::SolanaKiteError> { + let ix = Instruction::new_with_bytes( ctx.vault_program_id, - &vault_strategy::instruction::Invest { usdc_amount }.data(), - vault_strategy::accounts::InvestAccountConstraints { + &vault_strategy::instruction::SetWeight { weight_bps }.data(), + vault_strategy::accounts::SetWeightAccountConstraints { + manager: ctx.manager.pubkey(), + strategy: ctx.strategy_pda, + asset_config: ctx.asset_config(index), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&ctx.manager], + &ctx.manager.pubkey(), + ) +} + +/// init strategy + add only TSLAx at 40%, so total weight is 4000: the strategy is +/// under-allocated and rejects deposits until its weights reach 100%. +fn tsla_only_strategy(ctx: &mut TestContext) { + let router = ctx.router_program_id; + init_strategy(ctx, FEE_BPS, SLIPPAGE_BPS, router); + let (tm, wt, vt) = (ctx.tsla_mint, ctx.whitelist_tsla, ctx.vault_tsla); + add_asset(ctx, 0, tm, wt, vt, 4000).unwrap(); +} + +fn read_strategy(ctx: &TestContext) -> vault_strategy::state::Strategy { + let account = ctx.svm.get_account(&ctx.strategy_pda).unwrap(); + vault_strategy::state::Strategy::try_deserialize(&mut &account.data[..]).unwrap() +} + +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() +} + +/// (mint, asset_config, price_feed, vault, rate_pda) for an asset in the two-asset +/// standard strategy: index 0 is TSLAx, index 1 is NVDAx. +fn asset_accounts(ctx: &TestContext, index: u8) -> (Pubkey, Pubkey, Pubkey, Pubkey, Pubkey) { + match index { + 0 => ( + ctx.tsla_mint, + ctx.asset_config(0), + ctx.price_feed_tsla, + ctx.vault_tsla, + ctx.tsla_rate_pda, + ), + 1 => ( + ctx.nvda_mint, + ctx.asset_config(1), + ctx.price_feed_nvda, + ctx.vault_nvda, + ctx.nvda_rate_pda, + ), + _ => panic!("unknown asset index {index}"), + } +} + +fn do_rebalance( + ctx: &mut TestContext, + sell_index: u8, + buy_index: u8, + sell_amount: u64, + usdc_to_invest: u64, +) { + let (sell_mint, sell_config, sell_feed, vault_sell, sell_rate) = + asset_accounts(ctx, sell_index); + let (buy_mint, buy_config, buy_feed, vault_buy, buy_rate) = asset_accounts(ctx, buy_index); + let ix = 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, - asset_config: config, usdc_mint: ctx.usdc_mint, - asset_mint: mint, - price_feed: feed, + 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, - vault_asset: vault, - asset_rate: rate, + sell_rate, + buy_rate, router_config: ctx.router_config_pda, router_usdc_treasury: ctx.router_usdc_treasury, router_authority: ctx.router_authority_pda, @@ -483,7 +657,47 @@ fn invest_ix( system_program: system_program::id(), } .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&ctx.manager], + &ctx.manager.pubkey(), ) + .unwrap(); +} + +fn advance_one_year(ctx: &mut TestContext) { + let clock = ctx.svm.get_sysvar::(); + ctx.svm.set_sysvar(&Clock { + slot: clock.slot + 1_000_000, + epoch_start_timestamp: clock.epoch_start_timestamp, + epoch: clock.epoch, + leader_schedule_epoch: clock.leader_schedule_epoch, + unix_timestamp: PUBLISH_TIME + SECONDS_PER_YEAR, + }); +} + +fn do_collect_fees(ctx: &mut TestContext) -> Pubkey { + let manager_share = derive_ata(&ctx.manager.pubkey(), &ctx.share_mint_pda); + let ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::CollectFees {}.data(), + vault_strategy::accounts::CollectFeesAccountConstraints { + manager: ctx.manager.pubkey(), + strategy: ctx.strategy_pda, + share_mint: ctx.share_mint_pda, + manager_share_account: manager_share, + payer: ctx.payer.pubkey(), + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&ctx.payer], &ctx.payer.pubkey()) + .unwrap(); + manager_share } fn fund_user(ctx: &mut TestContext, usdc_amount: u64) -> Keypair { @@ -556,6 +770,58 @@ fn test_add_asset_rejects_weight_overflow() { assert!(result.is_err(), "weights over 10000 bps must fail"); } +/// Create a fresh mint and whitelist it in the registry. The bound price feed is an +/// arbitrary pubkey: callers that never value this asset (e.g. the cap boundary test) +/// do not need a real feed account. +fn create_and_whitelist_mint(ctx: &mut TestContext) -> (Pubkey, Pubkey) { + let mint = create_token_mint(&mut ctx.svm, &ctx.payer, TOKEN_DECIMALS, None).unwrap(); + let (entry, _) = Pubkey::find_program_address( + &[b"whitelist", ctx.registry_pda.as_ref(), mint.as_ref()], + &ctx.vault_program_id, + ); + let ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::WhitelistAsset { + price_feed: Keypair::new().pubkey(), + } + .data(), + vault_strategy::accounts::WhitelistAssetAccountConstraints { + authority: ctx.payer.pubkey(), + registry: ctx.registry_pda, + asset_mint: mint, + whitelist_entry: entry, + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&ctx.payer], &ctx.payer.pubkey()) + .unwrap(); + (mint, entry) +} + +#[test] +fn test_add_asset_enforces_max_assets() { + let mut ctx = setup_full(); + let router = ctx.router_program_id; + init_strategy(&mut ctx, FEE_BPS, SLIPPAGE_BPS, router); + + // Fill the basket to the cap: 16 assets at 625 bps each = 10000. + for index in 0..16u8 { + let (mint, entry) = create_and_whitelist_mint(&mut ctx); + let vault = derive_ata(&ctx.strategy_pda, &mint); + add_asset(&mut ctx, index, mint, entry, vault, 625).unwrap(); + } + let strategy = read_strategy(&ctx); + assert_eq!(strategy.asset_count, 16); + assert_eq!(strategy.total_weight_bps, 10_000); + + // The 17th asset must be rejected. Weight 0 so the cap, not the weight sum, trips. + let (mint, entry) = create_and_whitelist_mint(&mut ctx); + let vault = derive_ata(&ctx.strategy_pda, &mint); + let result = add_asset(&mut ctx, 16, mint, entry, vault, 0); + assert!(result.is_err(), "adding beyond MAX_ASSETS must revert"); +} + #[test] fn test_initialize_rejects_excessive_fee() { let mut ctx = setup_full(); @@ -563,6 +829,7 @@ fn test_initialize_rejects_excessive_fee() { let ix = Instruction::new_with_bytes( ctx.vault_program_id, &vault_strategy::instruction::InitializeStrategy { + index: STRATEGY_INDEX, fee_bps: excessive, max_slippage_bps: SLIPPAGE_BPS, swap_router: ctx.router_program_id, @@ -597,6 +864,7 @@ fn test_initialize_rejects_excessive_slippage() { let ix = Instruction::new_with_bytes( ctx.vault_program_id, &vault_strategy::instruction::InitializeStrategy { + index: STRATEGY_INDEX, fee_bps: FEE_BPS, max_slippage_bps: excessive, swap_router: ctx.router_program_id, @@ -636,266 +904,161 @@ fn test_deposit_first() { let user = fund_user(&mut ctx, amount); let user_share = do_deposit(&mut ctx, &user, amount, amount); + // First deposit is 1:1, then deployed at 40/60: 0.4 USDC -> TSLAx, 0.6 -> NVDAx, + // leaving no idle USDC. assert_eq!( get_token_account_balance(&ctx.svm, &user_share).unwrap(), amount ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), - amount + 0 + ); + // 400000 USDC / 250 = 1600 TSLAx; 600000 USDC / 180 = 3333 NVDAx (floor). + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), + 1_600 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), + 3_333 ); } #[test] -fn test_invest() { +fn test_deposit_rejects_underallocated() { let mut ctx = setup_full(); - standard_strategy(&mut ctx); + // TSLAx at 40% only: total weight is 4000, so the strategy is not investable yet. + tsla_only_strategy(&mut ctx); let user = fund_user(&mut ctx, 10_000_000); - do_deposit(&mut ctx, &user, 10_000_000, 1); - - let ix = invest_ix( - &ctx, - ctx.tsla_mint, - ctx.asset_config(0), - ctx.price_feed_tsla, - ctx.vault_tsla, - ctx.tsla_rate_pda, - 4_000_000, + let ix = deposit_instruction(&ctx, &user, 10_000_000, 1, deposit_remaining_tsla(&ctx)); + let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); + assert!( + r.is_err(), + "deposit into an under-allocated strategy must revert" ); - send_transaction_from_instructions( - &mut ctx.svm, - vec![ix], - &[&ctx.manager], - &ctx.manager.pubkey(), - ) - .unwrap(); - // 4 USDC / 250 = 16000 TSLAx + // Bring TSLAx to 100%; the deposit now succeeds and deploys fully into TSLAx. + // Fresh blockhash so the retry is not byte-identical to the reverted attempt. + set_weight(&mut ctx, 0, 10_000).unwrap(); + ctx.svm.expire_blockhash(); + do_deposit_tsla_only(&mut ctx, &user, 10_000_000, 1); + + // 10 USDC / 250 = 40000 TSLAx, with no idle USDC left. assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - 16_000 + 40_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), - 6_000_000 + 0 ); } #[test] -fn test_invest_rejects_slippage() { +fn test_deposit_rejects_slippage() { let mut ctx = setup_full(); standard_strategy(&mut ctx); - let user = fund_user(&mut ctx, 10_000_000); - do_deposit(&mut ctx, &user, 10_000_000, 1); - // Make the router quote far worse than the oracle: rate 300 vs Pyth-implied 250. - let bad_rate_ix = Instruction::new_with_bytes( - ctx.router_program_id, - &mock_swap_router::instruction::SetRate { - mint: ctx.tsla_mint, - usdc_per_token: 300, - } - .data(), - mock_swap_router::accounts::SetRateAccountConstraints { - authority: ctx.payer.pubkey(), - router_config: ctx.router_config_pda, - asset_mint: ctx.tsla_mint, - usdc_mint: ctx.usdc_mint, - asset_rate: ctx.tsla_rate_pda, - router_authority: ctx.router_authority_pda, - router_usdc_treasury: ctx.router_usdc_treasury, - associated_token_program: ata_program_id(), - token_program: token_program_id(), - system_program: system_program::id(), - } - .to_account_metas(None), - ); - send_transaction_from_instructions( - &mut ctx.svm, - vec![bad_rate_ix], - &[&ctx.payer], - &ctx.payer.pubkey(), - ) - .unwrap(); + // Router rate for TSLAx far worse than the oracle: a deposit's TSLAx deploy leg + // must revert, taking the whole deposit with it. + let (tsla_mint, tsla_rate_pda) = (ctx.tsla_mint, ctx.tsla_rate_pda); + set_router_rate(&mut ctx, tsla_mint, 300, tsla_rate_pda); - let ix = invest_ix( - &ctx, - ctx.tsla_mint, - ctx.asset_config(0), - ctx.price_feed_tsla, - ctx.vault_tsla, - ctx.tsla_rate_pda, - 4_000_000, - ); - let r = send_transaction_from_instructions( - &mut ctx.svm, - vec![ix], - &[&ctx.manager], - &ctx.manager.pubkey(), - ); + let user = fund_user(&mut ctx, 10_000_000); + let ix = deposit_instruction(&ctx, &user, 10_000_000, 1, deposit_remaining(&ctx)); + let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); assert!( r.is_err(), - "swap worse than oracle beyond tolerance must revert" + "deposit deploy leg worse than oracle must revert the deposit" ); } #[test] -fn test_invest_rejects_unregistered_router() { +fn test_deposit_rejects_unregistered_router() { let mut ctx = setup_full(); - // Register a different router than the deployed mock. + // Register a different router than the deployed mock, then fully allocate 40/60. let bogus_router = Pubkey::new_unique(); init_strategy(&mut ctx, FEE_BPS, SLIPPAGE_BPS, bogus_router); let (tm, wt, vt) = (ctx.tsla_mint, ctx.whitelist_tsla, ctx.vault_tsla); add_asset(&mut ctx, 0, tm, wt, vt, 4000).unwrap(); + let (nm, wn, vn) = (ctx.nvda_mint, ctx.whitelist_nvda, ctx.vault_nvda); + add_asset(&mut ctx, 1, nm, wn, vn, 6000).unwrap(); - let ix = invest_ix( - &ctx, - ctx.tsla_mint, - ctx.asset_config(0), - ctx.price_feed_tsla, - ctx.vault_tsla, - ctx.tsla_rate_pda, - 1_000_000, - ); - let r = send_transaction_from_instructions( - &mut ctx.svm, - vec![ix], - &[&ctx.manager], - &ctx.manager.pubkey(), - ); + let user = fund_user(&mut ctx, 10_000_000); + let ix = deposit_instruction(&ctx, &user, 10_000_000, 1, deposit_remaining(&ctx)); + let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); assert!( r.is_err(), - "invest through an unregistered router must fail" + "deposit deploying through an unregistered router must fail" ); } #[test] -fn test_deposit_after_invest() { +fn test_deposit_fair_pricing() { let mut ctx = setup_full(); standard_strategy(&mut ctx); - // Alice deposits 10 USDC (1:1 -> 10,000,000 shares). - let alice = fund_user(&mut ctx, 10_000_000); - do_deposit(&mut ctx, &alice, 10_000_000, 1); - - // Manager invests 4 USDC into TSLAx. - let ix = invest_ix( - &ctx, - ctx.tsla_mint, - ctx.asset_config(0), - ctx.price_feed_tsla, - ctx.vault_tsla, - ctx.tsla_rate_pda, - 4_000_000, + // Alice deposits 900 USDC (first deposit 1:1 -> 900,000,000 shares), auto-deployed + // 40/60: 1.44 TSLAx + 3.0 NVDAx. NAV = 900 USDC. + let alice = fund_user(&mut ctx, 900_000_000); + let alice_share = do_deposit(&mut ctx, &alice, 900_000_000, 1); + assert_eq!( + get_token_account_balance(&ctx.svm, &alice_share).unwrap(), + 900_000_000 ); - send_transaction_from_instructions( - &mut ctx.svm, - vec![ix], - &[&ctx.manager], - &ctx.manager.pubkey(), - ) - .unwrap(); - // NAV unchanged at 10 USDC (6 USDC + 16000 TSLAx * $250 = 6 + 4). Bob deposits 5 USDC -> 5,000,000 shares. - let bob = fund_user(&mut ctx, 5_000_000); - let bob_share = do_deposit(&mut ctx, &bob, 5_000_000, 1); + // NVDAx rises 180 -> 200. NAV rises to 0 + 1.44*250 + 3.0*200 = 960 USDC. + set_nvda_price(&mut ctx, 20_000_000_000, 200); + + // Bob deposits 480 USDC at the higher NAV: shares = 480 * 900 / 960 = 450,000,000. + // He pays today's price, so he does not dilute Alice's gain. + let bob = fund_user(&mut ctx, 480_000_000); + let bob_share = do_deposit(&mut ctx, &bob, 480_000_000, 1); assert_eq!( get_token_account_balance(&ctx.svm, &bob_share).unwrap(), - 5_000_000 + 450_000_000 + ); + + // Alice's shares are untouched; supply is the two deposits combined. + assert_eq!( + get_token_account_balance(&ctx.svm, &alice_share).unwrap(), + 900_000_000 ); + let strategy = read_strategy(&ctx); + assert_eq!(strategy.total_shares, 1_350_000_000); } #[test] fn test_rebalance() { let mut ctx = setup_full(); standard_strategy(&mut ctx); - let user = fund_user(&mut ctx, 100_000_000); - do_deposit(&mut ctx, &user, 100_000_000, 1); - // Invest 40 USDC -> TSLAx (160000), 30 USDC -> NVDAx (166666). - let i1 = invest_ix( - &ctx, - ctx.tsla_mint, - ctx.asset_config(0), - ctx.price_feed_tsla, - ctx.vault_tsla, - ctx.tsla_rate_pda, - 40_000_000, - ); - send_transaction_from_instructions( - &mut ctx.svm, - vec![i1], - &[&ctx.manager], - &ctx.manager.pubkey(), - ) - .unwrap(); - let i2 = invest_ix( - &ctx, - ctx.nvda_mint, - ctx.asset_config(1), - ctx.price_feed_nvda, - ctx.vault_nvda, - ctx.nvda_rate_pda, - 30_000_000, - ); - send_transaction_from_instructions( - &mut ctx.svm, - vec![i2], - &[&ctx.manager], - &ctx.manager.pubkey(), - ) - .unwrap(); + // Alice deposits 900 USDC, auto-deployed to 1.44 TSLAx + 3.0 NVDAx (exactly 40/60). + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); - let tsla_before = get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(); - let nvda_before = get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(); + // NVDAx rises 180 -> 200, pushing the basket to 37.5 / 62.5 by value. + set_nvda_price(&mut ctx, 20_000_000_000, 200); - // Sell 100000 TSLAx -> 25 USDC, buy NVDAx with 25 USDC -> 138888. - let ix = Instruction::new_with_bytes( - ctx.vault_program_id, - &vault_strategy::instruction::Rebalance { - sell_amount: 100_000, - usdc_to_invest: 25_000_000, - } - .data(), - vault_strategy::accounts::RebalanceAccountConstraints { - manager: ctx.manager.pubkey(), - strategy: ctx.strategy_pda, - usdc_mint: ctx.usdc_mint, - sell_mint: ctx.tsla_mint, - buy_mint: ctx.nvda_mint, - sell_config: ctx.asset_config(0), - buy_config: ctx.asset_config(1), - sell_price_feed: ctx.price_feed_tsla, - buy_price_feed: ctx.price_feed_nvda, - vault_sell: ctx.vault_tsla, - vault_buy: ctx.vault_nvda, - vault_usdc: ctx.vault_usdc, - sell_rate: ctx.tsla_rate_pda, - buy_rate: ctx.nvda_rate_pda, - router_config: ctx.router_config_pda, - router_usdc_treasury: ctx.router_usdc_treasury, - router_authority: ctx.router_authority_pda, - 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), - ); - send_transaction_from_instructions( - &mut ctx.svm, - vec![ix], - &[&ctx.manager], - &ctx.manager.pubkey(), - ) - .unwrap(); + // Rebalance back toward 40/60: sell 0.12 NVDAx for 24 USDC, buy 0.096 TSLAx with it. + do_rebalance(&mut ctx, 1, 0, 120_000, 24_000_000); + // 1.44 + 0.096 = 1.536 TSLAx; 3.0 - 0.12 = 2.88 NVDAx. Now 384 / 576 = 40 / 60. + // The USDC vault nets to zero across the two legs. assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - tsla_before - 100_000 + 1_536_000 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), + 2_880_000 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), + 0 ); - assert!(get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap() > nvda_before); } #[test] @@ -906,34 +1069,8 @@ fn test_collect_fees() { let user = fund_user(&mut ctx, 1_000_000_000); // 1000 USDC do_deposit(&mut ctx, &user, 1_000_000_000, 1); - // Advance a full year. - let clock = ctx.svm.get_sysvar::(); - ctx.svm.set_sysvar(&Clock { - slot: clock.slot + 1_000_000, - epoch_start_timestamp: clock.epoch_start_timestamp, - epoch: clock.epoch, - leader_schedule_epoch: clock.leader_schedule_epoch, - unix_timestamp: PUBLISH_TIME + SECONDS_PER_YEAR, - }); - - let manager_share = derive_ata(&ctx.manager.pubkey(), &ctx.share_mint_pda); - let ix = Instruction::new_with_bytes( - ctx.vault_program_id, - &vault_strategy::instruction::CollectFees {}.data(), - vault_strategy::accounts::CollectFeesAccountConstraints { - manager: ctx.manager.pubkey(), - strategy: ctx.strategy_pda, - share_mint: ctx.share_mint_pda, - manager_share_account: manager_share, - payer: ctx.payer.pubkey(), - associated_token_program: ata_program_id(), - token_program: token_program_id(), - system_program: system_program::id(), - } - .to_account_metas(None), - ); - send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&ctx.payer], &ctx.payer.pubkey()) - .unwrap(); + advance_one_year(&mut ctx); + let manager_share = do_collect_fees(&mut ctx); // 1% of 1,000,000,000 = 10,000,000 fee shares. assert_eq!( @@ -961,27 +1098,10 @@ fn test_withdraw() { standard_strategy(&mut ctx); let user = fund_user(&mut ctx, 10_000_000); + // Deposit auto-deploys 4 USDC -> 16000 TSLAx and 6 USDC -> 33333 NVDAx, no idle USDC. let user_share = do_deposit(&mut ctx, &user, 10_000_000, 1); let shares = get_token_account_balance(&ctx.svm, &user_share).unwrap(); - // Manager invests 4 USDC into TSLAx so the vault holds a mix. - let ix = invest_ix( - &ctx, - ctx.tsla_mint, - ctx.asset_config(0), - ctx.price_feed_tsla, - ctx.vault_tsla, - ctx.tsla_rate_pda, - 4_000_000, - ); - send_transaction_from_instructions( - &mut ctx.svm, - vec![ix], - &[&ctx.manager], - &ctx.manager.pubkey(), - ) - .unwrap(); - // User needs token accounts for each asset paid in kind. let user_usdc = derive_ata(&user.pubkey(), &ctx.usdc_mint); create_associated_token_account(&mut ctx.svm, &user.pubkey(), &ctx.tsla_mint, &ctx.payer) @@ -1015,15 +1135,16 @@ fn test_withdraw() { ); send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()).unwrap(); - // Sole holder withdraws everything: 6 USDC + all 16000 TSLAx back. - assert_eq!( - get_token_account_balance(&ctx.svm, &user_usdc).unwrap(), - 6_000_000 - ); + // Sole holder withdraws everything in kind: all 16000 TSLAx + 33333 NVDAx, no USDC. + assert_eq!(get_token_account_balance(&ctx.svm, &user_usdc).unwrap(), 0); assert_eq!( get_token_account_balance(&ctx.svm, &derive_ata(&user.pubkey(), &ctx.tsla_mint)).unwrap(), 16_000 ); + assert_eq!( + get_token_account_balance(&ctx.svm, &derive_ata(&user.pubkey(), &ctx.nvda_mint)).unwrap(), + 33_333 + ); } #[test] @@ -1060,7 +1181,9 @@ fn test_withdraw_rejects_slippage() { ctx.vault_program_id, &vault_strategy::instruction::Withdraw { shares_to_burn: shares, - min_usdc_out: 10_000_001, // more than available + // The deposit was fully deployed, so the USDC payout is 0; demanding any + // USDC back must revert. + min_usdc_out: 1, } .data(), metas, @@ -1076,36 +1199,198 @@ fn test_deposit_rejects_incomplete_assets() { let amount = 1_000_000u64; let user = fund_user(&mut ctx, amount); + + // Only one asset's accounts supplied (5) for a two-asset strategy (needs 10). + let ix = deposit_instruction(&ctx, &user, amount, 1, deposit_remaining_tsla(&ctx)); + let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); + assert!(r.is_err(), "incomplete asset accounts must revert"); +} + +fn do_withdraw(ctx: &mut TestContext, user: &Keypair, shares: u64, min_usdc_out: u64) { + create_associated_token_account(&mut ctx.svm, &user.pubkey(), &ctx.tsla_mint, &ctx.payer) + .unwrap(); + create_associated_token_account(&mut ctx.svm, &user.pubkey(), &ctx.nvda_mint, &ctx.payer) + .unwrap(); let user_usdc = derive_ata(&user.pubkey(), &ctx.usdc_mint); let user_share = derive_ata(&user.pubkey(), &ctx.share_mint_pda); - - // Only one asset's accounts supplied (3) for a two-asset strategy (needs 6). - let mut metas = vault_strategy::accounts::DepositAccountConstraints { - depositor: user.pubkey(), + let mut metas = vault_strategy::accounts::WithdrawAccountConstraints { + user: user.pubkey(), strategy: ctx.strategy_pda, share_mint: ctx.share_mint_pda, usdc_mint: ctx.usdc_mint, - depositor_usdc_account: user_usdc, - depositor_share_account: user_share, + user_share_account: user_share, + user_usdc_account: user_usdc, vault_usdc: ctx.vault_usdc, associated_token_program: ata_program_id(), token_program: token_program_id(), system_program: system_program::id(), } .to_account_metas(None); - metas.push(AccountMeta::new_readonly(ctx.asset_config(0), false)); - metas.push(AccountMeta::new_readonly(ctx.vault_tsla, false)); - metas.push(AccountMeta::new_readonly(ctx.price_feed_tsla, false)); - + metas.extend(withdraw_remaining(ctx, &user.pubkey())); let ix = Instruction::new_with_bytes( ctx.vault_program_id, - &vault_strategy::instruction::Deposit { - usdc_amount: amount, - minimum_shares: 1, + &vault_strategy::instruction::Withdraw { + shares_to_burn: shares, + min_usdc_out, } .data(), metas, ); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[user], &user.pubkey()).unwrap(); +} + +#[test] +fn test_set_weight_retire() { + let mut ctx = setup_full(); + standard_strategy(&mut ctx); + + // Retire NVDAx by setting its target weight to zero. Total drops to 4000, so the + // strategy is under-allocated and stops accepting deposits. + set_weight(&mut ctx, 1, 0).unwrap(); + let strategy = read_strategy(&ctx); + assert_eq!(strategy.total_weight_bps, 4000); + assert_eq!(read_asset_config(&ctx, 1).weight_bps, 0); + + let user = fund_user(&mut ctx, 100_000_000); + let ix = deposit_instruction(&ctx, &user, 100_000_000, 1, deposit_remaining(&ctx)); let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); - assert!(r.is_err(), "incomplete asset accounts must revert"); + assert!( + r.is_err(), + "an under-allocated (retired) strategy must reject deposits" + ); + + // Reassign the freed weight to TSLAx (back to 100%); deposits reopen and now deploy + // entirely into TSLAx, never touching the retired NVDAx vault. Fresh blockhash so + // the retry is not byte-identical to the reverted attempt. + set_weight(&mut ctx, 0, 10_000).unwrap(); + ctx.svm.expire_blockhash(); + do_deposit(&mut ctx, &user, 100_000_000, 1); + // 100 USDC / 250 = 400000 TSLAx, nothing to NVDAx, no idle USDC. + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), + 400_000 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), + 0 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), + 0 + ); +} + +#[test] +fn test_set_weight_rejects_overflow() { + let mut ctx = setup_full(); + standard_strategy(&mut ctx); + // TSLAx 4000 + NVDAx 6000 = 10000. Raising TSLAx to 6000 would total 12000. + let r = set_weight(&mut ctx, 0, 6000); + assert!( + r.is_err(), + "weight change pushing total over 10000 must revert" + ); +} + +#[test] +fn test_set_weight_rejects_non_manager() { + let mut ctx = setup_full(); + standard_strategy(&mut ctx); + + let intruder = create_wallet(&mut ctx.svm, 10_000_000_000).unwrap(); + let ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::SetWeight { weight_bps: 0 }.data(), + vault_strategy::accounts::SetWeightAccountConstraints { + manager: intruder.pubkey(), + strategy: ctx.strategy_pda, + asset_config: ctx.asset_config(1), + } + .to_account_metas(None), + ); + let r = send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&intruder], + &intruder.pubkey(), + ); + assert!(r.is_err(), "only the manager may set weights"); +} + +/// The whole lifecycle with the exact figures the video script narrates: deposit and +/// auto-deploy, a price move, a rebalance back to target, a second depositor priced at +/// the new NAV, a year's fee, and an in-kind withdrawal. +#[test] +fn test_full_lifecycle() { + let mut ctx = setup_full(); + standard_strategy(&mut ctx); + + // Alice deposits 900 USDC -> 900,000,000 shares, deployed to 1.44 TSLAx + 3.0 NVDAx. + let alice = fund_user(&mut ctx, 900_000_000); + let alice_share = do_deposit(&mut ctx, &alice, 900_000_000, 1); + assert_eq!( + get_token_account_balance(&ctx.svm, &alice_share).unwrap(), + 900_000_000 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), + 1_440_000 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), + 3_000_000 + ); + + // 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_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), + 1_536_000 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), + 2_880_000 + ); + + // Bob deposits 480 USDC at NAV 960 -> 450,000,000 shares, deployed 40/60. + let bob = fund_user(&mut ctx, 480_000_000); + let bob_share = do_deposit(&mut ctx, &bob, 480_000_000, 1); + assert_eq!( + get_token_account_balance(&ctx.svm, &bob_share).unwrap(), + 450_000_000 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), + 2_304_000 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(), + 4_320_000 + ); + + // 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); + assert_eq!( + get_token_account_balance(&ctx.svm, &manager_share).unwrap(), + 13_500_000 + ); + assert_eq!(read_strategy(&ctx).total_shares, 1_363_500_000); + + // Alice withdraws all 900,000,000 shares in kind: her 900/1363.5 slice of each vault. + do_withdraw(&mut ctx, &alice, 900_000_000, 0); + assert_eq!( + get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.tsla_mint)).unwrap(), + 1_520_792 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.nvda_mint)).unwrap(), + 2_851_485 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &derive_ata(&alice.pubkey(), &ctx.usdc_mint)).unwrap(), + 0 + ); + assert_eq!(read_strategy(&ctx).total_shares, 463_500_000); }