Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ An exchange with no order book: swaps fill instantly against a shared liquidity

A managed investment fund onchain, like an ETF or mutual fund. Investors deposit USDC for shares, a manager allocates the pool across a basket of assets (here, stocks like TSLAx and NVDAx), and each share's value tracks the fund's net asset value. The manager earns a management fee, and investors redeem a proportional slice of the underlying assets.

[⚓ Anchor](./finance/vault-strategy/anchor)
[⚓ Anchor](./finance/vault-strategy/anchor) [💫 Quasar](./finance/vault-strategy/quasar)

### Betting Market

Expand Down
132 changes: 132 additions & 0 deletions finance/vault-strategy/quasar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Vault Strategy, Quasar port

A tokenized multi-asset vault. A manager assembles a basket of whitelisted
assets at target weights; anyone can deposit USDC and receive shares priced at
the vault's net asset value, and each deposit is immediately deployed into the
basket by swapping USDC into every asset at its weight. Withdrawals burn shares
and redeem a proportional slice of every vault, paid in kind. This is the shape
of an onchain index fund or a managed ETF.

This is a [Quasar](https://github.com/blueshift-gg/quasar) port of the Anchor
example in [`../anchor`](../anchor). It contains two programs, each its own
Quasar project:

- `vault-strategy/` - the vault itself (program ID
`VLT5W7bqhRN4nCdRpXm8UfHRxZd9EuZGqiSAkGHQfGh`).
- `mock-swap-router/` - a stand-in constant-rate swap venue the vault trades
through (program ID `SWPR8Rk3aq3DrDGLdaANq7xCMnXoUFUJWJJmCWxc8Jm`).

Both share the same program IDs as the Anchor build. The mock router mints an
asset against USDC at an admin-set fixed rate, standing in for a real AMM or
aggregator so the example is self-contained.

## How it works

A separate protocol authority curates a registry of assets, binding each
approved mint to its official price feed. This authority is deliberately not the
strategy manager: it vets which real assets and feeds are safe, and the manager
only chooses among them, so a manager can never list a token they mint
themselves or pair a real mint with a feed they control.

- `initialize_registry` opens the registry; `whitelist_asset` approves a mint
and records its price feed.
- A manager opens a basket with `initialize_strategy` (choosing a management fee
and a slippage tolerance), then adds whitelisted assets with `add_asset`, each
at a target weight in basis points. The weights must sum to 100% before the
vault accepts deposits, so every deposit is fully invested. `set_weight`
retunes a weight or retires an asset by setting it to zero.
- `deposit` prices the incoming USDC against the vault's net asset value (the
USDC vault plus every asset vault valued at its oracle price), mints shares for
that fraction of the vault, and deploys the deposit across the basket by
swapping a weight-sized slice into each asset through the router. The first
deposit into an empty vault mints shares one-to-one.
- `withdraw` burns shares and pays out a proportional slice of the USDC vault and
every asset vault, in kind.
- `rebalance` lets the manager sell one asset for USDC and buy another with it,
keeping holdings near their targets as prices drift. Both legs are floored to
the oracle price so a bad swap route reverts.
- `collect_fees` accrues the time-based management fee by minting fresh shares to
the manager, diluting holders at the configured annual rate.

Every swap and rebalance leg is bounded by the registered price feed: the
program computes the oracle-implied output and rejects any swap that falls short
by more than the strategy's slippage tolerance.

## Accounts and PDAs

- **Registry** `["registry", authority]` and **WhitelistEntry**
`["whitelist", registry, mint]` - the curated asset set and each approved
mint's price feed.
- **Strategy** `["strategy", index]` - one basket, addressed by a counter. Holds
the manager, registry, share mint, USDC mint, router, fee, slippage, total
shares, and running weight sum. The Strategy PDA is the authority of the share
mint and every vault, so the program signs all mints and payouts.
- **AssetConfig** `["asset", strategy, index]` - one basket asset (mint, copied
price feed, vault, target weight). The set is the contiguous range
`0..asset_count`, so a valuation can re-derive every asset and refuse to
proceed if one is missing.
- **Share mint** `["share_mint", strategy]`, **USDC vault**
`["usdc_vault", strategy]`, and per-asset vaults `["asset_vault", strategy, index]`.

`deposit` and `withdraw` reference every asset at once, so the client passes the
per-asset accounts as remaining accounts (five per asset for deposit, four for
withdraw), in index order.

## Safety and custody

- Deposited USDC and every asset sit in program-owned vaults whose authority is
the Strategy PDA; only the deployed program can move them, and it does so only
along deposit, withdraw, and rebalance. There is no manager path to withdraw
holdings, only to trade them within the basket or collect the configured fee.
- The share supply is updated before any mint or burn (checks-effects-
interactions), and value computations use u128 intermediates with checked
arithmetic, flooring in the vault's favour.
- The management fee is capped (10% per year) and the slippage tolerance is
capped (10%), so neither can be configured to drain the vault.
- Price feeds are validated against the address recorded on the asset config and
rejected if stale or non-positive.

## What the Quasar port does differently

The valuation, fee, and swap-floor math are identical to the Anchor build. The
differences follow from Quasar's model:

- **The cross-program swap is built by hand.** Anchor generates a typed CPI
client (`mock_swap_router::cpi::*`); Quasar has no such generation, so each
swap is a `CpiDynamic` call whose account list and instruction data the vault
constructs directly. The router's instruction wire format (a one-byte
discriminator plus two little-endian u64s) is encoded inline.
- **The oracle and foreign token accounts are read by raw byte offset** through
`UncheckedAccount` views, the same field layout the Anchor build parses.
- **Pool vaults are program-derived token accounts** rather than associated
token accounts, matching the other Quasar finance examples.
- **The share mint carries no freeze authority** (it is never used); the Anchor
build sets it to the strategy PDA.

