diff --git a/README.md b/README.md index 02f94fa4..1248ce5d 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ An exchange with no order book: swaps fill instantly against a shared liquidity A managed investment fund onchain, like an ETF or mutual fund. Investors deposit USDC for shares, a manager allocates the pool across a basket of assets (here, stocks like TSLAx and NVDAx), and each share's value tracks the fund's net asset value. The manager earns a management fee, and investors redeem a proportional slice of the underlying assets. -[⚓ Anchor](./finance/vault-strategy/anchor) +[⚓ Anchor](./finance/vault-strategy/anchor) [💫 Quasar](./finance/vault-strategy/quasar) ### Betting Market diff --git a/finance/vault-strategy/quasar/README.md b/finance/vault-strategy/quasar/README.md new file mode 100644 index 00000000..d274bae1 --- /dev/null +++ b/finance/vault-strategy/quasar/README.md @@ -0,0 +1,132 @@ +# Vault Strategy, Quasar port + +A tokenized multi-asset vault. A manager assembles a basket of whitelisted +assets at target weights; anyone can deposit USDC and receive shares priced at +the vault's net asset value, and each deposit is immediately deployed into the +basket by swapping USDC into every asset at its weight. Withdrawals burn shares +and redeem a proportional slice of every vault, paid in kind. This is the shape +of an onchain index fund or a managed ETF. + +This is a [Quasar](https://github.com/blueshift-gg/quasar) port of the Anchor +example in [`../anchor`](../anchor). It contains two programs, each its own +Quasar project: + +- `vault-strategy/` - the vault itself (program ID + `VLT5W7bqhRN4nCdRpXm8UfHRxZd9EuZGqiSAkGHQfGh`). +- `mock-swap-router/` - a stand-in constant-rate swap venue the vault trades + through (program ID `SWPR8Rk3aq3DrDGLdaANq7xCMnXoUFUJWJJmCWxc8Jm`). + +Both share the same program IDs as the Anchor build. The mock router mints an +asset against USDC at an admin-set fixed rate, standing in for a real AMM or +aggregator so the example is self-contained. + +## How it works + +A separate protocol authority curates a registry of assets, binding each +approved mint to its official price feed. This authority is deliberately not the +strategy manager: it vets which real assets and feeds are safe, and the manager +only chooses among them, so a manager can never list a token they mint +themselves or pair a real mint with a feed they control. + +- `initialize_registry` opens the registry; `whitelist_asset` approves a mint + and records its price feed. +- A manager opens a basket with `initialize_strategy` (choosing a management fee + and a slippage tolerance), then adds whitelisted assets with `add_asset`, each + at a target weight in basis points. The weights must sum to 100% before the + vault accepts deposits, so every deposit is fully invested. `set_weight` + retunes a weight or retires an asset by setting it to zero. +- `deposit` prices the incoming USDC against the vault's net asset value (the + USDC vault plus every asset vault valued at its oracle price), mints shares for + that fraction of the vault, and deploys the deposit across the basket by + swapping a weight-sized slice into each asset through the router. The first + deposit into an empty vault mints shares one-to-one. +- `withdraw` burns shares and pays out a proportional slice of the USDC vault and + every asset vault, in kind. +- `rebalance` lets the manager sell one asset for USDC and buy another with it, + keeping holdings near their targets as prices drift. Both legs are floored to + the oracle price so a bad swap route reverts. +- `collect_fees` accrues the time-based management fee by minting fresh shares to + the manager, diluting holders at the configured annual rate. + +Every swap and rebalance leg is bounded by the registered price feed: the +program computes the oracle-implied output and rejects any swap that falls short +by more than the strategy's slippage tolerance. + +## Accounts and PDAs + +- **Registry** `["registry", authority]` and **WhitelistEntry** + `["whitelist", registry, mint]` - the curated asset set and each approved + mint's price feed. +- **Strategy** `["strategy", index]` - one basket, addressed by a counter. Holds + the manager, registry, share mint, USDC mint, router, fee, slippage, total + shares, and running weight sum. The Strategy PDA is the authority of the share + mint and every vault, so the program signs all mints and payouts. +- **AssetConfig** `["asset", strategy, index]` - one basket asset (mint, copied + price feed, vault, target weight). The set is the contiguous range + `0..asset_count`, so a valuation can re-derive every asset and refuse to + proceed if one is missing. +- **Share mint** `["share_mint", strategy]`, **USDC vault** + `["usdc_vault", strategy]`, and per-asset vaults `["asset_vault", strategy, index]`. + +`deposit` and `withdraw` reference every asset at once, so the client passes the +per-asset accounts as remaining accounts (five per asset for deposit, four for +withdraw), in index order. + +## Safety and custody + +- Deposited USDC and every asset sit in program-owned vaults whose authority is + the Strategy PDA; only the deployed program can move them, and it does so only + along deposit, withdraw, and rebalance. There is no manager path to withdraw + holdings, only to trade them within the basket or collect the configured fee. +- The share supply is updated before any mint or burn (checks-effects- + interactions), and value computations use u128 intermediates with checked + arithmetic, flooring in the vault's favour. +- The management fee is capped (10% per year) and the slippage tolerance is + capped (10%), so neither can be configured to drain the vault. +- Price feeds are validated against the address recorded on the asset config and + rejected if stale or non-positive. + +## What the Quasar port does differently + +The valuation, fee, and swap-floor math are identical to the Anchor build. The +differences follow from Quasar's model: + +- **The cross-program swap is built by hand.** Anchor generates a typed CPI + client (`mock_swap_router::cpi::*`); Quasar has no such generation, so each + swap is a `CpiDynamic` call whose account list and instruction data the vault + constructs directly. The router's instruction wire format (a one-byte + discriminator plus two little-endian u64s) is encoded inline. +- **The oracle and foreign token accounts are read by raw byte offset** through + `UncheckedAccount` views, the same field layout the Anchor build parses. +- **Pool vaults are program-derived token accounts** rather than associated + token accounts, matching the other Quasar finance examples. +- **The share mint carries no freeze authority** (it is never used); the Anchor + build sets it to the strategy PDA. + +## Building and testing + +Requires the [Solana toolchain](https://docs.anza.xyz/cli/install) and the +[Quasar CLI](https://github.com/blueshift-gg/quasar). Build both programs before +testing, because the deposit test loads the router's compiled `.so`: + +```sh +cargo install --git https://github.com/blueshift-gg/quasar quasar-cli --locked +(cd mock-swap-router && quasar build) +(cd vault-strategy && quasar build) +(cd mock-swap-router && cargo test) +(cd vault-strategy && cargo test) +``` + +The router suite (`mock-swap-router/src/tests.rs`) exercises initialize, set-rate, +and a USDC-for-asset swap. The vault suite (`vault-strategy/src/tests.rs`) drives +the manager setup (registry, whitelist, strategy, add asset) and a two-program +deposit that deploys USDC into the basket through the router CPI, asserting share +minting, vault balances, and treasury flow. + +## Extending + +- Multiple assets per basket (up to the 16-asset cap) with a v0 transaction plus + an Address Lookup Table for the larger account list. +- A real AMM or aggregator in place of the mock router. +- Deposit and withdraw fees in addition to the time-based management fee. +- Rebalance automation driven by weight drift beyond a threshold. diff --git a/finance/vault-strategy/quasar/mock-swap-router/Cargo.toml b/finance/vault-strategy/quasar/mock-swap-router/Cargo.toml new file mode 100644 index 00000000..ddc8139d --- /dev/null +++ b/finance/vault-strategy/quasar/mock-swap-router/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "quasar-mock-swap-router" +version = "0.1.0" +edition = "2021" + +# Standalone workspace - Quasar uses a different resolver and dependency tree. +[workspace] + +[lints.rust.unexpected_cfgs] +level = "warn" +check-cfg = [ + 'cfg(target_os, values("solana"))', +] + +[lib] +crate-type = ["cdylib", "lib"] + +[features] +alloc = [] +client = [] +debug = [] + +[dependencies] +# Pinned to match the finance/escrow Quasar example (623bb70 is the last rev +# before a zeropod 0.3 bump that breaks quasar-spl). Unpin once upstream fixes it. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +solana-address = { version = "2.2.0" } +solana-instruction = { version = "3.2.0" } + +[dev-dependencies] +quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm" } +spl-token-interface = { version = "2.0.0" } +solana-program-pack = { version = "3.1.0" } diff --git a/finance/vault-strategy/quasar/mock-swap-router/Quasar.toml b/finance/vault-strategy/quasar/mock-swap-router/Quasar.toml new file mode 100644 index 00000000..d1f7ddb1 --- /dev/null +++ b/finance/vault-strategy/quasar/mock-swap-router/Quasar.toml @@ -0,0 +1,22 @@ +[project] +name = "quasar_mock_swap_router" + +[toolchain] +type = "solana" + +[testing] +language = "rust" + +[testing.rust] +framework = "quasar-svm" + +[testing.rust.test] +program = "cargo" +args = [ + "test", + "tests::", +] + +[clients] +path = "target/client" +languages = ["rust"] diff --git a/finance/vault-strategy/quasar/mock-swap-router/src/errors.rs b/finance/vault-strategy/quasar/mock-swap-router/src/errors.rs new file mode 100644 index 00000000..54db7992 --- /dev/null +++ b/finance/vault-strategy/quasar/mock-swap-router/src/errors.rs @@ -0,0 +1,12 @@ +use quasar_lang::prelude::*; + +/// Program errors. Codes start at 6000 to match Anchor's custom-error base. +#[error_code] +pub enum RouterError { + ZeroRate = 6000, + SlippageExceeded, + InvalidAssetMint, + MathOverflow, + WrongUsdcMint, + ZeroAmount, +} diff --git a/finance/vault-strategy/quasar/mock-swap-router/src/instructions/initialize_router.rs b/finance/vault-strategy/quasar/mock-swap-router/src/instructions/initialize_router.rs new file mode 100644 index 00000000..266c3f74 --- /dev/null +++ b/finance/vault-strategy/quasar/mock-swap-router/src/instructions/initialize_router.rs @@ -0,0 +1,32 @@ +use quasar_lang::prelude::*; +use quasar_spl::prelude::*; + +use crate::state::{RouterConfig, RouterConfigInner}; + +#[derive(Accounts)] +pub struct InitializeRouterAccountConstraints { + #[account(mut)] + pub authority: Signer, + + pub usdc_mint: Account, + + #[account(init, payer = authority, address = RouterConfig::seeds())] + pub router_config: Account, + + pub rent: Sysvar, + pub token_program: Program, + pub system_program: Program, +} + +#[inline(always)] +pub fn handle_initialize_router( + accounts: &mut InitializeRouterAccountConstraints, + bumps: &InitializeRouterAccountConstraintsBumps, +) -> Result<(), ProgramError> { + accounts.router_config.set_inner(RouterConfigInner { + authority: *accounts.authority.address(), + usdc_mint: *accounts.usdc_mint.address(), + bump: bumps.router_config, + }); + Ok(()) +} diff --git a/finance/vault-strategy/quasar/mock-swap-router/src/instructions/mod.rs b/finance/vault-strategy/quasar/mock-swap-router/src/instructions/mod.rs new file mode 100644 index 00000000..44288a1b --- /dev/null +++ b/finance/vault-strategy/quasar/mock-swap-router/src/instructions/mod.rs @@ -0,0 +1,9 @@ +pub mod initialize_router; +pub mod set_rate; +pub mod swap_asset_for_usdc; +pub mod swap_usdc_for_asset; + +pub use initialize_router::*; +pub use set_rate::*; +pub use swap_asset_for_usdc::*; +pub use swap_usdc_for_asset::*; diff --git a/finance/vault-strategy/quasar/mock-swap-router/src/instructions/set_rate.rs b/finance/vault-strategy/quasar/mock-swap-router/src/instructions/set_rate.rs new file mode 100644 index 00000000..f72f7120 --- /dev/null +++ b/finance/vault-strategy/quasar/mock-swap-router/src/instructions/set_rate.rs @@ -0,0 +1,54 @@ +use quasar_lang::prelude::*; +use quasar_spl::prelude::*; + +use crate::state::{ + AssetRate, AssetRateInner, RouterAuthorityPda, RouterConfig, TreasuryPda, +}; + +#[derive(Accounts)] +pub struct SetRateAccountConstraints { + #[account(mut)] + pub authority: Signer, + + #[account(address = RouterConfig::seeds(), has_one(authority))] + pub router_config: Account, + + pub asset_mint: Account, + pub usdc_mint: Account, + + #[account( + init(idempotent), + payer = authority, + address = AssetRate::seeds(asset_mint.address()), + )] + pub asset_rate: Account, + + #[account(address = RouterAuthorityPda::seeds())] + pub router_authority: UncheckedAccount, + + #[account( + init(idempotent), + payer = authority, + token(mint = usdc_mint, authority = router_authority, token_program = token_program), + address = TreasuryPda::seeds(), + )] + pub router_usdc_treasury: InterfaceAccount, + + pub rent: Sysvar, + pub token_program: Program, + pub system_program: Program, +} + +#[inline(always)] +pub fn handle_set_rate( + accounts: &mut SetRateAccountConstraints, + usdc_per_token: u64, + bumps: &SetRateAccountConstraintsBumps, +) -> Result<(), ProgramError> { + accounts.asset_rate.set_inner(AssetRateInner { + mint: *accounts.asset_mint.address(), + usdc_per_token, + bump: bumps.asset_rate, + }); + Ok(()) +} diff --git a/finance/vault-strategy/quasar/mock-swap-router/src/instructions/swap_asset_for_usdc.rs b/finance/vault-strategy/quasar/mock-swap-router/src/instructions/swap_asset_for_usdc.rs new file mode 100644 index 00000000..d0f89564 --- /dev/null +++ b/finance/vault-strategy/quasar/mock-swap-router/src/instructions/swap_asset_for_usdc.rs @@ -0,0 +1,94 @@ +use quasar_lang::prelude::*; +use quasar_spl::prelude::*; + +use crate::errors::RouterError; +use crate::state::{AssetRate, RouterAuthorityPda, RouterConfig, TreasuryPda, ROUTER_AUTHORITY_SEED}; + +#[derive(Accounts)] +pub struct SwapAssetForUsdcAccountConstraints { + pub caller: Signer, + + #[account(address = RouterConfig::seeds())] + pub router_config: Account, + + pub asset_rate: Account, + + pub usdc_mint: Account, + + #[account(mut)] + pub asset_mint: Account, + + #[account(mut)] + pub caller_asset_account: Account, + + #[account(mut)] + pub caller_usdc_account: Account, + + #[account(mut, address = TreasuryPda::seeds())] + pub router_usdc_treasury: InterfaceAccount, + + #[account(address = RouterAuthorityPda::seeds())] + pub router_authority: UncheckedAccount, + + pub token_program: Program, +} + +#[inline(always)] +pub fn handle_swap_asset_for_usdc( + accounts: &mut SwapAssetForUsdcAccountConstraints, + asset_amount_in: u64, + minimum_usdc_out: u64, + bumps: &SwapAssetForUsdcAccountConstraintsBumps, +) -> Result<(), ProgramError> { + require_keys_eq!( + accounts.asset_rate.mint, + *accounts.asset_mint.address(), + RouterError::InvalidAssetMint + ); + require_keys_eq!( + *accounts.usdc_mint.address(), + accounts.router_config.usdc_mint, + RouterError::WrongUsdcMint + ); + + let rate = u64::from(accounts.asset_rate.usdc_per_token); + require!(rate > 0, RouterError::ZeroRate); + require!(asset_amount_in > 0, RouterError::ZeroAmount); + + // usdc_out = asset_amount_in * rate. + let usdc_out: u64 = (asset_amount_in as u128) + .checked_mul(rate as u128) + .ok_or(RouterError::MathOverflow)? + .try_into() + .map_err(|_| RouterError::MathOverflow)?; + require!(usdc_out >= minimum_usdc_out, RouterError::SlippageExceeded); + + // Burn the caller's asset tokens (caller signs). + accounts + .token_program + .burn( + &accounts.caller_asset_account, + &accounts.asset_mint, + &accounts.caller, + asset_amount_in, + ) + .invoke()?; + + // Pay USDC from the treasury to the caller; the router-authority PDA is the + // treasury authority and signs. + let bump = [bumps.router_authority]; + let seeds = [Seed::from(ROUTER_AUTHORITY_SEED), Seed::from(bump.as_ref())]; + accounts + .token_program + .transfer_checked( + &accounts.router_usdc_treasury, + &accounts.usdc_mint, + &accounts.caller_usdc_account, + &accounts.router_authority, + usdc_out, + accounts.usdc_mint.decimals, + ) + .invoke_signed(&seeds)?; + + Ok(()) +} diff --git a/finance/vault-strategy/quasar/mock-swap-router/src/instructions/swap_usdc_for_asset.rs b/finance/vault-strategy/quasar/mock-swap-router/src/instructions/swap_usdc_for_asset.rs new file mode 100644 index 00000000..88bb4a21 --- /dev/null +++ b/finance/vault-strategy/quasar/mock-swap-router/src/instructions/swap_usdc_for_asset.rs @@ -0,0 +1,89 @@ +use quasar_lang::prelude::*; +use quasar_spl::prelude::*; + +use crate::errors::RouterError; +use crate::state::{AssetRate, RouterAuthorityPda, RouterConfig, TreasuryPda, ROUTER_AUTHORITY_SEED}; + +#[derive(Accounts)] +pub struct SwapUsdcForAssetAccountConstraints { + // The caller - the vault-strategy PDA when invoked via CPI (a PDA signer). + pub caller: Signer, + + #[account(address = RouterConfig::seeds())] + pub router_config: Account, + + pub asset_rate: Account, + + pub usdc_mint: Account, + + #[account(mut)] + pub asset_mint: Account, + + #[account(mut)] + pub caller_usdc_account: Account, + + #[account(mut)] + pub caller_asset_account: Account, + + #[account(mut, address = TreasuryPda::seeds())] + pub router_usdc_treasury: InterfaceAccount, + + #[account(address = RouterAuthorityPda::seeds())] + pub router_authority: UncheckedAccount, + + pub token_program: Program, +} + +#[inline(always)] +pub fn handle_swap_usdc_for_asset( + accounts: &mut SwapUsdcForAssetAccountConstraints, + usdc_amount_in: u64, + minimum_asset_out: u64, + bumps: &SwapUsdcForAssetAccountConstraintsBumps, +) -> Result<(), ProgramError> { + require_keys_eq!( + accounts.asset_rate.mint, + *accounts.asset_mint.address(), + RouterError::InvalidAssetMint + ); + + let rate = u64::from(accounts.asset_rate.usdc_per_token); + require!(rate > 0, RouterError::ZeroRate); + + // asset_out = usdc_amount_in / rate (floor). + let asset_out: u64 = (usdc_amount_in as u128) + .checked_div(rate as u128) + .ok_or(RouterError::MathOverflow)? + .try_into() + .map_err(|_| RouterError::MathOverflow)?; + require!(asset_out >= minimum_asset_out, RouterError::SlippageExceeded); + + // USDC from caller to the router treasury (caller signs). + accounts + .token_program + .transfer_checked( + &accounts.caller_usdc_account, + &accounts.usdc_mint, + &accounts.router_usdc_treasury, + &accounts.caller, + usdc_amount_in, + accounts.usdc_mint.decimals, + ) + .invoke()?; + + // Mint asset tokens to the caller; the router-authority PDA is the mint + // authority and signs. + let bump = [bumps.router_authority]; + let seeds = [Seed::from(ROUTER_AUTHORITY_SEED), Seed::from(bump.as_ref())]; + accounts + .token_program + .mint_to( + &accounts.asset_mint, + &accounts.caller_asset_account, + &accounts.router_authority, + asset_out, + ) + .invoke_signed(&seeds)?; + + Ok(()) +} diff --git a/finance/vault-strategy/quasar/mock-swap-router/src/lib.rs b/finance/vault-strategy/quasar/mock-swap-router/src/lib.rs new file mode 100644 index 00000000..f0e75ffb --- /dev/null +++ b/finance/vault-strategy/quasar/mock-swap-router/src/lib.rs @@ -0,0 +1,67 @@ +#![cfg_attr(not(test), no_std)] + +use quasar_lang::prelude::*; + +pub mod errors; +pub mod instructions; +pub mod state; + +use instructions::*; + +#[cfg(test)] +mod tests; + +declare_id!("SWPR8Rk3aq3DrDGLdaANq7xCMnXoUFUJWJJmCWxc8Jm"); + +/// A mock constant-rate swap router used by the vault-strategy example. It +/// swaps a whitelisted asset against USDC at an admin-set fixed rate: buying an +/// asset mints it against USDC paid into the treasury; selling burns it and +/// pays USDC out. Stand-in for a real AMM or aggregator so the vault-strategy +/// example is self-contained. +#[program] +mod quasar_mock_swap_router { + use super::*; + + #[instruction(discriminator = 0)] + pub fn initialize_router( + ctx: Ctx, + ) -> Result<(), ProgramError> { + instructions::initialize_router::handle_initialize_router(&mut ctx.accounts, &ctx.bumps) + } + + #[instruction(discriminator = 1)] + pub fn set_rate( + ctx: Ctx, + usdc_per_token: u64, + ) -> Result<(), ProgramError> { + instructions::set_rate::handle_set_rate(&mut ctx.accounts, usdc_per_token, &ctx.bumps) + } + + #[instruction(discriminator = 2)] + pub fn swap_usdc_for_asset( + ctx: Ctx, + usdc_amount_in: u64, + minimum_asset_out: u64, + ) -> Result<(), ProgramError> { + instructions::swap_usdc_for_asset::handle_swap_usdc_for_asset( + &mut ctx.accounts, + usdc_amount_in, + minimum_asset_out, + &ctx.bumps, + ) + } + + #[instruction(discriminator = 3)] + pub fn swap_asset_for_usdc( + ctx: Ctx, + asset_amount_in: u64, + minimum_usdc_out: u64, + ) -> Result<(), ProgramError> { + instructions::swap_asset_for_usdc::handle_swap_asset_for_usdc( + &mut ctx.accounts, + asset_amount_in, + minimum_usdc_out, + &ctx.bumps, + ) + } +} diff --git a/finance/vault-strategy/quasar/mock-swap-router/src/state/mod.rs b/finance/vault-strategy/quasar/mock-swap-router/src/state/mod.rs new file mode 100644 index 00000000..085b0d4b --- /dev/null +++ b/finance/vault-strategy/quasar/mock-swap-router/src/state/mod.rs @@ -0,0 +1,38 @@ +use quasar_lang::prelude::*; + +pub const ROUTER_CONFIG_SEED: &[u8] = b"router_config"; +pub const ASSET_RATE_SEED: &[u8] = b"rate"; +pub const ROUTER_AUTHORITY_SEED: &[u8] = b"router_authority"; + +/// Router configuration. PDA: `["router_config"]`. A single deployment-wide +/// account naming the authority (who may set rates) and the base USDC mint. +#[account(discriminator = 1, set_inner)] +#[seeds(b"router_config")] +pub struct RouterConfig { + pub authority: Address, + pub usdc_mint: Address, + pub bump: u8, +} + +/// A fixed price for one asset. PDA: `["rate", mint]`. `usdc_per_token` is USDC +/// base units per token base unit (e.g. 250 means 1 token = 250 USDC when both +/// have 6 decimals). +#[account(discriminator = 2, set_inner)] +#[seeds(b"rate", mint: Address)] +pub struct AssetRate { + pub mint: Address, + pub usdc_per_token: u64, + pub bump: u8, +} + +/// PDA that is the mint authority of every asset mint and the authority of the +/// USDC treasury: `["router_authority"]`. +#[derive(Seeds)] +#[seeds(b"router_authority")] +pub struct RouterAuthorityPda; + +/// PDA token account (USDC) the router pays out of and collects into: +/// `["treasury"]`, authority = RouterAuthorityPda. +#[derive(Seeds)] +#[seeds(b"treasury")] +pub struct TreasuryPda; diff --git a/finance/vault-strategy/quasar/mock-swap-router/src/tests.rs b/finance/vault-strategy/quasar/mock-swap-router/src/tests.rs new file mode 100644 index 00000000..01285c1c --- /dev/null +++ b/finance/vault-strategy/quasar/mock-swap-router/src/tests.rs @@ -0,0 +1,241 @@ +//! QuasarSVM integration tests for the mock swap router: initialize it, set a +//! rate (which also creates the USDC treasury), then swap USDC for an asset and +//! back, asserting the minted/burned amounts and treasury flows. + +extern crate std; + +use { + alloc::vec, + quasar_svm::{Account, AccountMeta, Instruction, Pubkey, QuasarSvm}, + solana_program_pack::Pack, + spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, + std::println, +}; + +use crate::state::{ASSET_RATE_SEED, ROUTER_AUTHORITY_SEED, ROUTER_CONFIG_SEED}; + +const DECIMALS: u8 = 6; +const RATE: u64 = 250; // 250 USDC base units per asset base unit +const STARTING_LAMPORTS: u64 = 1_000_000_000; + +fn program_id() -> Pubkey { + Pubkey::new_from_array(crate::ID.to_bytes()) +} +fn setup() -> QuasarSvm { + let elf = std::fs::read("target/deploy/quasar_mock_swap_router.so").unwrap(); + QuasarSvm::new() + .with_program(&program_id(), &elf) + .with_token_program() +} +fn rent_id() -> Pubkey { + quasar_svm::solana_sdk_ids::sysvar::rent::ID +} +fn token_program_id() -> Pubkey { + quasar_svm::SPL_TOKEN_PROGRAM_ID +} +fn system_program_id() -> Pubkey { + quasar_svm::system_program::ID +} + +fn signer_account(address: Pubkey) -> Account { + quasar_svm::token::create_keyed_system_account(&address, STARTING_LAMPORTS) +} +fn empty_account(address: Pubkey) -> Account { + Account { + address, + lamports: 0, + data: vec![], + owner: system_program_id(), + executable: false, + } +} +fn mint_account(address: Pubkey, authority: Pubkey) -> Account { + quasar_svm::token::create_keyed_mint_account( + &address, + &Mint { + mint_authority: Some(authority).into(), + supply: 0, + decimals: DECIMALS, + is_initialized: true, + freeze_authority: None.into(), + }, + ) +} +fn token_account(address: Pubkey, mint: Pubkey, owner: Pubkey, amount: u64) -> Account { + quasar_svm::token::create_keyed_token_account( + &address, + &TokenAccount { + mint, + owner, + amount, + state: AccountState::Initialized, + ..TokenAccount::default() + }, + ) +} +fn token_amount(account: &Account) -> u64 { + TokenAccount::unpack(&account.data).unwrap().amount +} + +fn config_pda() -> Pubkey { + Pubkey::find_program_address(&[ROUTER_CONFIG_SEED], &program_id()).0 +} +fn authority_pda() -> Pubkey { + Pubkey::find_program_address(&[ROUTER_AUTHORITY_SEED], &program_id()).0 +} +fn treasury_pda() -> Pubkey { + Pubkey::find_program_address(&[b"treasury"], &program_id()).0 +} +fn rate_pda(mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address(&[ASSET_RATE_SEED, mint.as_ref()], &program_id()).0 +} + +fn init_router_ix(authority: Pubkey, usdc_mint: Pubkey, config: Pubkey) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(authority, true), + AccountMeta::new_readonly(usdc_mint, false), + AccountMeta::new(config, false), + AccountMeta::new_readonly(rent_id(), false), + AccountMeta::new_readonly(token_program_id(), false), + AccountMeta::new_readonly(system_program_id(), false), + ], + data: vec![0u8], + } +} + +#[allow(clippy::too_many_arguments)] +fn set_rate_ix( + authority: Pubkey, + config: Pubkey, + asset_mint: Pubkey, + usdc_mint: Pubkey, + rate: Pubkey, + router_authority: Pubkey, + treasury: Pubkey, + usdc_per_token: u64, +) -> Instruction { + let mut data = vec![1u8]; + data.extend_from_slice(&usdc_per_token.to_le_bytes()); + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(authority, true), + AccountMeta::new_readonly(config, false), + AccountMeta::new_readonly(asset_mint, false), + AccountMeta::new_readonly(usdc_mint, false), + AccountMeta::new(rate, false), + AccountMeta::new_readonly(router_authority, false), + AccountMeta::new(treasury, false), + AccountMeta::new_readonly(rent_id(), false), + AccountMeta::new_readonly(token_program_id(), false), + AccountMeta::new_readonly(system_program_id(), false), + ], + data, + } +} + +#[allow(clippy::too_many_arguments)] +fn swap_usdc_for_asset_ix( + caller: Pubkey, + config: Pubkey, + rate: Pubkey, + usdc_mint: Pubkey, + asset_mint: Pubkey, + caller_usdc: Pubkey, + caller_asset: Pubkey, + treasury: Pubkey, + router_authority: Pubkey, + usdc_amount_in: u64, + minimum_asset_out: u64, +) -> Instruction { + let mut data = vec![2u8]; + data.extend_from_slice(&usdc_amount_in.to_le_bytes()); + data.extend_from_slice(&minimum_asset_out.to_le_bytes()); + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new_readonly(caller, true), + AccountMeta::new_readonly(config, false), + AccountMeta::new_readonly(rate, false), + AccountMeta::new_readonly(usdc_mint, false), + AccountMeta::new(asset_mint, false), + AccountMeta::new(caller_usdc, false), + AccountMeta::new(caller_asset, false), + AccountMeta::new(treasury, false), + AccountMeta::new_readonly(router_authority, false), + AccountMeta::new_readonly(token_program_id(), false), + ], + data, + } +} + +#[test] +fn test_initialize_and_swap_usdc_for_asset() { + let mut svm = setup(); + + let authority = Pubkey::new_unique(); + let usdc_mint = Pubkey::new_unique(); + let asset_mint = Pubkey::new_unique(); + let config = config_pda(); + let router_authority = authority_pda(); + let treasury = treasury_pda(); + let rate = rate_pda(&asset_mint); + let caller_usdc = Pubkey::new_unique(); + let caller_asset = Pubkey::new_unique(); + + const USDC_IN: u64 = 1_000; + const ASSET_OUT: u64 = USDC_IN / RATE; // 4 + + let accounts = vec![ + signer_account(authority), + mint_account(usdc_mint, authority), + // The asset mint's authority is the router-authority PDA, so the router + // can mint it. + mint_account(asset_mint, router_authority), + empty_account(config), + empty_account(rate), + empty_account(router_authority), + empty_account(treasury), + token_account(caller_usdc, usdc_mint, authority, USDC_IN), + token_account(caller_asset, asset_mint, authority, 0), + ]; + + let instructions = vec![ + init_router_ix(authority, usdc_mint, config), + set_rate_ix( + authority, + config, + asset_mint, + usdc_mint, + rate, + router_authority, + treasury, + RATE, + ), + swap_usdc_for_asset_ix( + authority, + config, + rate, + usdc_mint, + asset_mint, + caller_usdc, + caller_asset, + treasury, + router_authority, + USDC_IN, + ASSET_OUT, + ), + ]; + + let result = svm.process_instruction_chain(&instructions, &accounts); + assert!(result.is_ok(), "router chain failed: {:?}", result.raw_result); + + // Caller paid all USDC and received the minted asset; treasury holds the USDC. + assert_eq!(token_amount(result.account(&caller_usdc).unwrap()), 0); + assert_eq!(token_amount(result.account(&caller_asset).unwrap()), ASSET_OUT); + assert_eq!(token_amount(result.account(&treasury).unwrap()), USDC_IN); + + println!(" ROUTER SWAP CU: {}", result.compute_units_consumed); +} diff --git a/finance/vault-strategy/quasar/vault-strategy/Cargo.toml b/finance/vault-strategy/quasar/vault-strategy/Cargo.toml new file mode 100644 index 00000000..d94a5228 --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "quasar-vault-strategy" +version = "0.1.0" +edition = "2021" + +# Standalone workspace - Quasar uses a different resolver and dependency tree. +[workspace] + +[lints.rust.unexpected_cfgs] +level = "warn" +check-cfg = [ + 'cfg(target_os, values("solana"))', +] + +[lib] +crate-type = ["cdylib", "lib"] + +[features] +alloc = [] +client = [] +debug = [] + +[dependencies] +# Pinned to match the finance/escrow Quasar example (623bb70 is the last rev +# before a zeropod 0.3 bump that breaks quasar-spl). Unpin once upstream fixes it. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +solana-address = { version = "2.2.0" } +solana-instruction = { version = "3.2.0" } + +[dev-dependencies] +quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm" } +spl-token-interface = { version = "2.0.0" } +solana-program-pack = { version = "3.1.0" } diff --git a/finance/vault-strategy/quasar/vault-strategy/Quasar.toml b/finance/vault-strategy/quasar/vault-strategy/Quasar.toml new file mode 100644 index 00000000..0e8f5282 --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/Quasar.toml @@ -0,0 +1,22 @@ +[project] +name = "quasar_vault_strategy" + +[toolchain] +type = "solana" + +[testing] +language = "rust" + +[testing.rust] +framework = "quasar-svm" + +[testing.rust.test] +program = "cargo" +args = [ + "test", + "tests::", +] + +[clients] +path = "target/client" +languages = ["rust"] diff --git a/finance/vault-strategy/quasar/vault-strategy/src/errors.rs b/finance/vault-strategy/quasar/vault-strategy/src/errors.rs new file mode 100644 index 00000000..4cbaeba3 --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/errors.rs @@ -0,0 +1,33 @@ +use quasar_lang::prelude::*; + +/// Program errors. Codes start at 6000 to match Anchor's custom-error base. +#[error_code] +pub enum VaultError { + SlippageTooHigh = 6000, + UsdcSlippage, + SwapSlippageExceeded, + SlippageConfigTooHigh, + AssetNotFound, + AssetNotWhitelisted, + TooManyAssets, + DuplicateAsset, + WeightOverflow, + StrategyNotFullyAllocated, + IncompleteAssetAccounts, + InvalidAssetAccount, + InvalidVaultAccount, + InvalidRecipient, + InvalidRegistry, + NoTimeElapsed, + MathOverflow, + ZeroShares, + ZeroDeposit, + ZeroTotalShares, + InvalidPriceFeed, + NegativePrice, + StalePriceFeed, + SameMint, + InvalidUsdcMint, + InvalidSwapRouter, + FeeTooHigh, +} diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/add_asset.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/add_asset.rs new file mode 100644 index 00000000..7f22d08e --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/add_asset.rs @@ -0,0 +1,87 @@ +use quasar_lang::prelude::*; +use quasar_spl::prelude::*; + +use crate::errors::VaultError; +use crate::state::{ + snapshot_strategy, AssetConfig, AssetConfigInner, AssetVaultPda, Registry, Strategy, + WhitelistEntry, MAX_ASSETS, +}; + +#[derive(Accounts)] +pub struct AddAssetAccountConstraints { + #[account(mut)] + pub manager: Signer, + + #[account( + mut, + address = Strategy::seeds(strategy.index.into()), + has_one(manager), + has_one(registry) @ VaultError::InvalidRegistry, + )] + pub strategy: Account, + + pub registry: Account, + + pub asset_mint: Account, + + /// Proof the mint is approved and the source of its official price feed. + #[account(address = WhitelistEntry::seeds(registry.address(), asset_mint.address()))] + pub whitelist_entry: Account, + + #[account( + init, + payer = manager, + address = AssetConfig::seeds(strategy.address(), strategy.asset_count), + )] + pub asset_config: Account, + + /// Strategy-owned vault for this asset. + #[account( + init, + payer = manager, + token(mint = asset_mint, authority = strategy, token_program = token_program), + address = AssetVaultPda::seeds(strategy.address(), strategy.asset_count), + )] + pub vault_asset: Account, + + pub rent: Sysvar, + pub token_program: Program, + pub system_program: Program, +} + +#[inline(always)] +pub fn handle_add_asset( + accounts: &mut AddAssetAccountConstraints, + weight_bps: u16, + bumps: &AddAssetAccountConstraintsBumps, +) -> Result<(), ProgramError> { + require!( + accounts.strategy.asset_count < MAX_ASSETS, + VaultError::TooManyAssets + ); + + let total_weight = u16::from(accounts.strategy.total_weight_bps); + let new_total = (total_weight as u32) + .checked_add(weight_bps as u32) + .ok_or(VaultError::MathOverflow)?; + require!(new_total <= 10_000, VaultError::WeightOverflow); + + let index = accounts.strategy.asset_count; + + accounts.asset_config.set_inner(AssetConfigInner { + strategy: *accounts.strategy.address(), + index, + mint: *accounts.asset_mint.address(), + // Copied from the registry entry, never supplied by the manager. + price_feed: accounts.whitelist_entry.price_feed, + vault: *accounts.vault_asset.address(), + weight_bps, + bump: bumps.asset_config, + }); + + let mut strategy = snapshot_strategy(&accounts.strategy); + strategy.asset_count = index.checked_add(1).ok_or(VaultError::MathOverflow)?; + strategy.total_weight_bps = new_total as u16; + accounts.strategy.set_inner(strategy); + Ok(()) +} diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/collect_fees.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/collect_fees.rs new file mode 100644 index 00000000..70e11b36 --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/collect_fees.rs @@ -0,0 +1,97 @@ +use quasar_lang::prelude::*; +use quasar_lang::sysvars::Sysvar as _; +use quasar_spl::prelude::*; + +use crate::errors::VaultError; +use crate::state::{snapshot_strategy, ShareMintPda, Strategy, STRATEGY_SEED}; + +const SECONDS_PER_YEAR: u64 = 31_536_000; + +#[derive(Accounts)] +pub struct CollectFeesAccountConstraints { + /// Read-only: the manager is stored on the strategy; fees are minted to + /// their share account. Not a signer - anyone may trigger accrual. + pub manager: UncheckedAccount, + + #[account(mut, address = Strategy::seeds(strategy.index.into()), has_one(manager))] + pub strategy: Account, + + #[account(mut, address = ShareMintPda::seeds(strategy.address()))] + pub share_mint: InterfaceAccount, + + /// The manager's share token account - receives fee shares. + #[account(mut)] + pub manager_share_account: Account, + + #[account(mut)] + pub payer: Signer, + + pub token_program: Program, +} + +#[inline(always)] +pub fn handle_collect_fees( + accounts: &mut CollectFeesAccountConstraints, +) -> Result<(), ProgramError> { + require_keys_eq!( + accounts.manager_share_account.owner, + accounts.strategy.manager, + VaultError::InvalidRecipient + ); + + let now = i64::from(Clock::get()?.unix_timestamp); + let last = i64::from(accounts.strategy.last_fee_accrual_timestamp); + require!(now > last, VaultError::NoTimeElapsed); + + let elapsed_seconds = (now - last) as u64; + let total_shares = u64::from(accounts.strategy.total_shares); + let fee_bps = u16::from(accounts.strategy.fee_bps); + let strategy_index = u64::from(accounts.strategy.index); + let strategy_bump = accounts.strategy.bump; + + // fee_shares = total_shares * fee_bps * elapsed / (10_000 * SECONDS_PER_YEAR) + let denominator = (10_000u128) + .checked_mul(SECONDS_PER_YEAR as u128) + .ok_or(VaultError::MathOverflow)?; + let fee_shares: u64 = (total_shares as u128) + .checked_mul(fee_bps as u128) + .ok_or(VaultError::MathOverflow)? + .checked_mul(elapsed_seconds as u128) + .ok_or(VaultError::MathOverflow)? + .checked_div(denominator) + .ok_or(VaultError::MathOverflow)? + .try_into() + .map_err(|_| VaultError::MathOverflow)?; + + // Advance the accrual clock even when the fee rounds to zero. + let mut strategy = snapshot_strategy(&accounts.strategy); + strategy.last_fee_accrual_timestamp = now; + if fee_shares == 0 { + accounts.strategy.set_inner(strategy); + return Ok(()); + } + strategy.total_shares = total_shares + .checked_add(fee_shares) + .ok_or(VaultError::MathOverflow)?; + accounts.strategy.set_inner(strategy); + + // Mint fee shares to the manager; the strategy PDA signs as mint authority. + let index_bytes = strategy_index.to_le_bytes(); + let bump = [strategy_bump]; + let seeds = [ + Seed::from(STRATEGY_SEED), + Seed::from(index_bytes.as_ref()), + Seed::from(bump.as_ref()), + ]; + accounts + .token_program + .mint_to( + &accounts.share_mint, + &accounts.manager_share_account, + &accounts.strategy, + fee_shares, + ) + .invoke_signed(&seeds)?; + + Ok(()) +} diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/deposit.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/deposit.rs new file mode 100644 index 00000000..2f3101fa --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/deposit.rs @@ -0,0 +1,257 @@ +use quasar_lang::prelude::*; +use quasar_lang::remaining::RemainingAccounts; +use quasar_lang::sysvars::Sysvar as _; +use quasar_spl::prelude::*; + +use crate::errors::VaultError; +use crate::oracle::{asset_value_in_usdc, load_price, read_token_amount, PYTH_PRICE_PRECISION}; +use crate::state::{ + load_asset_config, snapshot_strategy, ShareMintPda, Strategy, UsdcVaultPda, STRATEGY_SEED, +}; + +/// Discriminator of the router's `swap_usdc_for_asset` instruction. +const ROUTER_SWAP_USDC_FOR_ASSET: u8 = 2; +/// One `swap_usdc_for_asset` CPI: 10 accounts, 17 data bytes (disc + 2 u64). +const SWAP_ACCOUNTS: usize = 10; +const SWAP_DATA_LEN: usize = 17; +/// remaining_accounts arrive as, per asset index 0..asset_count: +/// [asset_config, vault, asset_mint, asset_rate, price_feed] +const ACCOUNTS_PER_ASSET: usize = 5; + +#[derive(Accounts)] +pub struct DepositAccountConstraints { + #[account(mut)] + pub depositor: Signer, + + #[account( + mut, + address = Strategy::seeds(strategy.index.into()), + has_one(usdc_mint) @ VaultError::InvalidUsdcMint, + )] + pub strategy: Account, + + #[account(mut, address = ShareMintPda::seeds(strategy.address()))] + pub share_mint: InterfaceAccount, + + pub usdc_mint: Account, + + #[account(mut)] + pub depositor_usdc_account: Account, + + // The depositor's share account. Must already exist and be owned by the + // depositor (verified before shares are minted). + #[account(mut)] + pub depositor_share_account: Account, + + #[account(mut, address = UsdcVaultPda::seeds(strategy.address()))] + pub vault_usdc: InterfaceAccount, + + /// Router config PDA (mock-swap-router). + #[account(mut)] + pub router_config: UncheckedAccount, + /// Router USDC treasury. + #[account(mut)] + pub router_usdc_treasury: UncheckedAccount, + /// Router mint-authority PDA. + pub router_authority: UncheckedAccount, + /// The swap router program, verified against the strategy's stored router. + pub swap_router_program: UncheckedAccount, + + pub token_program: Program, + pub system_program: Program, +} + +fn get_view<'a>( + remaining: &RemainingAccounts<'a>, + index: usize, +) -> Result { + let account = remaining + .get(index)? + .ok_or(VaultError::IncompleteAssetAccounts)?; + // SAFETY: forwarded/read-only; no mutable alias is taken across these views. + Ok(unsafe { account.as_account_view_unchecked() }.clone()) +} + +#[inline(always)] +pub fn handle_deposit( + accounts: &mut DepositAccountConstraints, + remaining: RemainingAccounts<'_>, + usdc_amount: u64, + minimum_shares: u64, +) -> Result<(), ProgramError> { + require!(usdc_amount > 0, VaultError::ZeroDeposit); + require!( + u16::from(accounts.strategy.total_weight_bps) == 10_000, + VaultError::StrategyNotFullyAllocated + ); + + let asset_count = accounts.strategy.asset_count as usize; + // Exactly five accounts per asset - no more, no less - so no asset can be + // omitted from the NAV computation. + require!( + remaining.get(asset_count * ACCOUNTS_PER_ASSET)?.is_none(), + VaultError::IncompleteAssetAccounts + ); + + let total_shares = u64::from(accounts.strategy.total_shares); + let usdc_decimals = accounts.usdc_mint.decimals; + let strategy_index = u64::from(accounts.strategy.index); + let strategy_bump = accounts.strategy.bump; + let strategy_key = *accounts.strategy.address(); + let max_slippage_bps = u16::from(accounts.strategy.max_slippage_bps); + let router_program_addr = *accounts.swap_router_program.to_account_view().address(); + + require_keys_eq!( + router_program_addr, + accounts.strategy.swap_router, + VaultError::InvalidSwapRouter + ); + + let now = i64::from(Clock::get()?.unix_timestamp); + + // Net asset value over the complete asset set. + let mut nav: u128 = accounts.vault_usdc.amount() as u128; + for index in 0..asset_count { + let config_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET)?; + let vault_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET + 1)?; + let feed_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET + 4)?; + + let config = load_asset_config(&config_view)?; + require_keys_eq!(config.strategy, strategy_key, VaultError::InvalidAssetAccount); + require!(config.index as usize == index, VaultError::InvalidAssetAccount); + require_keys_eq!(*vault_view.address(), config.vault, VaultError::InvalidAssetAccount); + + let price = load_price(&feed_view, &config.price_feed, now)?; + let amount = read_token_amount(&vault_view)?; + nav = nav + .checked_add(asset_value_in_usdc(amount, price)?) + .ok_or(VaultError::MathOverflow)?; + } + + // shares = usdc_amount * total_shares / nav (floor); first deposit is 1:1. + let shares_to_mint: u64 = if total_shares == 0 { + usdc_amount + } else { + (usdc_amount as u128) + .checked_mul(total_shares as u128) + .ok_or(VaultError::MathOverflow)? + .checked_div(nav) + .ok_or(VaultError::MathOverflow)? + .try_into() + .map_err(|_| VaultError::MathOverflow)? + }; + require!(shares_to_mint >= minimum_shares, VaultError::SlippageTooHigh); + + let mut strategy = snapshot_strategy(&accounts.strategy); + strategy.total_shares = total_shares + .checked_add(shares_to_mint) + .ok_or(VaultError::MathOverflow)?; + accounts.strategy.set_inner(strategy); + + // Pull the depositor's USDC into the strategy's USDC vault. + accounts + .token_program + .transfer_checked( + &accounts.depositor_usdc_account, + &accounts.usdc_mint, + &accounts.vault_usdc, + &accounts.depositor, + usdc_amount, + usdc_decimals, + ) + .invoke()?; + + let index_bytes = strategy_index.to_le_bytes(); + let bump = [strategy_bump]; + let seeds = [ + Seed::from(STRATEGY_SEED), + Seed::from(index_bytes.as_ref()), + Seed::from(bump.as_ref()), + ]; + + // Deploy the deposit across the basket at its target weights, each leg + // swapped through the router under an oracle-anchored slippage floor. The + // strategy PDA signs, since the USDC leaves a vault only it controls. + for index in 0..asset_count { + let config_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET)?; + let vault_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET + 1)?; + let mint_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET + 2)?; + let rate_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET + 3)?; + let feed_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET + 4)?; + + let config = load_asset_config(&config_view)?; + require_keys_eq!(*mint_view.address(), 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)? + .try_into() + .map_err(|_| VaultError::MathOverflow)?; + if deploy_usdc == 0 { + continue; + } + + // Oracle-anchored floor: expected_out = deploy_usdc * 10^8 / price, + // allowed to fall short by at most max_slippage_bps. + let price = load_price(&feed_view, &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 mut data = [0u8; SWAP_DATA_LEN]; + data[0] = ROUTER_SWAP_USDC_FOR_ASSET; + data[1..9].copy_from_slice(&deploy_usdc.to_le_bytes()); + data[9..17].copy_from_slice(&minimum_asset_out.to_le_bytes()); + + // Router `swap_usdc_for_asset` account order: + // caller, router_config, asset_rate, usdc_mint, asset_mint, + // caller_usdc_account, caller_asset_account, router_usdc_treasury, + // router_authority, token_program. + let mut cpi = CpiDynamic::::new(&router_program_addr); + cpi.push_account(accounts.strategy.to_account_view(), true, false)?; + cpi.push_account(accounts.router_config.to_account_view(), false, false)?; + cpi.push_account(&rate_view, false, false)?; + cpi.push_account(accounts.usdc_mint.to_account_view(), false, false)?; + cpi.push_account(&mint_view, false, true)?; + cpi.push_account(accounts.vault_usdc.to_account_view(), false, true)?; + cpi.push_account(&vault_view, false, true)?; + cpi.push_account(accounts.router_usdc_treasury.to_account_view(), false, true)?; + cpi.push_account(accounts.router_authority.to_account_view(), false, false)?; + cpi.push_account(accounts.token_program.to_account_view(), false, false)?; + cpi.set_data(&data)?; + cpi.invoke_signed(&seeds)?; + } + + // Mint the shares last, with the strategy PDA signing as the share-mint + // authority; the depositor's share account must belong to the depositor. + require_keys_eq!( + accounts.depositor_share_account.owner, + *accounts.depositor.address(), + VaultError::InvalidRecipient + ); + accounts + .token_program + .mint_to( + &accounts.share_mint, + &accounts.depositor_share_account, + &accounts.strategy, + shares_to_mint, + ) + .invoke_signed(&seeds)?; + + Ok(()) +} diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/initialize_registry.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/initialize_registry.rs new file mode 100644 index 00000000..607655fe --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/initialize_registry.rs @@ -0,0 +1,27 @@ +use quasar_lang::prelude::*; + +use crate::state::{Registry, RegistryInner}; + +#[derive(Accounts)] +pub struct InitializeRegistryAccountConstraints { + #[account(mut)] + pub authority: Signer, + + #[account(init, payer = authority, address = Registry::seeds(authority.address()))] + pub registry: Account, + + pub rent: Sysvar, + pub system_program: Program, +} + +#[inline(always)] +pub fn handle_initialize_registry( + accounts: &mut InitializeRegistryAccountConstraints, + bumps: &InitializeRegistryAccountConstraintsBumps, +) -> Result<(), ProgramError> { + accounts.registry.set_inner(RegistryInner { + authority: *accounts.authority.address(), + bump: bumps.registry, + }); + Ok(()) +} diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/initialize_strategy.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/initialize_strategy.rs new file mode 100644 index 00000000..88c32f4a --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/initialize_strategy.rs @@ -0,0 +1,84 @@ +use quasar_lang::prelude::*; +use quasar_lang::sysvars::Sysvar as _; +use quasar_spl::prelude::*; + +use crate::errors::VaultError; +use crate::state::{Registry, ShareMintPda, Strategy, StrategyInner, UsdcVaultPda}; + +/// Highest annual management fee a manager may set (10%). `collect_fees` mints +/// shares to the manager and dilutes every depositor, so an uncapped fee would +/// let a manager drain the vault by configuration. +pub const MAX_FEE_BPS: u16 = 1_000; + +/// Highest slippage tolerance a manager may set (10%). Caps how loose the +/// oracle-anchored swap bound in deposit/rebalance can be made. +pub const MAX_SLIPPAGE_BPS: u16 = 1_000; + +#[derive(Accounts)] +#[instruction(index: u64)] +pub struct InitializeStrategyAccountConstraints { + #[account(mut)] + pub manager: Signer, + + pub usdc_mint: Account, + + pub registry: Account, + + #[account(init, payer = manager, address = Strategy::seeds(index))] + pub strategy: Account, + + #[account( + init, + payer = manager, + mint(decimals = 6, authority = strategy, freeze_authority = None, token_program = token_program), + address = ShareMintPda::seeds(strategy.address()), + )] + pub share_mint: Account, + + #[account( + init, + payer = manager, + token(mint = usdc_mint, authority = strategy, token_program = token_program), + address = UsdcVaultPda::seeds(strategy.address()), + )] + pub vault_usdc: InterfaceAccount, + + pub rent: Sysvar, + pub token_program: Program, + pub system_program: Program, +} + +#[inline(always)] +pub fn handle_initialize_strategy( + accounts: &mut InitializeStrategyAccountConstraints, + index: u64, + fee_bps: u16, + max_slippage_bps: u16, + swap_router: Address, + bumps: &InitializeStrategyAccountConstraintsBumps, +) -> Result<(), ProgramError> { + require!(fee_bps <= MAX_FEE_BPS, VaultError::FeeTooHigh); + require!( + max_slippage_bps <= MAX_SLIPPAGE_BPS, + VaultError::SlippageConfigTooHigh + ); + + let now = i64::from(Clock::get()?.unix_timestamp); + + accounts.strategy.set_inner(StrategyInner { + index, + manager: *accounts.manager.address(), + registry: *accounts.registry.address(), + share_mint: *accounts.share_mint.address(), + usdc_mint: *accounts.usdc_mint.address(), + swap_router, + fee_bps, + max_slippage_bps, + total_shares: 0, + last_fee_accrual_timestamp: now, + asset_count: 0, + total_weight_bps: 0, + bump: bumps.strategy, + }); + Ok(()) +} diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/mod.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/mod.rs new file mode 100644 index 00000000..66e9eb18 --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/mod.rs @@ -0,0 +1,19 @@ +pub mod add_asset; +pub mod collect_fees; +pub mod deposit; +pub mod initialize_registry; +pub mod initialize_strategy; +pub mod rebalance; +pub mod set_weight; +pub mod whitelist_asset; +pub mod withdraw; + +pub use add_asset::*; +pub use collect_fees::*; +pub use deposit::*; +pub use initialize_registry::*; +pub use initialize_strategy::*; +pub use rebalance::*; +pub use set_weight::*; +pub use whitelist_asset::*; +pub use withdraw::*; diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/rebalance.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/rebalance.rs new file mode 100644 index 00000000..caeea6f2 --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/rebalance.rs @@ -0,0 +1,201 @@ +use quasar_lang::prelude::*; +use quasar_lang::sysvars::Sysvar as _; +use quasar_spl::prelude::*; + +use crate::errors::VaultError; +use crate::oracle::{load_price, PYTH_PRICE_PRECISION}; +use crate::state::{AssetConfig, Strategy, STRATEGY_SEED}; + +const ROUTER_SWAP_USDC_FOR_ASSET: u8 = 2; +const ROUTER_SWAP_ASSET_FOR_USDC: u8 = 3; +const SWAP_ACCOUNTS: usize = 10; +const SWAP_DATA_LEN: usize = 17; + +#[derive(Accounts)] +pub struct RebalanceAccountConstraints { + pub manager: Signer, + + #[account( + mut, + address = Strategy::seeds(strategy.index.into()), + has_one(manager), + has_one(usdc_mint) @ VaultError::InvalidUsdcMint, + )] + pub strategy: Account, + + pub usdc_mint: Account, + + #[account(mut)] + pub sell_mint: Account, + #[account(mut)] + pub buy_mint: Account, + + #[account(address = AssetConfig::seeds(strategy.address(), sell_config.index))] + pub sell_config: Account, + #[account(address = AssetConfig::seeds(strategy.address(), buy_config.index))] + pub buy_config: Account, + + /// Pyth feed - validated against the sell asset's registered feed. + pub sell_price_feed: UncheckedAccount, + /// Pyth feed - validated against the buy asset's registered feed. + pub buy_price_feed: UncheckedAccount, + + #[account(mut)] + pub vault_sell: Account, + #[account(mut)] + pub vault_buy: Account, + #[account(mut, address = crate::state::UsdcVaultPda::seeds(strategy.address()))] + pub vault_usdc: InterfaceAccount, + + /// Router rate accounts for the sell and buy assets. + pub sell_rate: UncheckedAccount, + pub buy_rate: UncheckedAccount, + + #[account(mut)] + pub router_config: UncheckedAccount, + #[account(mut)] + pub router_usdc_treasury: UncheckedAccount, + pub router_authority: UncheckedAccount, + pub swap_router_program: UncheckedAccount, + + pub token_program: Program, + pub system_program: Program, +} + +#[inline(always)] +pub fn handle_rebalance( + accounts: &mut RebalanceAccountConstraints, + sell_amount: u64, + usdc_to_invest: u64, +) -> Result<(), ProgramError> { + require!( + *accounts.sell_mint.address() != *accounts.buy_mint.address(), + VaultError::SameMint + ); + + // Bind each config to its declared mint and vault. + require_keys_eq!( + accounts.sell_config.mint, + *accounts.sell_mint.address(), + VaultError::AssetNotFound + ); + require_keys_eq!( + accounts.sell_config.vault, + *accounts.vault_sell.address(), + VaultError::InvalidAssetAccount + ); + require_keys_eq!( + accounts.buy_config.mint, + *accounts.buy_mint.address(), + VaultError::AssetNotFound + ); + require_keys_eq!( + accounts.buy_config.vault, + *accounts.vault_buy.address(), + VaultError::InvalidAssetAccount + ); + + let strategy_index = u64::from(accounts.strategy.index); + let strategy_bump = accounts.strategy.bump; + let slip = (10_000 - u16::from(accounts.strategy.max_slippage_bps)) as u128; + let router_program_addr = *accounts.swap_router_program.to_account_view().address(); + require_keys_eq!( + router_program_addr, + accounts.strategy.swap_router, + VaultError::InvalidSwapRouter + ); + + let now = i64::from(Clock::get()?.unix_timestamp); + let price_sell = load_price( + accounts.sell_price_feed.to_account_view(), + &accounts.sell_config.price_feed, + now, + )?; + let price_buy = load_price( + accounts.buy_price_feed.to_account_view(), + &accounts.buy_config.price_feed, + now, + )?; + + // Sell leg floor: USDC out within slippage of the oracle value of what we sell. + let expected_usdc = (sell_amount as u128) + .checked_mul(price_sell) + .ok_or(VaultError::MathOverflow)? + .checked_div(PYTH_PRICE_PRECISION) + .ok_or(VaultError::MathOverflow)?; + let minimum_usdc_from_sell: u64 = expected_usdc + .checked_mul(slip) + .ok_or(VaultError::MathOverflow)? + .checked_div(10_000) + .ok_or(VaultError::MathOverflow)? + .try_into() + .map_err(|_| VaultError::MathOverflow)?; + + // Buy leg floor: asset out within slippage of the oracle-implied amount. + let expected_buy = (usdc_to_invest as u128) + .checked_mul(PYTH_PRICE_PRECISION) + .ok_or(VaultError::MathOverflow)? + .checked_div(price_buy) + .ok_or(VaultError::MathOverflow)?; + let minimum_buy_amount: u64 = expected_buy + .checked_mul(slip) + .ok_or(VaultError::MathOverflow)? + .checked_div(10_000) + .ok_or(VaultError::MathOverflow)? + .try_into() + .map_err(|_| VaultError::MathOverflow)?; + + let index_bytes = strategy_index.to_le_bytes(); + let bump = [strategy_bump]; + let seeds = [ + Seed::from(STRATEGY_SEED), + Seed::from(index_bytes.as_ref()), + Seed::from(bump.as_ref()), + ]; + + // Step 1: sell the basket token for USDC. Router `swap_asset_for_usdc` + // order: caller, router_config, asset_rate, usdc_mint, asset_mint, + // caller_asset_account, caller_usdc_account, router_usdc_treasury, + // router_authority, token_program. + let mut sell_data = [0u8; SWAP_DATA_LEN]; + sell_data[0] = ROUTER_SWAP_ASSET_FOR_USDC; + sell_data[1..9].copy_from_slice(&sell_amount.to_le_bytes()); + sell_data[9..17].copy_from_slice(&minimum_usdc_from_sell.to_le_bytes()); + let mut sell_cpi = CpiDynamic::::new(&router_program_addr); + sell_cpi.push_account(accounts.strategy.to_account_view(), true, false)?; + sell_cpi.push_account(accounts.router_config.to_account_view(), false, false)?; + sell_cpi.push_account(accounts.sell_rate.to_account_view(), false, false)?; + sell_cpi.push_account(accounts.usdc_mint.to_account_view(), false, false)?; + sell_cpi.push_account(accounts.sell_mint.to_account_view(), false, true)?; + sell_cpi.push_account(accounts.vault_sell.to_account_view(), false, true)?; + sell_cpi.push_account(accounts.vault_usdc.to_account_view(), false, true)?; + sell_cpi.push_account(accounts.router_usdc_treasury.to_account_view(), false, true)?; + sell_cpi.push_account(accounts.router_authority.to_account_view(), false, false)?; + sell_cpi.push_account(accounts.token_program.to_account_view(), false, false)?; + sell_cpi.set_data(&sell_data)?; + sell_cpi.invoke_signed(&seeds)?; + + // Step 2: buy the basket token with USDC. Router `swap_usdc_for_asset` + // order: caller, router_config, asset_rate, usdc_mint, asset_mint, + // caller_usdc_account, caller_asset_account, router_usdc_treasury, + // router_authority, token_program. + let mut buy_data = [0u8; SWAP_DATA_LEN]; + buy_data[0] = ROUTER_SWAP_USDC_FOR_ASSET; + buy_data[1..9].copy_from_slice(&usdc_to_invest.to_le_bytes()); + buy_data[9..17].copy_from_slice(&minimum_buy_amount.to_le_bytes()); + let mut buy_cpi = CpiDynamic::::new(&router_program_addr); + buy_cpi.push_account(accounts.strategy.to_account_view(), true, false)?; + buy_cpi.push_account(accounts.router_config.to_account_view(), false, false)?; + buy_cpi.push_account(accounts.buy_rate.to_account_view(), false, false)?; + buy_cpi.push_account(accounts.usdc_mint.to_account_view(), false, false)?; + buy_cpi.push_account(accounts.buy_mint.to_account_view(), false, true)?; + buy_cpi.push_account(accounts.vault_usdc.to_account_view(), false, true)?; + buy_cpi.push_account(accounts.vault_buy.to_account_view(), false, true)?; + buy_cpi.push_account(accounts.router_usdc_treasury.to_account_view(), false, true)?; + buy_cpi.push_account(accounts.router_authority.to_account_view(), false, false)?; + buy_cpi.push_account(accounts.token_program.to_account_view(), false, false)?; + buy_cpi.set_data(&buy_data)?; + buy_cpi.invoke_signed(&seeds)?; + + Ok(()) +} diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/set_weight.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/set_weight.rs new file mode 100644 index 00000000..d529c662 --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/set_weight.rs @@ -0,0 +1,56 @@ +use quasar_lang::prelude::*; + +use crate::errors::VaultError; +use crate::state::{snapshot_strategy, AssetConfig, AssetConfigInner, Strategy}; + +#[derive(Accounts)] +pub struct SetWeightAccountConstraints { + pub manager: Signer, + + #[account(mut, address = Strategy::seeds(strategy.index.into()), has_one(manager))] + pub strategy: Account, + + #[account( + mut, + address = AssetConfig::seeds(strategy.address(), asset_config.index), + )] + pub asset_config: Account, +} + +/// 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. +#[inline(always)] +pub fn handle_set_weight( + accounts: &mut SetWeightAccountConstraints, + weight_bps: u16, +) -> Result<(), ProgramError> { + let total_weight = u16::from(accounts.strategy.total_weight_bps); + let old_weight = u16::from(accounts.asset_config.weight_bps); + + let new_total = total_weight + .checked_sub(old_weight) + .ok_or(VaultError::MathOverflow)? + .checked_add(weight_bps) + .ok_or(VaultError::MathOverflow)?; + require!(new_total <= 10_000, VaultError::WeightOverflow); + + let mut asset_config = AssetConfigInner { + strategy: accounts.asset_config.strategy, + index: accounts.asset_config.index, + mint: accounts.asset_config.mint, + price_feed: accounts.asset_config.price_feed, + vault: accounts.asset_config.vault, + weight_bps: old_weight, + bump: accounts.asset_config.bump, + }; + asset_config.weight_bps = weight_bps; + accounts.asset_config.set_inner(asset_config); + + let mut strategy = snapshot_strategy(&accounts.strategy); + strategy.total_weight_bps = new_total; + accounts.strategy.set_inner(strategy); + Ok(()) +} diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/whitelist_asset.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/whitelist_asset.rs new file mode 100644 index 00000000..f116b5bd --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/whitelist_asset.rs @@ -0,0 +1,40 @@ +use quasar_lang::prelude::*; +use quasar_spl::prelude::*; + +use crate::state::{Registry, WhitelistEntry, WhitelistEntryInner}; + +#[derive(Accounts)] +pub struct WhitelistAssetAccountConstraints { + #[account(mut)] + pub authority: Signer, + + #[account(address = Registry::seeds(authority.address()), has_one(authority))] + pub registry: Account, + + pub asset_mint: Account, + + #[account( + init, + payer = authority, + address = WhitelistEntry::seeds(registry.address(), asset_mint.address()), + )] + pub whitelist_entry: Account, + + pub rent: Sysvar, + pub system_program: Program, +} + +#[inline(always)] +pub fn handle_whitelist_asset( + accounts: &mut WhitelistAssetAccountConstraints, + price_feed: Address, + bumps: &WhitelistAssetAccountConstraintsBumps, +) -> Result<(), ProgramError> { + accounts.whitelist_entry.set_inner(WhitelistEntryInner { + registry: *accounts.registry.address(), + mint: *accounts.asset_mint.address(), + price_feed, + bump: bumps.whitelist_entry, + }); + Ok(()) +} diff --git a/finance/vault-strategy/quasar/vault-strategy/src/instructions/withdraw.rs b/finance/vault-strategy/quasar/vault-strategy/src/instructions/withdraw.rs new file mode 100644 index 00000000..5da87ce6 --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/instructions/withdraw.rs @@ -0,0 +1,175 @@ +use quasar_lang::prelude::*; +use quasar_lang::remaining::RemainingAccounts; +use quasar_spl::prelude::*; + +use crate::errors::VaultError; +use crate::oracle::{read_mint_decimals, read_token_amount, read_token_mint_and_owner}; +use crate::state::{ + load_asset_config, snapshot_strategy, ShareMintPda, Strategy, UsdcVaultPda, STRATEGY_SEED, +}; + +/// remaining_accounts arrive as, per asset index 0..asset_count: +/// [asset_config, vault, mint, user_token_account] +const ACCOUNTS_PER_ASSET: usize = 4; + +#[derive(Accounts)] +pub struct WithdrawAccountConstraints { + #[account(mut)] + pub user: Signer, + + #[account( + mut, + address = Strategy::seeds(strategy.index.into()), + has_one(usdc_mint) @ VaultError::InvalidUsdcMint, + )] + pub strategy: Account, + + #[account(mut, address = ShareMintPda::seeds(strategy.address()))] + pub share_mint: InterfaceAccount, + + pub usdc_mint: Account, + + #[account(mut)] + pub user_share_account: Account, + + #[account(mut)] + pub user_usdc_account: Account, + + #[account(mut, address = UsdcVaultPda::seeds(strategy.address()))] + pub vault_usdc: InterfaceAccount, + + pub token_program: Program, + pub system_program: Program, +} + +fn get_view(remaining: &RemainingAccounts<'_>, index: usize) -> Result { + let account = remaining + .get(index)? + .ok_or(VaultError::IncompleteAssetAccounts)?; + // SAFETY: read-only forwarding; no mutable alias taken across these views. + Ok(unsafe { account.as_account_view_unchecked() }.clone()) +} + +#[inline(always)] +pub fn handle_withdraw( + accounts: &mut WithdrawAccountConstraints, + remaining: RemainingAccounts<'_>, + shares_to_burn: u64, + min_usdc_out: u64, +) -> Result<(), ProgramError> { + require!(shares_to_burn > 0, VaultError::ZeroShares); + + let total_shares = u64::from(accounts.strategy.total_shares); + require!(total_shares > 0, VaultError::ZeroTotalShares); + + let asset_count = accounts.strategy.asset_count as usize; + require!( + remaining.get(asset_count * ACCOUNTS_PER_ASSET)?.is_none(), + VaultError::IncompleteAssetAccounts + ); + + let vault_usdc_amount = accounts.vault_usdc.amount(); + let usdc_decimals = accounts.usdc_mint.decimals; + let strategy_index = u64::from(accounts.strategy.index); + let strategy_bump = accounts.strategy.bump; + let strategy_key = *accounts.strategy.address(); + let user_key = *accounts.user.address(); + + let shares_u128 = shares_to_burn as u128; + let total_u128 = total_shares as u128; + + // USDC leg, floored in the protocol's favour. + let amount_usdc: u64 = (vault_usdc_amount as u128) + .checked_mul(shares_u128) + .ok_or(VaultError::MathOverflow)? + .checked_div(total_u128) + .ok_or(VaultError::MathOverflow)? + .try_into() + .map_err(|_| VaultError::MathOverflow)?; + require!(amount_usdc >= min_usdc_out, VaultError::UsdcSlippage); + + // Checks-effects-interactions: shrink supply before any transfer. + let mut strategy = snapshot_strategy(&accounts.strategy); + strategy.total_shares = total_shares + .checked_sub(shares_to_burn) + .ok_or(VaultError::MathOverflow)?; + accounts.strategy.set_inner(strategy); + + let index_bytes = strategy_index.to_le_bytes(); + let bump = [strategy_bump]; + let seeds = [ + Seed::from(STRATEGY_SEED), + Seed::from(index_bytes.as_ref()), + Seed::from(bump.as_ref()), + ]; + + // Burn the user's shares (user signs). + accounts + .token_program + .burn( + &accounts.user_share_account, + &accounts.share_mint, + &accounts.user, + shares_to_burn, + ) + .invoke()?; + + // USDC payout (strategy PDA signs). + if amount_usdc > 0 { + accounts + .token_program + .transfer_checked( + &accounts.vault_usdc, + &accounts.usdc_mint, + &accounts.user_usdc_account, + &accounts.strategy, + amount_usdc, + usdc_decimals, + ) + .invoke_signed(&seeds)?; + } + + // Each basket asset, paid in kind, proportional to shares burned. + for i in 0..asset_count { + let config_view = get_view(&remaining, i * ACCOUNTS_PER_ASSET)?; + let vault_view = get_view(&remaining, i * ACCOUNTS_PER_ASSET + 1)?; + let mint_view = get_view(&remaining, i * ACCOUNTS_PER_ASSET + 2)?; + let user_ata_view = get_view(&remaining, i * ACCOUNTS_PER_ASSET + 3)?; + + let config = load_asset_config(&config_view)?; + require_keys_eq!(config.strategy, strategy_key, VaultError::InvalidAssetAccount); + require!(config.index as usize == i, VaultError::InvalidAssetAccount); + require_keys_eq!(*vault_view.address(), config.vault, VaultError::InvalidAssetAccount); + require_keys_eq!(*mint_view.address(), config.mint, VaultError::InvalidAssetAccount); + + let (recipient_mint, recipient_owner) = read_token_mint_and_owner(&user_ata_view)?; + require_keys_eq!(recipient_owner, user_key, VaultError::InvalidRecipient); + require_keys_eq!(recipient_mint, config.mint, VaultError::InvalidRecipient); + + let vault_balance = read_token_amount(&vault_view)?; + let amount: u64 = (vault_balance as u128) + .checked_mul(shares_u128) + .ok_or(VaultError::MathOverflow)? + .checked_div(total_u128) + .ok_or(VaultError::MathOverflow)? + .try_into() + .map_err(|_| VaultError::MathOverflow)?; + + if amount > 0 { + let decimals = read_mint_decimals(&mint_view)?; + accounts + .token_program + .transfer_checked( + &vault_view, + &mint_view, + &user_ata_view, + &accounts.strategy, + amount, + decimals, + ) + .invoke_signed(&seeds)?; + } + } + + Ok(()) +} diff --git a/finance/vault-strategy/quasar/vault-strategy/src/lib.rs b/finance/vault-strategy/quasar/vault-strategy/src/lib.rs new file mode 100644 index 00000000..c90b9572 --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/lib.rs @@ -0,0 +1,138 @@ +#![cfg_attr(not(test), no_std)] + +use quasar_lang::prelude::*; + +pub mod errors; +pub mod instructions; +pub mod oracle; +pub mod state; + +use instructions::*; + +#[cfg(test)] +mod tests; + +declare_id!("VLT5W7bqhRN4nCdRpXm8UfHRxZd9EuZGqiSAkGHQfGh"); + +/// Tokenized multi-asset vault strategy. A manager assembles a basket of +/// whitelisted assets at target weights; depositors receive shares priced at +/// net asset value, and each deposit is immediately deployed into the basket by +/// swapping USDC through a registered router. Withdrawals redeem shares for a +/// proportional slice of every vault. See README.md for the full walkthrough. +#[program] +mod quasar_vault_strategy { + use super::*; + + /// Create a curated whitelist of assets, owned by `authority` (not a + /// manager). + #[instruction(discriminator = 0)] + pub fn initialize_registry( + ctx: Ctx, + ) -> Result<(), ProgramError> { + instructions::initialize_registry::handle_initialize_registry(&mut ctx.accounts, &ctx.bumps) + } + + /// Approve a mint and bind it to its official price feed. Registry authority + /// only. + #[instruction(discriminator = 1)] + pub fn whitelist_asset( + ctx: Ctx, + price_feed: Address, + ) -> Result<(), ProgramError> { + instructions::whitelist_asset::handle_whitelist_asset( + &mut ctx.accounts, + price_feed, + &ctx.bumps, + ) + } + + /// Open a strategy at a caller-chosen index. Manager pays and becomes the + /// strategy's manager. + #[instruction(discriminator = 2)] + pub fn initialize_strategy( + ctx: Ctx, + index: u64, + fee_bps: u16, + max_slippage_bps: u16, + swap_router: Address, + ) -> Result<(), ProgramError> { + instructions::initialize_strategy::handle_initialize_strategy( + &mut ctx.accounts, + index, + fee_bps, + max_slippage_bps, + swap_router, + &ctx.bumps, + ) + } + + /// Add a whitelisted asset to the strategy at the next index. Manager only. + #[instruction(discriminator = 3)] + pub fn add_asset( + ctx: Ctx, + weight_bps: u16, + ) -> Result<(), ProgramError> { + instructions::add_asset::handle_add_asset(&mut ctx.accounts, weight_bps, &ctx.bumps) + } + + /// Change an asset's target weight, or set it to zero to retire it. Manager + /// only. + #[instruction(discriminator = 4)] + pub fn set_weight( + ctx: Ctx, + weight_bps: u16, + ) -> Result<(), ProgramError> { + instructions::set_weight::handle_set_weight(&mut ctx.accounts, weight_bps) + } + + /// Deposit USDC, receive shares priced at net asset value, and immediately + /// deploy the deposit into the basket at its target weights. + #[instruction(discriminator = 5)] + pub fn deposit( + ctx: CtxWithRemaining, + usdc_amount: u64, + minimum_shares: u64, + ) -> Result<(), ProgramError> { + let remaining = ctx.remaining_accounts(); + instructions::deposit::handle_deposit( + &mut ctx.accounts, + remaining, + usdc_amount, + minimum_shares, + ) + } + + /// Accrue the time-based management fee, minting fresh shares to the manager. + #[instruction(discriminator = 6)] + pub fn collect_fees(ctx: Ctx) -> Result<(), ProgramError> { + instructions::collect_fees::handle_collect_fees(&mut ctx.accounts) + } + + /// Burn shares and redeem a proportional slice of the USDC vault and every + /// asset vault, paid in kind. + #[instruction(discriminator = 7)] + pub fn withdraw( + ctx: CtxWithRemaining, + shares_to_burn: u64, + min_usdc_out: u64, + ) -> Result<(), ProgramError> { + let remaining = ctx.remaining_accounts(); + instructions::withdraw::handle_withdraw( + &mut ctx.accounts, + remaining, + shares_to_burn, + min_usdc_out, + ) + } + + /// Sell one basket asset for USDC and buy another with it, keeping the + /// basket near its target weights. Manager only. + #[instruction(discriminator = 8)] + pub fn rebalance( + ctx: Ctx, + sell_amount: u64, + usdc_to_invest: u64, + ) -> Result<(), ProgramError> { + instructions::rebalance::handle_rebalance(&mut ctx.accounts, sell_amount, usdc_to_invest) + } +} diff --git a/finance/vault-strategy/quasar/vault-strategy/src/oracle.rs b/finance/vault-strategy/quasar/vault-strategy/src/oracle.rs new file mode 100644 index 00000000..7e453346 --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/oracle.rs @@ -0,0 +1,107 @@ +use quasar_lang::prelude::*; + +use crate::errors::VaultError; + +// Byte offset of `price` (i64) inside a Pyth PriceUpdateV2 account: +// 8 discriminator + 32 write_authority + 1 verification_level + 32 feed_id = 73 +const PYTH_PRICE_OFFSET: usize = 73; +// Byte offset of `publish_time` (i64): price(8) + conf(8) + exponent(4) after price. +const PYTH_PUBLISH_TIME_OFFSET: usize = PYTH_PRICE_OFFSET + 8 + 8 + 4; // 93 +/// Pyth USD pairs use exponent -8 (price * 10^-8 = dollars per token). +pub const PYTH_PRICE_PRECISION: u128 = 100_000_000; // 10^8 +/// Prices older than this (seconds) are rejected. +const MAX_PRICE_AGE_SECONDS: i64 = 60; + +// SPL token account layout, shared by the Classic and Extensions token programs. +const TOKEN_MINT_OFFSET: usize = 0; // mint: Pubkey [0..32] +const TOKEN_OWNER_OFFSET: usize = 32; // owner: Pubkey [32..64] +const TOKEN_AMOUNT_OFFSET: usize = 64; // amount: u64 [64..72] +// Mint layout: mint_authority option(36) + supply(8) = 44. +const MINT_DECIMALS_OFFSET: usize = 44; + +/// Borrow an account's raw data as a slice. Read-only; used for accounts that +/// are not deserialized into a Quasar wrapper (Pyth feeds, foreign token/mints). +fn account_data(view: &AccountView) -> &[u8] { + // SAFETY: read-only view of the account's bytes, same pattern as the pyth + // basics example. No mutable alias is taken. + unsafe { core::slice::from_raw_parts(view.data_ptr(), view.data_len()) } +} + +fn read_i64(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 8] = data + .get(offset..offset + 8) + .and_then(|slice| slice.try_into().ok()) + .ok_or(VaultError::InvalidPriceFeed)?; + Ok(i64::from_le_bytes(bytes)) +} + +/// Validate a price feed account against the one the strategy registered, then +/// return its positive, fresh price as u128. `now` is the current unix timestamp. +pub fn load_price( + price_feed: &AccountView, + expected_key: &Address, + now: i64, +) -> Result { + if price_feed.address() != expected_key { + return Err(VaultError::InvalidPriceFeed.into()); + } + + let data = account_data(price_feed); + if data.len() < PYTH_PUBLISH_TIME_OFFSET + 8 { + return Err(VaultError::InvalidPriceFeed.into()); + } + let price = read_i64(data, PYTH_PRICE_OFFSET)?; + let publish_time = read_i64(data, PYTH_PUBLISH_TIME_OFFSET)?; + + require!(price > 0, VaultError::NegativePrice); + require!( + now.checked_sub(publish_time) + .ok_or(VaultError::MathOverflow)? + <= MAX_PRICE_AGE_SECONDS, + VaultError::StalePriceFeed + ); + + Ok(price as u128) +} + +/// Read the `amount` field of a token account from its raw data. +pub fn read_token_amount(account: &AccountView) -> Result { + let data = account_data(account); + let bytes: [u8; 8] = data + .get(TOKEN_AMOUNT_OFFSET..TOKEN_AMOUNT_OFFSET + 8) + .and_then(|slice| slice.try_into().ok()) + .ok_or(VaultError::InvalidVaultAccount)?; + Ok(u64::from_le_bytes(bytes)) +} + +/// Read the `decimals` byte of a mint account. +pub fn read_mint_decimals(account: &AccountView) -> Result { + let data = account_data(account); + data.get(MINT_DECIMALS_OFFSET) + .copied() + .ok_or_else(|| VaultError::InvalidVaultAccount.into()) +} + +/// Read the `mint` and `owner` addresses of a token account from its raw data. +pub fn read_token_mint_and_owner(account: &AccountView) -> Result<(Address, Address), ProgramError> { + let data = account_data(account); + if data.len() < TOKEN_OWNER_OFFSET + 32 { + return Err(VaultError::InvalidVaultAccount.into()); + } + let mut mint = [0u8; 32]; + mint.copy_from_slice(&data[TOKEN_MINT_OFFSET..TOKEN_MINT_OFFSET + 32]); + let mut owner = [0u8; 32]; + owner.copy_from_slice(&data[TOKEN_OWNER_OFFSET..TOKEN_OWNER_OFFSET + 32]); + Ok((Address::from(mint), Address::from(owner))) +} + +/// Value of `amount` token minor units in USDC minor units, given a Pyth price. +/// Both USDC and the basket assets use 6 decimals, so the only scaling is the +/// Pyth exponent: value = amount * price / 10^8. Multiply before divide. +pub fn asset_value_in_usdc(amount: u64, price: u128) -> Result { + (amount as u128) + .checked_mul(price) + .ok_or(VaultError::MathOverflow)? + .checked_div(PYTH_PRICE_PRECISION) + .ok_or_else(|| VaultError::MathOverflow.into()) +} diff --git a/finance/vault-strategy/quasar/vault-strategy/src/state/mod.rs b/finance/vault-strategy/quasar/vault-strategy/src/state/mod.rs new file mode 100644 index 00000000..94ac4523 --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/state/mod.rs @@ -0,0 +1,5 @@ +pub mod registry; +pub mod strategy; + +pub use registry::*; +pub use strategy::*; diff --git a/finance/vault-strategy/quasar/vault-strategy/src/state/registry.rs b/finance/vault-strategy/quasar/vault-strategy/src/state/registry.rs new file mode 100644 index 00000000..0dfe321c --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/state/registry.rs @@ -0,0 +1,32 @@ +use quasar_lang::prelude::*; + +pub const REGISTRY_SEED: &[u8] = b"registry"; +pub const WHITELIST_SEED: &[u8] = b"whitelist"; + +/// A curated set of assets that strategies may hold, maintained by a protocol +/// authority that is deliberately not the strategy manager: the authority vets +/// which real assets (and which official price feed) are safe, and the manager +/// only chooses among them. This is what stops a manager from listing a token +/// they mint themselves, or pairing a real mint with a feed they control. +/// +/// PDA: `["registry", authority]`. +#[account(discriminator = 1, set_inner)] +#[seeds(b"registry", authority: Address)] +pub struct Registry { + pub authority: Address, + pub bump: u8, +} + +/// One approved mint, binding it to its official price feed. Created only by the +/// registry authority; `add_asset` copies `price_feed` from here so the manager +/// never supplies the feed. +/// +/// PDA: `["whitelist", registry, mint]`. +#[account(discriminator = 2, set_inner)] +#[seeds(b"whitelist", registry: Address, mint: Address)] +pub struct WhitelistEntry { + pub registry: Address, + pub mint: Address, + pub price_feed: Address, + pub bump: u8, +} diff --git a/finance/vault-strategy/quasar/vault-strategy/src/state/strategy.rs b/finance/vault-strategy/quasar/vault-strategy/src/state/strategy.rs new file mode 100644 index 00000000..c428e122 --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/state/strategy.rs @@ -0,0 +1,110 @@ +use quasar_lang::prelude::*; + +use crate::errors::VaultError; + +pub const STRATEGY_SEED: &[u8] = b"strategy"; +pub const ASSET_CONFIG_SEED: &[u8] = b"asset"; + +/// Largest number of basket assets one strategy can hold. Each asset is its own +/// account, so this is not a storage limit; it bounds how many accounts a +/// deposit or withdraw (which reference every asset at once) must pass in a +/// single transaction. +pub const MAX_ASSETS: u8 = 16; + +/// One strategy (basket). PDA `["strategy", index]`, addressed by a +/// caller-chosen counter rather than the manager's key. The index is stored so +/// every handler can re-derive the PDA to sign for the vaults and share mint. +#[account(discriminator = 3, set_inner)] +#[seeds(b"strategy", index: u64)] +pub struct Strategy { + pub index: u64, + pub manager: Address, + pub registry: Address, + pub share_mint: Address, + pub usdc_mint: Address, + pub swap_router: Address, + pub fee_bps: u16, + pub max_slippage_bps: u16, + pub total_shares: u64, + pub last_fee_accrual_timestamp: i64, + pub asset_count: u8, + pub total_weight_bps: u16, + pub bump: u8, +} + +/// One basket asset. PDA `["asset", strategy, index]`, so the full set is the +/// contiguous range `0..asset_count`: any handler computing net asset value +/// re-derives every index and refuses to proceed if an asset account is missing. +#[account(discriminator = 4, set_inner)] +#[seeds(b"asset", strategy: Address, index: u8)] +pub struct AssetConfig { + pub strategy: Address, + pub index: u8, + pub mint: Address, + /// Price feed account, copied from the registry whitelist entry at add time + /// so the manager cannot substitute a feed they control. + pub price_feed: Address, + /// Strategy-owned token account holding this asset. + pub vault: Address, + pub weight_bps: u16, + pub bump: u8, +} + +/// PDA marker for a strategy's share mint: `["share_mint", strategy]`. +#[derive(Seeds)] +#[seeds(b"share_mint", strategy: Address)] +pub struct ShareMintPda; + +/// PDA marker for a strategy's USDC vault: `["usdc_vault", strategy]`. +#[derive(Seeds)] +#[seeds(b"usdc_vault", strategy: Address)] +pub struct UsdcVaultPda; + +/// PDA marker for one asset's vault: `["asset_vault", strategy, index]`. +#[derive(Seeds)] +#[seeds(b"asset_vault", strategy: Address, index: u8)] +pub struct AssetVaultPda; + +pub fn snapshot_strategy(strategy: &Account) -> StrategyInner { + StrategyInner { + index: u64::from(strategy.index), + manager: strategy.manager, + registry: strategy.registry, + share_mint: strategy.share_mint, + usdc_mint: strategy.usdc_mint, + swap_router: strategy.swap_router, + fee_bps: u16::from(strategy.fee_bps), + max_slippage_bps: u16::from(strategy.max_slippage_bps), + total_shares: u64::from(strategy.total_shares), + last_fee_accrual_timestamp: i64::from(strategy.last_fee_accrual_timestamp), + asset_count: strategy.asset_count, + total_weight_bps: u16::from(strategy.total_weight_bps), + bump: strategy.bump, + } +} + +/// Read-only view of one asset config's fields, used both for declared accounts +/// and for configs passed via remaining accounts. +pub struct AssetConfigView { + pub strategy: Address, + pub index: u8, + pub mint: Address, + pub price_feed: Address, + pub vault: Address, + pub weight_bps: u16, +} + +/// Validate a remaining-account AssetConfig (owner + discriminator) and copy its +/// fields out. Mirrors the Anchor build's `AssetConfig::load_checked`. +pub fn load_asset_config(view: &AccountView) -> Result { + let account = Account::::from_account_view(view) + .map_err(|_| ProgramError::from(VaultError::InvalidAssetAccount))?; + Ok(AssetConfigView { + strategy: account.strategy, + index: account.index, + mint: account.mint, + price_feed: account.price_feed, + vault: account.vault, + weight_bps: u16::from(account.weight_bps), + }) +} diff --git a/finance/vault-strategy/quasar/vault-strategy/src/tests.rs b/finance/vault-strategy/quasar/vault-strategy/src/tests.rs new file mode 100644 index 00000000..7795d1bc --- /dev/null +++ b/finance/vault-strategy/quasar/vault-strategy/src/tests.rs @@ -0,0 +1,503 @@ +//! QuasarSVM integration tests. `test_strategy_setup` drives the manager-side +//! setup (registry, whitelist, strategy, add asset) and asserts state. +//! `test_deposit` is a two-program test: it loads the mock swap router too, +//! wires up rates and a Pyth-shaped price feed, and deposits, checking that the +//! deposit is priced 1:1 on the first deposit and deployed into the basket +//! through the router CPI. + +extern crate std; + +use { + alloc::vec, + alloc::vec::Vec, + quasar_svm::{Account, AccountMeta, Instruction, Pubkey, QuasarSvm}, + solana_program_pack::Pack, + spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, + std::println, +}; + +use crate::state::{ASSET_CONFIG_SEED, REGISTRY_SEED, STRATEGY_SEED, WHITELIST_SEED}; + +const DECIMALS: u8 = 6; +const FEE_BPS: u16 = 100; +const MAX_SLIPPAGE_BPS: u16 = 100; +const STARTING_LAMPORTS: u64 = 1_000_000_000; + +// Router program (loaded for the deposit test). +const ROUTER_ID_STR: &str = "SWPR8Rk3aq3DrDGLdaANq7xCMnXoUFUJWJJmCWxc8Jm"; +const RATE: u64 = 250; // USDC base units per asset base unit +const NOW: i64 = 1_000; // fixed clock for the deposit test + +fn program_id() -> Pubkey { + Pubkey::new_from_array(crate::ID.to_bytes()) +} +fn router_id() -> Pubkey { + ROUTER_ID_STR.parse().unwrap() +} +fn rent_id() -> Pubkey { + quasar_svm::solana_sdk_ids::sysvar::rent::ID +} +fn token_program_id() -> Pubkey { + quasar_svm::SPL_TOKEN_PROGRAM_ID +} +fn system_program_id() -> Pubkey { + quasar_svm::system_program::ID +} + +fn signer_account(address: Pubkey) -> Account { + quasar_svm::token::create_keyed_system_account(&address, STARTING_LAMPORTS) +} +fn empty_account(address: Pubkey) -> Account { + Account { + address, + lamports: 0, + data: vec![], + owner: system_program_id(), + executable: false, + } +} +fn mint_account(address: Pubkey, authority: Pubkey) -> Account { + quasar_svm::token::create_keyed_mint_account( + &address, + &Mint { + mint_authority: Some(authority).into(), + supply: 0, + decimals: DECIMALS, + is_initialized: true, + freeze_authority: None.into(), + }, + ) +} +fn token_account(address: Pubkey, mint: Pubkey, owner: Pubkey, amount: u64) -> Account { + quasar_svm::token::create_keyed_token_account( + &address, + &TokenAccount { + mint, + owner, + amount, + state: AccountState::Initialized, + ..TokenAccount::default() + }, + ) +} +fn token_amount(account: &Account) -> u64 { + TokenAccount::unpack(&account.data).unwrap().amount +} + +// A Pyth PriceUpdateV2-shaped account: `price` (i64) at offset 73, `publish_time` +// (i64) at offset 93. The program reads only those two fields. +fn pyth_feed_account(address: Pubkey, price: i64, publish_time: i64) -> Account { + let mut data = vec![0u8; 200]; + data[73..81].copy_from_slice(&price.to_le_bytes()); + data[93..101].copy_from_slice(&publish_time.to_le_bytes()); + Account { + address, + lamports: 1_000_000, + data, + owner: Pubkey::new_unique(), + executable: false, + } +} + +fn registry_pda(authority: &Pubkey) -> Pubkey { + Pubkey::find_program_address(&[REGISTRY_SEED, authority.as_ref()], &program_id()).0 +} +fn whitelist_pda(registry: &Pubkey, mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[WHITELIST_SEED, registry.as_ref(), mint.as_ref()], + &program_id(), + ) + .0 +} +fn strategy_pda(index: u64) -> Pubkey { + Pubkey::find_program_address(&[STRATEGY_SEED, &index.to_le_bytes()], &program_id()).0 +} +fn share_mint_pda(strategy: &Pubkey) -> Pubkey { + Pubkey::find_program_address(&[b"share_mint", strategy.as_ref()], &program_id()).0 +} +fn usdc_vault_pda(strategy: &Pubkey) -> Pubkey { + Pubkey::find_program_address(&[b"usdc_vault", strategy.as_ref()], &program_id()).0 +} +fn asset_config_pda(strategy: &Pubkey, index: u8) -> Pubkey { + Pubkey::find_program_address( + &[ASSET_CONFIG_SEED, strategy.as_ref(), &[index]], + &program_id(), + ) + .0 +} +fn asset_vault_pda(strategy: &Pubkey, index: u8) -> Pubkey { + Pubkey::find_program_address( + &[b"asset_vault", strategy.as_ref(), &[index]], + &program_id(), + ) + .0 +} +// Router PDAs. +fn router_config_pda() -> Pubkey { + Pubkey::find_program_address(&[b"router_config"], &router_id()).0 +} +fn router_authority_pda() -> Pubkey { + Pubkey::find_program_address(&[b"router_authority"], &router_id()).0 +} +fn router_treasury_pda() -> Pubkey { + Pubkey::find_program_address(&[b"treasury"], &router_id()).0 +} +fn router_rate_pda(mint: &Pubkey) -> Pubkey { + Pubkey::find_program_address(&[b"rate", mint.as_ref()], &router_id()).0 +} + +// --- vault-strategy instruction data --- +fn init_registry_data() -> Vec { + vec![0u8] +} +fn whitelist_data(price_feed: &Pubkey) -> Vec { + let mut data = vec![1u8]; + data.extend_from_slice(price_feed.as_ref()); + data +} +fn init_strategy_data(index: u64, swap_router: &Pubkey) -> Vec { + let mut data = vec![2u8]; + data.extend_from_slice(&index.to_le_bytes()); + data.extend_from_slice(&FEE_BPS.to_le_bytes()); + data.extend_from_slice(&MAX_SLIPPAGE_BPS.to_le_bytes()); + data.extend_from_slice(swap_router.as_ref()); + data +} +fn add_asset_data(weight_bps: u16) -> Vec { + let mut data = vec![3u8]; + data.extend_from_slice(&weight_bps.to_le_bytes()); + data +} +fn deposit_data(usdc_amount: u64, minimum_shares: u64) -> Vec { + let mut data = vec![5u8]; + data.extend_from_slice(&usdc_amount.to_le_bytes()); + data.extend_from_slice(&minimum_shares.to_le_bytes()); + data +} + +// --- router instruction data --- +fn router_init_data() -> Vec { + vec![0u8] +} +fn router_set_rate_data(usdc_per_token: u64) -> Vec { + let mut data = vec![1u8]; + data.extend_from_slice(&usdc_per_token.to_le_bytes()); + data +} + +fn init_registry_ix(authority: Pubkey, registry: Pubkey) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(authority, true), + AccountMeta::new(registry, false), + AccountMeta::new_readonly(rent_id(), false), + AccountMeta::new_readonly(system_program_id(), false), + ], + data: init_registry_data(), + } +} + +fn whitelist_ix( + authority: Pubkey, + registry: Pubkey, + asset_mint: Pubkey, + whitelist: Pubkey, + price_feed: Pubkey, +) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(authority, true), + AccountMeta::new_readonly(registry, false), + AccountMeta::new_readonly(asset_mint, false), + AccountMeta::new(whitelist, false), + AccountMeta::new_readonly(rent_id(), false), + AccountMeta::new_readonly(system_program_id(), false), + ], + data: whitelist_data(&price_feed), + } +} + +#[allow(clippy::too_many_arguments)] +fn init_strategy_ix( + manager: Pubkey, + usdc_mint: Pubkey, + registry: Pubkey, + strategy: Pubkey, + share_mint: Pubkey, + vault_usdc: Pubkey, + index: u64, + swap_router: Pubkey, +) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(manager, true), + AccountMeta::new_readonly(usdc_mint, false), + AccountMeta::new_readonly(registry, false), + AccountMeta::new(strategy, false), + AccountMeta::new(share_mint, false), + AccountMeta::new(vault_usdc, false), + AccountMeta::new_readonly(rent_id(), false), + AccountMeta::new_readonly(token_program_id(), false), + AccountMeta::new_readonly(system_program_id(), false), + ], + data: init_strategy_data(index, &swap_router), + } +} + +#[allow(clippy::too_many_arguments)] +fn add_asset_ix( + manager: Pubkey, + strategy: Pubkey, + registry: Pubkey, + asset_mint: Pubkey, + whitelist: Pubkey, + asset_config: Pubkey, + vault_asset: Pubkey, + weight_bps: u16, +) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(manager, true), + AccountMeta::new(strategy, false), + AccountMeta::new_readonly(registry, false), + AccountMeta::new_readonly(asset_mint, false), + AccountMeta::new_readonly(whitelist, false), + AccountMeta::new(asset_config, false), + AccountMeta::new(vault_asset, false), + AccountMeta::new_readonly(rent_id(), false), + AccountMeta::new_readonly(token_program_id(), false), + AccountMeta::new_readonly(system_program_id(), false), + ], + data: add_asset_data(weight_bps), + } +} + +// Strategy account offsets (1-byte discriminator, tight packing). +const STRATEGY_ASSET_COUNT_OFFSET: usize = 1 + 8 + 32 + 32 + 32 + 32 + 32 + 2 + 2 + 8 + 8; +const STRATEGY_TOTAL_WEIGHT_OFFSET: usize = STRATEGY_ASSET_COUNT_OFFSET + 1; +// AssetConfig weight_bps offset. +const ASSET_CONFIG_WEIGHT_OFFSET: usize = 1 + 32 + 1 + 32 + 32 + 32; + +fn read_u16(data: &[u8], offset: usize) -> u16 { + u16::from_le_bytes([data[offset], data[offset + 1]]) +} + +#[test] +fn test_strategy_setup() { + let mut svm = QuasarSvm::new() + .with_program(&program_id(), &std::fs::read("target/deploy/quasar_vault_strategy.so").unwrap()) + .with_token_program(); + + let authority = Pubkey::new_unique(); + let manager = Pubkey::new_unique(); + let usdc_mint = Pubkey::new_unique(); + let asset_mint = Pubkey::new_unique(); + let price_feed = Pubkey::new_unique(); + let swap_router = router_id(); + + let registry = registry_pda(&authority); + let whitelist = whitelist_pda(®istry, &asset_mint); + let index = 0u64; + let strategy = strategy_pda(index); + let share_mint = share_mint_pda(&strategy); + let vault_usdc = usdc_vault_pda(&strategy); + let asset_config = asset_config_pda(&strategy, 0); + let vault_asset = asset_vault_pda(&strategy, 0); + + let accounts = vec![ + signer_account(authority), + signer_account(manager), + mint_account(usdc_mint, authority), + mint_account(asset_mint, authority), + empty_account(registry), + empty_account(whitelist), + empty_account(strategy), + empty_account(share_mint), + empty_account(vault_usdc), + empty_account(asset_config), + empty_account(vault_asset), + ]; + + let instructions = vec![ + init_registry_ix(authority, registry), + whitelist_ix(authority, registry, asset_mint, whitelist, price_feed), + init_strategy_ix(manager, usdc_mint, registry, strategy, share_mint, vault_usdc, index, swap_router), + add_asset_ix(manager, strategy, registry, asset_mint, whitelist, asset_config, vault_asset, 10_000), + ]; + + let result = svm.process_instruction_chain(&instructions, &accounts); + assert!(result.is_ok(), "setup chain failed: {:?}", result.raw_result); + + let strategy_data = &result.account(&strategy).unwrap().data; + assert_eq!(strategy_data[0], 3, "strategy discriminator"); + assert_eq!(strategy_data[STRATEGY_ASSET_COUNT_OFFSET], 1, "asset_count"); + assert_eq!(read_u16(strategy_data, STRATEGY_TOTAL_WEIGHT_OFFSET), 10_000, "total_weight_bps"); + + let asset_config_data = &result.account(&asset_config).unwrap().data; + assert_eq!(asset_config_data[0], 4, "asset_config discriminator"); + assert_eq!(read_u16(asset_config_data, ASSET_CONFIG_WEIGHT_OFFSET), 10_000, "weight_bps"); + + println!(" STRATEGY SETUP CU: {}", result.compute_units_consumed); +} + +/// Two-program deposit: set up the router + a single-asset strategy, then +/// deposit USDC. The first deposit mints shares 1:1 and deploys the whole amount +/// into the asset through the router CPI. +#[test] +fn test_deposit() { + let mut svm = QuasarSvm::new() + .with_program(&program_id(), &std::fs::read("target/deploy/quasar_vault_strategy.so").unwrap()) + .with_program(&router_id(), &std::fs::read("../mock-swap-router/target/deploy/quasar_mock_swap_router.so").unwrap()) + .with_token_program(); + svm.warp_to_timestamp(NOW); + + let authority = Pubkey::new_unique(); + let manager = Pubkey::new_unique(); + let depositor = Pubkey::new_unique(); + let usdc_mint = Pubkey::new_unique(); + let asset_mint = Pubkey::new_unique(); + let price_feed = Pubkey::new_unique(); + + let registry = registry_pda(&authority); + let whitelist = whitelist_pda(®istry, &asset_mint); + let index = 0u64; + let strategy = strategy_pda(index); + let share_mint = share_mint_pda(&strategy); + let vault_usdc = usdc_vault_pda(&strategy); + let asset_config = asset_config_pda(&strategy, 0); + let vault_asset = asset_vault_pda(&strategy, 0); + + let r_config = router_config_pda(); + let r_authority = router_authority_pda(); + let r_treasury = router_treasury_pda(); + let r_rate = router_rate_pda(&asset_mint); + + let depositor_usdc = Pubkey::new_unique(); + let depositor_share = Pubkey::new_unique(); + + // Asset priced 250 USDC/token: Pyth price = 250 * 10^8 so + // asset_value = amount * price / 10^8 gives 250 USDC per token base unit. + let pyth_price: i64 = 250 * 100_000_000; + + const DEPOSIT: u64 = 1_000; + const ASSET_OUT: u64 = DEPOSIT / RATE; // 4 + + let accounts = vec![ + signer_account(authority), + signer_account(manager), + signer_account(depositor), + // The asset mint is minted by the router authority. + mint_account(usdc_mint, authority), + mint_account(asset_mint, r_authority), + // Router accounts. + empty_account(r_config), + empty_account(r_authority), + empty_account(r_treasury), + empty_account(r_rate), + // Vault-strategy accounts. + empty_account(registry), + empty_account(whitelist), + empty_account(strategy), + empty_account(share_mint), + empty_account(vault_usdc), + empty_account(asset_config), + empty_account(vault_asset), + // Depositor token accounts (share account created up front). + token_account(depositor_usdc, usdc_mint, depositor, DEPOSIT), + token_account(depositor_share, share_mint, depositor, 0), + // Pyth feed. + pyth_feed_account(price_feed, pyth_price, NOW), + ]; + + // Router account order for set_rate: + // authority, router_config, asset_mint, usdc_mint, asset_rate, + // router_authority, router_usdc_treasury, rent, token_program, system_program + let router_set_rate = Instruction { + program_id: router_id(), + accounts: vec![ + AccountMeta::new(authority, true), + AccountMeta::new_readonly(r_config, false), + AccountMeta::new_readonly(asset_mint, false), + AccountMeta::new_readonly(usdc_mint, false), + AccountMeta::new(r_rate, false), + AccountMeta::new_readonly(r_authority, false), + AccountMeta::new(r_treasury, false), + AccountMeta::new_readonly(rent_id(), false), + AccountMeta::new_readonly(token_program_id(), false), + AccountMeta::new_readonly(system_program_id(), false), + ], + data: router_set_rate_data(RATE), + }; + let router_init = Instruction { + program_id: router_id(), + accounts: vec![ + AccountMeta::new(authority, true), + AccountMeta::new_readonly(usdc_mint, false), + AccountMeta::new(r_config, false), + AccountMeta::new_readonly(rent_id(), false), + AccountMeta::new_readonly(token_program_id(), false), + AccountMeta::new_readonly(system_program_id(), false), + ], + data: router_init_data(), + }; + + // Deposit: declared accounts then remaining (asset_config, vault_asset, + // asset_mint, asset_rate, price_feed). + let mut deposit_accounts = vec![ + AccountMeta::new(depositor, true), + AccountMeta::new(strategy, false), + AccountMeta::new(share_mint, false), + AccountMeta::new_readonly(usdc_mint, false), + AccountMeta::new(depositor_usdc, false), + AccountMeta::new(depositor_share, false), + AccountMeta::new(vault_usdc, false), + AccountMeta::new(r_config, false), + AccountMeta::new(r_treasury, false), + AccountMeta::new_readonly(r_authority, false), + AccountMeta::new_readonly(router_id(), false), + AccountMeta::new_readonly(token_program_id(), false), + AccountMeta::new_readonly(system_program_id(), false), + ]; + for meta in [ + AccountMeta::new_readonly(asset_config, false), + AccountMeta::new(vault_asset, false), + AccountMeta::new(asset_mint, false), + AccountMeta::new_readonly(r_rate, false), + AccountMeta::new_readonly(price_feed, false), + ] { + deposit_accounts.push(meta); + } + let deposit_ix = Instruction { + program_id: program_id(), + accounts: deposit_accounts, + data: deposit_data(DEPOSIT, DEPOSIT), + }; + + let instructions = vec![ + router_init, + router_set_rate, + init_registry_ix(authority, registry), + whitelist_ix(authority, registry, asset_mint, whitelist, price_feed), + init_strategy_ix(manager, usdc_mint, registry, strategy, share_mint, vault_usdc, index, router_id()), + add_asset_ix(manager, strategy, registry, asset_mint, whitelist, asset_config, vault_asset, 10_000), + deposit_ix, + ]; + + let result = svm.process_instruction_chain(&instructions, &accounts); + assert!(result.is_ok(), "deposit chain failed: {:?}", result.raw_result); + + // First deposit mints shares 1:1 with USDC. + assert_eq!(token_amount(result.account(&depositor_share).unwrap()), DEPOSIT); + // The deposit was deployed into the asset via the router. + assert_eq!(token_amount(result.account(&vault_asset).unwrap()), ASSET_OUT); + assert_eq!(token_amount(result.account(&depositor_usdc).unwrap()), 0); + assert_eq!(token_amount(result.account(&r_treasury).unwrap()), DEPOSIT); + // All USDC was swapped out of the vault into the asset. + assert_eq!(token_amount(result.account(&vault_usdc).unwrap()), 0); + + println!(" DEPOSIT CU: {}", result.compute_units_consumed); +}