Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions finance/vault-strategy/anchor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@

- **Curated asset registry.** A `Registry` plus per-mint `WhitelistEntry` accounts, maintained by a protocol authority separate from strategy managers. Each entry binds an approved mint to its official Pyth price feed. New instructions: `initialize_registry`, `whitelist_asset`.
- **Dynamic assets.** A strategy now grows its portfolio with `add_asset`, which registers a whitelisted mint at the next index as an `AssetConfig` PDA (`["asset", strategy, index]`) and creates its vault. Assets occupy the contiguous range `0..asset_count`, up to `MAX_ASSETS` (8). Replaces the previous fixed two-asset layout.
- **Oracle-bounded slippage.** `invest` and `rebalance` now compute each swap's minimum output from the Pyth price and a strategy-level `max_slippage_bps` (capped at `MAX_SLIPPAGE_BPS` = 10%), instead of trusting a caller-supplied minimum. Set at creation via `initialize_strategy`.
- **Oracle-bounded slippage.** `deposit`, `invest`, and `rebalance` compute each swap's minimum output from the Pyth price and a strategy-level `max_slippage_bps` (capped at `MAX_SLIPPAGE_BPS` = 10%), instead of trusting a caller-supplied minimum. Set at creation via `initialize_strategy`.
- **Immediate deployment on deposit.** `deposit` swaps each depositor's USDC into the basket at its target weights through the registered router in the same transaction. The USDC vault no longer holds idle deposits; only the unallocated remainder (when the weights sum below 10,000) stays as USDC.
- **Retirable assets.** `set_weight(weight_bps)` changes an asset's target weight after creation, including setting it to zero to retire it. The asset's index is preserved, so the `0..asset_count` range the valuation handlers depend on stays contiguous.

### Changed

- `initialize_strategy` now takes `(fee_bps, max_slippage_bps, swap_router)` and binds the strategy to a registry; weights and price feeds move to `add_asset`.
- `deposit` and `withdraw` take each asset's accounts as remaining accounts and validate the complete `0..asset_count` set, so NAV and in-kind payouts always cover every asset.
- `initialize_strategy` now takes `(index, fee_bps, max_slippage_bps, swap_router)` and binds the strategy to a registry; the strategy PDA is seeded by a caller-chosen index (`["strategy", index]`) rather than the manager's key, with the manager kept as a stored field. Weights and price feeds move to `add_asset`.
- `deposit` takes each asset's `[asset_config, vault, mint, rate, price_feed]` plus the router accounts, validates the complete `0..asset_count` set for NAV, and deploys the deposit at the target weights.
- `withdraw` takes each asset's `[asset_config, vault, mint, user_token_account]` and pays out every asset in kind over the complete `0..asset_count` set.
- `invest` takes `(usdc_amount)` and `rebalance` takes `(sell_amount, usdc_to_invest)`; per-call minimums are gone.

### Fixed
Expand Down
52 changes: 18 additions & 34 deletions finance/vault-strategy/anchor/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Vault Strategy

