Skip to content

Commit 083fbf9

Browse files
committed
Add Quasar port of the vault-strategy example
Port finance/vault-strategy/anchor to finance/vault-strategy/quasar as two Quasar programs sharing the Anchor program IDs, and link it in the root README example index: - vault-strategy: the tokenized multi-asset vault (deposit at NAV, withdraw in kind, rebalance, time-based management fee). - mock-swap-router: a constant-rate swap venue the vault trades through. The valuation, fee, and oracle-anchored swap-floor math are preserved. Quasar-specific adaptations: - The cross-program swap is built by hand with CpiDynamic (Quasar has no generated typed CPI client): the router account list and its wire-format instruction data (1-byte discriminator + two LE u64s) are constructed in deposit and rebalance directly. - The Pyth feed and foreign token/mint accounts are read by raw byte offset through UncheckedAccount views. - Pool vaults are program-derived token accounts rather than ATAs; the share mint carries no freeze authority. Includes QuasarSVM tests: the router suite (initialize, set-rate, swap) and the vault suite (manager setup plus a two-program deposit that deploys USDC into the basket through the router CPI), and a README covering both programs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ATxCcgrZxaL5dMZSyWj1K
1 parent c846179 commit 083fbf9

32 files changed

Lines changed: 2852 additions & 1 deletion

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ An exchange with no order book: swaps fill instantly against a shared liquidity
6161

6262
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.
6363

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

