diff --git a/Cargo.lock b/Cargo.lock index 90462839..59f2c1cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,6 +369,21 @@ dependencies = [ "solana-signer", ] +[[package]] +name = "anchor-spl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e2e8058e674e8d6ea7c72dfb8be4349609dd9c3760ce729fc6406199624fe" +dependencies = [ + "anchor-lang", + "spl-associated-token-account-interface", + "spl-pod", + "spl-token-2022-interface", + "spl-token-group-interface", + "spl-token-interface", + "spl-token-metadata-interface", +] + [[package]] name = "anchor-syn" version = "1.0.0" @@ -2094,6 +2109,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "mock-swap-router" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "borsh", + "litesvm", + "solana-keypair", + "solana-kite", + "solana-signer", +] + [[package]] name = "num" version = "0.2.1" @@ -5468,6 +5496,21 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "vault-strategy" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "anchor-spl", + "borsh", + "litesvm", + "mock-swap-router", + "solana-account 3.4.0", + "solana-keypair", + "solana-kite", + "solana-signer", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index cb5007a2..9bb26535 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,10 @@ members = [ "basics/transfer-sol/anchor/programs/*", "basics/transfer-sol/asm", + # defi + "defi/vault-strategy/anchor/programs/vault-strategy", + "defi/vault-strategy/anchor/programs/mock-swap-router", + # tokens "tokens/token-extensions/mint-close-authority/native/program", "tokens/token-extensions/non-transferable/native/program", diff --git a/README.md b/README.md index 9bbb1c7c..d3729187 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,12 @@ Read offchain price data [onchain](https://solana.com/docs/terminology#onchain) [โš“ Anchor](./oracles/pyth/anchor) [๐Ÿ’ซ Quasar](./oracles/pyth/quasar) +### Vault Strategy + +Manager-run investment vault โ€” deposit USDC, manager allocates across a basket of assets (TSLAx, NVDAx), withdraw proportional in-kind assets. Demonstrates share minting, NAV-based pricing, management fee accrual, and CPI to a swap router. + +[โš“ Anchor](./defi/vault-strategy/anchor) + ## Basics ### Hello Solana diff --git a/defi/vault-strategy/anchor/.gitignore b/defi/vault-strategy/anchor/.gitignore new file mode 100644 index 00000000..58b27306 --- /dev/null +++ b/defi/vault-strategy/anchor/.gitignore @@ -0,0 +1,6 @@ +.anchor +.DS_Store +target +**/*.rs.bk +node_modules +test-ledger diff --git a/defi/vault-strategy/anchor/Anchor.toml b/defi/vault-strategy/anchor/Anchor.toml new file mode 100644 index 00000000..dc91cdb3 --- /dev/null +++ b/defi/vault-strategy/anchor/Anchor.toml @@ -0,0 +1,17 @@ +[toolchain] +solana_version = "3.1.8" + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +vault_strategy = "VLT5W7bqhRN4nCdRpXm8UfHRxZd9EuZGqiSAkGHQfGh" +mock_swap_router = "SWPR8Rk3aq3DrDGLdaANq7xCMnXoUFUJWJJmCWxc8Jm" + +[provider] +cluster = "Localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "cargo test" diff --git a/defi/vault-strategy/anchor/Cargo.toml b/defi/vault-strategy/anchor/Cargo.toml new file mode 100644 index 00000000..14a951ce --- /dev/null +++ b/defi/vault-strategy/anchor/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 + +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/defi/vault-strategy/anchor/README.md b/defi/vault-strategy/anchor/README.md new file mode 100644 index 00000000..48239b25 --- /dev/null +++ b/defi/vault-strategy/anchor/README.md @@ -0,0 +1,306 @@ +# Vault Strategy + +A manager-run investment vault on Solana. Users deposit [USDC](https://www.investopedia.com/terms/u/usd-coin-usdc.asp) and receive shares representing proportional ownership of a basket of assets. The manager allocates funds across the basket, earns a fee, and depositors withdraw their proportional slice when they choose. + +The example uses two tokenised stocks as the basket assets: **TSLAx** (Tesla) and **NVDAx** (Nvidia) โ€” [xStocks](https://backed.fi/xstocks) issued on Solana by Backed Finance. In tests these are mock [SPL tokens](https://solana.com/docs/terminology#token). + +--- + +## Programs + +| Program | Description | +|---------|-------------| +| `vault-strategy` | Main vault: deposits, share minting, fee accrual, rebalancing, withdrawals | +| `mock-swap-router` | Test-only fake Jupiter. Stores exchange rates, mints/burns basket tokens for USDC. Replaced by real [Jupiter](https://jup.ag) in production. | + +--- + +## Key Financial Concepts + +### Net Asset Value (NAV) + +[NAV](https://www.investopedia.com/terms/n/nav.asp) is the total dollar value of everything the vault holds right now. This vault computes it as: + +``` +NAV = vault_usdc_balance + + vault_tsla_balance ร— tsla_price_in_usdc + + vault_nvda_balance ร— nvda_price_in_usdc +``` + +NAV answers: *"if we liquidated the entire vault at today's prices, how many USDC would we get?"* It is used to price new deposits fairly โ€” every depositor pays the same per-share price regardless of when they join. + +Prices come from [Pyth Network](https://pyth.network/) oracle accounts (`PriceUpdateV2`). A staleness window of 60 seconds is enforced โ€” deposits fail if either price is older than that. + +### Shares + +A [share](https://www.investopedia.com/terms/s/shares.asp) (also called an LP token or vault token) represents a fraction of the total vault. If you hold 1% of all shares, you own 1% of every asset in the vault. + +- **First deposit**: shares are issued 1:1 with USDC base units (sets an initial share price of 1 USDC). +- **Later deposits**: `shares_to_mint = deposit_usdc ร— total_shares / NAV`. If the vault has grown, each new USDC buys fewer shares โ€” correctly reflecting that the vault is worth more per share than when it started. +- Shares are [SPL tokens](https://solana.com/docs/terminology#token) stored in the depositor's [associated token account (ATA)](https://solana.com/docs/terminology#associated-token-account). + +### Management Fee + +A [management fee](https://www.investopedia.com/terms/m/managementfee.asp) is charged annually as a percentage of assets under management. This vault uses [basis points](https://www.investopedia.com/terms/b/basispoint.asp) (bps) โ€” 100 bps = 1%. + +The fee is collected by *minting new shares to the manager*, which dilutes existing holders proportionally. This avoids the need to know the current price at fee-collection time: + +``` +fee_shares = total_shares ร— fee_bps ร— elapsed_seconds / (10_000 ร— 31_536_000) +``` + +Anyone can call `collect_fees` โ€” it is permissionless. + +### Basket Allocation and Rebalancing + +A [basket](https://www.investopedia.com/terms/b/basket.asp) is a group of assets held together. This vault targets a fixed allocation (e.g., 40% TSLAx, 60% NVDAx). Over time, price movements cause the actual allocation to drift from the target. [Rebalancing](https://www.investopedia.com/terms/r/rebalancing.asp) restores the target by selling the over-weight asset and buying the under-weight one. + +### Slippage + +[Slippage](https://www.investopedia.com/terms/s/slippage.asp) is the difference between the price you expected and the price you actually received. Every instruction that moves tokens accepts a `minimum_*` parameter โ€” the transaction reverts if the output would fall below that floor. + +### In-Kind Withdrawal + +An [in-kind distribution](https://www.investopedia.com/terms/i/in-kind.asp) means you receive the underlying assets themselves, not cash. When you withdraw from this vault you receive a proportional slice of whatever the vault holds at that moment โ€” some USDC, some TSLAx, some NVDAx โ€” rather than a forced conversion to USDC. You can then sell those assets on a DEX yourself. + +--- + +## Program Flow + +### Participants + +| Person | Role | Motivation | +|--------|------|-----------| +| **Alice** | Vault manager | Earn a 1% annual management fee on AUM; run a structured basket strategy she has a thesis on | +| **Bob** | Early depositor | Gain diversified exposure to TSLAx + NVDAx without managing individual positions | +| **Carol** | Later depositor | Join the same strategy after it has been running for a while | + +Alice's `manager` key can be a [Squads](https://squads.so/) multisig address โ€” the vault stores it as a plain `Pubkey` and checks only that the transaction is signed by it. No code change is needed to use a multisig. + +--- + +### Step 1 โ€” Alice initialises the vault + +**Instruction:** `initialize_strategy(weight_bps_a=4000, weight_bps_b=6000, fee_bps=100, swap_router, price_feed_a, price_feed_b)` + +**Accounts created:** + +| Account | Seeds / Derivation | What it stores | +|---------|--------------------|----------------| +| `Strategy` [PDA](https://solana.com/docs/terminology#program-derived-address-pda) | `["strategy", alice_pubkey]` | manager, mint addresses, weights, fee, total shares, fee timestamp, Pyth feed pubkeys | +| `share_mint` PDA | `["share_mint", strategy_pubkey]` | The SPL mint for vault shares. Strategy PDA is mint authority. | +| `vault_usdc` ATA | Associated token account of strategy PDA for USDC | Holds deposited USDC | +| `vault_asset_a` ATA | Associated token account of strategy PDA for TSLAx | Holds TSLAx after investing | +| `vault_asset_b` ATA | Associated token account of strategy PDA for NVDAx | Holds NVDAx after investing | + +--- + +### Step 2 โ€” Bob deposits 1,000 USDC + +**Instruction:** `deposit(usdc_amount=1_000_000_000, minimum_shares=990_000_000)` + +Pyth prices are read; NAV is computed. Since `total_shares == 0` this is the first deposit, so shares are issued 1:1. + +**Accounts modified:** + +| Account | Change | +|---------|--------| +| `bob_usdc_ata` | โˆ’1,000 USDC | +| `vault_usdc` | +1,000 USDC | +| `bob_share_ata` (created) | +1,000,000,000 shares | +| `strategy.total_shares` | 0 โ†’ 1,000,000,000 | + +Bob now holds 100% of the vault. His motivation: rather than buying TSLAx and NVDAx directly and rebalancing himself, he trusts Alice's management and pays her 1% per year for the service. + +--- + +### Step 3 โ€” Alice invests: USDC โ†’ TSLAx and NVDAx + +Alice calls `invest` twice, once per asset, to deploy the deposited USDC into the basket according to the 40/60 target. + +**Instruction (call 1):** `invest(usdc_amount=400_000_000, minimum_asset_out=1_550_000)` โ€” buys TSLAx at $250 + +**Accounts modified (call 1):** + +| Account | Change | +|---------|--------| +| `vault_usdc` | โˆ’400 USDC | +| `vault_asset_a` (TSLAx) | +1,600,000 base units (1.6 TSLAx @ $250) | +| `router_usdc_treasury` | +400 USDC | + +**Instruction (call 2):** `invest(usdc_amount=600_000_000, minimum_asset_out=3_300_000)` โ€” buys NVDAx at $180 + +**Accounts modified (call 2):** + +| Account | Change | +|---------|--------| +| `vault_usdc` | โˆ’600 USDC | +| `vault_asset_b` (NVDAx) | +3,333,333 base units (3.33 NVDAx @ $180) | +| `router_usdc_treasury` | +600 USDC | + +After both calls the vault holds: ~0 USDC, 1.6 TSLAx, 3.33 NVDAx โ€” all worth ~1,000 USDC at current prices. + +--- + +### Step 4 โ€” Carol deposits 1,000 USDC (after investing) + +**Instruction:** `deposit(usdc_amount=1_000_000_000, minimum_shares=990_000_000)` + +Pyth prices are read. NAV โ‰ˆ 1,000 USDC (same total value as before, just now held as basket tokens). The share price is still ~1 USDC per share, so Carol receives approximately the same number of shares as Bob. + +`shares_to_mint = 1,000 USDC ร— 1,000,000,000 shares / 1,000 USDC NAV โ‰ˆ 1,000,000,000` + +**Accounts modified:** + +| Account | Change | +|---------|--------| +| `carol_usdc_ata` | โˆ’1,000 USDC | +| `vault_usdc` | +1,000 USDC | +| `carol_share_ata` (created) | +~1,000,000,000 shares | +| `strategy.total_shares` | ~1,000,000,000 โ†’ ~2,000,000,000 | + +Bob and Carol now each own ~50% of the vault. + +--- + +### Step 5 โ€” Alice rebalances (optional) + +Suppose TSLAx has risen and the allocation has drifted to 45% TSLAx / 55% NVDAx. Alice calls `rebalance` to sell some TSLAx and buy more NVDAx, restoring the 40/60 target. + +**Instruction:** `rebalance(sell_amount=800_000, minimum_usdc_from_sell=195_000_000, usdc_to_invest=200_000_000, minimum_buy_amount=1_100_000)` + +Two CPI legs execute atomically: +1. Sell 800,000 TSLAx base units โ†’ receive ~200 USDC from router treasury +2. Buy NVDAx with 200 USDC โ†’ receive ~1,111,111 NVDAx base units + +**Accounts modified:** + +| Account | Change | +|---------|--------| +| `vault_asset_a` (TSLAx) | โˆ’800,000 base units | +| `vault_usdc` | net zero (briefly +200 USDC, then โˆ’200 USDC) | +| `vault_asset_b` (NVDAx) | +1,111,111 base units | +| `router_usdc_treasury` | net: +USDC from TSLAx sale, โˆ’USDC for NVDAx purchase | + +If either slippage check fails, both legs revert โ€” no partial rebalance. + +--- + +### Step 6 โ€” Alice collects fees + +Six months have elapsed. Anyone calls `collect_fees` (it is permissionless). + +**Instruction:** `collect_fees()` + +``` +fee_shares = 2,000,000,000 ร— 100 bps ร— 15,768,000 s / (10,000 ร— 31,536,000 s) โ‰ˆ 10,000,000 +``` + +**Accounts modified:** + +| Account | Change | +|---------|--------| +| `alice_share_ata` (created if needed) | +10,000,000 shares | +| `share_mint` total supply | +10,000,000 | +| `strategy.total_shares` | โ†’ ~2,010,000,000 | +| `strategy.last_fee_accrual_timestamp` | updated to now | + +Bob and Carol are each diluted by ~0.5%. Alice now holds ~0.5% of the vault. + +--- + +### Step 7 โ€” Bob withdraws + +Bob burns all his shares and receives his proportional slice of the vault in-kind. + +**Instruction:** `withdraw(shares_to_burn=1_000_000_000, min_usdc_out=0, min_asset_a_out=0, min_asset_b_out=0)` + +Bob's proportion: 1,000,000,000 / 2,010,000,000 โ‰ˆ 49.75% + +**Accounts modified:** + +| Account | Change | +|---------|--------| +| `bob_share_ata` | โˆ’1,000,000,000 (burned) | +| `share_mint` total supply | โˆ’1,000,000,000 | +| `strategy.total_shares` | โˆ’1,000,000,000 | +| `vault_usdc` | โˆ’~497 USDC | +| `vault_asset_a` (TSLAx) | โˆ’~49.75% of TSLAx balance | +| `vault_asset_b` (NVDAx) | โˆ’~49.75% of NVDAx balance | +| `bob_usdc_ata` | +~497 USDC | +| `bob_tsla_ata` (created if needed) | +proportional TSLAx | +| `bob_nvda_ata` (created if needed) | +proportional NVDAx | + +Bob receives TSLAx and NVDAx directly in his own ATAs. He can sell them on a DEX if he wants USDC back. + +--- + +## Instruction Reference + +| Instruction | Signer | Key Accounts Read | Key Accounts Written | +|------------|--------|-------------------|----------------------| +| `initialize_strategy` | manager | โ€” | Strategy PDA, share_mint, vault_usdc, vault_asset_a, vault_asset_b | +| `deposit` | depositor | vault_usdc, vault_asset_a, vault_asset_b, price_feed_a, price_feed_b | vault_usdc (+), depositor_usdc_ata (โˆ’), depositor_share_ata (+), strategy.total_shares (+) | +| `invest` | manager | strategy | vault_usdc (โˆ’), vault_asset (+), router_usdc_treasury (+) | +| `rebalance` | manager | strategy | vault_sell (โˆ’), vault_buy (+), vault_usdc (net 0), router_usdc_treasury | +| `collect_fees` | payer (anyone) | strategy, clock | manager_share_ata (+), share_mint supply (+), strategy.total_shares (+), strategy.last_fee_accrual_timestamp | +| `withdraw` | user | strategy | user_share_ata (โˆ’), vault_usdc (โˆ’), vault_asset_a (โˆ’), vault_asset_b (โˆ’), user_usdc_ata (+), user_asset_a_ata (+), user_asset_b_ata (+), strategy.total_shares (โˆ’) | + +--- + +## Oracle Integration (Pyth) + +Prices come from [Pyth Network](https://pyth.network/) `PriceUpdateV2` accounts. Two feed pubkeys are stored in the `Strategy` account at creation time and validated on every deposit via a key constraint. + +- Pyth USD pairs report price with exponent โˆ’8 (i.e., `price ร— 10โปโธ = USD per token`) +- With both USDC and basket tokens using 6 decimal places, the scaling cancels: `usdc_base_per_token_base = price / 10โธ` +- Prices older than 60 seconds are rejected (`StalePriceFeed`) +- Zero or negative prices are rejected (`NegativePrice`) +- Price data is read from raw account bytes at fixed offsets to avoid borsh version incompatibility between the Pyth SDK and Anchor 1.0 + +In tests, mock `PriceUpdateV2` accounts are injected directly into LiteSVM with the Pyth Receiver program as owner (TSLAx at $250, NVDAx at $180). + +--- + +## Mock Swap Router vs Production + +The `mock-swap-router` exists only for testing. It: +- Stores `usdc_per_token` rate in an `AssetRate` PDA per basket token +- Acts as mint authority for basket tokens (`router_authority` PDA signs mint CPIs) +- `swap_usdc_for_asset`: receives USDC into its treasury, mints basket tokens to caller +- `swap_asset_for_usdc`: burns basket tokens from caller, releases USDC from its treasury + +In production, replace the router CPIs in `invest` and `rebalance` with [Jupiter](https://jup.ag) CPI calls. The strategy PDA still signs; only the target program ID and account list change. + +--- + +## Custody and Trust + +This is a **manager-custodial** vault. The strategy [PDA](https://solana.com/docs/terminology#program-derived-address-pda) holds all assets; the manager controls `invest` and `rebalance` with no on-chain constraint that they follow the stated allocation. Depositors trust the manager to act in their interest. + +The `manager` field is a plain `Pubkey`. It can be a [Squads](https://squads.so/) multisig address โ€” the vault checks only that the transaction carries a valid signature from that key. Squads handles threshold approval before the transaction reaches the vault. No program changes are required. + +--- + +## Financial Math Implementation + +- No floating point โ€” integer arithmetic only throughout +- All intermediate products use `u128` to prevent overflow (`u64 ร— u64` overflows at ~1.8 ร— 10ยนโน) +- Multiply before divide to preserve precision +- All arithmetic uses `checked_*` methods โ€” raw `+ - * /` are never used on token amounts +- The user always receives floor division; the protocol retains the rounding remainder +- `transfer_checked` is used for all SPL token transfers (carries decimals through the CPI to catch wrong-mint errors) + +--- + +## Build and Test + +```bash +# Build both programs (requires anchor-cli and solana toolchain) +anchor build + +# Run tests (LiteSVM โ€” no local validator needed) +cargo test +``` + +Tests use [LiteSVM](https://github.com/LiteSVM/litesvm) for fast, self-contained program simulation. Both `.so` files are loaded from `target/deploy/`. The test suite covers all eight instructions including slippage rejection and time-based fee accrual. diff --git a/defi/vault-strategy/anchor/programs/mock-swap-router/Cargo.toml b/defi/vault-strategy/anchor/programs/mock-swap-router/Cargo.toml new file mode 100644 index 00000000..950dca41 --- /dev/null +++ b/defi/vault-strategy/anchor/programs/mock-swap-router/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "mock-swap-router" +version = "0.1.0" +description = "Mock swap router for vault-strategy tests" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "mock_swap_router" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +anchor-lang = { version = "1.0.0", features = ["init-if-needed"] } +anchor-spl = "1.0.0" + +[dev-dependencies] +litesvm = "0.11.0" +solana-signer = "3.0.0" +solana-keypair = "3.0.1" +solana-kite = "0.3.0" +borsh = "1.6.1" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/defi/vault-strategy/anchor/programs/mock-swap-router/Xargo.toml b/defi/vault-strategy/anchor/programs/mock-swap-router/Xargo.toml new file mode 100644 index 00000000..475fb71e --- /dev/null +++ b/defi/vault-strategy/anchor/programs/mock-swap-router/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/defi/vault-strategy/anchor/programs/mock-swap-router/src/error.rs b/defi/vault-strategy/anchor/programs/mock-swap-router/src/error.rs new file mode 100644 index 00000000..a92f8c5a --- /dev/null +++ b/defi/vault-strategy/anchor/programs/mock-swap-router/src/error.rs @@ -0,0 +1,17 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum RouterError { + #[msg("Rate is zero โ€” cannot compute swap")] + ZeroRate, + #[msg("Output is below minimum โ€” slippage exceeded")] + SlippageExceeded, + #[msg("Asset mint does not match rate record")] + InvalidAssetMint, + #[msg("Arithmetic overflow")] + MathOverflow, + #[msg("USDC mint does not match router config")] + WrongUsdcMint, + #[msg("Asset amount in must be greater than zero")] + ZeroAmount, +} diff --git a/defi/vault-strategy/anchor/programs/mock-swap-router/src/instructions/initialize_router.rs b/defi/vault-strategy/anchor/programs/mock-swap-router/src/instructions/initialize_router.rs new file mode 100644 index 00000000..8583a450 --- /dev/null +++ b/defi/vault-strategy/anchor/programs/mock-swap-router/src/instructions/initialize_router.rs @@ -0,0 +1,43 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{Mint, TokenInterface}; + +use crate::state::RouterConfig; + +#[derive(Accounts)] +pub struct InitializeRouterAccountConstraints<'info> { + #[account(mut)] + pub authority: Signer<'info>, + + pub usdc_mint: InterfaceAccount<'info, Mint>, + + #[account( + init, + payer = authority, + space = RouterConfig::DISCRIMINATOR.len() + RouterConfig::INIT_SPACE, + seeds = [b"router_config"], + bump + )] + pub router_config: Account<'info, RouterConfig>, + + /// CHECK: PDA used as mint authority only โ€” no data stored + #[account( + seeds = [b"router_authority"], + bump + )] + pub router_authority: UncheckedAccount<'info>, + + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +pub fn handle_initialize_router( + context: Context, + _usdc_mint: Pubkey, +) -> Result<()> { + context.accounts.router_config.set_inner(RouterConfig { + authority: context.accounts.authority.key(), + usdc_mint: context.accounts.usdc_mint.key(), + bump: context.bumps.router_config, + }); + Ok(()) +} diff --git a/defi/vault-strategy/anchor/programs/mock-swap-router/src/instructions/mod.rs b/defi/vault-strategy/anchor/programs/mock-swap-router/src/instructions/mod.rs new file mode 100644 index 00000000..44288a1b --- /dev/null +++ b/defi/vault-strategy/anchor/programs/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/defi/vault-strategy/anchor/programs/mock-swap-router/src/instructions/set_rate.rs b/defi/vault-strategy/anchor/programs/mock-swap-router/src/instructions/set_rate.rs new file mode 100644 index 00000000..09c5d954 --- /dev/null +++ b/defi/vault-strategy/anchor/programs/mock-swap-router/src/instructions/set_rate.rs @@ -0,0 +1,66 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::state::{AssetRate, RouterConfig}; + +#[derive(Accounts)] +pub struct SetRateAccountConstraints<'info> { + #[account(mut)] + pub authority: Signer<'info>, + + #[account( + has_one = authority, + seeds = [b"router_config"], + bump = router_config.bump + )] + pub router_config: Account<'info, RouterConfig>, + + pub asset_mint: InterfaceAccount<'info, Mint>, + + pub usdc_mint: InterfaceAccount<'info, Mint>, + + #[account( + init_if_needed, + payer = authority, + space = AssetRate::DISCRIMINATOR.len() + AssetRate::INIT_SPACE, + seeds = [b"rate", asset_mint.key().as_ref()], + bump + )] + pub asset_rate: Account<'info, AssetRate>, + + /// CHECK: PDA used as mint authority only + #[account( + seeds = [b"router_authority"], + bump + )] + pub router_authority: UncheckedAccount<'info>, + + #[account( + init_if_needed, + payer = authority, + associated_token::mint = usdc_mint, + associated_token::authority = router_authority, + associated_token::token_program = token_program + )] + pub router_usdc_treasury: InterfaceAccount<'info, TokenAccount>, + + pub associated_token_program: Program<'info, AssociatedToken>, + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +pub fn handle_set_rate( + context: Context, + _mint: Pubkey, + usdc_per_token: u64, +) -> Result<()> { + context.accounts.asset_rate.set_inner(AssetRate { + mint: context.accounts.asset_mint.key(), + usdc_per_token, + bump: context.bumps.asset_rate, + }); + Ok(()) +} diff --git a/defi/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs b/defi/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs new file mode 100644 index 00000000..af3664e0 --- /dev/null +++ b/defi/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs @@ -0,0 +1,120 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{ + burn, transfer_checked, Burn, Mint, TokenAccount, TokenInterface, TransferChecked, + }, +}; + +use crate::error::RouterError; +use crate::state::{AssetRate, RouterConfig}; + +#[derive(Accounts)] +pub struct SwapAssetForUsdcAccountConstraints<'info> { + pub caller: Signer<'info>, + + #[account( + seeds = [b"router_config"], + bump = router_config.bump + )] + pub router_config: Account<'info, RouterConfig>, + + #[account( + constraint = asset_rate.mint == asset_mint.key() @ RouterError::InvalidAssetMint + )] + pub asset_rate: Account<'info, AssetRate>, + + #[account(constraint = usdc_mint.key() == router_config.usdc_mint @ RouterError::WrongUsdcMint)] + pub usdc_mint: InterfaceAccount<'info, Mint>, + + #[account(mut)] + pub asset_mint: InterfaceAccount<'info, Mint>, + + /// Caller's asset token account โ€” asset tokens are burned from here + #[account( + mut, + associated_token::mint = asset_mint, + associated_token::authority = caller, + associated_token::token_program = token_program + )] + pub caller_asset_account: InterfaceAccount<'info, TokenAccount>, + + /// Caller's USDC account โ€” receives the USDC + #[account( + mut, + associated_token::mint = usdc_mint, + associated_token::authority = caller, + associated_token::token_program = token_program + )] + pub caller_usdc_account: InterfaceAccount<'info, TokenAccount>, + + /// Router's USDC treasury โ€” sends the USDC + #[account( + mut, + associated_token::mint = usdc_mint, + associated_token::authority = router_authority, + associated_token::token_program = token_program + )] + pub router_usdc_treasury: InterfaceAccount<'info, TokenAccount>, + + /// CHECK: PDA used as treasury authority โ€” validated by seeds constraint + #[account( + seeds = [b"router_authority"], + bump + )] + pub router_authority: UncheckedAccount<'info>, + + pub associated_token_program: Program<'info, AssociatedToken>, + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +pub fn handle_swap_asset_for_usdc( + context: Context, + asset_amount_in: u64, + minimum_usdc_out: u64, +) -> Result<()> { + let rate = context.accounts.asset_rate.usdc_per_token; + require!(rate > 0, RouterError::ZeroRate); + require!(asset_amount_in > 0, RouterError::ZeroAmount); + + // usdc_out = asset_amount_in * rate (u128 intermediate, protocol gets ceil on sell) + let usdc_out: u64 = (asset_amount_in as u128) + .checked_mul(rate as u128) + .ok_or(RouterError::MathOverflow)? as u64; + + require!(usdc_out >= minimum_usdc_out, RouterError::SlippageExceeded); + + // Burn asset tokens from caller + let burn_accounts = Burn { + mint: context.accounts.asset_mint.to_account_info(), + from: context.accounts.caller_asset_account.to_account_info(), + authority: context.accounts.caller.to_account_info(), + }; + burn( + CpiContext::new(context.accounts.token_program.key(), burn_accounts), + asset_amount_in, + )?; + + // Transfer USDC from router treasury to caller โ€” router_authority PDA signs + let router_authority_bump = context.bumps.router_authority; + let signer_seeds: &[&[&[u8]]] = &[&[b"router_authority", &[router_authority_bump]]]; + + let transfer_accounts = TransferChecked { + from: context.accounts.router_usdc_treasury.to_account_info(), + mint: context.accounts.usdc_mint.to_account_info(), + to: context.accounts.caller_usdc_account.to_account_info(), + authority: context.accounts.router_authority.to_account_info(), + }; + transfer_checked( + CpiContext::new_with_signer( + context.accounts.token_program.key(), + transfer_accounts, + signer_seeds, + ), + usdc_out, + context.accounts.usdc_mint.decimals, + )?; + + Ok(()) +} diff --git a/defi/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs b/defi/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs new file mode 100644 index 00000000..a1da0640 --- /dev/null +++ b/defi/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs @@ -0,0 +1,119 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{ + mint_to, transfer_checked, Mint, MintTo, TokenAccount, TokenInterface, TransferChecked, + }, +}; + +use crate::error::RouterError; +use crate::state::{AssetRate, RouterConfig}; + +#[derive(Accounts)] +pub struct SwapUsdcForAssetAccountConstraints<'info> { + /// The caller โ€” e.g. the vault strategy PDA (can be a signer or a PDA signer via CPI) + pub caller: Signer<'info>, + + #[account( + seeds = [b"router_config"], + bump = router_config.bump + )] + pub router_config: Account<'info, RouterConfig>, + + #[account( + constraint = asset_rate.mint == asset_mint.key() @ RouterError::InvalidAssetMint + )] + pub asset_rate: Account<'info, AssetRate>, + + pub usdc_mint: InterfaceAccount<'info, Mint>, + + #[account(mut)] + pub asset_mint: InterfaceAccount<'info, Mint>, + + /// Caller's USDC token account โ€” USDC flows from here to the treasury + #[account( + mut, + associated_token::mint = usdc_mint, + associated_token::authority = caller, + associated_token::token_program = token_program + )] + pub caller_usdc_account: InterfaceAccount<'info, TokenAccount>, + + /// Caller's asset token account โ€” minted asset tokens land here + #[account( + mut, + associated_token::mint = asset_mint, + associated_token::authority = caller, + associated_token::token_program = token_program + )] + pub caller_asset_account: InterfaceAccount<'info, TokenAccount>, + + /// Router's USDC treasury โ€” receives the USDC payment + #[account( + mut, + associated_token::mint = usdc_mint, + associated_token::authority = router_authority, + associated_token::token_program = token_program + )] + pub router_usdc_treasury: InterfaceAccount<'info, TokenAccount>, + + /// CHECK: PDA used as mint authority โ€” validated by seeds constraint + #[account( + seeds = [b"router_authority"], + bump + )] + pub router_authority: UncheckedAccount<'info>, + + pub associated_token_program: Program<'info, AssociatedToken>, + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +pub fn handle_swap_usdc_for_asset( + context: Context, + usdc_amount_in: u64, + minimum_asset_out: u64, +) -> Result<()> { + let rate = context.accounts.asset_rate.usdc_per_token; + require!(rate > 0, RouterError::ZeroRate); + + // asset_out = usdc_amount_in / rate (u128 intermediate, user gets floor) + let asset_out: u64 = (usdc_amount_in as u128) + .checked_mul(1u128) + .ok_or(RouterError::MathOverflow)? + .checked_div(rate as u128) + .ok_or(RouterError::MathOverflow)? as u64; + + require!( + asset_out >= minimum_asset_out, + RouterError::SlippageExceeded + ); + + // Transfer USDC from caller to router treasury + let transfer_accounts = TransferChecked { + from: context.accounts.caller_usdc_account.to_account_info(), + mint: context.accounts.usdc_mint.to_account_info(), + to: context.accounts.router_usdc_treasury.to_account_info(), + authority: context.accounts.caller.to_account_info(), + }; + let cpi_ctx = CpiContext::new(context.accounts.token_program.key(), transfer_accounts); + transfer_checked(cpi_ctx, usdc_amount_in, context.accounts.usdc_mint.decimals)?; + + // Mint asset tokens to caller โ€” router_authority PDA signs + let router_authority_bump = context.bumps.router_authority; + let signer_seeds: &[&[&[u8]]] = &[&[b"router_authority", &[router_authority_bump]]]; + + let mint_accounts = MintTo { + mint: context.accounts.asset_mint.to_account_info(), + to: context.accounts.caller_asset_account.to_account_info(), + authority: context.accounts.router_authority.to_account_info(), + }; + let cpi_ctx = CpiContext::new_with_signer( + context.accounts.token_program.key(), + mint_accounts, + signer_seeds, + ); + mint_to(cpi_ctx, asset_out)?; + + Ok(()) +} diff --git a/defi/vault-strategy/anchor/programs/mock-swap-router/src/lib.rs b/defi/vault-strategy/anchor/programs/mock-swap-router/src/lib.rs new file mode 100644 index 00000000..7ee35826 --- /dev/null +++ b/defi/vault-strategy/anchor/programs/mock-swap-router/src/lib.rs @@ -0,0 +1,54 @@ +pub mod error; +pub mod instructions; +pub mod state; + +use anchor_lang::prelude::*; + +pub use instructions::*; +pub use state::*; + +declare_id!("SWPR8Rk3aq3DrDGLdaANq7xCMnXoUFUJWJJmCWxc8Jm"); + +#[program] +pub mod mock_swap_router { + use super::*; + + pub fn initialize_router( + context: Context, + usdc_mint: Pubkey, + ) -> Result<()> { + instructions::initialize_router::handle_initialize_router(context, usdc_mint) + } + + pub fn set_rate( + context: Context, + mint: Pubkey, + usdc_per_token: u64, + ) -> Result<()> { + instructions::set_rate::handle_set_rate(context, mint, usdc_per_token) + } + + pub fn swap_usdc_for_asset( + context: Context, + usdc_amount_in: u64, + minimum_asset_out: u64, + ) -> Result<()> { + instructions::swap_usdc_for_asset::handle_swap_usdc_for_asset( + context, + usdc_amount_in, + minimum_asset_out, + ) + } + + pub fn swap_asset_for_usdc( + context: Context, + asset_amount_in: u64, + minimum_usdc_out: u64, + ) -> Result<()> { + instructions::swap_asset_for_usdc::handle_swap_asset_for_usdc( + context, + asset_amount_in, + minimum_usdc_out, + ) + } +} diff --git a/defi/vault-strategy/anchor/programs/mock-swap-router/src/state/asset_rate.rs b/defi/vault-strategy/anchor/programs/mock-swap-router/src/state/asset_rate.rs new file mode 100644 index 00000000..332ebfa8 --- /dev/null +++ b/defi/vault-strategy/anchor/programs/mock-swap-router/src/state/asset_rate.rs @@ -0,0 +1,12 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct AssetRate { + pub mint: Pubkey, + /// USDC base units per token base unit. + /// e.g. 250 means 1 token base unit = 250 USDC base units + /// (so 1.0 TSLAx = $250 when both have 6 decimals) + pub usdc_per_token: u64, + pub bump: u8, +} diff --git a/defi/vault-strategy/anchor/programs/mock-swap-router/src/state/mod.rs b/defi/vault-strategy/anchor/programs/mock-swap-router/src/state/mod.rs new file mode 100644 index 00000000..a9ecd347 --- /dev/null +++ b/defi/vault-strategy/anchor/programs/mock-swap-router/src/state/mod.rs @@ -0,0 +1,5 @@ +pub mod asset_rate; +pub mod router_config; + +pub use asset_rate::*; +pub use router_config::*; diff --git a/defi/vault-strategy/anchor/programs/mock-swap-router/src/state/router_config.rs b/defi/vault-strategy/anchor/programs/mock-swap-router/src/state/router_config.rs new file mode 100644 index 00000000..308a2e4a --- /dev/null +++ b/defi/vault-strategy/anchor/programs/mock-swap-router/src/state/router_config.rs @@ -0,0 +1,9 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct RouterConfig { + pub authority: Pubkey, + pub usdc_mint: Pubkey, + pub bump: u8, +} diff --git a/defi/vault-strategy/anchor/programs/vault-strategy/Cargo.toml b/defi/vault-strategy/anchor/programs/vault-strategy/Cargo.toml new file mode 100644 index 00000000..4ce49a44 --- /dev/null +++ b/defi/vault-strategy/anchor/programs/vault-strategy/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "vault-strategy" +version = "0.1.0" +description = "Manager-run investment vault with NAV-based shares" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "vault_strategy" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +anchor-lang = { version = "1.0.0", features = ["init-if-needed"] } +anchor-spl = "1.0.0" +mock-swap-router = { path = "../mock-swap-router", features = ["cpi"] } + +[dev-dependencies] +litesvm = "0.11.0" +solana-account = "3.0.0" +solana-signer = "3.0.0" +solana-keypair = "3.0.1" +solana-kite = "0.3.0" +borsh = "1.6.1" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/defi/vault-strategy/anchor/programs/vault-strategy/Xargo.toml b/defi/vault-strategy/anchor/programs/vault-strategy/Xargo.toml new file mode 100644 index 00000000..475fb71e --- /dev/null +++ b/defi/vault-strategy/anchor/programs/vault-strategy/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/defi/vault-strategy/anchor/programs/vault-strategy/src/error.rs b/defi/vault-strategy/anchor/programs/vault-strategy/src/error.rs new file mode 100644 index 00000000..e30ee13e --- /dev/null +++ b/defi/vault-strategy/anchor/programs/vault-strategy/src/error.rs @@ -0,0 +1,35 @@ +use anchor_lang::prelude::*; + +#[error_code] +pub enum VaultError { + #[msg("Weights must sum to 10000 basis points")] + InvalidWeights, + #[msg("Shares minted are below the minimum โ€” slippage exceeded")] + SlippageTooHigh, + #[msg("USDC out is below minimum โ€” slippage exceeded")] + UsdcSlippage, + #[msg("Asset A out is below minimum โ€” slippage exceeded")] + AssetASlippage, + #[msg("Asset B out is below minimum โ€” slippage exceeded")] + AssetBSlippage, + #[msg("Asset mint is neither asset_a nor asset_b")] + InvalidAssetMint, + #[msg("No time has elapsed since last fee accrual")] + NoTimeElapsed, + #[msg("Arithmetic overflow")] + MathOverflow, + #[msg("Cannot withdraw zero shares")] + ZeroShares, + #[msg("Cannot deposit zero USDC")] + ZeroDeposit, + #[msg("Total shares are zero โ€” cannot compute proportional withdraw")] + ZeroTotalShares, + #[msg("Price feed account does not match the strategy's registered feed")] + InvalidPriceFeed, + #[msg("Pyth price is zero or negative")] + NegativePrice, + #[msg("Pyth price feed is stale")] + StalePriceFeed, + #[msg("Sell and buy mints must be different")] + SameMint, +} diff --git a/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/collect_fees.rs b/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/collect_fees.rs new file mode 100644 index 00000000..2c9708db --- /dev/null +++ b/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/collect_fees.rs @@ -0,0 +1,104 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{mint_to, Mint, MintTo, TokenAccount, TokenInterface}, +}; + +use crate::error::VaultError; +use crate::state::Strategy; + +const SECONDS_PER_YEAR: u64 = 31_536_000; + +#[derive(Accounts)] +pub struct CollectFeesAccountConstraints<'info> { + /// CHECK: manager is stored in strategy; we only read their pubkey for derivation + pub manager: UncheckedAccount<'info>, + + #[account( + mut, + has_one = manager, + seeds = [b"strategy", strategy.manager.as_ref()], + bump = strategy.bump + )] + pub strategy: Account<'info, Strategy>, + + #[account( + mut, + seeds = [b"share_mint", strategy.key().as_ref()], + bump + )] + pub share_mint: InterfaceAccount<'info, Mint>, + + /// Manager's share token account โ€” receives fee shares + #[account( + init_if_needed, + payer = payer, + associated_token::mint = share_mint, + associated_token::authority = manager, + associated_token::token_program = token_program + )] + pub manager_share_account: InterfaceAccount<'info, TokenAccount>, + + #[account(mut)] + pub payer: Signer<'info>, + + pub associated_token_program: Program<'info, AssociatedToken>, + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +pub fn handle_collect_fees(context: Context) -> Result<()> { + let clock = Clock::get()?; + let current_ts = clock.unix_timestamp; + let last_ts = context.accounts.strategy.last_fee_accrual_timestamp; + + require!(current_ts > last_ts, VaultError::NoTimeElapsed); + + let elapsed_seconds = (current_ts - last_ts) as u64; + let total_shares = context.accounts.strategy.total_shares; + let fee_bps = context.accounts.strategy.fee_bps; + let manager_key = context.accounts.strategy.manager; + let strategy_bump = context.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)? as u64; + + // Update timestamp even if fee_shares rounds to zero + context.accounts.strategy.last_fee_accrual_timestamp = current_ts; + + if fee_shares == 0 { + return Ok(()); + } + + // Checks-effects-interactions: update total_shares before CPI + context.accounts.strategy.total_shares = total_shares + .checked_add(fee_shares) + .ok_or(VaultError::MathOverflow)?; + + // Mint fee shares to manager โ€” strategy PDA signs + let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", manager_key.as_ref(), &[strategy_bump]]]; + + let mint_accounts = MintTo { + mint: context.accounts.share_mint.to_account_info(), + to: context.accounts.manager_share_account.to_account_info(), + authority: context.accounts.strategy.to_account_info(), + }; + let cpi_ctx = CpiContext::new_with_signer( + context.accounts.token_program.key(), + mint_accounts, + signer_seeds, + ); + mint_to(cpi_ctx, fee_shares)?; + + Ok(()) +} diff --git a/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/deposit.rs b/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/deposit.rs new file mode 100644 index 00000000..7659238b --- /dev/null +++ b/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/deposit.rs @@ -0,0 +1,243 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{ + mint_to, transfer_checked, Mint, MintTo, TokenAccount, TokenInterface, TransferChecked, + }, +}; + +use crate::error::VaultError; +use crate::state::Strategy; + +/// Byte offset of `price` (i64) inside a PriceUpdateV2 account data: +/// 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) = +20 bytes after price +const PYTH_PUBLISH_TIME_OFFSET: usize = PYTH_PRICE_OFFSET + 8 + 8 + 4; // 93 + +fn read_pyth_price(account_data: &[u8]) -> Result<(i64, i64)> { + if account_data.len() < PYTH_PUBLISH_TIME_OFFSET + 8 { + return err!(VaultError::InvalidPriceFeed); + } + let price = i64::from_le_bytes( + account_data[PYTH_PRICE_OFFSET..PYTH_PRICE_OFFSET + 8] + .try_into() + .map_err(|_| VaultError::InvalidPriceFeed)?, + ); + let publish_time = i64::from_le_bytes( + account_data[PYTH_PUBLISH_TIME_OFFSET..PYTH_PUBLISH_TIME_OFFSET + 8] + .try_into() + .map_err(|_| VaultError::InvalidPriceFeed)?, + ); + Ok((price, publish_time)) +} + +#[derive(Accounts)] +pub struct DepositAccountConstraints<'info> { + #[account(mut)] + pub depositor: Signer<'info>, + + #[account( + mut, + seeds = [b"strategy", strategy.manager.as_ref()], + bump = strategy.bump + )] + pub strategy: Account<'info, Strategy>, + + #[account( + mut, + seeds = [b"share_mint", strategy.key().as_ref()], + bump + )] + pub share_mint: InterfaceAccount<'info, Mint>, + + pub usdc_mint: InterfaceAccount<'info, Mint>, + + pub asset_mint_a: InterfaceAccount<'info, Mint>, + + pub asset_mint_b: InterfaceAccount<'info, Mint>, + + #[account( + mut, + associated_token::mint = usdc_mint, + associated_token::authority = depositor, + associated_token::token_program = token_program + )] + pub depositor_usdc_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + init_if_needed, + payer = depositor, + associated_token::mint = share_mint, + associated_token::authority = depositor, + associated_token::token_program = token_program + )] + pub depositor_share_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + associated_token::mint = usdc_mint, + associated_token::authority = strategy, + associated_token::token_program = token_program + )] + pub vault_usdc: InterfaceAccount<'info, TokenAccount>, + + #[account( + associated_token::mint = asset_mint_a, + associated_token::authority = strategy, + associated_token::token_program = token_program + )] + pub vault_asset_a: InterfaceAccount<'info, TokenAccount>, + + #[account( + associated_token::mint = asset_mint_b, + associated_token::authority = strategy, + associated_token::token_program = token_program + )] + pub vault_asset_b: InterfaceAccount<'info, TokenAccount>, + + /// CHECK: Pyth PriceUpdateV2 for asset_a โ€” key validated against strategy.price_feed_a + #[account( + constraint = price_feed_a.key() == strategy.price_feed_a @ VaultError::InvalidPriceFeed + )] + pub price_feed_a: UncheckedAccount<'info>, + + /// CHECK: Pyth PriceUpdateV2 for asset_b โ€” key validated against strategy.price_feed_b + #[account( + constraint = price_feed_b.key() == strategy.price_feed_b @ VaultError::InvalidPriceFeed + )] + pub price_feed_b: UncheckedAccount<'info>, + + pub associated_token_program: Program<'info, AssociatedToken>, + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +pub fn handle_deposit( + context: Context, + usdc_amount: u64, + minimum_shares: u64, +) -> Result<()> { + require!(usdc_amount > 0, VaultError::ZeroDeposit); + + // Snapshot all values needed before any mutable borrow + let vault_usdc_amount = context.accounts.vault_usdc.amount; + let vault_asset_a_amount = context.accounts.vault_asset_a.amount; + let vault_asset_b_amount = context.accounts.vault_asset_b.amount; + let total_shares = context.accounts.strategy.total_shares; + let usdc_decimals = context.accounts.usdc_mint.decimals; + let manager_key = context.accounts.strategy.manager; + let strategy_bump = context.accounts.strategy.bump; + + // Read Pyth prices from raw account data (avoids borsh version incompatibility) + let price_feed_a_data = context.accounts.price_feed_a.try_borrow_data()?; + let (price_a, publish_time_a) = read_pyth_price(&price_feed_a_data)?; + + let price_feed_b_data = context.accounts.price_feed_b.try_borrow_data()?; + let (price_b, publish_time_b) = read_pyth_price(&price_feed_b_data)?; + + require!(price_a > 0, VaultError::NegativePrice); + require!(price_b > 0, VaultError::NegativePrice); + + // Pyth price accounts expose publish_time as unix timestamp rather than slot. + // We accept prices up to MAX_PRICE_AGE_SECONDS old. + const MAX_PRICE_AGE_SECONDS: i64 = 60; + let clock = Clock::get()?; + require!( + clock + .unix_timestamp + .checked_sub(publish_time_a) + .ok_or(VaultError::MathOverflow)? + <= MAX_PRICE_AGE_SECONDS, + VaultError::StalePriceFeed + ); + require!( + clock + .unix_timestamp + .checked_sub(publish_time_b) + .ok_or(VaultError::MathOverflow)? + <= MAX_PRICE_AGE_SECONDS, + VaultError::StalePriceFeed + ); + + // Pyth USD pairs use exponent -8 (price * 10^-8 = dollars per token). + // With both USDC and basket tokens at 6 decimals, usdc_base_per_token_base + // = price_dollars * 10^(usdc_decimals - token_decimals) = price_dollars * 1 + // = price * 10^(-8). Integer form (multiply before divide): + // asset_value = vault_balance * price / 10^8 + const PYTH_PRICE_PRECISION: u128 = 100_000_000; // 10^8 + + // Compute NAV: vault_usdc + vault_asset_a * price_a / 10^8 + vault_asset_b * price_b / 10^8 + let usdc_value = vault_usdc_amount as u128; + + let asset_a_value = (vault_asset_a_amount as u128) + .checked_mul(price_a as u128) + .ok_or(VaultError::MathOverflow)? + .checked_div(PYTH_PRICE_PRECISION) + .ok_or(VaultError::MathOverflow)?; + + let asset_b_value = (vault_asset_b_amount as u128) + .checked_mul(price_b as u128) + .ok_or(VaultError::MathOverflow)? + .checked_div(PYTH_PRICE_PRECISION) + .ok_or(VaultError::MathOverflow)?; + + let nav = usdc_value + .checked_add(asset_a_value) + .ok_or(VaultError::MathOverflow)? + .checked_add(asset_b_value) + .ok_or(VaultError::MathOverflow)?; + + // shares = usdc_amount * total_shares / nav (floor) + // first deposit (total_shares == 0): 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)? as u64 + }; + + require!( + shares_to_mint >= minimum_shares, + VaultError::SlippageTooHigh + ); + + // Checks-effects-interactions: update state before CPIs + context.accounts.strategy.total_shares = context + .accounts + .strategy + .total_shares + .checked_add(shares_to_mint) + .ok_or(VaultError::MathOverflow)?; + + // Transfer USDC from depositor to vault + let transfer_accounts = TransferChecked { + from: context.accounts.depositor_usdc_account.to_account_info(), + mint: context.accounts.usdc_mint.to_account_info(), + to: context.accounts.vault_usdc.to_account_info(), + authority: context.accounts.depositor.to_account_info(), + }; + let cpi_ctx = CpiContext::new(context.accounts.token_program.key(), transfer_accounts); + transfer_checked(cpi_ctx, usdc_amount, usdc_decimals)?; + + // Mint shares to depositor โ€” strategy PDA signs + let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", manager_key.as_ref(), &[strategy_bump]]]; + + let mint_accounts = MintTo { + mint: context.accounts.share_mint.to_account_info(), + to: context.accounts.depositor_share_account.to_account_info(), + authority: context.accounts.strategy.to_account_info(), + }; + let cpi_ctx = CpiContext::new_with_signer( + context.accounts.token_program.key(), + mint_accounts, + signer_seeds, + ); + mint_to(cpi_ctx, shares_to_mint)?; + + Ok(()) +} diff --git a/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_strategy.rs b/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_strategy.rs new file mode 100644 index 00000000..9726d584 --- /dev/null +++ b/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_strategy.rs @@ -0,0 +1,114 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::error::VaultError; +use crate::state::Strategy; + +#[derive(Accounts)] +pub struct InitializeStrategyAccountConstraints<'info> { + #[account(mut)] + pub manager: Signer<'info>, + + pub usdc_mint: InterfaceAccount<'info, Mint>, + + pub asset_mint_a: InterfaceAccount<'info, Mint>, + + pub asset_mint_b: InterfaceAccount<'info, Mint>, + + #[account( + init, + payer = manager, + space = Strategy::DISCRIMINATOR.len() + Strategy::INIT_SPACE, + seeds = [b"strategy", manager.key().as_ref()], + bump + )] + pub strategy: Account<'info, Strategy>, + + #[account( + init, + payer = manager, + mint::decimals = 6, + mint::authority = strategy, + mint::freeze_authority = strategy, + mint::token_program = token_program, + seeds = [b"share_mint", strategy.key().as_ref()], + bump + )] + pub share_mint: InterfaceAccount<'info, Mint>, + + /// Vault's USDC token account โ€” strategy PDA is the authority + #[account( + init, + payer = manager, + associated_token::mint = usdc_mint, + associated_token::authority = strategy, + associated_token::token_program = token_program + )] + pub vault_usdc: InterfaceAccount<'info, TokenAccount>, + + /// Vault's asset_a token account โ€” strategy PDA is the authority + #[account( + init, + payer = manager, + associated_token::mint = asset_mint_a, + associated_token::authority = strategy, + associated_token::token_program = token_program + )] + pub vault_asset_a: InterfaceAccount<'info, TokenAccount>, + + /// Vault's asset_b token account โ€” strategy PDA is the authority + #[account( + init, + payer = manager, + associated_token::mint = asset_mint_b, + associated_token::authority = strategy, + associated_token::token_program = token_program + )] + pub vault_asset_b: InterfaceAccount<'info, TokenAccount>, + + pub associated_token_program: Program<'info, AssociatedToken>, + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +pub fn handle_initialize_strategy( + context: Context, + weight_bps_a: u16, + weight_bps_b: u16, + fee_bps: u16, + swap_router: Pubkey, + price_feed_a: Pubkey, + price_feed_b: Pubkey, +) -> Result<()> { + require!( + weight_bps_a + .checked_add(weight_bps_b) + .ok_or(VaultError::InvalidWeights)? + == 10_000, + VaultError::InvalidWeights + ); + + let clock = Clock::get()?; + + context.accounts.strategy.set_inner(Strategy { + manager: context.accounts.manager.key(), + share_mint: context.accounts.share_mint.key(), + usdc_mint: context.accounts.usdc_mint.key(), + asset_mint_a: context.accounts.asset_mint_a.key(), + asset_mint_b: context.accounts.asset_mint_b.key(), + weight_bps_a, + weight_bps_b, + fee_bps, + total_shares: 0, + last_fee_accrual_timestamp: clock.unix_timestamp, + swap_router, + price_feed_a, + price_feed_b, + bump: context.bumps.strategy, + }); + + Ok(()) +} diff --git a/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/invest.rs b/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/invest.rs new file mode 100644 index 00000000..8893ade0 --- /dev/null +++ b/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/invest.rs @@ -0,0 +1,112 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; +use mock_swap_router::{ + cpi::accounts::SwapUsdcForAssetAccountConstraints as RouterSwapAccounts, state::AssetRate, +}; + +use crate::error::VaultError; +use crate::state::Strategy; + +#[derive(Accounts)] +pub struct InvestAccountConstraints<'info> { + /// Only the manager may call invest + pub manager: Signer<'info>, + + #[account( + mut, + has_one = manager, + seeds = [b"strategy", strategy.manager.as_ref()], + bump = strategy.bump + )] + pub strategy: Account<'info, Strategy>, + + pub usdc_mint: InterfaceAccount<'info, Mint>, + + /// The asset mint to buy โ€” must be asset_mint_a or asset_mint_b + #[account(mut)] + pub asset_mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + associated_token::mint = usdc_mint, + associated_token::authority = strategy, + associated_token::token_program = token_program + )] + pub vault_usdc: InterfaceAccount<'info, TokenAccount>, + + /// Vault's asset token account for the asset being bought + #[account( + mut, + associated_token::mint = asset_mint, + associated_token::authority = strategy, + associated_token::token_program = token_program + )] + pub vault_asset: InterfaceAccount<'info, TokenAccount>, + + pub asset_rate: Account<'info, AssetRate>, + + /// CHECK: Router config PDA from the mock-swap-router program + #[account(mut)] + pub router_config: UncheckedAccount<'info>, + + /// CHECK: Router USDC treasury ATA + #[account(mut)] + pub router_usdc_treasury: UncheckedAccount<'info>, + + /// CHECK: Router authority PDA from the mock-swap-router program + #[account(mut)] + pub router_authority: UncheckedAccount<'info>, + + pub swap_router_program: Program<'info, mock_swap_router::program::MockSwapRouter>, + + pub associated_token_program: Program<'info, AssociatedToken>, + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +pub fn handle_invest( + context: Context, + usdc_amount: u64, + minimum_asset_out: u64, +) -> Result<()> { + let strategy = &context.accounts.strategy; + + // Validate asset mint is one of the two basket assets + require!( + context.accounts.asset_mint.key() == strategy.asset_mint_a + || context.accounts.asset_mint.key() == strategy.asset_mint_b, + VaultError::InvalidAssetMint + ); + + let manager_key = strategy.manager; + let strategy_bump = strategy.bump; + let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", manager_key.as_ref(), &[strategy_bump]]]; + + let cpi_accounts = RouterSwapAccounts { + caller: context.accounts.strategy.to_account_info(), + router_config: context.accounts.router_config.to_account_info(), + asset_rate: context.accounts.asset_rate.to_account_info(), + usdc_mint: context.accounts.usdc_mint.to_account_info(), + asset_mint: context.accounts.asset_mint.to_account_info(), + caller_usdc_account: context.accounts.vault_usdc.to_account_info(), + caller_asset_account: context.accounts.vault_asset.to_account_info(), + router_usdc_treasury: context.accounts.router_usdc_treasury.to_account_info(), + router_authority: context.accounts.router_authority.to_account_info(), + associated_token_program: context.accounts.associated_token_program.to_account_info(), + token_program: context.accounts.token_program.to_account_info(), + system_program: context.accounts.system_program.to_account_info(), + }; + + let cpi_ctx = CpiContext::new_with_signer( + context.accounts.swap_router_program.key(), + cpi_accounts, + signer_seeds, + ); + + mock_swap_router::cpi::swap_usdc_for_asset(cpi_ctx, usdc_amount, minimum_asset_out)?; + + Ok(()) +} diff --git a/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/mod.rs b/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/mod.rs new file mode 100644 index 00000000..485d6bc7 --- /dev/null +++ b/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/mod.rs @@ -0,0 +1,13 @@ +pub mod collect_fees; +pub mod deposit; +pub mod initialize_strategy; +pub mod invest; +pub mod rebalance; +pub mod withdraw; + +pub use collect_fees::*; +pub use deposit::*; +pub use initialize_strategy::*; +pub use invest::*; +pub use rebalance::*; +pub use withdraw::*; diff --git a/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/rebalance.rs b/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/rebalance.rs new file mode 100644 index 00000000..619c7d50 --- /dev/null +++ b/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/rebalance.rs @@ -0,0 +1,165 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; +use mock_swap_router::{ + cpi::accounts::SwapAssetForUsdcAccountConstraints as RouterSellAccounts, + cpi::accounts::SwapUsdcForAssetAccountConstraints as RouterBuyAccounts, state::AssetRate, +}; + +use crate::error::VaultError; +use crate::state::Strategy; + +#[derive(Accounts)] +pub struct RebalanceAccountConstraints<'info> { + pub manager: Signer<'info>, + + #[account( + mut, + has_one = manager, + seeds = [b"strategy", strategy.manager.as_ref()], + bump = strategy.bump + )] + pub strategy: Account<'info, Strategy>, + + pub usdc_mint: InterfaceAccount<'info, Mint>, + + /// The basket token being sold + #[account(mut)] + pub sell_mint: InterfaceAccount<'info, Mint>, + + /// The basket token being bought + #[account(mut)] + pub buy_mint: InterfaceAccount<'info, Mint>, + + /// Vault's token account for the asset being sold + #[account( + mut, + associated_token::mint = sell_mint, + associated_token::authority = strategy, + associated_token::token_program = token_program + )] + pub vault_sell: InterfaceAccount<'info, TokenAccount>, + + /// Vault's token account for the asset being bought + #[account( + mut, + associated_token::mint = buy_mint, + associated_token::authority = strategy, + associated_token::token_program = token_program + )] + pub vault_buy: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + associated_token::mint = usdc_mint, + associated_token::authority = strategy, + associated_token::token_program = token_program + )] + pub vault_usdc: InterfaceAccount<'info, TokenAccount>, + + pub sell_rate: Account<'info, AssetRate>, + + pub buy_rate: Account<'info, AssetRate>, + + /// CHECK: Router config PDA + #[account(mut)] + pub router_config: UncheckedAccount<'info>, + + /// CHECK: Router USDC treasury ATA + #[account(mut)] + pub router_usdc_treasury: UncheckedAccount<'info>, + + /// CHECK: Router authority PDA + #[account(mut)] + pub router_authority: UncheckedAccount<'info>, + + pub swap_router_program: Program<'info, mock_swap_router::program::MockSwapRouter>, + + pub associated_token_program: Program<'info, AssociatedToken>, + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +pub fn handle_rebalance( + context: Context, + sell_amount: u64, + minimum_usdc_from_sell: u64, + usdc_to_invest: u64, + minimum_buy_amount: u64, +) -> Result<()> { + let strategy = &context.accounts.strategy; + + // Both sell and buy mints must be registered basket assets + require!( + context.accounts.sell_mint.key() == strategy.asset_mint_a + || context.accounts.sell_mint.key() == strategy.asset_mint_b, + VaultError::InvalidAssetMint + ); + require!( + context.accounts.buy_mint.key() == strategy.asset_mint_a + || context.accounts.buy_mint.key() == strategy.asset_mint_b, + VaultError::InvalidAssetMint + ); + require!( + context.accounts.sell_mint.key() != context.accounts.buy_mint.key(), + VaultError::SameMint + ); + + let manager_key = strategy.manager; + let strategy_bump = strategy.bump; + let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", manager_key.as_ref(), &[strategy_bump]]]; + + // Step 1: sell basket token โ†’ USDC + let sell_cpi_accounts = RouterSellAccounts { + caller: context.accounts.strategy.to_account_info(), + router_config: context.accounts.router_config.to_account_info(), + asset_rate: context.accounts.sell_rate.to_account_info(), + usdc_mint: context.accounts.usdc_mint.to_account_info(), + asset_mint: context.accounts.sell_mint.to_account_info(), + caller_asset_account: context.accounts.vault_sell.to_account_info(), + caller_usdc_account: context.accounts.vault_usdc.to_account_info(), + router_usdc_treasury: context.accounts.router_usdc_treasury.to_account_info(), + router_authority: context.accounts.router_authority.to_account_info(), + associated_token_program: context.accounts.associated_token_program.to_account_info(), + token_program: context.accounts.token_program.to_account_info(), + system_program: context.accounts.system_program.to_account_info(), + }; + mock_swap_router::cpi::swap_asset_for_usdc( + CpiContext::new_with_signer( + context.accounts.swap_router_program.key(), + sell_cpi_accounts, + signer_seeds, + ), + sell_amount, + minimum_usdc_from_sell, + )?; + + // Step 2: buy basket token with USDC + let buy_cpi_accounts = RouterBuyAccounts { + caller: context.accounts.strategy.to_account_info(), + router_config: context.accounts.router_config.to_account_info(), + asset_rate: context.accounts.buy_rate.to_account_info(), + usdc_mint: context.accounts.usdc_mint.to_account_info(), + asset_mint: context.accounts.buy_mint.to_account_info(), + caller_usdc_account: context.accounts.vault_usdc.to_account_info(), + caller_asset_account: context.accounts.vault_buy.to_account_info(), + router_usdc_treasury: context.accounts.router_usdc_treasury.to_account_info(), + router_authority: context.accounts.router_authority.to_account_info(), + associated_token_program: context.accounts.associated_token_program.to_account_info(), + token_program: context.accounts.token_program.to_account_info(), + system_program: context.accounts.system_program.to_account_info(), + }; + mock_swap_router::cpi::swap_usdc_for_asset( + CpiContext::new_with_signer( + context.accounts.swap_router_program.key(), + buy_cpi_accounts, + signer_seeds, + ), + usdc_to_invest, + minimum_buy_amount, + )?; + + Ok(()) +} diff --git a/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/withdraw.rs b/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/withdraw.rs new file mode 100644 index 00000000..0338a7b7 --- /dev/null +++ b/defi/vault-strategy/anchor/programs/vault-strategy/src/instructions/withdraw.rs @@ -0,0 +1,214 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{ + burn, transfer_checked, Burn, Mint, TokenAccount, TokenInterface, TransferChecked, + }, +}; + +use crate::error::VaultError; +use crate::state::Strategy; + +#[derive(Accounts)] +pub struct WithdrawAccountConstraints<'info> { + #[account(mut)] + pub user: Signer<'info>, + + #[account( + mut, + seeds = [b"strategy", strategy.manager.as_ref()], + bump = strategy.bump + )] + pub strategy: Account<'info, Strategy>, + + #[account( + mut, + seeds = [b"share_mint", strategy.key().as_ref()], + bump + )] + pub share_mint: InterfaceAccount<'info, Mint>, + + pub usdc_mint: InterfaceAccount<'info, Mint>, + + pub asset_mint_a: InterfaceAccount<'info, Mint>, + + pub asset_mint_b: InterfaceAccount<'info, Mint>, + + #[account( + mut, + associated_token::mint = share_mint, + associated_token::authority = user, + associated_token::token_program = token_program + )] + pub user_share_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + init_if_needed, + payer = user, + associated_token::mint = usdc_mint, + associated_token::authority = user, + associated_token::token_program = token_program + )] + pub user_usdc_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + init_if_needed, + payer = user, + associated_token::mint = asset_mint_a, + associated_token::authority = user, + associated_token::token_program = token_program + )] + pub user_asset_a_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + init_if_needed, + payer = user, + associated_token::mint = asset_mint_b, + associated_token::authority = user, + associated_token::token_program = token_program + )] + pub user_asset_b_account: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + associated_token::mint = usdc_mint, + associated_token::authority = strategy, + associated_token::token_program = token_program + )] + pub vault_usdc: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + associated_token::mint = asset_mint_a, + associated_token::authority = strategy, + associated_token::token_program = token_program + )] + pub vault_asset_a: InterfaceAccount<'info, TokenAccount>, + + #[account( + mut, + associated_token::mint = asset_mint_b, + associated_token::authority = strategy, + associated_token::token_program = token_program + )] + pub vault_asset_b: InterfaceAccount<'info, TokenAccount>, + + pub associated_token_program: Program<'info, AssociatedToken>, + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +pub fn handle_withdraw( + context: Context, + shares_to_burn: u64, + min_usdc_out: u64, + min_asset_a_out: u64, + min_asset_b_out: u64, +) -> Result<()> { + require!(shares_to_burn > 0, VaultError::ZeroShares); + + let total_shares = context.accounts.strategy.total_shares; + require!(total_shares > 0, VaultError::ZeroTotalShares); + + // Snapshot values before any state mutation + let vault_usdc_amount = context.accounts.vault_usdc.amount; + let vault_asset_a_amount = context.accounts.vault_asset_a.amount; + let vault_asset_b_amount = context.accounts.vault_asset_b.amount; + let usdc_decimals = context.accounts.usdc_mint.decimals; + let asset_a_decimals = context.accounts.asset_mint_a.decimals; + let asset_b_decimals = context.accounts.asset_mint_b.decimals; + let manager_key = context.accounts.strategy.manager; + let strategy_bump = context.accounts.strategy.bump; + + let shares_u128 = shares_to_burn as u128; + let total_u128 = total_shares as u128; + + // Proportional amounts โ€” floor division (user gets floor) + let amount_usdc: u64 = (vault_usdc_amount as u128) + .checked_mul(shares_u128) + .ok_or(VaultError::MathOverflow)? + .checked_div(total_u128) + .ok_or(VaultError::MathOverflow)? as u64; + + let amount_a: u64 = (vault_asset_a_amount as u128) + .checked_mul(shares_u128) + .ok_or(VaultError::MathOverflow)? + .checked_div(total_u128) + .ok_or(VaultError::MathOverflow)? as u64; + + let amount_b: u64 = (vault_asset_b_amount as u128) + .checked_mul(shares_u128) + .ok_or(VaultError::MathOverflow)? + .checked_div(total_u128) + .ok_or(VaultError::MathOverflow)? as u64; + + require!(amount_usdc >= min_usdc_out, VaultError::UsdcSlippage); + require!(amount_a >= min_asset_a_out, VaultError::AssetASlippage); + require!(amount_b >= min_asset_b_out, VaultError::AssetBSlippage); + + // Checks-effects-interactions: update total_shares before any CPIs + context.accounts.strategy.total_shares = total_shares + .checked_sub(shares_to_burn) + .ok_or(VaultError::MathOverflow)?; + + let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", manager_key.as_ref(), &[strategy_bump]]]; + + // Burn shares (user is signer authority) + let burn_accounts = Burn { + mint: context.accounts.share_mint.to_account_info(), + from: context.accounts.user_share_account.to_account_info(), + authority: context.accounts.user.to_account_info(), + }; + let cpi_ctx = CpiContext::new(context.accounts.token_program.key(), burn_accounts); + burn(cpi_ctx, shares_to_burn)?; + + // Transfer USDC from vault to user + if amount_usdc > 0 { + let transfer_accounts = TransferChecked { + from: context.accounts.vault_usdc.to_account_info(), + mint: context.accounts.usdc_mint.to_account_info(), + to: context.accounts.user_usdc_account.to_account_info(), + authority: context.accounts.strategy.to_account_info(), + }; + let cpi_ctx = CpiContext::new_with_signer( + context.accounts.token_program.key(), + transfer_accounts, + signer_seeds, + ); + transfer_checked(cpi_ctx, amount_usdc, usdc_decimals)?; + } + + // Transfer asset_a from vault to user + if amount_a > 0 { + let transfer_accounts = TransferChecked { + from: context.accounts.vault_asset_a.to_account_info(), + mint: context.accounts.asset_mint_a.to_account_info(), + to: context.accounts.user_asset_a_account.to_account_info(), + authority: context.accounts.strategy.to_account_info(), + }; + let cpi_ctx = CpiContext::new_with_signer( + context.accounts.token_program.key(), + transfer_accounts, + signer_seeds, + ); + transfer_checked(cpi_ctx, amount_a, asset_a_decimals)?; + } + + // Transfer asset_b from vault to user + if amount_b > 0 { + let transfer_accounts = TransferChecked { + from: context.accounts.vault_asset_b.to_account_info(), + mint: context.accounts.asset_mint_b.to_account_info(), + to: context.accounts.user_asset_b_account.to_account_info(), + authority: context.accounts.strategy.to_account_info(), + }; + let cpi_ctx = CpiContext::new_with_signer( + context.accounts.token_program.key(), + transfer_accounts, + signer_seeds, + ); + transfer_checked(cpi_ctx, amount_b, asset_b_decimals)?; + } + + Ok(()) +} diff --git a/defi/vault-strategy/anchor/programs/vault-strategy/src/lib.rs b/defi/vault-strategy/anchor/programs/vault-strategy/src/lib.rs new file mode 100644 index 00000000..c6f1da0d --- /dev/null +++ b/defi/vault-strategy/anchor/programs/vault-strategy/src/lib.rs @@ -0,0 +1,87 @@ +pub mod error; +pub mod instructions; +pub mod state; + +use anchor_lang::prelude::*; + +pub use instructions::*; +pub use state::*; + +declare_id!("VLT5W7bqhRN4nCdRpXm8UfHRxZd9EuZGqiSAkGHQfGh"); + +#[program] +pub mod vault_strategy { + use super::*; + + pub fn initialize_strategy( + context: Context, + weight_bps_a: u16, + weight_bps_b: u16, + fee_bps: u16, + swap_router: Pubkey, + price_feed_a: Pubkey, + price_feed_b: Pubkey, + ) -> Result<()> { + instructions::initialize_strategy::handle_initialize_strategy( + context, + weight_bps_a, + weight_bps_b, + fee_bps, + swap_router, + price_feed_a, + price_feed_b, + ) + } + + pub fn deposit( + context: Context, + usdc_amount: u64, + minimum_shares: u64, + ) -> Result<()> { + instructions::deposit::handle_deposit(context, usdc_amount, minimum_shares) + } + + pub fn invest( + context: Context, + usdc_amount: u64, + minimum_asset_out: u64, + ) -> Result<()> { + instructions::invest::handle_invest(context, usdc_amount, minimum_asset_out) + } + + pub fn collect_fees(context: Context) -> Result<()> { + instructions::collect_fees::handle_collect_fees(context) + } + + pub fn withdraw( + context: Context, + shares_to_burn: u64, + min_usdc_out: u64, + min_asset_a_out: u64, + min_asset_b_out: u64, + ) -> Result<()> { + instructions::withdraw::handle_withdraw( + context, + shares_to_burn, + min_usdc_out, + min_asset_a_out, + min_asset_b_out, + ) + } + + pub fn rebalance( + context: Context, + sell_amount: u64, + minimum_usdc_from_sell: u64, + usdc_to_invest: u64, + minimum_buy_amount: u64, + ) -> Result<()> { + instructions::rebalance::handle_rebalance( + context, + sell_amount, + minimum_usdc_from_sell, + usdc_to_invest, + minimum_buy_amount, + ) + } +} diff --git a/defi/vault-strategy/anchor/programs/vault-strategy/src/state/mod.rs b/defi/vault-strategy/anchor/programs/vault-strategy/src/state/mod.rs new file mode 100644 index 00000000..781e460e --- /dev/null +++ b/defi/vault-strategy/anchor/programs/vault-strategy/src/state/mod.rs @@ -0,0 +1,3 @@ +pub mod strategy; + +pub use strategy::*; diff --git a/defi/vault-strategy/anchor/programs/vault-strategy/src/state/strategy.rs b/defi/vault-strategy/anchor/programs/vault-strategy/src/state/strategy.rs new file mode 100644 index 00000000..f5f9f8ed --- /dev/null +++ b/defi/vault-strategy/anchor/programs/vault-strategy/src/state/strategy.rs @@ -0,0 +1,23 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct Strategy { + pub manager: Pubkey, + pub share_mint: Pubkey, + pub usdc_mint: Pubkey, + pub asset_mint_a: Pubkey, + pub asset_mint_b: Pubkey, + /// Allocation weight for asset A in basis points (e.g. 4000 = 40%) + pub weight_bps_a: u16, + /// Allocation weight for asset B in basis points (e.g. 6000 = 60%) + pub weight_bps_b: u16, + /// Annual management fee in basis points (e.g. 100 = 1%) + pub fee_bps: u16, + pub total_shares: u64, + pub last_fee_accrual_timestamp: i64, + pub swap_router: Pubkey, + pub price_feed_a: Pubkey, // Pyth PriceUpdateV2 account for asset_mint_a + pub price_feed_b: Pubkey, // Pyth PriceUpdateV2 account for asset_mint_b + pub bump: u8, +} diff --git a/defi/vault-strategy/anchor/tests/vault_strategy.rs b/defi/vault-strategy/anchor/tests/vault_strategy.rs new file mode 100644 index 00000000..128c039d --- /dev/null +++ b/defi/vault-strategy/anchor/tests/vault_strategy.rs @@ -0,0 +1,990 @@ +use { + anchor_lang::{ + solana_program::{clock::Clock, instruction::Instruction, pubkey::Pubkey, system_program}, + InstructionData, ToAccountMetas, + }, + litesvm::LiteSVM, + solana_account::Account as SolanaAccount, + solana_keypair::Keypair, + solana_kite::{ + create_associated_token_account, create_token_mint, create_wallet, + get_token_account_balance, mint_tokens_to_token_account, send_transaction_from_instructions, + }, + solana_signer::Signer, +}; + +fn token_program_id() -> Pubkey { + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + .parse() + .unwrap() +} + +fn ata_program_id() -> Pubkey { + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + .parse() + .unwrap() +} + +fn pyth_receiver_program_id() -> Pubkey { + "rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ" + .parse() + .unwrap() +} + +fn derive_ata(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { + let (ata, _bump) = Pubkey::find_program_address( + &[wallet.as_ref(), token_program_id().as_ref(), mint.as_ref()], + &ata_program_id(), + ); + ata +} + +/// Build a mock PriceUpdateV2 account data buffer. +/// Layout (matching pyth-solana-receiver-sdk PriceUpdateV2): +/// [0..8] discriminator sha256("account:PriceUpdateV2")[..8] +/// [8..40] write_authority (Pubkey, 32 bytes) +/// [40] verification_level (1 byte enum: Full = 1) +/// [41..73] feed_id ([u8;32]) +/// [73..81] price (i64 LE) +/// [81..89] conf (u64 LE) +/// [89..93] exponent (i32 LE) +/// [93..101] publish_time (i64 LE) +/// [101..109] prev_publish_time (i64 LE) +/// [109..117] ema_price (i64 LE) +/// [117..125] ema_conf (u64 LE) +/// [125..133] posted_slot (u64 LE) +fn build_mock_price_update_account(price: i64, exponent: i32, publish_time: i64) -> Vec { + let discriminator: [u8; 8] = [34, 241, 35, 99, 157, 126, 244, 205]; + let mut data = Vec::with_capacity(133); + data.extend_from_slice(&discriminator); + data.extend_from_slice(&[0u8; 32]); // write_authority placeholder + data.push(1u8); // verification_level: Full + data.extend_from_slice(&[0xEFu8; 32]); // feed_id + data.extend_from_slice(&price.to_le_bytes()); + data.extend_from_slice(&100_000u64.to_le_bytes()); // conf + data.extend_from_slice(&exponent.to_le_bytes()); + data.extend_from_slice(&publish_time.to_le_bytes()); + data.extend_from_slice(&(publish_time - 1).to_le_bytes()); // prev_publish_time + data.extend_from_slice(&price.to_le_bytes()); // ema_price + data.extend_from_slice(&120_000u64.to_le_bytes()); // ema_conf + data.extend_from_slice(&1u64.to_le_bytes()); // posted_slot + data +} + +/// Fixed publish time matching the test clock +const PUBLISH_TIME: i64 = 1_700_000_000; + +struct TestContext { + svm: LiteSVM, + vault_program_id: Pubkey, + router_program_id: Pubkey, + manager: Keypair, + payer: Keypair, + usdc_mint: Pubkey, + tsla_mint: Pubkey, + nvda_mint: Pubkey, + strategy_pda: Pubkey, + share_mint_pda: Pubkey, + router_config_pda: Pubkey, + router_authority_pda: Pubkey, + tsla_rate_pda: Pubkey, + nvda_rate_pda: Pubkey, + vault_usdc: Pubkey, + vault_tsla: Pubkey, + vault_nvda: Pubkey, + router_usdc_treasury: Pubkey, + price_feed_tsla: Pubkey, + price_feed_nvda: Pubkey, +} + +fn setup_full() -> TestContext { + let vault_program_id = vault_strategy::id(); + let router_program_id = mock_swap_router::id(); + + let mut svm = LiteSVM::new(); + + let vault_bytes = include_bytes!("../../../target/deploy/vault_strategy.so"); + let router_bytes = include_bytes!("../../../target/deploy/mock_swap_router.so"); + + svm.add_program(vault_program_id, vault_bytes).unwrap(); + svm.add_program(router_program_id, router_bytes).unwrap(); + + // Set a fixed clock so Pyth staleness check passes + svm.set_sysvar(&Clock { + slot: 1, + epoch_start_timestamp: PUBLISH_TIME, + epoch: 0, + leader_schedule_epoch: 0, + unix_timestamp: PUBLISH_TIME, + }); + + let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); + let manager = create_wallet(&mut svm, 10_000_000_000).unwrap(); + + let decimals: u8 = 6; + + // Create mints โ€” payer is initial mint authority (we'll transfer TSLAx/NVDAx to router_authority) + let usdc_mint = create_token_mint(&mut svm, &payer, decimals, None).unwrap(); + + // Derive router_authority PDA before creating mints + let (router_authority_pda, _) = + Pubkey::find_program_address(&[b"router_authority"], &router_program_id); + + // TSLAx and NVDAx have router_authority as mint authority + let tsla_mint = + create_token_mint(&mut svm, &payer, decimals, Some(&router_authority_pda)).unwrap(); + let nvda_mint = + create_token_mint(&mut svm, &payer, decimals, Some(&router_authority_pda)).unwrap(); + + // Derive PDAs + let (strategy_pda, _) = Pubkey::find_program_address( + &[b"strategy", manager.pubkey().as_ref()], + &vault_program_id, + ); + let (share_mint_pda, _) = Pubkey::find_program_address( + &[b"share_mint", strategy_pda.as_ref()], + &vault_program_id, + ); + let (router_config_pda, _) = + Pubkey::find_program_address(&[b"router_config"], &router_program_id); + let (tsla_rate_pda, _) = + Pubkey::find_program_address(&[b"rate", tsla_mint.as_ref()], &router_program_id); + let (nvda_rate_pda, _) = + Pubkey::find_program_address(&[b"rate", nvda_mint.as_ref()], &router_program_id); + + // ATAs + let vault_usdc = derive_ata(&strategy_pda, &usdc_mint); + let vault_tsla = derive_ata(&strategy_pda, &tsla_mint); + let vault_nvda = derive_ata(&strategy_pda, &nvda_mint); + let router_usdc_treasury = derive_ata(&router_authority_pda, &usdc_mint); + + // Create mock Pyth price feed accounts + // TSLAx: $250 = 25_000_000_000 * 10^-8 + let price_feed_tsla_key = Keypair::new(); + let tsla_data = build_mock_price_update_account(25_000_000_000i64, -8i32, PUBLISH_TIME); + let rent_tsla = svm.minimum_balance_for_rent_exemption(tsla_data.len()); + svm.set_account( + price_feed_tsla_key.pubkey(), + SolanaAccount { + lamports: rent_tsla, + data: tsla_data, + owner: pyth_receiver_program_id(), + executable: false, + rent_epoch: 0, + }, + ) + .unwrap(); + + // NVDAx: $180 = 18_000_000_000 * 10^-8 + let price_feed_nvda_key = Keypair::new(); + let nvda_data = build_mock_price_update_account(18_000_000_000i64, -8i32, PUBLISH_TIME); + let rent_nvda = svm.minimum_balance_for_rent_exemption(nvda_data.len()); + svm.set_account( + price_feed_nvda_key.pubkey(), + SolanaAccount { + lamports: rent_nvda, + data: nvda_data, + owner: pyth_receiver_program_id(), + executable: false, + rent_epoch: 0, + }, + ) + .unwrap(); + + let price_feed_tsla = price_feed_tsla_key.pubkey(); + let price_feed_nvda = price_feed_nvda_key.pubkey(); + + // Step 1: Initialize router + let init_router_ix = Instruction::new_with_bytes( + router_program_id, + &mock_swap_router::instruction::InitializeRouter { + usdc_mint, + } + .data(), + mock_swap_router::accounts::InitializeRouterAccountConstraints { + authority: payer.pubkey(), + usdc_mint, + router_config: router_config_pda, + router_authority: router_authority_pda, + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut svm, + vec![init_router_ix], + &[&payer], + &payer.pubkey(), + ) + .unwrap(); + + // Step 2: Set TSLAx rate = 250 usdc per token + let set_tsla_rate_ix = Instruction::new_with_bytes( + router_program_id, + &mock_swap_router::instruction::SetRate { + mint: tsla_mint, + usdc_per_token: 250, + } + .data(), + mock_swap_router::accounts::SetRateAccountConstraints { + authority: payer.pubkey(), + router_config: router_config_pda, + asset_mint: tsla_mint, + usdc_mint, + asset_rate: tsla_rate_pda, + router_authority: router_authority_pda, + router_usdc_treasury, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut svm, + vec![set_tsla_rate_ix], + &[&payer], + &payer.pubkey(), + ) + .unwrap(); + + // Step 3: Set NVDAx rate = 180 usdc per token + let set_nvda_rate_ix = Instruction::new_with_bytes( + router_program_id, + &mock_swap_router::instruction::SetRate { + mint: nvda_mint, + usdc_per_token: 180, + } + .data(), + mock_swap_router::accounts::SetRateAccountConstraints { + authority: payer.pubkey(), + router_config: router_config_pda, + asset_mint: nvda_mint, + usdc_mint, + asset_rate: nvda_rate_pda, + router_authority: router_authority_pda, + router_usdc_treasury, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut svm, + vec![set_nvda_rate_ix], + &[&payer], + &payer.pubkey(), + ) + .unwrap(); + + // Step 4: Seed the router USDC treasury with 10,000 USDC so swap_asset_for_usdc can pay out + mint_tokens_to_token_account( + &mut svm, + &usdc_mint, + &router_usdc_treasury, + 10_000_000_000u64, // 10,000 USDC + &payer, + ) + .unwrap(); + + TestContext { + svm, + vault_program_id, + router_program_id, + manager, + payer, + usdc_mint, + tsla_mint, + nvda_mint, + strategy_pda, + share_mint_pda, + router_config_pda, + router_authority_pda, + tsla_rate_pda, + nvda_rate_pda, + vault_usdc, + vault_tsla, + vault_nvda, + router_usdc_treasury, + price_feed_tsla, + price_feed_nvda, + } +} + +fn initialize_strategy(ctx: &mut TestContext) { + let init_strategy_ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::InitializeStrategy { + weight_bps_a: 4000, + weight_bps_b: 6000, + fee_bps: 100, + swap_router: ctx.router_program_id, + price_feed_a: ctx.price_feed_tsla, + price_feed_b: ctx.price_feed_nvda, + } + .data(), + vault_strategy::accounts::InitializeStrategyAccountConstraints { + manager: ctx.manager.pubkey(), + usdc_mint: ctx.usdc_mint, + asset_mint_a: ctx.tsla_mint, + asset_mint_b: ctx.nvda_mint, + strategy: ctx.strategy_pda, + share_mint: ctx.share_mint_pda, + vault_usdc: ctx.vault_usdc, + vault_asset_a: ctx.vault_tsla, + vault_asset_b: ctx.vault_nvda, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut ctx.svm, + vec![init_strategy_ix], + &[&ctx.payer, &ctx.manager], + &ctx.payer.pubkey(), + ) + .unwrap(); +} + +#[test] +fn test_initialize_strategy() { + let mut ctx = setup_full(); + initialize_strategy(&mut ctx); + + // Verify strategy PDA exists + assert!( + ctx.svm.get_account(&ctx.strategy_pda).is_some(), + "Strategy PDA should exist" + ); + + // Verify share mint exists + assert!( + ctx.svm.get_account(&ctx.share_mint_pda).is_some(), + "Share mint PDA should exist" + ); + + // Verify vault ATAs exist + assert!( + ctx.svm.get_account(&ctx.vault_usdc).is_some(), + "Vault USDC ATA should exist" + ); + assert!( + ctx.svm.get_account(&ctx.vault_tsla).is_some(), + "Vault TSLAx ATA should exist" + ); + assert!( + ctx.svm.get_account(&ctx.vault_nvda).is_some(), + "Vault NVDAx ATA should exist" + ); +} + +#[test] +fn test_deposit_first() { + let mut ctx = setup_full(); + initialize_strategy(&mut ctx); + + let user = create_wallet(&mut ctx.svm, 10_000_000_000).unwrap(); + let deposit_amount: u64 = 1_000_000; // 1 USDC + + let user_usdc = + create_associated_token_account(&mut ctx.svm, &user.pubkey(), &ctx.usdc_mint, &ctx.payer) + .unwrap(); + let user_share = derive_ata(&user.pubkey(), &ctx.share_mint_pda); + + mint_tokens_to_token_account(&mut ctx.svm, &ctx.usdc_mint, &user_usdc, deposit_amount, &ctx.payer) + .unwrap(); + + let deposit_ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Deposit { + usdc_amount: deposit_amount, + minimum_shares: deposit_amount, // 1:1 on first deposit + } + .data(), + vault_strategy::accounts::DepositAccountConstraints { + depositor: user.pubkey(), + strategy: ctx.strategy_pda, + share_mint: ctx.share_mint_pda, + usdc_mint: ctx.usdc_mint, + asset_mint_a: ctx.tsla_mint, + asset_mint_b: ctx.nvda_mint, + depositor_usdc_account: user_usdc, + depositor_share_account: user_share, + vault_usdc: ctx.vault_usdc, + vault_asset_a: ctx.vault_tsla, + vault_asset_b: ctx.vault_nvda, + price_feed_a: ctx.price_feed_tsla, + price_feed_b: ctx.price_feed_nvda, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + + send_transaction_from_instructions( + &mut ctx.svm, + vec![deposit_ix], + &[&ctx.payer, &user], + &ctx.payer.pubkey(), + ) + .unwrap(); + + // First deposit is 1:1 โ€” shares == usdc_amount + let share_balance = get_token_account_balance(&ctx.svm, &user_share).unwrap(); + assert_eq!(share_balance, deposit_amount, "First deposit should be 1:1"); + + let vault_usdc_balance = get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(); + assert_eq!(vault_usdc_balance, deposit_amount, "Vault USDC should hold deposit"); +} + +fn do_deposit(ctx: &mut TestContext, user: &Keypair, usdc_amount: u64) -> Pubkey { + let user_usdc = derive_ata(&user.pubkey(), &ctx.usdc_mint); + let user_share = derive_ata(&user.pubkey(), &ctx.share_mint_pda); + + let deposit_ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Deposit { + usdc_amount, + minimum_shares: 0, + } + .data(), + vault_strategy::accounts::DepositAccountConstraints { + depositor: user.pubkey(), + strategy: ctx.strategy_pda, + share_mint: ctx.share_mint_pda, + usdc_mint: ctx.usdc_mint, + asset_mint_a: ctx.tsla_mint, + asset_mint_b: ctx.nvda_mint, + depositor_usdc_account: user_usdc, + depositor_share_account: user_share, + vault_usdc: ctx.vault_usdc, + vault_asset_a: ctx.vault_tsla, + vault_asset_b: ctx.vault_nvda, + price_feed_a: ctx.price_feed_tsla, + price_feed_b: ctx.price_feed_nvda, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + + send_transaction_from_instructions( + &mut ctx.svm, + vec![deposit_ix], + &[&ctx.payer, user], + &ctx.payer.pubkey(), + ) + .unwrap(); + + user_share +} + +#[test] +fn test_invest() { + let mut ctx = setup_full(); + initialize_strategy(&mut ctx); + + // Setup user and deposit 10 USDC + let user = create_wallet(&mut ctx.svm, 10_000_000_000).unwrap(); + let deposit_amount: u64 = 10_000_000; // 10 USDC + let user_usdc = + create_associated_token_account(&mut ctx.svm, &user.pubkey(), &ctx.usdc_mint, &ctx.payer) + .unwrap(); + mint_tokens_to_token_account( + &mut ctx.svm, + &ctx.usdc_mint, + &user_usdc, + deposit_amount, + &ctx.payer, + ) + .unwrap(); + do_deposit(&mut ctx, &user, deposit_amount); + + // Invest 4 USDC into TSLAx (rate=250, so 4/250 = 0.016 TSLAx = 16000 tokens at 6 decimals) + let invest_amount: u64 = 4_000_000; + let invest_ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Invest { + usdc_amount: invest_amount, + minimum_asset_out: 0, + } + .data(), + vault_strategy::accounts::InvestAccountConstraints { + manager: ctx.manager.pubkey(), + strategy: ctx.strategy_pda, + usdc_mint: ctx.usdc_mint, + asset_mint: ctx.tsla_mint, + vault_usdc: ctx.vault_usdc, + vault_asset: ctx.vault_tsla, + asset_rate: ctx.tsla_rate_pda, + router_config: ctx.router_config_pda, + router_usdc_treasury: ctx.router_usdc_treasury, + router_authority: ctx.router_authority_pda, + swap_router_program: ctx.router_program_id, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + + send_transaction_from_instructions( + &mut ctx.svm, + vec![invest_ix], + &[&ctx.payer, &ctx.manager], + &ctx.payer.pubkey(), + ) + .unwrap(); + + // 4_000_000 USDC / 250 rate = 16_000 TSLAx tokens + let tsla_balance = get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(); + assert_eq!(tsla_balance, 16_000, "Vault should hold 16000 TSLAx tokens"); + + let usdc_balance = get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(); + assert_eq!( + usdc_balance, + deposit_amount - invest_amount, + "Vault USDC should decrease by invest amount" + ); +} + +#[test] +fn test_deposit_after_invest() { + let mut ctx = setup_full(); + initialize_strategy(&mut ctx); + + // Alice deposits 10 USDC first + let alice = create_wallet(&mut ctx.svm, 10_000_000_000).unwrap(); + let alice_deposit: u64 = 10_000_000; + let alice_usdc = + create_associated_token_account(&mut ctx.svm, &alice.pubkey(), &ctx.usdc_mint, &ctx.payer) + .unwrap(); + mint_tokens_to_token_account( + &mut ctx.svm, + &ctx.usdc_mint, + &alice_usdc, + alice_deposit, + &ctx.payer, + ) + .unwrap(); + let alice_share = do_deposit(&mut ctx, &alice, alice_deposit); + + let alice_shares = get_token_account_balance(&ctx.svm, &alice_share).unwrap(); + assert_eq!(alice_shares, 10_000_000, "Alice first deposit 1:1"); + + // Manager invests 4 USDC into TSLAx + let invest_ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Invest { + usdc_amount: 4_000_000, + minimum_asset_out: 0, + } + .data(), + vault_strategy::accounts::InvestAccountConstraints { + manager: ctx.manager.pubkey(), + strategy: ctx.strategy_pda, + usdc_mint: ctx.usdc_mint, + asset_mint: ctx.tsla_mint, + vault_usdc: ctx.vault_usdc, + vault_asset: ctx.vault_tsla, + asset_rate: ctx.tsla_rate_pda, + router_config: ctx.router_config_pda, + router_usdc_treasury: ctx.router_usdc_treasury, + router_authority: ctx.router_authority_pda, + swap_router_program: ctx.router_program_id, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut ctx.svm, + vec![invest_ix], + &[&ctx.payer, &ctx.manager], + &ctx.payer.pubkey(), + ) + .unwrap(); + + // NAV after invest (using Pyth prices, PYTH_PRICE_PRECISION = 10^8): + // vault_usdc = 6_000_000 + // vault_tsla = 16_000 tokens * 25_000_000_000 / 10^8 = 16_000 * 250 = 4_000_000 USDC value + // total NAV = 10_000_000 (same as before) + // total_shares = 10_000_000 + // share price = 1.0 USDC per share (unchanged) + + // Bob deposits 5 USDC + let bob = create_wallet(&mut ctx.svm, 10_000_000_000).unwrap(); + let bob_deposit: u64 = 5_000_000; + let bob_usdc = + create_associated_token_account(&mut ctx.svm, &bob.pubkey(), &ctx.usdc_mint, &ctx.payer) + .unwrap(); + mint_tokens_to_token_account( + &mut ctx.svm, + &ctx.usdc_mint, + &bob_usdc, + bob_deposit, + &ctx.payer, + ) + .unwrap(); + let bob_share = do_deposit(&mut ctx, &bob, bob_deposit); + + // shares = 5_000_000 * 10_000_000 / 10_000_000 = 5_000_000 + let bob_shares = get_token_account_balance(&ctx.svm, &bob_share).unwrap(); + assert_eq!(bob_shares, 5_000_000, "Bob should get 5M shares at par"); +} + +#[test] +fn test_collect_fees() { + let mut ctx = setup_full(); + initialize_strategy(&mut ctx); + + // Deposit 1M USDC so there are shares outstanding + let user = create_wallet(&mut ctx.svm, 100_000_000_000).unwrap(); + let deposit_amount: u64 = 1_000_000_000; // 1000 USDC + let user_usdc = + create_associated_token_account(&mut ctx.svm, &user.pubkey(), &ctx.usdc_mint, &ctx.payer) + .unwrap(); + mint_tokens_to_token_account( + &mut ctx.svm, + &ctx.usdc_mint, + &user_usdc, + deposit_amount, + &ctx.payer, + ) + .unwrap(); + do_deposit(&mut ctx, &user, deposit_amount); + + // Advance clock by 1 year to trigger fee accrual + let current_clock = ctx.svm.get_sysvar::(); + ctx.svm.set_sysvar(&Clock { + slot: current_clock.slot + 1_000_000, + epoch_start_timestamp: current_clock.epoch_start_timestamp, + epoch: current_clock.epoch + 100, + leader_schedule_epoch: current_clock.leader_schedule_epoch + 100, + unix_timestamp: current_clock.unix_timestamp + 31_536_000i64, + }); + + let manager_share = derive_ata(&ctx.manager.pubkey(), &ctx.share_mint_pda); + + let collect_fees_ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::CollectFees {}.data(), + vault_strategy::accounts::CollectFeesAccountConstraints { + manager: ctx.manager.pubkey(), + strategy: ctx.strategy_pda, + share_mint: ctx.share_mint_pda, + manager_share_account: manager_share, + payer: ctx.payer.pubkey(), + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + + send_transaction_from_instructions( + &mut ctx.svm, + vec![collect_fees_ix], + &[&ctx.payer], + &ctx.payer.pubkey(), + ) + .unwrap(); + + // Fee = 1000_000_000 * 100 / 10_000 * (31_536_000 / 31_536_000) = 10_000_000 + // ~1% of 1000 USDC worth of shares = 10 USDC worth of shares + let fee_shares = get_token_account_balance(&ctx.svm, &manager_share).unwrap(); + assert!(fee_shares > 0, "Manager should receive fee shares"); + // 1% of 1_000_000_000 = 10_000_000 + assert_eq!(fee_shares, 10_000_000, "Annual fee should be 1% of total shares"); +} + +#[test] +fn test_withdraw() { + let mut ctx = setup_full(); + initialize_strategy(&mut ctx); + + // User deposits 10 USDC + let user = create_wallet(&mut ctx.svm, 10_000_000_000).unwrap(); + let deposit_amount: u64 = 10_000_000; + let user_usdc = + create_associated_token_account(&mut ctx.svm, &user.pubkey(), &ctx.usdc_mint, &ctx.payer) + .unwrap(); + mint_tokens_to_token_account( + &mut ctx.svm, + &ctx.usdc_mint, + &user_usdc, + deposit_amount, + &ctx.payer, + ) + .unwrap(); + let user_share = do_deposit(&mut ctx, &user, deposit_amount); + + let shares = get_token_account_balance(&ctx.svm, &user_share).unwrap(); + assert_eq!(shares, deposit_amount); + + // Withdraw all shares + let user_tsla = derive_ata(&user.pubkey(), &ctx.tsla_mint); + let user_nvda = derive_ata(&user.pubkey(), &ctx.nvda_mint); + + let withdraw_ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Withdraw { + shares_to_burn: shares, + min_usdc_out: 0, + min_asset_a_out: 0, + min_asset_b_out: 0, + } + .data(), + vault_strategy::accounts::WithdrawAccountConstraints { + user: user.pubkey(), + strategy: ctx.strategy_pda, + share_mint: ctx.share_mint_pda, + usdc_mint: ctx.usdc_mint, + asset_mint_a: ctx.tsla_mint, + asset_mint_b: ctx.nvda_mint, + user_share_account: user_share, + user_usdc_account: user_usdc, + user_asset_a_account: user_tsla, + user_asset_b_account: user_nvda, + vault_usdc: ctx.vault_usdc, + vault_asset_a: ctx.vault_tsla, + vault_asset_b: ctx.vault_nvda, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + + send_transaction_from_instructions( + &mut ctx.svm, + vec![withdraw_ix], + &[&ctx.payer, &user], + &ctx.payer.pubkey(), + ) + .unwrap(); + + // User should have their USDC back (all shares were minted for USDC only, no assets in vault) + let usdc_back = get_token_account_balance(&ctx.svm, &user_usdc).unwrap(); + assert_eq!(usdc_back, deposit_amount, "User should get all USDC back"); + + // Shares should be burned + let remaining_shares = get_token_account_balance(&ctx.svm, &user_share).unwrap(); + assert_eq!(remaining_shares, 0, "All shares should be burned"); +} + +#[test] +fn test_withdraw_rejects_slippage() { + let mut ctx = setup_full(); + initialize_strategy(&mut ctx); + + let user = create_wallet(&mut ctx.svm, 10_000_000_000).unwrap(); + let deposit_amount: u64 = 10_000_000; + let user_usdc = + create_associated_token_account(&mut ctx.svm, &user.pubkey(), &ctx.usdc_mint, &ctx.payer) + .unwrap(); + mint_tokens_to_token_account( + &mut ctx.svm, + &ctx.usdc_mint, + &user_usdc, + deposit_amount, + &ctx.payer, + ) + .unwrap(); + let user_share = do_deposit(&mut ctx, &user, deposit_amount); + + let shares = get_token_account_balance(&ctx.svm, &user_share).unwrap(); + + let user_tsla = derive_ata(&user.pubkey(), &ctx.tsla_mint); + let user_nvda = derive_ata(&user.pubkey(), &ctx.nvda_mint); + + // Set min_usdc_out too high to trigger slippage rejection + let withdraw_ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Withdraw { + shares_to_burn: shares, + min_usdc_out: deposit_amount + 1, // more than available โ€” should fail + min_asset_a_out: 0, + min_asset_b_out: 0, + } + .data(), + vault_strategy::accounts::WithdrawAccountConstraints { + user: user.pubkey(), + strategy: ctx.strategy_pda, + share_mint: ctx.share_mint_pda, + usdc_mint: ctx.usdc_mint, + asset_mint_a: ctx.tsla_mint, + asset_mint_b: ctx.nvda_mint, + user_share_account: user_share, + user_usdc_account: user_usdc, + user_asset_a_account: user_tsla, + user_asset_b_account: user_nvda, + vault_usdc: ctx.vault_usdc, + vault_asset_a: ctx.vault_tsla, + vault_asset_b: ctx.vault_nvda, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + + let result = send_transaction_from_instructions( + &mut ctx.svm, + vec![withdraw_ix], + &[&ctx.payer, &user], + &ctx.payer.pubkey(), + ); + assert!(result.is_err(), "Withdraw should fail when slippage too high"); +} + +#[test] +fn test_rebalance() { + let mut ctx = setup_full(); + initialize_strategy(&mut ctx); + + // Deposit 100 USDC + let user = create_wallet(&mut ctx.svm, 100_000_000_000).unwrap(); + let deposit_amount: u64 = 100_000_000; // 100 USDC + let user_usdc = + create_associated_token_account(&mut ctx.svm, &user.pubkey(), &ctx.usdc_mint, &ctx.payer) + .unwrap(); + mint_tokens_to_token_account( + &mut ctx.svm, + &ctx.usdc_mint, + &user_usdc, + deposit_amount, + &ctx.payer, + ) + .unwrap(); + do_deposit(&mut ctx, &user, deposit_amount); + + // Invest some into TSLAx: invest 40 USDC โ†’ 160_000 TSLAx base (40_000_000 / 250) + let invest_tsla_ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Invest { + usdc_amount: 40_000_000, + minimum_asset_out: 0, + } + .data(), + vault_strategy::accounts::InvestAccountConstraints { + manager: ctx.manager.pubkey(), + strategy: ctx.strategy_pda, + usdc_mint: ctx.usdc_mint, + asset_mint: ctx.tsla_mint, + vault_usdc: ctx.vault_usdc, + vault_asset: ctx.vault_tsla, + asset_rate: ctx.tsla_rate_pda, + router_config: ctx.router_config_pda, + router_usdc_treasury: ctx.router_usdc_treasury, + router_authority: ctx.router_authority_pda, + swap_router_program: ctx.router_program_id, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut ctx.svm, + vec![invest_tsla_ix], + &[&ctx.payer, &ctx.manager], + &ctx.payer.pubkey(), + ) + .unwrap(); + + // Invest some into NVDAx: invest 30 USDC โ†’ 166_666 NVDAx base (30_000_000 / 180) + let invest_nvda_ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Invest { + usdc_amount: 30_000_000, + minimum_asset_out: 0, + } + .data(), + vault_strategy::accounts::InvestAccountConstraints { + manager: ctx.manager.pubkey(), + strategy: ctx.strategy_pda, + usdc_mint: ctx.usdc_mint, + asset_mint: ctx.nvda_mint, + vault_usdc: ctx.vault_usdc, + vault_asset: ctx.vault_nvda, + asset_rate: ctx.nvda_rate_pda, + router_config: ctx.router_config_pda, + router_usdc_treasury: ctx.router_usdc_treasury, + router_authority: ctx.router_authority_pda, + swap_router_program: ctx.router_program_id, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions( + &mut ctx.svm, + vec![invest_nvda_ix], + &[&ctx.payer, &ctx.manager], + &ctx.payer.pubkey(), + ) + .unwrap(); + + let tsla_before = get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(); + let nvda_before = get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(); + + // Rebalance: sell 800_000 TSLAx โ†’ receive 200_000_000 USDC (800_000 * 250) + // then buy NVDAx with 200_000_000 USDC โ†’ 1_111_111 NVDAx (200_000_000 / 180) + let sell_amount: u64 = 800_000; + let usdc_from_sell: u64 = sell_amount * 250; // 200_000_000 + let nvda_bought: u64 = usdc_from_sell / 180; // 1_111_111 + + let rebalance_ix = Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Rebalance { + sell_amount, + minimum_usdc_from_sell: usdc_from_sell, + usdc_to_invest: usdc_from_sell, + minimum_buy_amount: nvda_bought, + } + .data(), + vault_strategy::accounts::RebalanceAccountConstraints { + manager: ctx.manager.pubkey(), + strategy: ctx.strategy_pda, + usdc_mint: ctx.usdc_mint, + sell_mint: ctx.tsla_mint, + buy_mint: ctx.nvda_mint, + vault_sell: ctx.vault_tsla, + vault_buy: ctx.vault_nvda, + vault_usdc: ctx.vault_usdc, + sell_rate: ctx.tsla_rate_pda, + buy_rate: ctx.nvda_rate_pda, + router_config: ctx.router_config_pda, + router_usdc_treasury: ctx.router_usdc_treasury, + router_authority: ctx.router_authority_pda, + swap_router_program: ctx.router_program_id, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None), + ); + + send_transaction_from_instructions( + &mut ctx.svm, + vec![rebalance_ix], + &[&ctx.payer, &ctx.manager], + &ctx.payer.pubkey(), + ) + .unwrap(); + + let tsla_after = get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(); + let nvda_after = get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap(); + + assert_eq!(tsla_after, tsla_before - sell_amount, "TSLAx balance should decrease by sell_amount"); + assert_eq!(nvda_after, nvda_before + nvda_bought, "NVDAx balance should increase by nvda_bought"); +}