## Building and testing

Requires the [Solana toolchain](https://docs.anza.xyz/cli/install) and the
[Quasar CLI](https://github.com/blueshift-gg/quasar). Build both programs before
testing, because the deposit test loads the router's compiled `.so`:

```sh
cargo install --git https://github.com/blueshift-gg/quasar quasar-cli --locked
(cd mock-swap-router && quasar build)
(cd vault-strategy && quasar build)
(cd mock-swap-router && cargo test)
(cd vault-strategy && cargo test)
```

The router suite (`mock-swap-router/src/tests.rs`) exercises initialize, set-rate,
and a USDC-for-asset swap. The vault suite (`vault-strategy/src/tests.rs`) drives
the manager setup (registry, whitelist, strategy, add asset) and a two-program
deposit that deploys USDC into the basket through the router CPI, asserting share
minting, vault balances, and treasury flow.

## Extending

- Multiple assets per basket (up to the 16-asset cap) with a v0 transaction plus
an Address Lookup Table for the larger account list.
- A real AMM or aggregator in place of the mock router.
- Deposit and withdraw fees in addition to the time-based management fee.
- Rebalance automation driven by weight drift beyond a threshold.
34 changes: 34 additions & 0 deletions finance/vault-strategy/quasar/mock-swap-router/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[package]
name = "quasar-mock-swap-router"
version = "0.1.0"
edition = "2021"

# Standalone workspace - Quasar uses a different resolver and dependency tree.
[workspace]

[lints.rust.unexpected_cfgs]
level = "warn"
check-cfg = [
'cfg(target_os, values("solana"))',
]

[lib]
crate-type = ["cdylib", "lib"]

[features]
alloc = []
client = []
debug = []

[dependencies]
# Pinned to match the finance/escrow Quasar example (623bb70 is the last rev
# before a zeropod 0.3 bump that breaks quasar-spl). Unpin once upstream fixes it.
quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" }
quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" }
solana-address = { version = "2.2.0" }
solana-instruction = { version = "3.2.0" }

[dev-dependencies]
quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm" }
spl-token-interface = { version = "2.0.0" }
solana-program-pack = { version = "3.1.0" }
22 changes: 22 additions & 0 deletions finance/vault-strategy/quasar/mock-swap-router/Quasar.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[project]
name = "quasar_mock_swap_router"

[toolchain]
type = "solana"

[testing]
language = "rust"

[testing.rust]
framework = "quasar-svm"

[testing.rust.test]
program = "cargo"
args = [
"test",
"tests::",
]

[clients]
path = "target/client"
languages = ["rust"]
12 changes: 12 additions & 0 deletions finance/vault-strategy/quasar/mock-swap-router/src/errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
use quasar_lang::prelude::*;

/// Program errors. Codes start at 6000 to match Anchor's custom-error base.
#[error_code]
pub enum RouterError {
ZeroRate = 6000,
SlippageExceeded,
InvalidAssetMint,
MathOverflow,
WrongUsdcMint,
ZeroAmount,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
use quasar_lang::prelude::*;
use quasar_spl::prelude::*;

use crate::state::{RouterConfig, RouterConfigInner};

#[derive(Accounts)]
pub struct InitializeRouterAccountConstraints {
#[account(mut)]
pub authority: Signer,

pub usdc_mint: Account<Mint>,

#[account(init, payer = authority, address = RouterConfig::seeds())]
pub router_config: Account<RouterConfig>,

pub rent: Sysvar<Rent>,
pub token_program: Program<TokenProgram>,
pub system_program: Program<SystemProgram>,
}

#[inline(always)]
pub fn handle_initialize_router(
accounts: &mut InitializeRouterAccountConstraints,
bumps: &InitializeRouterAccountConstraintsBumps,
) -> Result<(), ProgramError> {
accounts.router_config.set_inner(RouterConfigInner {
authority: *accounts.authority.address(),
usdc_mint: *accounts.usdc_mint.address(),
bump: bumps.router_config,
});
Ok(())
}
Original file line number Diff line number Diff line change
@@ -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::*;
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
use quasar_lang::prelude::*;
use quasar_spl::prelude::*;

use crate::state::{
AssetRate, AssetRateInner, RouterAuthorityPda, RouterConfig, TreasuryPda,
};

#[derive(Accounts)]
pub struct SetRateAccountConstraints {
#[account(mut)]
pub authority: Signer,

#[account(address = RouterConfig::seeds(), has_one(authority))]
pub router_config: Account<RouterConfig>,

pub asset_mint: Account<Mint>,
pub usdc_mint: Account<Mint>,

#[account(
init(idempotent),
payer = authority,
address = AssetRate::seeds(asset_mint.address()),
)]
pub asset_rate: Account<AssetRate>,

#[account(address = RouterAuthorityPda::seeds())]
pub router_authority: UncheckedAccount,

#[account(
init(idempotent),
payer = authority,
token(mint = usdc_mint, authority = router_authority, token_program = token_program),
address = TreasuryPda::seeds(),
)]
pub router_usdc_treasury: InterfaceAccount<Token>,

pub rent: Sysvar<Rent>,
pub token_program: Program<TokenProgram>,
pub system_program: Program<SystemProgram>,
}

#[inline(always)]
pub fn handle_set_rate(
accounts: &mut SetRateAccountConstraints,
usdc_per_token: u64,
bumps: &SetRateAccountConstraintsBumps,
) -> Result<(), ProgramError> {
accounts.asset_rate.set_inner(AssetRateInner {
mint: *accounts.asset_mint.address(),
usdc_per_token,
bump: bumps.asset_rate,
});
Ok(())
}
Loading
Loading