6666
### Betting Market
6767

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Vault Strategy, Quasar port
2+
3+
A tokenized multi-asset vault. A manager assembles a basket of whitelisted
4+
assets at target weights; anyone can deposit USDC and receive shares priced at
5+
the vault's net asset value, and each deposit is immediately deployed into the
6+
basket by swapping USDC into every asset at its weight. Withdrawals burn shares
7+
and redeem a proportional slice of every vault, paid in kind. This is the shape
8+
of an onchain index fund or a managed ETF.
9+
10+
This is a [Quasar](https://github.com/blueshift-gg/quasar) port of the Anchor
11+
example in [`../anchor`](../anchor). It contains two programs, each its own
12+
Quasar project:
13+
14+
- `vault-strategy/` - the vault itself (program ID
15+
`VLT5W7bqhRN4nCdRpXm8UfHRxZd9EuZGqiSAkGHQfGh`).
16+
- `mock-swap-router/` - a stand-in constant-rate swap venue the vault trades
17+
through (program ID `SWPR8Rk3aq3DrDGLdaANq7xCMnXoUFUJWJJmCWxc8Jm`).
18+
19+
Both share the same program IDs as the Anchor build. The mock router mints an
20+
asset against USDC at an admin-set fixed rate, standing in for a real AMM or
21+
aggregator so the example is self-contained.
22+
23+
## How it works
24+
25+
A separate protocol authority curates a registry of assets, binding each
26+
approved mint to its official price feed. This authority is deliberately not the
27+
strategy manager: it vets which real assets and feeds are safe, and the manager
28+
only chooses among them, so a manager can never list a token they mint
29+
themselves or pair a real mint with a feed they control.
30+
31+
- `initialize_registry` opens the registry; `whitelist_asset` approves a mint
32+
and records its price feed.
33+
- A manager opens a basket with `initialize_strategy` (choosing a management fee
34+
and a slippage tolerance), then adds whitelisted assets with `add_asset`, each
35+
at a target weight in basis points. The weights must sum to 100% before the
36+
vault accepts deposits, so every deposit is fully invested. `set_weight`
37+
retunes a weight or retires an asset by setting it to zero.
38+
- `deposit` prices the incoming USDC against the vault's net asset value (the
39+
USDC vault plus every asset vault valued at its oracle price), mints shares for
40+
that fraction of the vault, and deploys the deposit across the basket by
41+
swapping a weight-sized slice into each asset through the router. The first
42+
deposit into an empty vault mints shares one-to-one.
43+
- `withdraw` burns shares and pays out a proportional slice of the USDC vault and
44+
every asset vault, in kind.
45+
- `rebalance` lets the manager sell one asset for USDC and buy another with it,
46+
keeping holdings near their targets as prices drift. Both legs are floored to
47+
the oracle price so a bad swap route reverts.
48+
- `collect_fees` accrues the time-based management fee by minting fresh shares to
49+
the manager, diluting holders at the configured annual rate.
50+
51+
Every swap and rebalance leg is bounded by the registered price feed: the
52+
program computes the oracle-implied output and rejects any swap that falls short
53+
by more than the strategy's slippage tolerance.
54+
55+
## Accounts and PDAs
56+
57+
- **Registry** `["registry", authority]` and **WhitelistEntry**
58+
`["whitelist", registry, mint]` - the curated asset set and each approved
59+
mint's price feed.
60+
- **Strategy** `["strategy", index]` - one basket, addressed by a counter. Holds
61+
the manager, registry, share mint, USDC mint, router, fee, slippage, total
62+
shares, and running weight sum. The Strategy PDA is the authority of the share
63+
mint and every vault, so the program signs all mints and payouts.
64+
- **AssetConfig** `["asset", strategy, index]` - one basket asset (mint, copied
65+
price feed, vault, target weight). The set is the contiguous range
66+
`0..asset_count`, so a valuation can re-derive every asset and refuse to
67+
proceed if one is missing.
68+
- **Share mint** `["share_mint", strategy]`, **USDC vault**
69+
`["usdc_vault", strategy]`, and per-asset vaults `["asset_vault", strategy, index]`.
70+
71+
`deposit` and `withdraw` reference every asset at once, so the client passes the
72+
per-asset accounts as remaining accounts (five per asset for deposit, four for
73+
withdraw), in index order.
74+
75+
## Safety and custody
76+
77+
- Deposited USDC and every asset sit in program-owned vaults whose authority is
78+
the Strategy PDA; only the deployed program can move them, and it does so only
79+
along deposit, withdraw, and rebalance. There is no manager path to withdraw
80+
holdings, only to trade them within the basket or collect the configured fee.
81+
- The share supply is updated before any mint or burn (checks-effects-
82+
interactions), and value computations use u128 intermediates with checked
83+
arithmetic, flooring in the vault's favour.
84+
- The management fee is capped (10% per year) and the slippage tolerance is
85+
capped (10%), so neither can be configured to drain the vault.
86+
- Price feeds are validated against the address recorded on the asset config and
87+
rejected if stale or non-positive.
88+
89+
## What the Quasar port does differently
90+
91+
The valuation, fee, and swap-floor math are identical to the Anchor build. The
92+
differences follow from Quasar's model:
93+
94+
- **The cross-program swap is built by hand.** Anchor generates a typed CPI
95+
client (`mock_swap_router::cpi::*`); Quasar has no such generation, so each
96+
swap is a `CpiDynamic` call whose account list and instruction data the vault
97+
constructs directly. The router's instruction wire format (a one-byte
98+
discriminator plus two little-endian u64s) is encoded inline.
99+
- **The oracle and foreign token accounts are read by raw byte offset** through
100+
`UncheckedAccount` views, the same field layout the Anchor build parses.
101+
- **Pool vaults are program-derived token accounts** rather than associated
102+
token accounts, matching the other Quasar finance examples.
103+
- **The share mint carries no freeze authority** (it is never used); the Anchor
104+
build sets it to the strategy PDA.
105+
106+
## Building and testing
107+
108+
Requires the [Solana toolchain](https://docs.anza.xyz/cli/install) and the
109+
[Quasar CLI](https://github.com/blueshift-gg/quasar). Build both programs before
110+
testing, because the deposit test loads the router's compiled `.so`:
111+
112+
```sh
113+
cargo install --git https://github.com/blueshift-gg/quasar quasar-cli --locked
114+
(cd mock-swap-router && quasar build)
115+
(cd vault-strategy && quasar build)
116+
(cd mock-swap-router && cargo test)
117+
(cd vault-strategy && cargo test)
118+
```
119+
120+
The router suite (`mock-swap-router/src/tests.rs`) exercises initialize, set-rate,
121+
and a USDC-for-asset swap. The vault suite (`vault-strategy/src/tests.rs`) drives
122+
the manager setup (registry, whitelist, strategy, add asset) and a two-program
123+
deposit that deploys USDC into the basket through the router CPI, asserting share
124+
minting, vault balances, and treasury flow.
125+
126+
## Extending
127+
128+
- Multiple assets per basket (up to the 16-asset cap) with a v0 transaction plus
129+
an Address Lookup Table for the larger account list.
130+
- A real AMM or aggregator in place of the mock router.
131+
- Deposit and withdraw fees in addition to the time-based management fee.
132+
- Rebalance automation driven by weight drift beyond a threshold.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
[package]
2+
name = "quasar-mock-swap-router"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
# Standalone workspace - Quasar uses a different resolver and dependency tree.
7+
[workspace]
8+
9+
[lints.rust.unexpected_cfgs]
10+
level = "warn"
11+
check-cfg = [
12+
'cfg(target_os, values("solana"))',
13+
]
14+
15+
[lib]
16+
crate-type = ["cdylib", "lib"]
17+
18+
[features]
19+
alloc = []
20+
client = []
21+
debug = []
22+
23+
[dependencies]
24+
# Pinned to match the finance/escrow Quasar example (623bb70 is the last rev
25+
# before a zeropod 0.3 bump that breaks quasar-spl). Unpin once upstream fixes it.
26+
quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" }
27+
quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" }
28+
solana-address = { version = "2.2.0" }
29+
solana-instruction = { version = "3.2.0" }
30+
31+
[dev-dependencies]
32+
quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm" }
33+
spl-token-interface = { version = "2.0.0" }
34+
solana-program-pack = { version = "3.1.0" }
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[project]
2+
name = "quasar_mock_swap_router"
3+
4+
[toolchain]
5+
type = "solana"
6+
7+
[testing]
8+
language = "rust"
9+
10+
[testing.rust]
11+
framework = "quasar-svm"
12+
13+
[testing.rust.test]
14+
program = "cargo"
15+
args = [
16+
"test",
17+
"tests::",
18+
]
19+
20+
[clients]
21+
path = "target/client"
22+
languages = ["rust"]
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
use quasar_lang::prelude::*;
2+
3+
/// Program errors. Codes start at 6000 to match Anchor's custom-error base.
4+
#[error_code]
5+
pub enum RouterError {
6+
ZeroRate = 6000,
7+
SlippageExceeded,
8+
InvalidAssetMint,
9+
MathOverflow,
10+
WrongUsdcMint,
11+
ZeroAmount,
12+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
use quasar_lang::prelude::*;
2+
use quasar_spl::prelude::*;
3+
4+
use crate::state::{RouterConfig, RouterConfigInner};
5+
6+
#[derive(Accounts)]
7+
pub struct InitializeRouterAccountConstraints {
8+
#[account(mut)]
9+
pub authority: Signer,
10+
11+
pub usdc_mint: Account<Mint>,
12+
13+
#[account(init, payer = authority, address = RouterConfig::seeds())]
14+
pub router_config: Account<RouterConfig>,
15+
16+
pub rent: Sysvar<Rent>,
17+
pub token_program: Program<TokenProgram>,
18+
pub system_program: Program<SystemProgram>,
19+
}
20+
21+
#[inline(always)]
22+
pub fn handle_initialize_router(
23+
accounts: &mut InitializeRouterAccountConstraints,
24+
bumps: &InitializeRouterAccountConstraintsBumps,
25+
) -> Result<(), ProgramError> {
26+
accounts.router_config.set_inner(RouterConfigInner {
27+
authority: *accounts.authority.address(),
28+
usdc_mint: *accounts.usdc_mint.address(),
29+
bump: bumps.router_config,
30+
});
31+
Ok(())
32+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
pub mod initialize_router;
2+
pub mod set_rate;
3+
pub mod swap_asset_for_usdc;
4+
pub mod swap_usdc_for_asset;
5+
6+
pub use initialize_router::*;
7+
pub use set_rate::*;
8+
pub use swap_asset_for_usdc::*;
9+
pub use swap_usdc_for_asset::*;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
use quasar_lang::prelude::*;
2+
use quasar_spl::prelude::*;
3+
4+
use crate::state::{
5+
AssetRate, AssetRateInner, RouterAuthorityPda, RouterConfig, TreasuryPda,
6+
};
7+
8+
#[derive(Accounts)]
9+
pub struct SetRateAccountConstraints {
10+
#[account(mut)]
11+
pub authority: Signer,
12+
13+
#[account(address = RouterConfig::seeds(), has_one(authority))]
14+
pub router_config: Account<RouterConfig>,
15+
16+
pub asset_mint: Account<Mint>,
17+
pub usdc_mint: Account<Mint>,
18+
19+
#[account(
20+
init(idempotent),
21+
payer = authority,
22+
address = AssetRate::seeds(asset_mint.address()),
23+
)]
24+
pub asset_rate: Account<AssetRate>,
25+
26+
#[account(address = RouterAuthorityPda::seeds())]
27+
pub router_authority: UncheckedAccount,
28+
29+
#[account(
30+
init(idempotent),
31+
payer = authority,
32+
token(mint = usdc_mint, authority = router_authority, token_program = token_program),
33+
address = TreasuryPda::seeds(),
34+
)]
35+
pub router_usdc_treasury: InterfaceAccount<Token>,
36+
37+
pub rent: Sysvar<Rent>,
38+
pub token_program: Program<TokenProgram>,
39+
pub system_program: Program<SystemProgram>,
40+
}
41+
42+
#[inline(always)]
43+
pub fn handle_set_rate(
44+
accounts: &mut SetRateAccountConstraints,
45+
usdc_per_token: u64,
46+
bumps: &SetRateAccountConstraintsBumps,
47+
) -> Result<(), ProgramError> {
48+
accounts.asset_rate.set_inner(AssetRateInner {
49+
mint: *accounts.asset_mint.address(),
50+
usdc_per_token,
51+
bump: bumps.asset_rate,
52+
});
53+
Ok(())
54+
}

0 commit comments

Comments
 (0)