Skip to content
300 changes: 300 additions & 0 deletions finance/vault-strategy/VIDEO_SCRIPT.md

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions finance/vault-strategy/anchor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Changelog

## Unreleased

### Added

- **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`.

### 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.

### Fixed

- Boxed the `mock-swap-router` swap account structs, which overflowed the 4096-byte SBF stack frame under current platform-tools.
- Documented the per-manifest build (the workspace build strips the router entrypoint via feature unification).
293 changes: 70 additions & 223 deletions finance/vault-strategy/anchor/README.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ pub struct SwapAssetForUsdcAccountConstraints<'info> {
pub asset_rate: Account<'info, AssetRate>,

#[account(constraint = usdc_mint.key() == router_config.usdc_mint @ RouterError::WrongUsdcMint)]
pub usdc_mint: InterfaceAccount<'info, Mint>,
pub usdc_mint: Box<InterfaceAccount<'info, Mint>>,

#[account(mut)]
pub asset_mint: InterfaceAccount<'info, Mint>,
pub asset_mint: Box<InterfaceAccount<'info, Mint>>,

/// Caller's asset token account - asset tokens are burned from here
#[account(
Expand All @@ -37,7 +37,7 @@ pub struct SwapAssetForUsdcAccountConstraints<'info> {
associated_token::authority = caller,
associated_token::token_program = token_program
)]
pub caller_asset_account: InterfaceAccount<'info, TokenAccount>,
pub caller_asset_account: Box<InterfaceAccount<'info, TokenAccount>>,

/// Caller's USDC account - receives the USDC
#[account(
Expand All @@ -46,7 +46,7 @@ pub struct SwapAssetForUsdcAccountConstraints<'info> {
associated_token::authority = caller,
associated_token::token_program = token_program
)]
pub caller_usdc_account: InterfaceAccount<'info, TokenAccount>,
pub caller_usdc_account: Box<InterfaceAccount<'info, TokenAccount>>,

/// Router's USDC treasury - sends the USDC
#[account(
Expand All @@ -55,7 +55,7 @@ pub struct SwapAssetForUsdcAccountConstraints<'info> {
associated_token::authority = router_authority,
associated_token::token_program = token_program
)]
pub router_usdc_treasury: InterfaceAccount<'info, TokenAccount>,
pub router_usdc_treasury: Box<InterfaceAccount<'info, TokenAccount>>,

/// CHECK: PDA used as treasury authority - validated by seeds constraint
#[account(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ pub struct SwapUsdcForAssetAccountConstraints<'info> {
)]
pub asset_rate: Account<'info, AssetRate>,

pub usdc_mint: InterfaceAccount<'info, Mint>,
pub usdc_mint: Box<InterfaceAccount<'info, Mint>>,

#[account(mut)]
pub asset_mint: InterfaceAccount<'info, Mint>,
pub asset_mint: Box<InterfaceAccount<'info, Mint>>,

/// Caller's USDC token account - USDC flows from here to the treasury
#[account(
Expand All @@ -37,7 +37,7 @@ pub struct SwapUsdcForAssetAccountConstraints<'info> {
associated_token::authority = caller,
associated_token::token_program = token_program
)]
pub caller_usdc_account: InterfaceAccount<'info, TokenAccount>,
pub caller_usdc_account: Box<InterfaceAccount<'info, TokenAccount>>,

/// Caller's asset token account - minted asset tokens land here
#[account(
Expand All @@ -46,7 +46,7 @@ pub struct SwapUsdcForAssetAccountConstraints<'info> {
associated_token::authority = caller,
associated_token::token_program = token_program
)]
pub caller_asset_account: InterfaceAccount<'info, TokenAccount>,
pub caller_asset_account: Box<InterfaceAccount<'info, TokenAccount>>,

/// Router's USDC treasury - receives the USDC payment
#[account(
Expand All @@ -55,7 +55,7 @@ pub struct SwapUsdcForAssetAccountConstraints<'info> {
associated_token::authority = router_authority,
associated_token::token_program = token_program
)]
pub router_usdc_treasury: InterfaceAccount<'info, TokenAccount>,
pub router_usdc_treasury: Box<InterfaceAccount<'info, TokenAccount>>,

/// CHECK: PDA used as mint authority - validated by seeds constraint
#[account(
Expand Down
34 changes: 25 additions & 9 deletions finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,34 @@ use anchor_lang::prelude::*;

#[error_code]
pub enum VaultError {
#[msg("Weights must sum to 10000 basis points")]
InvalidWeights,
#[msg("Shares minted are below the minimum - slippage exceeded")]
SlippageTooHigh,
#[msg("USDC out is below minimum - slippage exceeded")]
UsdcSlippage,
#[msg("Asset A out is below minimum - slippage exceeded")]
AssetASlippage,
#[msg("Asset B out is below minimum - slippage exceeded")]
AssetBSlippage,
#[msg("Asset mint is neither asset_a nor asset_b")]
InvalidAssetMint,
#[msg("Swap output deviates from the oracle price by more than the allowed slippage")]
SwapSlippageExceeded,
#[msg("Max slippage exceeds the maximum allowed configuration")]
SlippageConfigTooHigh,
#[msg("Asset mint is not part of this strategy")]
AssetNotFound,
#[msg("Asset mint is not whitelisted in the registry")]
AssetNotWhitelisted,
#[msg("Strategy already holds the maximum number of assets")]
TooManyAssets,
#[msg("Asset is already part of this strategy")]
DuplicateAsset,
#[msg("Total target weight would exceed 10000 basis points")]
WeightOverflow,
#[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")]
InvalidAssetAccount,
#[msg("Token account could not be read")]
InvalidVaultAccount,
#[msg("Recipient token account is not owned by the withdrawing user")]
InvalidRecipient,
#[msg("Registry does not match the strategy's registered registry")]
InvalidRegistry,
#[msg("No time has elapsed since last fee accrual")]
NoTimeElapsed,
#[msg("Arithmetic overflow")]
Expand All @@ -24,7 +40,7 @@ pub enum VaultError {
ZeroDeposit,
#[msg("Total shares are zero - cannot compute proportional withdraw")]
ZeroTotalShares,
#[msg("Price feed account does not match the strategy's registered feed")]
#[msg("Price feed account does not match the registered feed")]
InvalidPriceFeed,
#[msg("Pyth price is zero or negative")]
NegativePrice,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use anchor_lang::prelude::*;
use anchor_spl::{
associated_token::AssociatedToken,
token_interface::{Mint, TokenAccount, TokenInterface},
};

use crate::error::VaultError;
use crate::state::{AssetConfig, Registry, Strategy, WhitelistEntry, MAX_ASSETS};

#[derive(Accounts)]
pub struct AddAssetAccountConstraints<'info> {
#[account(mut)]
pub manager: Signer<'info>,

#[account(
mut,
has_one = manager,
has_one = registry @ VaultError::InvalidRegistry,
seeds = [b"strategy", strategy.manager.as_ref()],
bump = strategy.bump
)]
pub strategy: Box<Account<'info, Strategy>>,

pub registry: Box<Account<'info, Registry>>,

pub asset_mint: Box<InterfaceAccount<'info, Mint>>,

/// Proof the mint is approved, and the source of its official price feed.
/// Seeds tie it to this registry and this mint; existence means whitelisted.
#[account(
seeds = [b"whitelist", registry.key().as_ref(), asset_mint.key().as_ref()],
bump = whitelist_entry.bump
)]
pub whitelist_entry: Box<Account<'info, WhitelistEntry>>,

#[account(
init,
payer = manager,
space = AssetConfig::DISCRIMINATOR.len() + AssetConfig::INIT_SPACE,
seeds = [b"asset", strategy.key().as_ref(), &[strategy.asset_count]],
bump
)]
pub asset_config: Box<Account<'info, AssetConfig>>,

/// Strategy-owned vault for this asset.
#[account(
init,
payer = manager,
associated_token::mint = asset_mint,
associated_token::authority = strategy,
associated_token::token_program = token_program
)]
pub vault_asset: Box<InterfaceAccount<'info, TokenAccount>>,

pub associated_token_program: Program<'info, AssociatedToken>,
pub token_program: Interface<'info, TokenInterface>,
pub system_program: Program<'info, System>,
}

pub fn handle_add_asset(
context: Context<AddAssetAccountConstraints>,
weight_bps: u16,
) -> Result<()> {
let strategy = &mut context.accounts.strategy;

require!(strategy.asset_count < MAX_ASSETS, VaultError::TooManyAssets);

let new_total = (strategy.total_weight_bps as u32)
.checked_add(weight_bps as u32)
.ok_or(VaultError::MathOverflow)?;
require!(new_total <= 10_000, VaultError::WeightOverflow);

let index = strategy.asset_count;

context.accounts.asset_config.set_inner(AssetConfig {
strategy: strategy.key(),
index,
mint: context.accounts.asset_mint.key(),
// Copied from the registry entry, never supplied by the manager.
price_feed: context.accounts.whitelist_entry.price_feed,
vault: context.accounts.vault_asset.key(),
weight_bps,
bump: context.bumps.asset_config,
});

strategy.asset_count = index.checked_add(1).ok_or(VaultError::MathOverflow)?;
strategy.total_weight_bps = new_total as u16;

Ok(())
}
Loading
Loading