From a7dc39368e59ea2a41831421345ebf502e9ec86f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 18:34:23 +0000 Subject: [PATCH 1/3] Address vault Strategy PDA by index instead of manager key The Strategy PDA was derived from seeds "strategy" + manager pubkey. Switch to a simpler caller-chosen index, e.g. "strategy" + 0, so strategies are addressed by a counter rather than the manager's key. - Add `index: u64` to the Strategy account, set at creation and used to re-derive the PDA wherever it signs for the vaults and share mint. - `initialize_strategy` now takes an `index` argument used in the PDA seeds. - deposit, invest, rebalance, collect_fees, withdraw, add_asset re-derive the strategy PDA from the stored index; the manager is still recorded as a field and keeps its manager-only powers, it is just no longer part of the address. - Update tests, README, and the video script to match. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RajngzX57RGaQx5sKPbysZ --- finance/vault-strategy/VIDEO_SCRIPT.md | 10 +++++----- finance/vault-strategy/anchor/README.md | 2 +- .../vault-strategy/src/instructions/add_asset.rs | 2 +- .../vault-strategy/src/instructions/collect_fees.rs | 7 ++++--- .../vault-strategy/src/instructions/deposit.rs | 7 ++++--- .../src/instructions/initialize_strategy.rs | 5 ++++- .../programs/vault-strategy/src/instructions/invest.rs | 7 ++++--- .../vault-strategy/src/instructions/rebalance.rs | 7 ++++--- .../vault-strategy/src/instructions/withdraw.rs | 7 ++++--- .../anchor/programs/vault-strategy/src/lib.rs | 4 ++++ .../programs/vault-strategy/src/state/strategy.rs | 6 ++++++ .../programs/vault-strategy/tests/vault_strategy.rs | 8 ++++++-- 12 files changed, 47 insertions(+), 25 deletions(-) diff --git a/finance/vault-strategy/VIDEO_SCRIPT.md b/finance/vault-strategy/VIDEO_SCRIPT.md index c998b671..8c66bcbc 100644 --- a/finance/vault-strategy/VIDEO_SCRIPT.md +++ b/finance/vault-strategy/VIDEO_SCRIPT.md @@ -29,7 +29,7 @@ Custody is the whole game, so let us name the boxes before we move money. First, the word vault, because it gets overloaded. By the common standard a vault holds a single asset: you put one kind of token in, you get shares out. A managed mix of several assets is not one vault; it lives in several vaults, one per asset, and is usually called a basket or a fund. Symmetry calls its multi-asset products baskets. We will keep it simple: a vault is one single-asset token account, and the strategy is the whole construct that owns them. So vault strategy reads literally, a strategy built from vaults. -The center of everything is the `Strategy` account, whose address is a PDA derived from the seeds `"strategy"` plus Maria's public key. A PDA is an address with no private key: it is found deliberately off the signing curve, so no key can sign for it and only the program can, by supplying the seeds. The strategy PDA is the authority over the USDC vault, every asset vault, and the share mint. Each vault is an associated token account owned by the strategy PDA and holds exactly one asset. The share mint's address is also a PDA, seeds `"share_mint"` plus the strategy address, so it is deterministic, one share mint per strategy, with the strategy PDA as its mint authority. +The center of everything is the `Strategy` account, whose address is a PDA derived from the seeds `"strategy"` plus an index: zero for the first strategy, one for the next, and so on. A PDA is an address with no private key: it is found deliberately off the signing curve, so no key can sign for it and only the program can, by supplying the seeds. The strategy PDA is the authority over the USDC vault, every asset vault, and the share mint. Each vault is an associated token account owned by the strategy PDA and holds exactly one asset. The share mint's address is also a PDA, seeds `"share_mint"` plus the strategy address, so it is deterministic, one share mint per strategy, with the strategy PDA as its mint authority. The asset set is not fixed. Each asset the strategy holds gets its own small account, an `AssetConfig`, whose address is a PDA seeded by the strategy and an index: zero, one, two, and so on. That indexing matters later: the assets are exactly the range zero up to the count, so any handler that values the whole strategy can re-derive every one and refuse to run if a single asset account is missing. @@ -39,7 +39,7 @@ ON SCREEN: ``` Registry [off curve - PDA, seeds: "registry" + authority] owner = curator, not a manager -Strategy [off curve - PDA, seeds: "strategy" + manager] +Strategy [off curve - PDA, seeds: "strategy" + index] manager stored as a field, not a seed authority over: vault_usdc, every asset vault, share_mint share_mint [off curve - PDA, seeds: "share_mint" + strategy] authority = Strategy PDA AssetConfig #i [off curve - PDA, seeds: "asset" + strategy + index] one per asset @@ -72,15 +72,15 @@ Fee generated: none NARRATION: -Maria is our portfolio manager, and she wants to run the basket and earn the fee. She calls `initialize_strategy`, binding her strategy to Victor's registry. She sets two numbers and no assets yet: a fee of one hundred basis points, which is one percent a year, and a maximum slippage of one hundred basis points, which we will use when she trades. Both are capped in code, the fee at ten percent and the slippage tolerance at ten percent, and both are fixed here at creation with no setter to change them later. +Maria is our portfolio manager, and she wants to run the basket and earn the fee. She calls `initialize_strategy`, picking index zero, which fixes her strategy's address at the seeds `"strategy"` plus zero, and binding it to Victor's registry. Her own key is still recorded on the strategy as the manager, the account that holds her management powers; it is just no longer part of the address. She sets two numbers and no assets yet: a fee of one hundred basis points, which is one percent a year, and a maximum slippage of one hundred basis points, which we will use when she trades. Both are capped in code, the fee at ten percent and the slippage tolerance at ten percent, and both are fixed here at creation with no setter to change them later. The fee cap exists because the fee is paid by minting new shares to the manager; an uncapped fee would let a manager dilute depositors to nothing by configuration alone. ON SCREEN: ``` -ADDED - Strategy [off curve - PDA] - manager: Maria registry: Victor's registry +ADDED - Strategy [off curve - PDA, seeds: "strategy" + 0] + index: 0 manager: Maria registry: Victor's registry fee_bps: 100 max_slippage_bps: 100 total_shares: 0 asset_count: 0 total_weight_bps: 0 last_fee_accrual_timestamp: now diff --git a/finance/vault-strategy/anchor/README.md b/finance/vault-strategy/anchor/README.md index a15c6db3..84282784 100644 --- a/finance/vault-strategy/anchor/README.md +++ b/finance/vault-strategy/anchor/README.md @@ -78,7 +78,7 @@ An [in-kind distribution](https://www.investopedia.com/terms/i/in-kind.asp) retu ### 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. +`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 3 - Maria adds assets 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..4e38660d 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 @@ -18,7 +18,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>, @@ -74,7 +74,7 @@ pub fn handle_deposit<'info>( 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 asset_count = context.accounts.strategy.asset_count as usize; @@ -146,7 +146,8 @@ 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]]]; 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..c7353b3b 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 @@ -20,6 +20,7 @@ pub const MAX_FEE_BPS: u16 = 1_000; 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 index cab4add6..a24c89de 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/invest.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/invest.rs @@ -20,7 +20,7 @@ pub struct InvestAccountConstraints<'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>, @@ -86,7 +86,7 @@ pub struct InvestAccountConstraints<'info> { pub fn handle_invest(context: Context, usdc_amount: u64) -> Result<()> { let strategy = &context.accounts.strategy; - let manager_key = strategy.manager; + let strategy_index = strategy.index; let strategy_bump = strategy.bump; let max_slippage_bps = strategy.max_slippage_bps; @@ -113,7 +113,8 @@ pub fn handle_invest(context: Context, usdc_amount: u6 .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]]]; let cpi_accounts = RouterSwapAccounts { caller: context.accounts.strategy.to_account_info(), 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/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..e36af24b 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, 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..12be8de3 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 @@ -6,9 +6,15 @@ use anchor_lang::prelude::*; /// is the base currency, held separately, and does not count against this. pub const MAX_ASSETS: u8 = 8; +/// 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. 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..b798080b 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, From fe8def44415d48d3ce6492313b40e6034cb00f6d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 18:49:31 +0000 Subject: [PATCH 2/3] Drop video script changes from this PR Keep VIDEO_SCRIPT.md as-is on this branch; the index-seed program change stands on its own. The updated script is delivered separately. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RajngzX57RGaQx5sKPbysZ --- finance/vault-strategy/VIDEO_SCRIPT.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/finance/vault-strategy/VIDEO_SCRIPT.md b/finance/vault-strategy/VIDEO_SCRIPT.md index 8c66bcbc..c998b671 100644 --- a/finance/vault-strategy/VIDEO_SCRIPT.md +++ b/finance/vault-strategy/VIDEO_SCRIPT.md @@ -29,7 +29,7 @@ Custody is the whole game, so let us name the boxes before we move money. First, the word vault, because it gets overloaded. By the common standard a vault holds a single asset: you put one kind of token in, you get shares out. A managed mix of several assets is not one vault; it lives in several vaults, one per asset, and is usually called a basket or a fund. Symmetry calls its multi-asset products baskets. We will keep it simple: a vault is one single-asset token account, and the strategy is the whole construct that owns them. So vault strategy reads literally, a strategy built from vaults. -The center of everything is the `Strategy` account, whose address is a PDA derived from the seeds `"strategy"` plus an index: zero for the first strategy, one for the next, and so on. A PDA is an address with no private key: it is found deliberately off the signing curve, so no key can sign for it and only the program can, by supplying the seeds. The strategy PDA is the authority over the USDC vault, every asset vault, and the share mint. Each vault is an associated token account owned by the strategy PDA and holds exactly one asset. The share mint's address is also a PDA, seeds `"share_mint"` plus the strategy address, so it is deterministic, one share mint per strategy, with the strategy PDA as its mint authority. +The center of everything is the `Strategy` account, whose address is a PDA derived from the seeds `"strategy"` plus Maria's public key. A PDA is an address with no private key: it is found deliberately off the signing curve, so no key can sign for it and only the program can, by supplying the seeds. The strategy PDA is the authority over the USDC vault, every asset vault, and the share mint. Each vault is an associated token account owned by the strategy PDA and holds exactly one asset. The share mint's address is also a PDA, seeds `"share_mint"` plus the strategy address, so it is deterministic, one share mint per strategy, with the strategy PDA as its mint authority. The asset set is not fixed. Each asset the strategy holds gets its own small account, an `AssetConfig`, whose address is a PDA seeded by the strategy and an index: zero, one, two, and so on. That indexing matters later: the assets are exactly the range zero up to the count, so any handler that values the whole strategy can re-derive every one and refuse to run if a single asset account is missing. @@ -39,7 +39,7 @@ ON SCREEN: ``` Registry [off curve - PDA, seeds: "registry" + authority] owner = curator, not a manager -Strategy [off curve - PDA, seeds: "strategy" + index] manager stored as a field, not a seed +Strategy [off curve - PDA, seeds: "strategy" + manager] authority over: vault_usdc, every asset vault, share_mint share_mint [off curve - PDA, seeds: "share_mint" + strategy] authority = Strategy PDA AssetConfig #i [off curve - PDA, seeds: "asset" + strategy + index] one per asset @@ -72,15 +72,15 @@ Fee generated: none NARRATION: -Maria is our portfolio manager, and she wants to run the basket and earn the fee. She calls `initialize_strategy`, picking index zero, which fixes her strategy's address at the seeds `"strategy"` plus zero, and binding it to Victor's registry. Her own key is still recorded on the strategy as the manager, the account that holds her management powers; it is just no longer part of the address. She sets two numbers and no assets yet: a fee of one hundred basis points, which is one percent a year, and a maximum slippage of one hundred basis points, which we will use when she trades. Both are capped in code, the fee at ten percent and the slippage tolerance at ten percent, and both are fixed here at creation with no setter to change them later. +Maria is our portfolio manager, and she wants to run the basket and earn the fee. She calls `initialize_strategy`, binding her strategy to Victor's registry. She sets two numbers and no assets yet: a fee of one hundred basis points, which is one percent a year, and a maximum slippage of one hundred basis points, which we will use when she trades. Both are capped in code, the fee at ten percent and the slippage tolerance at ten percent, and both are fixed here at creation with no setter to change them later. The fee cap exists because the fee is paid by minting new shares to the manager; an uncapped fee would let a manager dilute depositors to nothing by configuration alone. ON SCREEN: ``` -ADDED - Strategy [off curve - PDA, seeds: "strategy" + 0] - index: 0 manager: Maria registry: Victor's registry +ADDED - Strategy [off curve - PDA] + manager: Maria registry: Victor's registry fee_bps: 100 max_slippage_bps: 100 total_shares: 0 asset_count: 0 total_weight_bps: 0 last_fee_accrual_timestamp: now From 1397994cce561080b1ee8375480187ad1dad1520 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 21:38:37 +0000 Subject: [PATCH 3/3] vault-strategy: deploy deposits at target weights, add set_weight deposit now invests each depositor's USDC into the basket at its target weights in the same transaction, routing each weight-sized slice through the registered swap router with the same oracle-computed slippage floor invest uses. The USDC vault no longer holds idle deposits; only the unallocated remainder (when the weights sum below 10000) stays as USDC. Its remaining_accounts are now [asset_config, vault, mint, rate, price_feed] per asset, plus the router accounts. Add set_weight, a manager-only instruction that changes an asset's target weight, including setting it to zero to retire it. The asset's index is preserved so the contiguous 0..asset_count range the valuation handlers depend on stays intact; the manager sells the retired asset's holdings out with rebalance. Rework the tests around the new behavior: a full-lifecycle test (deposit and auto-deploy, a price move, a rebalance back to target, a second depositor priced at the new NAV, a year's fee, in-kind withdrawal), focused per-handler tests, set_weight retire and rejection paths, and oracle-bounded slippage on both deposit and invest. Also fixes two InitializeStrategy constructions left without the index field. 20 tests pass under cargo build-sbf + cargo test. Update README and CHANGELOG to match. --- finance/vault-strategy/anchor/CHANGELOG.md | 9 +- finance/vault-strategy/anchor/README.md | 50 +- .../src/instructions/deposit.rs | 135 +++- .../vault-strategy/src/instructions/mod.rs | 2 + .../src/instructions/set_weight.rs | 50 ++ .../anchor/programs/vault-strategy/src/lib.rs | 8 + .../vault-strategy/tests/vault_strategy.rs | 754 ++++++++++++------ 7 files changed, 717 insertions(+), 291 deletions(-) create mode 100644 finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/set_weight.rs diff --git a/finance/vault-strategy/anchor/CHANGELOG.md b/finance/vault-strategy/anchor/CHANGELOG.md index 46cfa360..1228d4bd 100644 --- a/finance/vault-strategy/anchor/CHANGELOG.md +++ b/finance/vault-strategy/anchor/CHANGELOG.md @@ -6,12 +6,15 @@ - **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`, `invest`, 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`. +- **Immediate deployment on deposit.** `deposit` swaps each depositor's USDC into the basket at its target weights through the registered router in the same transaction. The USDC vault no longer holds idle deposits; only the unallocated remainder (when the weights sum below 10,000) stays as USDC. +- **Retirable assets.** `set_weight(weight_bps)` changes an asset's target weight after creation, including setting it to zero to retire it. 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. +- `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, 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. - `invest` takes `(usdc_amount)` and `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 84282784..644c4b5c 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). @@ -47,11 +47,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); the running sum is kept at or below 10,000. The weights are enforced where it matters most: `deposit` deploys each depositor's USDC straight into the basket at those weights, so money is never left sitting idle by default. When the weights sum below 10,000 (for example after an asset is retired), the unallocated slice stays in the USDC vault for the manager to place with `invest`. + +[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`, `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%). ### In-Kind Withdrawal @@ -63,12 +65,10 @@ 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. @@ -84,21 +84,21 @@ An [in-kind distribution](https://www.investopedia.com/terms/i/in-kind.asp) retu `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. -### Step 4 - Alice deposits +### Step 4 - Alice deposits, and her money is deployed at once -`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. +`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 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 carrying the same oracle slippage floor `invest` uses. With the weights at 40/60, a 900 USDC deposit lands as 1.44 TSLAx and 3.0 NVDAx with no idle USDC. -### Step 5 - Maria invests +### Step 5 - Maria invests idle USDC (when there is any) -`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. +`invest(usdc_amount)` for one registered asset, passing its `asset_config` and `price_feed`. Because `deposit` already deploys at the target weights, the USDC vault is normally empty; `invest` is the tool for the leftover cases, such as the unallocated remainder when the weights sum below 10,000, or the proceeds of a retired asset. The handler reads the Pyth price, computes the minimum acceptable output, and CPIs the router; a fill worse than the bound reverts. ### Step 6 - 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 step 4. 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 -`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. ### Step 8 - Fees accrue @@ -110,22 +110,6 @@ Same as step 4. Because shares are priced at NAV, Bob pays the current per-share --- -## 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 +118,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`, `invest`, 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 +155,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) plus focused tests for each handler, retiring an asset with `set_weight`, and the rejection paths: non-whitelisted asset, weight overflow, over-cap fee and slippage, oracle-bounded slippage on both deposit and invest, non-manager `set_weight`, unregistered router, and incomplete asset accounts on deposit. 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 4e38660d..0457eab3 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)] @@ -57,13 +58,36 @@ 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 USDC does not sit idle: +/// 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. Any unallocated remainder (when the weights sum below 10000, +/// e.g. after an asset is retired) stays in the USDC vault for the manager to deploy. pub fn handle_deposit<'info>( context: Context<'info, DepositAccountConstraints<'info>>, usdc_amount: u64, @@ -77,41 +101,34 @@ pub fn handle_deposit<'info>( 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)?; - require_keys_eq!( - config.strategy, - strategy_key, - VaultError::InvalidAssetAccount - ); - require!(config.index as usize == i, VaultError::InvalidAssetAccount); - require_keys_eq!( - vault_ai.key(), - config.vault, - VaultError::InvalidAssetAccount - ); + let config = AssetConfig::load_checked(config_account)?; + require_keys_eq!(config.strategy, strategy_key, VaultError::InvalidAssetAccount); + require!(config.index as usize == index, VaultError::InvalidAssetAccount); + require_keys_eq!(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)?; @@ -128,15 +145,13 @@ pub fn handle_deposit<'info>( .ok_or(VaultError::MathOverflow)? as u64 }; - require!( - shares_to_mint >= minimum_shares, - VaultError::SlippageTooHigh - ); + require!(shares_to_mint >= minimum_shares, VaultError::SlippageTooHigh); context.accounts.strategy.total_shares = total_shares .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(), @@ -148,6 +163,74 @@ pub fn handle_deposit<'info>( 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, with the same + // oracle-computed slippage floor `invest` uses. 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/mod.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/mod.rs index 7def5baa..fc81af70 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 @@ -5,6 +5,7 @@ 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; @@ -15,5 +16,6 @@ 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/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/lib.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/lib.rs index e36af24b..ad1b54f1 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/lib.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/lib.rs @@ -52,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, 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 b798080b..bdf72c3a 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 @@ -407,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, @@ -451,9 +487,202 @@ 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(); + derive_ata(&user.pubkey(), &ctx.share_mint_pda) +} + +/// Deposit into a TSLAx-only strategy. +fn do_deposit_tsla_only( + ctx: &mut TestContext, + user: &Keypair, + usdc_amount: u64, + 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(); - user_share + 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::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%, leaving 60% of any deposit as idle USDC +/// for the manager to deploy with `invest`. +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, + 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, + 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(); +} + +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 invest_ix( @@ -567,6 +796,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, @@ -601,6 +831,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, @@ -640,24 +871,47 @@ 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() { let mut ctx = setup_full(); - standard_strategy(&mut ctx); + // TSLAx-only at 40%, so a deposit leaves 60% idle USDC for the manager to deploy. + tsla_only_strategy(&mut ctx); let user = fund_user(&mut ctx, 10_000_000); - do_deposit(&mut ctx, &user, 10_000_000, 1); + do_deposit_tsla_only(&mut ctx, &user, 10_000_000, 1); + + // Deposit deployed 4 USDC -> 16000 TSLAx, leaving 6 USDC idle. + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), + 16_000 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), + 6_000_000 + ); + // Manager deploys 4 of the idle USDC into TSLAx. let ix = invest_ix( &ctx, ctx.tsla_mint, @@ -675,53 +929,28 @@ fn test_invest() { ) .unwrap(); - // 4 USDC / 250 = 16000 TSLAx + // 4 more USDC / 250 = 16000 TSLAx, bringing the vault to 32000; 2 USDC left idle. assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), - 16_000 + 32_000 ); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), - 6_000_000 + 2_000_000 ); } #[test] fn test_invest_rejects_slippage() { let mut ctx = setup_full(); - standard_strategy(&mut ctx); + tsla_only_strategy(&mut ctx); let user = fund_user(&mut ctx, 10_000_000); - do_deposit(&mut ctx, &user, 10_000_000, 1); + // Deposit deploys 4 USDC at the honest rate, leaving 6 USDC idle. + do_deposit_tsla_only(&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(); + // Now make the router quote far worse than the oracle: rate 300 vs Pyth-implied 250. + 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, @@ -744,6 +973,25 @@ fn test_invest_rejects_slippage() { ); } +#[test] +fn test_deposit_rejects_slippage() { + let mut ctx = setup_full(); + standard_strategy(&mut ctx); + + // 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 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(), + "deposit deploy leg worse than oracle must revert the deposit" + ); +} + #[test] fn test_invest_rejects_unregistered_router() { let mut ctx = setup_full(); @@ -775,131 +1023,69 @@ fn test_invest_rejects_unregistered_router() { } #[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] @@ -910,34 +1096,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!( @@ -965,27 +1125,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) @@ -1019,15 +1162,19 @@ 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. + // Sole holder withdraws everything in kind: all 16000 TSLAx + 33333 NVDAx, no USDC. assert_eq!( get_token_account_balance(&ctx.svm, &user_usdc).unwrap(), - 6_000_000 + 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] @@ -1064,7 +1211,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, @@ -1080,36 +1229,183 @@ 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, ); - let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); - assert!(r.is_err(), "incomplete asset accounts must revert"); + 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. + 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); + + // A deposit now allocates only the 40% TSLAx slice; the rest stays idle USDC and + // NVDAx is never bought. + let user = fund_user(&mut ctx, 100_000_000); + do_deposit(&mut ctx, &user, 100_000_000, 1); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), + 160_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(), + 60_000_000 + ); +} + +#[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); }