A manager-run investment vault on Solana. Users deposit [USDC](https://www.investopedia.com/terms/u/usd-coin-usdc.asp) and receive shares representing proportional ownership of a portfolio of assets. The manager adds assets from a curated whitelist, deploys deposited USDC into them, earns a fee, and depositors withdraw their proportional slice in kind when they choose.
A manager-run investment vault on Solana. Users deposit [USDC](https://www.investopedia.com/terms/u/usd-coin-usdc.asp) and receive shares representing proportional ownership of a portfolio of assets. The manager adds assets from a curated whitelist and sets their target weights; each deposit is deployed across those assets at its weights in the same transaction. The manager rebalances as prices drift, earns a fee, and depositors withdraw their proportional slice in kind when they choose.

The example uses two stocks as the portfolio assets: **TSLAx** (Tesla) and **NVDAx** (NVIDIA) - [xStocks](https://backed.fi/xstocks) issued on Solana by Backed Finance. In tests these are mock [tokens](https://solana.com/docs/terminology#token).

Expand Down Expand Up @@ -47,11 +47,13 @@ fee_shares = total_shares × fee_bps × elapsed_seconds / (10_000 × 31_536_000)

### Weights and Rebalancing

Each asset carries a target **weight** in basis points (e.g. 40% TSLAx, 60% NVDAx); the running sum is kept at or below 10,000. Weights are advisory targets the manager maintains with `invest` and `rebalance`; the program does not force an allocation on deposit. [Rebalancing](https://www.investopedia.com/terms/r/rebalancing.asp) sells an over-weight asset and buys an under-weight one in a single atomic instruction.
Each asset carries a target **weight** in basis points (e.g. 40% TSLAx, 60% NVDAx); the running sum is kept at or below 10,000. The weights are enforced where it matters most: `deposit` deploys each depositor's USDC straight into the basket at those weights, so money is never left sitting idle by default. When the weights sum below 10,000 (for example after an asset is retired), the unallocated slice stays in the USDC vault for the manager to place with `invest`.

[Rebalancing](https://www.investopedia.com/terms/r/rebalancing.asp) handles the drift that prices create after a deposit: `rebalance` sells an over-weight asset for USDC and buys an under-weight one in a single atomic instruction. `set_weight` changes a target after creation, including setting it to zero to **retire** an asset: deposits stop allocating to it, the manager sells its holdings out with `rebalance`, and the now-empty vault keeps its index so the contiguous `0..asset_count` range stays intact (the index is never reused).

### Slippage, bounded by the oracle

[Slippage](https://www.investopedia.com/terms/s/slippage.asp) is the gap between the expected and the realized amount of a swap. Rather than trust a manager-supplied minimum, `invest` and `rebalance` compute the floor themselves from the Pyth price and the strategy's `max_slippage_bps`: a swap whose output falls more than that tolerance below the oracle-implied amount reverts. `max_slippage_bps` is set at creation and capped at `MAX_SLIPPAGE_BPS` (1,000 bps = 10%).
[Slippage](https://www.investopedia.com/terms/s/slippage.asp) is the gap between the expected and the realized amount of a swap. Rather than trust a manager-supplied minimum, `deposit`, `invest`, and `rebalance` compute the floor themselves from the Pyth price and the strategy's `max_slippage_bps`: a swap whose output falls more than that tolerance below the oracle-implied amount reverts. `max_slippage_bps` is set at creation and capped at `MAX_SLIPPAGE_BPS` (1,000 bps = 10%).

### In-Kind Withdrawal

Expand All @@ -63,12 +65,10 @@ An [in-kind distribution](https://www.investopedia.com/terms/i/in-kind.asp) retu

### Participants

| Person | Role | Motivation |
|--------|------|-----------|
| **Victor** | Registry authority | Curate which assets (and which official Pyth feed) are safe to hold; a protocol role, not a manager |
| **Maria** | Strategy manager | Earn a 1% annual fee; run a basket she has a thesis on |
| **Alice** | Early depositor | Diversified TSLAx + NVDAx exposure without managing positions |
| **Bob** | Later depositor | Join the same strategy after it has been running |
- **Victor**, the registry authority: curates which assets, and which official Pyth feed, are safe to hold. A protocol role, not a manager.
- **Maria**, the strategy manager: earns a 1% annual fee running a basket she has a thesis on.
- **Alice**, the early depositor: wants diversified TSLAx and NVDAx exposure without managing positions.
- **Bob**, the later depositor: joins the same strategy after it has been running.

`Maria` and `Victor` are stored as plain `Pubkey`s and may each be a [Squads](https://squads.so/) multisig; the program only checks the signature.

Expand All @@ -78,27 +78,27 @@ An [in-kind distribution](https://www.investopedia.com/terms/i/in-kind.asp) retu

### Step 2 - Maria initializes the strategy

`initialize_strategy(fee_bps=100, max_slippage_bps=100, swap_router)` creates the `Strategy` PDA (`["strategy", maria]`), the share mint, and the USDC vault, binding the strategy to Victor's registry. No assets yet.
`initialize_strategy(index=0, fee_bps=100, max_slippage_bps=100, swap_router)` creates the `Strategy` PDA (`["strategy", 0]`), the share mint, and the USDC vault, binding the strategy to Victor's registry. The strategy is addressed by a caller-chosen index (`"strategy" + 0`, `"strategy" + 1`, …) rather than the manager's key. No assets yet.

### Step 3 - Maria adds assets

`add_asset(weight_bps)`, once per asset, creates an `AssetConfig` at `["asset", strategy, index]` (index = current `asset_count`), copies the official feed from the whitelist entry, and creates that asset's vault. TSLAx at index 0 (4000 bps), NVDAx at index 1 (6000 bps). Rejected if the mint is not whitelisted, if the weights would exceed 10,000 bps, or once `MAX_ASSETS` (8) is reached.

### Step 4 - Alice deposits
### Step 4 - Alice deposits, and her money is deployed at once

`deposit(usdc_amount, minimum_shares)`, with each asset's `[asset_config, vault, price_feed]` passed as remaining accounts. First deposit is 1:1. USDC moves into the USDC vault; shares are minted to Alice.
`deposit(usdc_amount, minimum_shares)`, with each asset's `[asset_config, vault, mint, rate, price_feed]` passed as remaining accounts, plus the router accounts. The handler values every asset for NAV (first deposit is 1:1), mints shares to Alice, then deploys her USDC across the basket at its target weights through the router, each leg carrying the same oracle slippage floor `invest` uses. With the weights at 40/60, a 900 USDC deposit lands as 1.44 TSLAx and 3.0 NVDAx with no idle USDC.

### Step 5 - Maria invests
### Step 5 - Maria invests idle USDC (when there is any)

`invest(usdc_amount)` for one registered asset, passing its `asset_config` and `price_feed`. The handler reads the Pyth price, computes the minimum acceptable output, and CPIs the router; a fill worse than the bound reverts.
`invest(usdc_amount)` for one registered asset, passing its `asset_config` and `price_feed`. Because `deposit` already deploys at the target weights, the USDC vault is normally empty; `invest` is the tool for the leftover cases, such as the unallocated remainder when the weights sum below 10,000, or the proceeds of a retired asset. The handler reads the Pyth price, computes the minimum acceptable output, and CPIs the router; a fill worse than the bound reverts.

### Step 6 - Bob deposits at the current share price

Same as step 4. Because shares are priced at NAV, Bob pays the current per-share value and does not dilute Alice's gain.
Same as step 4. Because shares are priced at NAV, Bob pays the current per-share value and does not dilute Alice's gain; his USDC is deployed at the target weights too.

### Step 7 - Maria rebalances

`rebalance(sell_amount, usdc_to_invest)` sells one asset for USDC and buys another, both legs bounded against their Pyth prices, in one atomic instruction.
A price move pushes the basket off target. `rebalance(sell_amount, usdc_to_invest)` sells the over-weight asset for USDC and buys the under-weight one, both legs bounded against their Pyth prices, in one atomic instruction. `set_weight(weight_bps)` changes a target between rebalances, or retires an asset by setting it to zero.

### Step 8 - Fees accrue

Expand All @@ -110,22 +110,6 @@ Same as step 4. Because shares are priced at NAV, Bob pays the current per-share

---

## Instruction Reference

| Instruction | Signer | Notes |
|------------|--------|-------|
| `initialize_registry` | registry authority | Creates the whitelist |
| `whitelist_asset` | registry authority | Approves a mint, binds it to its Pyth feed |
| `initialize_strategy` | manager | Sets fee and slippage caps, binds to a registry |
| `add_asset` | manager | Adds a whitelisted asset at the next index, creates its vault |
| `deposit` | depositor | NAV over all assets (remaining accounts); mints shares |
| `invest` | manager | USDC → asset, slippage floor computed from Pyth |
| `rebalance` | manager | asset → USDC → asset, both legs Pyth-bounded |
| `collect_fees` | anyone | Mints fee shares to the manager |
| `withdraw` | user | Burns shares, pays out USDC + every asset in kind (remaining accounts) |

---

## Oracle Integration (Pyth)

`PriceUpdateV2` price (i64) is read at byte offset 73 and `publish_time` at 93, directly from account bytes to avoid borsh version incompatibility with Anchor. Pyth USD pairs use exponent −8; with USDC and the basket tokens all at 6 decimals, value in USDC minor units is `amount × price / 10⁸`. Each asset's feed pubkey is fixed in its `AssetConfig` (copied from the registry), and validated on every read. In tests, mock `PriceUpdateV2` accounts are injected into LiteSVM (TSLAx $250, NVDAx $180).
Expand All @@ -134,7 +118,7 @@ Same as step 4. Because shares are priced at NAV, Bob pays the current per-share

## Mock Swap Router vs Production

The `mock-swap-router` exists only for testing: it stores a `usdc_per_token` rate per asset, holds the basket mints' authority, and mints/burns to simulate swaps. The `Strategy` stores the router program pubkey at creation, and `invest`/`rebalance` require the router account to match it (`InvalidSwapRouter`). In production, replace the router CPIs with [Jupiter](https://jup.ag); the strategy PDA still signs.
The `mock-swap-router` exists only for testing: it stores a `usdc_per_token` rate per asset, holds the basket mints' authority, and mints/burns to simulate swaps. The `Strategy` stores the router program pubkey at creation, and `deposit`, `invest`, and `rebalance` require the router account to match it (`InvalidSwapRouter`). In production, replace the router CPIs with [Jupiter](https://jup.ag); the strategy PDA still signs.

---

Expand Down Expand Up @@ -171,4 +155,4 @@ cargo build-sbf --manifest-path programs/vault-strategy/Cargo.toml
cargo test --manifest-path programs/vault-strategy/Cargo.toml
```

Tests live in `programs/vault-strategy/tests/vault_strategy.rs` and use [LiteSVM](https://github.com/LiteSVM/litesvm). Both `.so` files are loaded from `target/deploy/`, so build before testing. The suite covers the full lifecycle (registry, whitelist, strategy, add-asset, deposit, invest, rebalance, fees, in-kind withdraw) and the rejection paths: non-whitelisted asset, weight overflow, over-cap fee and slippage, oracle-bounded swap slippage, unregistered router, and incomplete asset accounts on deposit.
Tests live in `programs/vault-strategy/tests/vault_strategy.rs` and use [LiteSVM](https://github.com/LiteSVM/litesvm). Both `.so` files are loaded from `target/deploy/`, so build before testing. The suite covers the full lifecycle end to end (deposit with auto-deployment, a price move, rebalance back to target, a second depositor priced at the new NAV, a year's fee, in-kind withdrawal) plus focused tests for each handler, retiring an asset with `set_weight`, and the rejection paths: non-whitelisted asset, weight overflow, over-cap fee and slippage, oracle-bounded slippage on both deposit and invest, non-manager `set_weight`, unregistered router, and incomplete asset accounts on deposit.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ pub struct AddAssetAccountConstraints<'info> {
mut,
has_one = manager,
has_one = registry @ VaultError::InvalidRegistry,
seeds = [b"strategy", strategy.manager.as_ref()],
seeds = [b"strategy", strategy.index.to_le_bytes().as_ref()],
bump = strategy.bump
)]
pub strategy: Box<Account<'info, Strategy>>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pub struct CollectFeesAccountConstraints<'info> {
#[account(
mut,
has_one = manager,
seeds = [b"strategy", strategy.manager.as_ref()],
seeds = [b"strategy", strategy.index.to_le_bytes().as_ref()],
bump = strategy.bump
)]
pub strategy: Account<'info, Strategy>,
Expand Down Expand Up @@ -57,7 +57,7 @@ pub fn handle_collect_fees(context: Context<CollectFeesAccountConstraints>) -> R
let elapsed_seconds = (current_ts - last_ts) as u64;
let total_shares = context.accounts.strategy.total_shares;
let fee_bps = context.accounts.strategy.fee_bps;
let manager_key = context.accounts.strategy.manager;
let strategy_index = context.accounts.strategy.index;
let strategy_bump = context.accounts.strategy.bump;

// fee_shares = total_shares * fee_bps * elapsed / (10_000 * SECONDS_PER_YEAR)
Expand Down Expand Up @@ -86,7 +86,8 @@ pub fn handle_collect_fees(context: Context<CollectFeesAccountConstraints>) -> R
.ok_or(VaultError::MathOverflow)?;

// Mint fee shares to manager - strategy PDA signs
let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", manager_key.as_ref(), &[strategy_bump]]];
let index_bytes = strategy_index.to_le_bytes();
let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", index_bytes.as_ref(), &[strategy_bump]]];

let mint_accounts = MintTo {
mint: context.accounts.share_mint.to_account_info(),
Expand Down
Loading
Loading