From 78393be298d94eead5d89fb7efa9e5efbcb972be Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 00:10:34 +0000 Subject: [PATCH 1/8] Add vault-strategy walkthrough video script Persona-driven 5-10 minute script explaining the vault-strategy example through Maria (manager), Alice and Bob, with per-step state-transition and token-movement ledgers and verified arithmetic. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01C5vHRAwvmnXhz8tzcq3xHX --- finance/vault-strategy/VIDEO_SCRIPT.md | 247 +++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 finance/vault-strategy/VIDEO_SCRIPT.md diff --git a/finance/vault-strategy/VIDEO_SCRIPT.md b/finance/vault-strategy/VIDEO_SCRIPT.md new file mode 100644 index 00000000..0b245298 --- /dev/null +++ b/finance/vault-strategy/VIDEO_SCRIPT.md @@ -0,0 +1,247 @@ +# Vault Strategy: a five-minute walkthrough + +A video script for the `vault-strategy` example. Target runtime is roughly seven minutes at a normal speaking pace. Narration lines are what the presenter says; the indented blocks are what is on screen as a running ledger of onchain state. + +Prices for TSLAx and NVDAx in this script are illustrative and match the rates the example's tests configure. They are not live quotes. USDC, TSLAx (Tesla stock) and NVDAx (NVIDIA stock) are real assets; the swap behind the scenes is a deterministic test stand-in, which we will be honest about when we reach it. + +## Cold open: where everyone ends up + +NARRATION: + +Here is the ending first, then we will earn it. + +Maria runs a managed basket: forty percent Tesla stock, sixty percent NVIDIA stock, with a one percent annual management fee. Alice puts in 900 dollars because she wants that basket without having to buy and rebalance two stocks herself. NVIDIA rises. Bob shows up later and pays the new, higher price per share, not a discount. Maria collects her fee. Alice cashes out and walks away with about 957 dollars, paid not in pure cash but as her exact slice of everything the vault holds. + +Nobody trusted anybody to hold cash off to the side. Every dollar lived in a program-owned vault the entire time. Let us watch it happen, one instruction handler at a time. + +## The cast + +NARRATION: + +- Maria is the manager. She wants to operate the basket and earn the one percent annual fee on the assets under management. +- Alice wants exposure to a Tesla-plus-NVIDIA basket without managing the two positions herself. +- Bob wants the same thing, but he arrives after the basket has gained value, so he is the one who shows us how shares are priced. + +This is a vault in the family of Solana basket and vault managers like Symmetry and Kamino: deposit one asset, receive shares in a managed portfolio, redeem your shares later for your proportional slice. + +## The accounts, and who can move what + +NARRATION: + +Custody is the whole game, so let us name the boxes before we move money. + +The center of everything is the `Strategy` account, a PDA derived from the seeds `"strategy"` plus Maria's public key. That PDA is the authority over four things: a USDC vault, a TSLAx vault, an NVDAx vault, and the share mint. The three vaults are associated token accounts owned by the strategy PDA. The share mint is its own PDA, seeds `"share_mint"` plus the strategy address, and the strategy PDA is its mint authority. + +What that buys us: only the strategy PDA can sign to move tokens out of those vaults or to mint shares. Maria is the manager, but Maria cannot reach into the vaults with her own keypair. Her powers are exactly three instruction handlers, and the program caps the worst one. We will see each. + +ON SCREEN: + +``` +Strategy [off curve - PDA, seeds: "strategy" + manager] + authority over: vault_usdc, vault_asset_a, vault_asset_b, share_mint +share_mint [off curve - PDA, seeds: "share_mint" + strategy] authority = Strategy PDA +vault_usdc / _a / _b [off curve - ATAs] authority = Strategy PDA +``` + +## Step 1: Maria opens the strategy + +NARRATION: + +Maria calls `initialize_strategy`. She sets the two weights, which must sum to ten thousand basis points, a fee of one hundred basis points, which is one percent a year, and she registers the swap router and the two Pyth price feeds the vault will trust. + +One honest detail up front: those weights are a target Maria maintains by hand. The program records them, but no handler reads them to force an allocation. Deposits arrive as plain USDC and sit idle until Maria chooses to invest. The forty-sixty split is a promise Maria keeps with `invest` and `rebalance`, not a rule the bytecode enforces on each deposit. + +The fee, though, is bounded. `initialize_strategy` rejects any fee above `MAX_FEE_BPS`, ten percent, because the fee is paid by minting new shares, and an uncapped fee would let a manager dilute depositors to nothing by configuration alone. + +ON SCREEN: + +``` +ADDED - Strategy [off curve - PDA] + manager: Maria weight_bps_a: 4000 (TSLAx) weight_bps_b: 6000 (NVDAx) + fee_bps: 100 total_shares: 0 last_fee_accrual_timestamp: now + +ADDED - share_mint, vault_usdc, vault_asset_a, vault_asset_b (all empty) + +TOKEN MOVEMENT: none - setup only +Fee generated: none +``` + +## Step 2: Alice deposits 900 USDC + +NARRATION: + +Alice calls `deposit` with 900 USDC. `deposit` is permissionless: any user can call it, not just the manager. + +The handler prices her shares against net asset value, the total worth of the vault. It reads both Pyth feeds straight from the raw account bytes at fixed offsets, checks each price is positive and no more than sixty seconds stale, and computes net asset value as the USDC balance plus each asset balance times its price. The vault is empty, so net asset value is zero, and the first deposit is defined as one to one. Alice gets 900 shares. Shares carry six decimals, so under the hood that is 900 million minor units, but think of it as 900 shares worth a dollar each. + +Checks, effects, interactions: the handler raises `total_shares` first, then pulls her USDC into the vault, then mints her the shares with the strategy PDA signing. + +ON SCREEN: + +``` +UPDATED - Strategy + total_shares: 0 -> 900,000,000 + +UPDATED - vault_usdc [authority = Strategy PDA] + balance: 0 -> 900 USDC + +UPDATED - Alice share ATA + balance: 0 -> 900 shares + +TOKEN MOVEMENT: + Alice USDC ATA -> vault_usdc 900 USDC (deposit) + share_mint -> Alice share ATA 900 shares (minted, Strategy PDA signs) + +Fee generated: none - deposits do not accrue fees +``` + +## Steps 3 and 4: Maria puts the cash to work + +NARRATION: + +Now Maria earns her title. She calls `invest` twice. `invest` is manager-only; the account constraints require her signature via `has_one = manager`. + +`invest` does not hold a price of its own. It makes a cross-program call into the swap router, which for this example is a deterministic mock: at a fixed rate it mints the asset to the vault and takes the USDC. First, 360 dollars into TSLAx at 250 dollars a share, so the vault receives 1.44 TSLAx. Then 540 dollars into NVDAx at 180 dollars a share, so the vault receives exactly 3 NVDAx. That is the forty-sixty split, by hand. + +The strategy PDA signs both swaps, because the USDC is leaving a vault that only the PDA controls. + +ON SCREEN: + +``` +UPDATED - vault_usdc 540 USDC -> 0 USDC (across both invests) +UPDATED - vault_asset_a 0 -> 1.44 TSLAx +UPDATED - vault_asset_b 0 -> 3.0 NVDAx + +TOKEN MOVEMENT (invest #1): + vault_usdc -> router treasury 360 USDC + router -> vault_asset_a 1.44 TSLAx (router mints; 360 / 250) +TOKEN MOVEMENT (invest #2): + vault_usdc -> router treasury 540 USDC + router -> vault_asset_b 3.0 NVDAx (router mints; 540 / 180) + +Net asset value now: 0 + 1.44 x 250 + 3.0 x 180 = 360 + 540 = 900 USDC +Fee generated: none +``` + +## Step 5 and 6: NVIDIA rises, and Bob pays the new price + +NARRATION: + +Time passes. NVDAx climbs from 180 to 200. Nothing onchain changes from a price move by itself; the vault simply holds 3 NVDAx that are now worth more. Net asset value rises to 960 dollars while the share count is still 900. Each share is now worth about a dollar and seven cents. + +Bob calls `deposit` with 480 dollars. This is the moment the share math matters. Bob does not get 480 shares. The handler computes shares as his deposit times total shares divided by net asset value: 480 times 900 divided by 960, which is exactly 450 shares. He pays the current price, so he does not dilute Alice's gain, and Alice's earlier deposit does not subsidize his. + +ON SCREEN: + +``` +Net asset value before Bob: 0 + 1.44 x 250 + 3.0 x 200 = 360 + 600 = 960 USDC + +UPDATED - Strategy + total_shares: 900,000,000 -> 1,350,000,000 + +UPDATED - vault_usdc 0 -> 480 USDC +UPDATED - Bob share ATA 0 -> 450 shares + +TOKEN MOVEMENT: + Bob USDC ATA -> vault_usdc 480 USDC + share_mint -> Bob share ATA 450 shares (480 x 900 / 960 = 450) + +Fee generated: none +``` + +## Step 7: Maria rebalances back toward target + +NARRATION: + +NVIDIA's run pushed the basket away from forty-sixty, so Maria calls `rebalance`. One handler, two swaps, both signed by the strategy PDA: it sells one asset for USDC, then spends that USDC on the other. + +She sells 0.36 TSLAx, receiving 90 dollars, then buys 0.5 NVDAx with that same 90 dollars. Both legs name a minimum out, so a bad rate would revert rather than silently lose value. USDC nets to zero change across the two legs; the vault just shifts weight from Tesla into NVIDIA. + +ON SCREEN: + +``` +UPDATED - vault_asset_a 1.44 TSLAx -> 1.08 TSLAx (sold 0.36) +UPDATED - vault_asset_b 3.0 NVDAx -> 3.5 NVDAx (bought 0.5) +UPDATED - vault_usdc 480 USDC -> 480 USDC (+90 then -90) + +TOKEN MOVEMENT: + sell leg: vault_asset_a -> router (burned) 0.36 TSLAx; router treasury -> vault_usdc 90 USDC + buy leg: vault_usdc -> router treasury 90 USDC; router -> vault_asset_b 0.5 NVDAx + +Net asset value: 480 + 1.08 x 250 + 3.5 x 200 = 480 + 270 + 700 = 1,450 USDC +Fee generated: none - rebalance moves assets, it does not charge a fee +``` + +## Step 8: Maria collects her fee + +NARRATION: + +Maria calls `collect_fees`. This is a streaming management fee, and the mechanism is the point: the program does not skim tokens from a vault. It mints new shares to the manager, proportional to time elapsed and the fee rate. Over a full year at one percent, that is one percent of the share supply, 13.5 shares, minted to Maria. + +New shares with no new assets behind them means every existing share is now a slightly thinner slice. That dilution, spread across all holders, is how Alice and Bob actually pay the fee. It is honest to say so out loud: there is no separate performance fee here, only this management fee on assets under management, and it is the cap from step one that stops it from ever becoming a drain. + +ON SCREEN: + +``` +elapsed: 1 year (illustrative) +fee_shares = total_shares x fee_bps x elapsed / (10,000 x seconds_per_year) + = 1,350,000,000 x 100 x 1yr / (10,000 x 1yr) = 13,500,000 (13.5 shares) + +UPDATED - Strategy + total_shares: 1,350,000,000 -> 1,363,500,000 + last_fee_accrual_timestamp: updated + +UPDATED - Maria share ATA 0 -> 13.5 shares + +TOKEN MOVEMENT: + share_mint -> Maria share ATA 13.5 shares (minted, Strategy PDA signs) +Fee generated: 13.5 shares to the manager; all other holders diluted ~1% +``` + +## Step 9: Alice withdraws + +NARRATION: + +Alice calls `withdraw` and burns all 900 of her shares. Here is the part people miss: withdrawal is in kind and proportional. She does not get cash. She gets her exact fraction of every balance the vault holds, USDC and TSLAx and NVDAx alike. + +Her fraction is 900 shares out of the 1,363.5 that now exist. The handler floors each amount in the protocol's favor, so any rounding dust stays with the remaining holders. + +ON SCREEN: + +``` +Alice fraction = 900,000,000 / 1,363,500,000 + +amount_usdc = 480,000,000 x 900,000,000 / 1,363,500,000 = 316,831,683 (316.83 USDC, floor) +amount_a = 1,080,000 x 900,000,000 / 1,363,500,000 = 712,871 (0.712871 TSLAx, floor) +amount_b = 3,500,000 x 900,000,000 / 1,363,500,000 = 2,310,231 (2.310231 NVDAx, floor) + +UPDATED - Strategy total_shares: 1,363,500,000 -> 463,500,000 +UPDATED - Alice share ATA 900 shares -> 0 (burned) + +TOKEN MOVEMENT: + share_mint burns 900 shares from Alice + vault_usdc -> Alice 316.83 USDC + vault_asset_a -> Alice 0.712871 TSLAx + vault_asset_b -> Alice 2.310231 NVDAx + +Alice payout value @ 250 / 200 = 316.83 + 0.712871 x 250 + 2.310231 x 200 = about 957.10 USDC +Fee generated: none - withdrawals do not accrue fees +``` + +## Reconcile, and where everyone ended up + +NARRATION: + +Let us check the books. USDC into the vault was 900 from Alice plus 480 from Bob, 1,380 total. The invests sent 900 to the router; rebalance was a wash. That leaves 480 in the vault, and after Alice's withdrawal, 163.17 remains. Tokens in equal tokens out. + +So: Alice came in with 900 dollars, rode NVIDIA up, paid her share of a one percent fee through dilution, and left with about 957 dollars worth of basket, in kind. Bob bought in fairly at the higher share price and still holds 450 shares worth roughly 478 dollars. Maria earned 13.5 shares, about 14 dollars, for running the book. The vault held custody from the first deposit to the last withdrawal, the manager never touched the vaults with her own key, and the fee she could charge was capped in the bytecode. + +## Two honest footnotes + +NARRATION: + +First, the swap router here is a deterministic test stand-in. It mints and burns at a fixed rate with no spread, and its rate matches the Pyth price, which keeps the math clean for teaching. A real deployment would call out to a live venue and the strategy would only trust the one router address it registered at initialization. That registration is checked on every `invest` and `rebalance`. + +Second, the forty-sixty weights are a target Maria maintains, not an allocation the program enforces per deposit. If you want the vault to auto-allocate on deposit, that is a feature to add, not something to assume is already there. + +That is the whole lifecycle: open, deposit, invest, price in new depositors fairly, rebalance, charge a bounded streaming fee, and redeem in kind. Thanks for watching. From bfe8a1be65e114ab10d46f5507e147462631e6e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 20:30:31 +0000 Subject: [PATCH 2/8] Add traditional-finance and Solana-peer framing to vault-strategy script Map the vault to a mutual fund / actively managed ETF (units, NAV pricing, expense ratio, in-kind redemption), name current Solana peers (Drift Vaults, Symmetry, Kamino), and contrast what actually differs onchain. Introduce the cast as each actor first appears instead of upfront. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01C5vHRAwvmnXhz8tzcq3xHX --- finance/vault-strategy/VIDEO_SCRIPT.md | 29 +++++++++++++++++--------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/finance/vault-strategy/VIDEO_SCRIPT.md b/finance/vault-strategy/VIDEO_SCRIPT.md index 0b245298..4b5dc085 100644 --- a/finance/vault-strategy/VIDEO_SCRIPT.md +++ b/finance/vault-strategy/VIDEO_SCRIPT.md @@ -14,15 +14,24 @@ Maria runs a managed basket: forty percent Tesla stock, sixty percent NVIDIA sto Nobody trusted anybody to hold cash off to the side. Every dollar lived in a program-owned vault the entire time. Let us watch it happen, one instruction handler at a time. -## The cast +## What it is, in finance you already know NARRATION: -- Maria is the manager. She wants to operate the basket and earn the one percent annual fee on the assets under management. -- Alice wants exposure to a Tesla-plus-NVIDIA basket without managing the two positions herself. -- Bob wants the same thing, but he arrives after the basket has gained value, so he is the one who shows us how shares are priced. +Strip the jargon and this is an actively managed fund. In traditional finance you would call it a mutual fund, or an actively managed ETF: you hand cash to a portfolio manager, you receive units, the manager buys a basket and rebalances it over time, the fund prices your units at net asset value, and the manager takes an expense ratio every year for running it. When you leave an ETF, it can even pay you in kind, handing back the underlying shares instead of cash. -This is a vault in the family of Solana basket and vault managers like Symmetry and Kamino: deposit one asset, receive shares in a managed portfolio, redeem your shares later for your proportional slice. +Every one of those pieces has a line in this program. Units are share tokens. Net asset value is computed live from a Pyth oracle. The expense ratio is the management fee. In-kind redemption is exactly how `withdraw` works. The portfolio manager is Maria. + +You have seen this shape on Solana, too. Drift Vaults let a manager trade depositors' pooled funds for a fee. Symmetry runs weighted token baskets that rebalance. Kamino issues vault shares priced at net asset value. This example is the teaching-sized version of that family. + +So what actually changes when the fund is onchain, past the buzzwords? Four things that matter: + +- The rules are the deployed bytecode, not a prospectus you trust a custodian to honor. Maria cannot freeze redemptions or quietly raise the fee. The fee is even capped in code at ten percent. +- Entry and exit are permissionless and settle instantly. No minimum, no transfer agent, no end-of-day cutoff. Alice deposits and redeems in single transactions, and so can anyone. +- The price comes from an oracle, not an end-of-day accountant. That is a real dependency, not a free lunch: a stale or wrong Pyth price would misprice every deposit, which is why the program refuses any price older than sixty seconds. +- You custody your own units. Your shares live in your wallet, not on a broker's ledger, and an onchain bug is final in a way a fund's back-office error is not. + +Keep that mapping in your head. We will hit each piece as it shows up. ## The accounts, and who can move what @@ -47,7 +56,7 @@ vault_usdc / _a / _b [off curve - ATAs] authori NARRATION: -Maria calls `initialize_strategy`. She sets the two weights, which must sum to ten thousand basis points, a fee of one hundred basis points, which is one percent a year, and she registers the swap router and the two Pyth price feeds the vault will trust. +Maria is our portfolio manager, and she wants to run the basket and earn the fee. She calls `initialize_strategy`. She sets the two weights, which must sum to ten thousand basis points, a fee of one hundred basis points, which is one percent a year, and she registers the swap router and the two Pyth price feeds the vault will trust. One honest detail up front: those weights are a target Maria maintains by hand. The program records them, but no handler reads them to force an allocation. Deposits arrive as plain USDC and sit idle until Maria chooses to invest. The forty-sixty split is a promise Maria keeps with `invest` and `rebalance`, not a rule the bytecode enforces on each deposit. @@ -70,7 +79,7 @@ Fee generated: none NARRATION: -Alice calls `deposit` with 900 USDC. `deposit` is permissionless: any user can call it, not just the manager. +Alice wants the Tesla-plus-NVIDIA basket without buying and rebalancing two stocks herself, so she calls `deposit` with 900 USDC. `deposit` is permissionless: any user can call it, not just the manager. This is buying into the fund. The handler prices her shares against net asset value, the total worth of the vault. It reads both Pyth feeds straight from the raw account bytes at fixed offsets, checks each price is positive and no more than sixty seconds stale, and computes net asset value as the USDC balance plus each asset balance times its price. The vault is empty, so net asset value is zero, and the first deposit is defined as one to one. Alice gets 900 shares. Shares carry six decimals, so under the hood that is 900 million minor units, but think of it as 900 shares worth a dollar each. @@ -129,7 +138,7 @@ NARRATION: Time passes. NVDAx climbs from 180 to 200. Nothing onchain changes from a price move by itself; the vault simply holds 3 NVDAx that are now worth more. Net asset value rises to 960 dollars while the share count is still 900. Each share is now worth about a dollar and seven cents. -Bob calls `deposit` with 480 dollars. This is the moment the share math matters. Bob does not get 480 shares. The handler computes shares as his deposit times total shares divided by net asset value: 480 times 900 divided by 960, which is exactly 450 shares. He pays the current price, so he does not dilute Alice's gain, and Alice's earlier deposit does not subsidize his. +Bob wants the same basket Alice does, but he arrives now, after the gain, so he is the one who shows us how units are priced. He calls `deposit` with 480 dollars. This is the moment the share math matters, and it is the same rule a mutual fund uses: you buy units at today's net asset value. Bob does not get 480 shares. The handler computes shares as his deposit times total shares divided by net asset value: 480 times 900 divided by 960, which is exactly 450 shares. He pays the current price, so he does not dilute Alice's gain, and Alice's earlier deposit does not subsidize his. ON SCREEN: @@ -178,7 +187,7 @@ NARRATION: Maria calls `collect_fees`. This is a streaming management fee, and the mechanism is the point: the program does not skim tokens from a vault. It mints new shares to the manager, proportional to time elapsed and the fee rate. Over a full year at one percent, that is one percent of the share supply, 13.5 shares, minted to Maria. -New shares with no new assets behind them means every existing share is now a slightly thinner slice. That dilution, spread across all holders, is how Alice and Bob actually pay the fee. It is honest to say so out loud: there is no separate performance fee here, only this management fee on assets under management, and it is the cap from step one that stops it from ever becoming a drain. +New shares with no new assets behind them means every existing share is now a slightly thinner slice. That dilution, spread across all holders, is how Alice and Bob actually pay the fee. This is the expense ratio of a mutual fund, charged the Solana way: by minting the manager new units rather than by selling fund assets to cut her a check. It is honest to say so out loud: there is no separate performance fee here, only this management fee on assets under management, and it is the cap from step one that stops it from ever becoming a drain. ON SCREEN: @@ -202,7 +211,7 @@ Fee generated: 13.5 shares to the manager; all other holders diluted ~1% NARRATION: -Alice calls `withdraw` and burns all 900 of her shares. Here is the part people miss: withdrawal is in kind and proportional. She does not get cash. She gets her exact fraction of every balance the vault holds, USDC and TSLAx and NVDAx alike. +Alice calls `withdraw` and burns all 900 of her shares. Here is the part people miss: withdrawal is in kind and proportional. She does not get cash. She gets her exact fraction of every balance the vault holds, USDC and TSLAx and NVDAx alike. This is an ETF in-kind redemption: just as an authorized participant hands back fund units and receives the underlying shares, Alice's burn returns her slice of the actual holdings, not a cash settlement. Her fraction is 900 shares out of the 1,363.5 that now exist. The handler floors each amount in the protocol's favor, so any rounding dust stays with the remaining holders. From 128fd790478b6973d9db29cddb85b62fc717e8cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 20:39:20 +0000 Subject: [PATCH 3/8] Rework vault-strategy script intro and tighten skill compliance Lead with the concept (an onchain mutual fund / actively managed ETF) instead of granular outcome numbers, drop numbered Step headings for descriptive ones, gloss USDC on first use, and note the vault passes through losses as well as gains. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01C5vHRAwvmnXhz8tzcq3xHX --- finance/vault-strategy/VIDEO_SCRIPT.md | 38 ++++++++++---------------- 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/finance/vault-strategy/VIDEO_SCRIPT.md b/finance/vault-strategy/VIDEO_SCRIPT.md index 4b5dc085..4840e4a0 100644 --- a/finance/vault-strategy/VIDEO_SCRIPT.md +++ b/finance/vault-strategy/VIDEO_SCRIPT.md @@ -1,28 +1,18 @@ -# Vault Strategy: a five-minute walkthrough +# Vault Strategy: a walkthrough A video script for the `vault-strategy` example. Target runtime is roughly seven minutes at a normal speaking pace. Narration lines are what the presenter says; the indented blocks are what is on screen as a running ledger of onchain state. -Prices for TSLAx and NVDAx in this script are illustrative and match the rates the example's tests configure. They are not live quotes. USDC, TSLAx (Tesla stock) and NVDAx (NVIDIA stock) are real assets; the swap behind the scenes is a deterministic test stand-in, which we will be honest about when we reach it. +Prices for TSLAx and NVDAx in this script are illustrative and match the rates the example's tests configure. They are not live quotes. USDC (US dollars), TSLAx (Tesla stock) and NVDAx (NVIDIA stock) are real assets; the swap behind the scenes is a deterministic test stand-in, which we will be honest about when we reach it. -## Cold open: where everyone ends up +## What we are building NARRATION: -Here is the ending first, then we will earn it. +Let's build a vault strategy: the onchain equivalent of a mutual fund, or an actively managed ETF. You hand cash to a manager, you receive units, the manager buys a basket of assets and rebalances it over time, your units are priced at net asset value, and the manager earns a fee for running the book. By the end you will have watched someone buy in, a manager invest and rebalance, a fee come due, and someone redeem, and you will know exactly which instruction handler does each one. The whole time, every dollar stays in a program-owned vault. Nobody hands their cash to a person to hold. -Maria runs a managed basket: forty percent Tesla stock, sixty percent NVIDIA stock, with a one percent annual management fee. Alice puts in 900 dollars because she wants that basket without having to buy and rebalance two stocks herself. NVIDIA rises. Bob shows up later and pays the new, higher price per share, not a discount. Maria collects her fee. Alice cashes out and walks away with about 957 dollars, paid not in pure cash but as her exact slice of everything the vault holds. +Each piece of that fund maps to a line in this program. Units are share tokens. Net asset value is computed live from a Pyth oracle. The expense ratio is the management fee. In-kind redemption is exactly how `withdraw` works. The portfolio manager is Maria. -Nobody trusted anybody to hold cash off to the side. Every dollar lived in a program-owned vault the entire time. Let us watch it happen, one instruction handler at a time. - -## What it is, in finance you already know - -NARRATION: - -Strip the jargon and this is an actively managed fund. In traditional finance you would call it a mutual fund, or an actively managed ETF: you hand cash to a portfolio manager, you receive units, the manager buys a basket and rebalances it over time, the fund prices your units at net asset value, and the manager takes an expense ratio every year for running it. When you leave an ETF, it can even pay you in kind, handing back the underlying shares instead of cash. - -Every one of those pieces has a line in this program. Units are share tokens. Net asset value is computed live from a Pyth oracle. The expense ratio is the management fee. In-kind redemption is exactly how `withdraw` works. The portfolio manager is Maria. - -You have seen this shape on Solana, too. Drift Vaults let a manager trade depositors' pooled funds for a fee. Symmetry runs weighted token baskets that rebalance. Kamino issues vault shares priced at net asset value. This example is the teaching-sized version of that family. +You have seen this shape on Solana before. Drift Vaults let a manager trade depositors' pooled funds for a fee. Symmetry runs weighted token baskets that rebalance. Kamino issues vault shares priced at net asset value. This example is the teaching-sized version of that family. So what actually changes when the fund is onchain, past the buzzwords? Four things that matter: @@ -52,7 +42,7 @@ share_mint [off curve - PDA, seeds: "share_mint" + strategy] authorit vault_usdc / _a / _b [off curve - ATAs] authority = Strategy PDA ``` -## Step 1: Maria opens the strategy +## Maria opens the strategy NARRATION: @@ -75,7 +65,7 @@ TOKEN MOVEMENT: none - setup only Fee generated: none ``` -## Step 2: Alice deposits 900 USDC +## Alice deposits 900 USDC NARRATION: @@ -104,7 +94,7 @@ TOKEN MOVEMENT: Fee generated: none - deposits do not accrue fees ``` -## Steps 3 and 4: Maria puts the cash to work +## Maria puts the cash to work NARRATION: @@ -132,7 +122,7 @@ Net asset value now: 0 + 1.44 x 250 + 3.0 x 180 = 360 + 540 = 900 USDC Fee generated: none ``` -## Step 5 and 6: NVIDIA rises, and Bob pays the new price +## NVIDIA rises, and Bob pays the new price NARRATION: @@ -158,7 +148,7 @@ TOKEN MOVEMENT: Fee generated: none ``` -## Step 7: Maria rebalances back toward target +## Maria rebalances back toward target NARRATION: @@ -181,7 +171,7 @@ Net asset value: 480 + 1.08 x 250 + 3.5 x 200 = 480 + 270 + 700 = 1,450 USDC Fee generated: none - rebalance moves assets, it does not charge a fee ``` -## Step 8: Maria collects her fee +## Maria collects her fee NARRATION: @@ -207,7 +197,7 @@ TOKEN MOVEMENT: Fee generated: 13.5 shares to the manager; all other holders diluted ~1% ``` -## Step 9: Alice withdraws +## Alice withdraws NARRATION: @@ -243,7 +233,7 @@ NARRATION: Let us check the books. USDC into the vault was 900 from Alice plus 480 from Bob, 1,380 total. The invests sent 900 to the router; rebalance was a wash. That leaves 480 in the vault, and after Alice's withdrawal, 163.17 remains. Tokens in equal tokens out. -So: Alice came in with 900 dollars, rode NVIDIA up, paid her share of a one percent fee through dilution, and left with about 957 dollars worth of basket, in kind. Bob bought in fairly at the higher share price and still holds 450 shares worth roughly 478 dollars. Maria earned 13.5 shares, about 14 dollars, for running the book. The vault held custody from the first deposit to the last withdrawal, the manager never touched the vaults with her own key, and the fee she could charge was capped in the bytecode. +So: Alice came in with 900 dollars, rode NVIDIA up, paid her share of a one percent fee through dilution, and left with about 957 dollars worth of basket, in kind. The vault passes returns through in both directions: had NVIDIA fallen instead of risen, the same arithmetic would have redeemed Alice for less than her 900 dollars. That market risk is hers, and the program neither cushions it nor hides it. Bob bought in fairly at the higher share price and still holds 450 shares worth roughly 478 dollars. Maria earned 13.5 shares, about 14 dollars, for running the book. The vault held custody from the first deposit to the last withdrawal, the manager never touched the vaults with her own key, and the fee she could charge was capped in the bytecode. ## Two honest footnotes From f21261bba018bed77ca2746034ab03b2ecaa4e1e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 21:25:57 +0000 Subject: [PATCH 4/8] Tighten vault-strategy script: shares not units, define NAV, fix peers Standardize on 'shares' (the program's term) throughout, define net asset value and its 'net', restate custody as program-controlled with no manager access, drop the dead Drift peer for Symmetry/Kamino/Meteora, and cut the obvious oracle and bug asides. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01C5vHRAwvmnXhz8tzcq3xHX --- finance/vault-strategy/VIDEO_SCRIPT.md | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/finance/vault-strategy/VIDEO_SCRIPT.md b/finance/vault-strategy/VIDEO_SCRIPT.md index 4840e4a0..9a3bc2b2 100644 --- a/finance/vault-strategy/VIDEO_SCRIPT.md +++ b/finance/vault-strategy/VIDEO_SCRIPT.md @@ -8,20 +8,18 @@ Prices for TSLAx and NVDAx in this script are illustrative and match the rates t NARRATION: -Let's build a vault strategy: the onchain equivalent of a mutual fund, or an actively managed ETF. You hand cash to a manager, you receive units, the manager buys a basket of assets and rebalances it over time, your units are priced at net asset value, and the manager earns a fee for running the book. By the end you will have watched someone buy in, a manager invest and rebalance, a fee come due, and someone redeem, and you will know exactly which instruction handler does each one. The whole time, every dollar stays in a program-owned vault. Nobody hands their cash to a person to hold. +Let's build a vault strategy: the onchain equivalent of a mutual fund, or an actively managed ETF. You deposit cash with a manager, you receive shares in the vault, the manager buys a basket of assets and rebalances it over time, and your shares are priced at net asset value: the worth of everything the vault holds, divided by the shares outstanding. The word net is a fund convention for value after subtracting what the fund owes; this vault borrows nothing, so its net asset value is simply its holdings. For running the book, the manager earns a fee. -Each piece of that fund maps to a line in this program. Units are share tokens. Net asset value is computed live from a Pyth oracle. The expense ratio is the management fee. In-kind redemption is exactly how `withdraw` works. The portfolio manager is Maria. +By the end you will have watched someone deposit, the manager invest and rebalance, a fee accrue, and someone redeem, and you will know which instruction handler does each one. The program controls every dollar the whole time: the manager invests the funds but has no access to them for herself. -You have seen this shape on Solana before. Drift Vaults let a manager trade depositors' pooled funds for a fee. Symmetry runs weighted token baskets that rebalance. Kamino issues vault shares priced at net asset value. This example is the teaching-sized version of that family. +You have seen this shape on Solana, in protocols like Symmetry, Kamino, and Meteora. This is the teaching-sized version. -So what actually changes when the fund is onchain, past the buzzwords? Four things that matter: +Two things genuinely change once the fund is onchain: -- The rules are the deployed bytecode, not a prospectus you trust a custodian to honor. Maria cannot freeze redemptions or quietly raise the fee. The fee is even capped in code at ten percent. -- Entry and exit are permissionless and settle instantly. No minimum, no transfer agent, no end-of-day cutoff. Alice deposits and redeems in single transactions, and so can anyone. -- The price comes from an oracle, not an end-of-day accountant. That is a real dependency, not a free lunch: a stale or wrong Pyth price would misprice every deposit, which is why the program refuses any price older than sixty seconds. -- You custody your own units. Your shares live in your wallet, not on a broker's ledger, and an onchain bug is final in a way a fund's back-office error is not. +- The rules are the deployed bytecode. Maria cannot freeze redemptions, and the fee is fixed at creation and capped in code at ten percent. There is no admin lever to pull. +- Entry and exit are permissionless and settle instantly. Anyone can deposit or redeem in a single transaction, priced live, with no minimum and no end-of-day cutoff. -Keep that mapping in your head. We will hit each piece as it shows up. +We will hit each piece as it shows up. ## The accounts, and who can move what @@ -128,7 +126,7 @@ NARRATION: Time passes. NVDAx climbs from 180 to 200. Nothing onchain changes from a price move by itself; the vault simply holds 3 NVDAx that are now worth more. Net asset value rises to 960 dollars while the share count is still 900. Each share is now worth about a dollar and seven cents. -Bob wants the same basket Alice does, but he arrives now, after the gain, so he is the one who shows us how units are priced. He calls `deposit` with 480 dollars. This is the moment the share math matters, and it is the same rule a mutual fund uses: you buy units at today's net asset value. Bob does not get 480 shares. The handler computes shares as his deposit times total shares divided by net asset value: 480 times 900 divided by 960, which is exactly 450 shares. He pays the current price, so he does not dilute Alice's gain, and Alice's earlier deposit does not subsidize his. +Bob wants the same basket Alice does, but he arrives now, after the gain, so he is the one who shows us how shares are priced. He calls `deposit` with 480 dollars. This is the moment the share math matters, and it is the same rule a mutual fund uses: you buy shares at today's net asset value. Bob does not get 480 shares. The handler computes shares as his deposit times total shares divided by net asset value: 480 times 900 divided by 960, which is exactly 450 shares. He pays the current price, so he does not dilute Alice's gain, and Alice's earlier deposit does not subsidize his. ON SCREEN: @@ -177,7 +175,7 @@ NARRATION: Maria calls `collect_fees`. This is a streaming management fee, and the mechanism is the point: the program does not skim tokens from a vault. It mints new shares to the manager, proportional to time elapsed and the fee rate. Over a full year at one percent, that is one percent of the share supply, 13.5 shares, minted to Maria. -New shares with no new assets behind them means every existing share is now a slightly thinner slice. That dilution, spread across all holders, is how Alice and Bob actually pay the fee. This is the expense ratio of a mutual fund, charged the Solana way: by minting the manager new units rather than by selling fund assets to cut her a check. It is honest to say so out loud: there is no separate performance fee here, only this management fee on assets under management, and it is the cap from step one that stops it from ever becoming a drain. +New shares with no new assets behind them means every existing share is now a slightly thinner slice. That dilution, spread across all holders, is how Alice and Bob actually pay the fee. This is the expense ratio of a mutual fund, charged the Solana way: by minting the manager new shares rather than by selling fund assets to cut her a check. It is honest to say so out loud: there is no separate performance fee here, only this management fee on assets under management, and it is the cap from the start that stops it from ever becoming a drain. ON SCREEN: @@ -201,7 +199,7 @@ Fee generated: 13.5 shares to the manager; all other holders diluted ~1% NARRATION: -Alice calls `withdraw` and burns all 900 of her shares. Here is the part people miss: withdrawal is in kind and proportional. She does not get cash. She gets her exact fraction of every balance the vault holds, USDC and TSLAx and NVDAx alike. This is an ETF in-kind redemption: just as an authorized participant hands back fund units and receives the underlying shares, Alice's burn returns her slice of the actual holdings, not a cash settlement. +Alice calls `withdraw` and burns all 900 of her shares. Here is the part people miss: withdrawal is in kind and proportional. She does not get cash. She gets her exact fraction of every balance the vault holds, USDC and TSLAx and NVDAx alike. It is the same move an ETF makes when it redeems in kind, handing back the underlying holdings instead of cash. Her fraction is 900 shares out of the 1,363.5 that now exist. The handler floors each amount in the protocol's favor, so any rounding dust stays with the remaining holders. From 65f426cecd858a57c3296cc1648c98c2dcee222f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 22:07:47 +0000 Subject: [PATCH 5/8] Disambiguate 'vault': single-asset token account vs the strategy/fund A vault is one single-asset token account (a Solana token account holds one mint); the whole construct is the strategy or the fund. Reserve 'vault' for the three per-asset accounts throughout, name which vault each token movement touches, and explain the term where it first appears, matching the production pattern (one token vault per asset plus a shares mint). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01C5vHRAwvmnXhz8tzcq3xHX --- finance/vault-strategy/VIDEO_SCRIPT.md | 32 ++++++++++++++------------ 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/finance/vault-strategy/VIDEO_SCRIPT.md b/finance/vault-strategy/VIDEO_SCRIPT.md index 9a3bc2b2..f264c989 100644 --- a/finance/vault-strategy/VIDEO_SCRIPT.md +++ b/finance/vault-strategy/VIDEO_SCRIPT.md @@ -8,7 +8,7 @@ Prices for TSLAx and NVDAx in this script are illustrative and match the rates t NARRATION: -Let's build a vault strategy: the onchain equivalent of a mutual fund, or an actively managed ETF. You deposit cash with a manager, you receive shares in the vault, the manager buys a basket of assets and rebalances it over time, and your shares are priced at net asset value: the worth of everything the vault holds, divided by the shares outstanding. The word net is a fund convention for value after subtracting what the fund owes; this vault borrows nothing, so its net asset value is simply its holdings. For running the book, the manager earns a fee. +Let's build a vault strategy: the onchain equivalent of a mutual fund, or an actively managed ETF. You deposit cash with a manager, you receive shares in the fund, the manager buys a basket of assets and rebalances it over time, and your shares are priced at net asset value: the worth of everything the fund holds, divided by the shares outstanding. The word net is a fund convention for value after subtracting what the fund owes; this fund borrows nothing, so its net asset value is simply its holdings. For running the book, the manager earns a fee. By the end you will have watched someone deposit, the manager invest and rebalance, a fee accrue, and someone redeem, and you will know which instruction handler does each one. The program controls every dollar the whole time: the manager invests the funds but has no access to them for herself. @@ -27,7 +27,9 @@ NARRATION: Custody is the whole game, so let us name the boxes before we move money. -The center of everything is the `Strategy` account, a PDA derived from the seeds `"strategy"` plus Maria's public key. That PDA is the authority over four things: a USDC vault, a TSLAx vault, an NVDAx vault, and the share mint. The three vaults are associated token accounts owned by the strategy PDA. The share mint is its own PDA, seeds `"share_mint"` plus the strategy address, and the strategy PDA is its mint authority. +First, a word on the term vault, because it is easy to overload. A vault here is a single token account, and a Solana token account holds exactly one kind of token. So the fund does not keep one vault with everything in it; it keeps three, one per asset. From here on, a vault means one of those single-asset accounts, and the strategy, or the fund, means the whole construct. Production works the same way: a Kamino vault's state points at one token vault per asset, alongside a separate shares mint. + +The center of everything is the `Strategy` account, a PDA derived from the seeds `"strategy"` plus Maria's public key. That PDA is the authority over four things: a USDC vault, a TSLAx vault, an NVDAx vault, and the share mint. Each of the three vaults is an associated token account owned by the strategy PDA, holding exactly one asset. The share mint is its own PDA, seeds `"share_mint"` plus the strategy address, and the strategy PDA is its mint authority. What that buys us: only the strategy PDA can sign to move tokens out of those vaults or to mint shares. Maria is the manager, but Maria cannot reach into the vaults with her own keypair. Her powers are exactly three instruction handlers, and the program caps the worst one. We will see each. @@ -37,14 +39,14 @@ ON SCREEN: Strategy [off curve - PDA, seeds: "strategy" + manager] authority over: vault_usdc, vault_asset_a, vault_asset_b, share_mint share_mint [off curve - PDA, seeds: "share_mint" + strategy] authority = Strategy PDA -vault_usdc / _a / _b [off curve - ATAs] authority = Strategy PDA +vault_usdc / _a / _b [off curve - ATAs, one asset each] authority = Strategy PDA ``` ## Maria opens the strategy NARRATION: -Maria is our portfolio manager, and she wants to run the basket and earn the fee. She calls `initialize_strategy`. She sets the two weights, which must sum to ten thousand basis points, a fee of one hundred basis points, which is one percent a year, and she registers the swap router and the two Pyth price feeds the vault will trust. +Maria is our portfolio manager, and she wants to run the basket and earn the fee. She calls `initialize_strategy`. She sets the two weights, which must sum to ten thousand basis points, a fee of one hundred basis points, which is one percent a year, and she registers the swap router and the two Pyth price feeds the strategy will trust. One honest detail up front: those weights are a target Maria maintains by hand. The program records them, but no handler reads them to force an allocation. Deposits arrive as plain USDC and sit idle until Maria chooses to invest. The forty-sixty split is a promise Maria keeps with `invest` and `rebalance`, not a rule the bytecode enforces on each deposit. @@ -69,9 +71,9 @@ NARRATION: Alice wants the Tesla-plus-NVIDIA basket without buying and rebalancing two stocks herself, so she calls `deposit` with 900 USDC. `deposit` is permissionless: any user can call it, not just the manager. This is buying into the fund. -The handler prices her shares against net asset value, the total worth of the vault. It reads both Pyth feeds straight from the raw account bytes at fixed offsets, checks each price is positive and no more than sixty seconds stale, and computes net asset value as the USDC balance plus each asset balance times its price. The vault is empty, so net asset value is zero, and the first deposit is defined as one to one. Alice gets 900 shares. Shares carry six decimals, so under the hood that is 900 million minor units, but think of it as 900 shares worth a dollar each. +The handler prices her shares against net asset value, the total worth of the fund. It reads both Pyth feeds straight from the raw account bytes at fixed offsets, checks each price is positive and no more than sixty seconds stale, and computes net asset value as the USDC vault balance plus each asset vault balance times its price. The fund is empty, so net asset value is zero, and the first deposit is defined as one to one. Alice gets 900 shares. Shares carry six decimals, so under the hood that is 900 million minor units, but think of it as 900 shares worth a dollar each. -Checks, effects, interactions: the handler raises `total_shares` first, then pulls her USDC into the vault, then mints her the shares with the strategy PDA signing. +Checks, effects, interactions: the handler raises `total_shares` first, then pulls her USDC into the USDC vault, then mints her the shares with the strategy PDA signing. ON SCREEN: @@ -98,9 +100,9 @@ NARRATION: Now Maria earns her title. She calls `invest` twice. `invest` is manager-only; the account constraints require her signature via `has_one = manager`. -`invest` does not hold a price of its own. It makes a cross-program call into the swap router, which for this example is a deterministic mock: at a fixed rate it mints the asset to the vault and takes the USDC. First, 360 dollars into TSLAx at 250 dollars a share, so the vault receives 1.44 TSLAx. Then 540 dollars into NVDAx at 180 dollars a share, so the vault receives exactly 3 NVDAx. That is the forty-sixty split, by hand. +`invest` does not hold a price of its own. It makes a cross-program call into the swap router, which for this example is a deterministic mock: at a fixed rate it mints the asset into the matching vault and takes the USDC. First, 360 dollars into TSLAx at 250 dollars a share, so the TSLAx vault receives 1.44 TSLAx. Then 540 dollars into NVDAx at 180 dollars a share, so the NVDAx vault receives exactly 3 NVDAx. That is the forty-sixty split, by hand. -The strategy PDA signs both swaps, because the USDC is leaving a vault that only the PDA controls. +The strategy PDA signs both swaps, because the USDC is leaving the USDC vault, which only the PDA controls. ON SCREEN: @@ -124,7 +126,7 @@ Fee generated: none NARRATION: -Time passes. NVDAx climbs from 180 to 200. Nothing onchain changes from a price move by itself; the vault simply holds 3 NVDAx that are now worth more. Net asset value rises to 960 dollars while the share count is still 900. Each share is now worth about a dollar and seven cents. +Time passes. NVDAx climbs from 180 to 200. Nothing onchain changes from a price move by itself; the NVDAx vault still holds the same 3 NVDAx, now worth more. Net asset value rises to 960 dollars while the share count is still 900. Each share is now worth about a dollar and seven cents. Bob wants the same basket Alice does, but he arrives now, after the gain, so he is the one who shows us how shares are priced. He calls `deposit` with 480 dollars. This is the moment the share math matters, and it is the same rule a mutual fund uses: you buy shares at today's net asset value. Bob does not get 480 shares. The handler computes shares as his deposit times total shares divided by net asset value: 480 times 900 divided by 960, which is exactly 450 shares. He pays the current price, so he does not dilute Alice's gain, and Alice's earlier deposit does not subsidize his. @@ -152,7 +154,7 @@ NARRATION: NVIDIA's run pushed the basket away from forty-sixty, so Maria calls `rebalance`. One handler, two swaps, both signed by the strategy PDA: it sells one asset for USDC, then spends that USDC on the other. -She sells 0.36 TSLAx, receiving 90 dollars, then buys 0.5 NVDAx with that same 90 dollars. Both legs name a minimum out, so a bad rate would revert rather than silently lose value. USDC nets to zero change across the two legs; the vault just shifts weight from Tesla into NVIDIA. +She sells 0.36 TSLAx, receiving 90 dollars, then buys 0.5 NVDAx with that same 90 dollars. Both legs name a minimum out, so a bad rate would revert rather than silently lose value. The USDC vault nets to zero change across the two legs; the strategy just shifts weight from Tesla into NVIDIA. ON SCREEN: @@ -173,7 +175,7 @@ Fee generated: none - rebalance moves assets, it does not charge a fee NARRATION: -Maria calls `collect_fees`. This is a streaming management fee, and the mechanism is the point: the program does not skim tokens from a vault. It mints new shares to the manager, proportional to time elapsed and the fee rate. Over a full year at one percent, that is one percent of the share supply, 13.5 shares, minted to Maria. +Maria calls `collect_fees`. This is a streaming management fee, and the mechanism is the point: the program does not pull tokens out of any vault. It mints new shares to the manager, proportional to time elapsed and the fee rate. Over a full year at one percent, that is one percent of the share supply, 13.5 shares, minted to Maria. New shares with no new assets behind them means every existing share is now a slightly thinner slice. That dilution, spread across all holders, is how Alice and Bob actually pay the fee. This is the expense ratio of a mutual fund, charged the Solana way: by minting the manager new shares rather than by selling fund assets to cut her a check. It is honest to say so out loud: there is no separate performance fee here, only this management fee on assets under management, and it is the cap from the start that stops it from ever becoming a drain. @@ -199,7 +201,7 @@ Fee generated: 13.5 shares to the manager; all other holders diluted ~1% NARRATION: -Alice calls `withdraw` and burns all 900 of her shares. Here is the part people miss: withdrawal is in kind and proportional. She does not get cash. She gets her exact fraction of every balance the vault holds, USDC and TSLAx and NVDAx alike. It is the same move an ETF makes when it redeems in kind, handing back the underlying holdings instead of cash. +Alice calls `withdraw` and burns all 900 of her shares. Here is the part people miss: withdrawal is in kind and proportional. She does not get cash. She gets her exact fraction of every balance the fund holds, across all three vaults: USDC, TSLAx, and NVDAx alike. It is the same move an ETF makes when it redeems in kind, handing back the underlying holdings instead of cash. Her fraction is 900 shares out of the 1,363.5 that now exist. The handler floors each amount in the protocol's favor, so any rounding dust stays with the remaining holders. @@ -229,9 +231,9 @@ Fee generated: none - withdrawals do not accrue fees NARRATION: -Let us check the books. USDC into the vault was 900 from Alice plus 480 from Bob, 1,380 total. The invests sent 900 to the router; rebalance was a wash. That leaves 480 in the vault, and after Alice's withdrawal, 163.17 remains. Tokens in equal tokens out. +Let us check the books. USDC into the USDC vault was 900 from Alice plus 480 from Bob, 1,380 total. The invests sent 900 to the router; rebalance was a wash. That leaves 480 in the USDC vault, and after Alice's withdrawal, 163.17 remains. Tokens in equal tokens out. -So: Alice came in with 900 dollars, rode NVIDIA up, paid her share of a one percent fee through dilution, and left with about 957 dollars worth of basket, in kind. The vault passes returns through in both directions: had NVIDIA fallen instead of risen, the same arithmetic would have redeemed Alice for less than her 900 dollars. That market risk is hers, and the program neither cushions it nor hides it. Bob bought in fairly at the higher share price and still holds 450 shares worth roughly 478 dollars. Maria earned 13.5 shares, about 14 dollars, for running the book. The vault held custody from the first deposit to the last withdrawal, the manager never touched the vaults with her own key, and the fee she could charge was capped in the bytecode. +So: Alice came in with 900 dollars, rode NVIDIA up, paid her share of a one percent fee through dilution, and left with about 957 dollars worth of basket, in kind. The fund passes returns through in both directions: had NVIDIA fallen instead of risen, the same arithmetic would have redeemed Alice for less than her 900 dollars. That market risk is hers, and the program neither cushions it nor hides it. Bob bought in fairly at the higher share price and still holds 450 shares worth roughly 478 dollars. Maria earned 13.5 shares, about 14 dollars, for running the book. The strategy held custody from the first deposit to the last withdrawal, the manager never touched the vaults with her own key, and the fee she could charge was capped in the bytecode. ## Two honest footnotes @@ -239,6 +241,6 @@ NARRATION: First, the swap router here is a deterministic test stand-in. It mints and burns at a fixed rate with no spread, and its rate matches the Pyth price, which keeps the math clean for teaching. A real deployment would call out to a live venue and the strategy would only trust the one router address it registered at initialization. That registration is checked on every `invest` and `rebalance`. -Second, the forty-sixty weights are a target Maria maintains, not an allocation the program enforces per deposit. If you want the vault to auto-allocate on deposit, that is a feature to add, not something to assume is already there. +Second, the forty-sixty weights are a target Maria maintains, not an allocation the program enforces per deposit. If you want the strategy to auto-allocate on deposit, that is a feature to add, not something to assume is already there. That is the whole lifecycle: open, deposit, invest, price in new depositors fairly, rebalance, charge a bounded streaming fee, and redeem in kind. Thanks for watching. From 1a7da975adcb71ae654406004159f726b1755c60 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 22:55:04 +0000 Subject: [PATCH 6/8] Sharpen terms and trust model in vault-strategy script Define vault as a single-asset account vs the multi-asset strategy (ERC-4626 sense), define the two target weights and the fixed two-asset limit, detail exactly what the manager can and cannot do, clarify a share mint's address is a PDA (a derived public key), contrast fee-by-minting-shares with offchain expense ratios, and write the split as 40/60. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01C5vHRAwvmnXhz8tzcq3xHX --- finance/vault-strategy/VIDEO_SCRIPT.md | 57 +++++++++++++++----------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/finance/vault-strategy/VIDEO_SCRIPT.md b/finance/vault-strategy/VIDEO_SCRIPT.md index f264c989..141faaba 100644 --- a/finance/vault-strategy/VIDEO_SCRIPT.md +++ b/finance/vault-strategy/VIDEO_SCRIPT.md @@ -1,6 +1,6 @@ # Vault Strategy: a walkthrough -A video script for the `vault-strategy` example. Target runtime is roughly seven minutes at a normal speaking pace. Narration lines are what the presenter says; the indented blocks are what is on screen as a running ledger of onchain state. +A video script for the `vault-strategy` example. Target runtime is roughly eight minutes at a normal speaking pace. Narration lines are what the presenter says; the indented blocks are what is on screen as a running ledger of onchain state. Prices for TSLAx and NVDAx in this script are illustrative and match the rates the example's tests configure. They are not live quotes. USDC (US dollars), TSLAx (Tesla stock) and NVDAx (NVIDIA stock) are real assets; the swap behind the scenes is a deterministic test stand-in, which we will be honest about when we reach it. @@ -8,15 +8,15 @@ Prices for TSLAx and NVDAx in this script are illustrative and match the rates t NARRATION: -Let's build a vault strategy: the onchain equivalent of a mutual fund, or an actively managed ETF. You deposit cash with a manager, you receive shares in the fund, the manager buys a basket of assets and rebalances it over time, and your shares are priced at net asset value: the worth of everything the fund holds, divided by the shares outstanding. The word net is a fund convention for value after subtracting what the fund owes; this fund borrows nothing, so its net asset value is simply its holdings. For running the book, the manager earns a fee. +Let's build a vault strategy: the onchain equivalent of a mutual fund, or an actively managed ETF. You deposit cash with a manager, you receive shares, the manager invests across several assets and rebalances them over time, and your shares are priced at net asset value: the worth of everything the strategy holds, divided by the shares outstanding. The word net is a finance convention for value after subtracting what a fund owes; this strategy borrows nothing, so its net asset value is simply its holdings. For running the book, the manager earns a fee. -By the end you will have watched someone deposit, the manager invest and rebalance, a fee accrue, and someone redeem, and you will know which instruction handler does each one. The program controls every dollar the whole time: the manager invests the funds but has no access to them for herself. +By the end you will have watched someone deposit, the manager invest and rebalance, a fee accrue, and someone redeem, and you will know which instruction handler does each one. The program controls every dollar the whole time: the manager invests the deposits but can never move them to herself, a limit we will pin down precisely. -You have seen this shape on Solana, in protocols like Symmetry, Kamino, and Meteora. This is the teaching-sized version. +You have seen this shape on Solana, in protocols like Symmetry and Kamino. This is the teaching-sized version. -Two things genuinely change once the fund is onchain: +Two things genuinely change once the strategy is onchain: -- The rules are the deployed bytecode. Maria cannot freeze redemptions, and the fee is fixed at creation and capped in code at ten percent. There is no admin lever to pull. +- The rules are the deployed bytecode. Maria cannot freeze redemptions, the fee is fixed at creation and capped in code at ten percent, and there is no admin lever to pull. - Entry and exit are permissionless and settle instantly. Anyone can deposit or redeem in a single transaction, priced live, with no minimum and no end-of-day cutoff. We will hit each piece as it shows up. @@ -27,11 +27,13 @@ NARRATION: Custody is the whole game, so let us name the boxes before we move money. -First, a word on the term vault, because it is easy to overload. A vault here is a single token account, and a Solana token account holds exactly one kind of token. So the fund does not keep one vault with everything in it; it keeps three, one per asset. From here on, a vault means one of those single-asset accounts, and the strategy, or the fund, means the whole construct. Production works the same way: a Kamino vault's state points at one token vault per asset, alongside a separate shares mint. +First, the word vault, because it gets overloaded. By the common standard a vault holds a single asset: you put one kind of token in, you get shares out. A managed mix of several assets is not one vault; it lives in several vaults, one per asset, and is usually called a basket or a fund. Symmetry calls its multi-asset products baskets. We will keep it simple: a vault is one single-asset token account, and the strategy is the whole construct that owns them. So vault strategy reads literally, a strategy built from vaults. -The center of everything is the `Strategy` account, a PDA derived from the seeds `"strategy"` plus Maria's public key. That PDA is the authority over four things: a USDC vault, a TSLAx vault, an NVDAx vault, and the share mint. Each of the three vaults is an associated token account owned by the strategy PDA, holding exactly one asset. The share mint is its own PDA, seeds `"share_mint"` plus the strategy address, and the strategy PDA is its mint authority. +The center of everything is the `Strategy` account, a PDA derived from the seeds `"strategy"` plus Maria's public key. It is the authority over four accounts: a USDC vault, a TSLAx vault, an NVDAx vault, and the share mint. Each vault is an associated token account owned by the strategy PDA, and holds exactly one asset. The share mint is also created at a PDA, seeds `"share_mint"` plus the strategy address. A PDA is just a public key with no private key behind it, derived from seeds so the program can find it and sign for it; the mint's address is therefore deterministic, one share mint per strategy, and the strategy PDA is its mint and freeze authority. -What that buys us: only the strategy PDA can sign to move tokens out of those vaults or to mint shares. Maria is the manager, but Maria cannot reach into the vaults with her own keypair. Her powers are exactly three instruction handlers, and the program caps the worst one. We will see each. +One structural limit to call out now: the program holds exactly two assets, A and B, plus USDC. Not three, not ten thousand. Supporting more would mean storing a list of mints, weights, and vaults and looping over them, which is a real code change, not a setting. + +What that buys us: only the strategy PDA can sign to move tokens out of those vaults or to mint shares. Maria manages the strategy, but she cannot reach into the vaults with her own keypair. Her powers are exactly three instruction handlers, and we will see the limit on each. ON SCREEN: @@ -46,11 +48,20 @@ vault_usdc / _a / _b [off curve - ATAs, one asset each] authori NARRATION: -Maria is our portfolio manager, and she wants to run the basket and earn the fee. She calls `initialize_strategy`. She sets the two weights, which must sum to ten thousand basis points, a fee of one hundred basis points, which is one percent a year, and she registers the swap router and the two Pyth price feeds the strategy will trust. +Maria is the strategy's manager, and she wants to run it and earn the fee. She calls `initialize_strategy`. She sets two target weights, one per asset: forty percent to TSLAx and sixty percent to NVDAx. Weights are written in basis points, so that is 4000 and 6000, and the program requires they sum to 10000, which is one hundred percent. A weight is a target share of the strategy's value, not a token balance and not a count of assets. + +One honest detail up front, and it is the crux of how much you must trust Maria: those weights are a target she maintains by hand. The program stores them but no handler reads them to force an allocation. Deposits arrive as USDC and sit in the USDC vault until Maria chooses to invest. The 40/60 split is a promise she keeps with `invest` and `rebalance`, not a rule the bytecode enforces on each deposit. + +So let us pin down exactly what Maria can and cannot do. She can do three things, all visible in this walkthrough: invest USDC into one of the two registered assets, rebalance from one registered asset into the other, and collect her fee. Every one of those is fenced: + +- She can only trade the two asset mints registered at creation, and only through the one swap router registered at creation. The program checks the router's address on every call, so she cannot route through a different one later. +- Each swap carries a minimum-out that the transaction specifies, so a bad fill reverts instead of quietly bleeding a vault. +- Her fee is set once at creation, capped at ten percent a year, and paid only in newly minted shares. There is no setter to raise it later. +- There is no instruction anywhere that sends a vault's tokens to the manager. She can direct the assets; she cannot withdraw them. -One honest detail up front: those weights are a target Maria maintains by hand. The program records them, but no handler reads them to force an allocation. Deposits arrive as plain USDC and sit idle until Maria chooses to invest. The forty-sixty split is a promise Maria keeps with `invest` and `rebalance`, not a rule the bytecode enforces on each deposit. +What is left to trust, honestly, is the router Maria registered and that she sets sane slippage. With an honest router, the worst a careless manager can do is churn and pay market slippage, which hurts depositors but does not enrich her. She cannot abscond with the principal. That is the same trust boundary as a delegate-managed vault, where one manager trades pooled funds but can never pull them out. -The fee, though, is bounded. `initialize_strategy` rejects any fee above `MAX_FEE_BPS`, ten percent, because the fee is paid by minting new shares, and an uncapped fee would let a manager dilute depositors to nothing by configuration alone. +The fee cap lives in code. `initialize_strategy` rejects any fee above `MAX_FEE_BPS`, ten percent, because the fee is paid by minting new shares, and an uncapped fee would let a manager dilute depositors to nothing by configuration alone. ON SCREEN: @@ -69,9 +80,9 @@ Fee generated: none NARRATION: -Alice wants the Tesla-plus-NVIDIA basket without buying and rebalancing two stocks herself, so she calls `deposit` with 900 USDC. `deposit` is permissionless: any user can call it, not just the manager. This is buying into the fund. +Alice wants exposure to both stocks without buying and rebalancing them herself, so she calls `deposit` with 900 USDC. `deposit` is permissionless: any user can call it, not just the manager. This is buying into the strategy. -The handler prices her shares against net asset value, the total worth of the fund. It reads both Pyth feeds straight from the raw account bytes at fixed offsets, checks each price is positive and no more than sixty seconds stale, and computes net asset value as the USDC vault balance plus each asset vault balance times its price. The fund is empty, so net asset value is zero, and the first deposit is defined as one to one. Alice gets 900 shares. Shares carry six decimals, so under the hood that is 900 million minor units, but think of it as 900 shares worth a dollar each. +The handler prices her shares against net asset value, the total worth of the strategy. It reads both Pyth feeds straight from the raw account bytes at fixed offsets, checks each price is positive and no more than sixty seconds stale, and computes net asset value as the USDC vault balance plus each asset vault balance times its price. The strategy is empty, so net asset value is zero, and the first deposit is defined as one to one. Alice gets 900 shares. Shares carry six decimals, so under the hood that is 900 million minor units, but think of it as 900 shares worth a dollar each. Checks, effects, interactions: the handler raises `total_shares` first, then pulls her USDC into the USDC vault, then mints her the shares with the strategy PDA signing. @@ -100,7 +111,7 @@ NARRATION: Now Maria earns her title. She calls `invest` twice. `invest` is manager-only; the account constraints require her signature via `has_one = manager`. -`invest` does not hold a price of its own. It makes a cross-program call into the swap router, which for this example is a deterministic mock: at a fixed rate it mints the asset into the matching vault and takes the USDC. First, 360 dollars into TSLAx at 250 dollars a share, so the TSLAx vault receives 1.44 TSLAx. Then 540 dollars into NVDAx at 180 dollars a share, so the NVDAx vault receives exactly 3 NVDAx. That is the forty-sixty split, by hand. +`invest` does not hold a price of its own. It makes a cross-program call into the swap router, which for this example is a deterministic mock: at a fixed rate it mints the asset into the matching vault and takes the USDC. First, 360 dollars into TSLAx at 250 dollars a share, so the TSLAx vault receives 1.44 TSLAx. Then 540 dollars into NVDAx at 180 dollars a share, so the NVDAx vault receives exactly 3 NVDAx. That is the 40/60 split, by hand. The strategy PDA signs both swaps, because the USDC is leaving the USDC vault, which only the PDA controls. @@ -128,7 +139,7 @@ NARRATION: Time passes. NVDAx climbs from 180 to 200. Nothing onchain changes from a price move by itself; the NVDAx vault still holds the same 3 NVDAx, now worth more. Net asset value rises to 960 dollars while the share count is still 900. Each share is now worth about a dollar and seven cents. -Bob wants the same basket Alice does, but he arrives now, after the gain, so he is the one who shows us how shares are priced. He calls `deposit` with 480 dollars. This is the moment the share math matters, and it is the same rule a mutual fund uses: you buy shares at today's net asset value. Bob does not get 480 shares. The handler computes shares as his deposit times total shares divided by net asset value: 480 times 900 divided by 960, which is exactly 450 shares. He pays the current price, so he does not dilute Alice's gain, and Alice's earlier deposit does not subsidize his. +Bob wants the same exposure Alice has, but he arrives now, after the gain, so he is the one who shows us how shares are priced. He calls `deposit` with 480 dollars. This is the moment the share math matters, and it is the same rule a mutual fund uses: you buy shares at today's net asset value. Bob does not get 480 shares. The handler computes shares as his deposit times total shares divided by net asset value: 480 times 900 divided by 960, which is exactly 450 shares. He pays the current price, so he does not dilute Alice's gain, and Alice's earlier deposit does not subsidize his. ON SCREEN: @@ -152,7 +163,7 @@ Fee generated: none NARRATION: -NVIDIA's run pushed the basket away from forty-sixty, so Maria calls `rebalance`. One handler, two swaps, both signed by the strategy PDA: it sells one asset for USDC, then spends that USDC on the other. +NVIDIA's run pushed the holdings away from 40/60, so Maria calls `rebalance`. One handler, two swaps, both signed by the strategy PDA: it sells one asset for USDC, then spends that USDC on the other. She sells 0.36 TSLAx, receiving 90 dollars, then buys 0.5 NVDAx with that same 90 dollars. Both legs name a minimum out, so a bad rate would revert rather than silently lose value. The USDC vault nets to zero change across the two legs; the strategy just shifts weight from Tesla into NVIDIA. @@ -175,9 +186,9 @@ Fee generated: none - rebalance moves assets, it does not charge a fee NARRATION: -Maria calls `collect_fees`. This is a streaming management fee, and the mechanism is the point: the program does not pull tokens out of any vault. It mints new shares to the manager, proportional to time elapsed and the fee rate. Over a full year at one percent, that is one percent of the share supply, 13.5 shares, minted to Maria. +Maria calls `collect_fees`. This is a streaming management fee, and the mechanism is worth dwelling on, because it is the opposite of the offchain world. A traditional fund deducts its expense ratio from fund assets, selling holdings to pay the manager in cash, which lowers net asset value. This program touches no vault at all. It mints new shares to the manager, proportional to time elapsed and the fee rate. Over a full year at one percent, that is one percent of the share supply, 13.5 shares, minted to Maria. -New shares with no new assets behind them means every existing share is now a slightly thinner slice. That dilution, spread across all holders, is how Alice and Bob actually pay the fee. This is the expense ratio of a mutual fund, charged the Solana way: by minting the manager new shares rather than by selling fund assets to cut her a check. It is honest to say so out loud: there is no separate performance fee here, only this management fee on assets under management, and it is the cap from the start that stops it from ever becoming a drain. +Same economics, different lever. New shares with no new assets behind them make every existing share a slightly thinner slice, so the dilution, spread across all holders, is how Alice and Bob pay the fee. Minting fee shares is the common onchain pattern: Yearn and Lido both charge their fees this way rather than skimming assets. And it is honest to say there is no performance fee here, only this management fee on assets under management, bounded by the cap from creation. ON SCREEN: @@ -201,7 +212,7 @@ Fee generated: 13.5 shares to the manager; all other holders diluted ~1% NARRATION: -Alice calls `withdraw` and burns all 900 of her shares. Here is the part people miss: withdrawal is in kind and proportional. She does not get cash. She gets her exact fraction of every balance the fund holds, across all three vaults: USDC, TSLAx, and NVDAx alike. It is the same move an ETF makes when it redeems in kind, handing back the underlying holdings instead of cash. +Alice calls `withdraw` and burns all 900 of her shares. Here is the part people miss: withdrawal is in kind and proportional. She does not get cash. She gets her exact fraction of every balance the strategy holds, across all three vaults: USDC, TSLAx, and NVDAx alike. It is the same move an ETF makes when it redeems in kind, handing back the underlying holdings instead of cash. Her fraction is 900 shares out of the 1,363.5 that now exist. The handler floors each amount in the protocol's favor, so any rounding dust stays with the remaining holders. @@ -233,14 +244,14 @@ NARRATION: Let us check the books. USDC into the USDC vault was 900 from Alice plus 480 from Bob, 1,380 total. The invests sent 900 to the router; rebalance was a wash. That leaves 480 in the USDC vault, and after Alice's withdrawal, 163.17 remains. Tokens in equal tokens out. -So: Alice came in with 900 dollars, rode NVIDIA up, paid her share of a one percent fee through dilution, and left with about 957 dollars worth of basket, in kind. The fund passes returns through in both directions: had NVIDIA fallen instead of risen, the same arithmetic would have redeemed Alice for less than her 900 dollars. That market risk is hers, and the program neither cushions it nor hides it. Bob bought in fairly at the higher share price and still holds 450 shares worth roughly 478 dollars. Maria earned 13.5 shares, about 14 dollars, for running the book. The strategy held custody from the first deposit to the last withdrawal, the manager never touched the vaults with her own key, and the fee she could charge was capped in the bytecode. +So: Alice came in with 900 dollars, rode NVIDIA up, paid her share of a one percent fee through dilution, and left with about 957 dollars of assets, in kind. The strategy passes returns through in both directions: had NVIDIA fallen instead of risen, the same arithmetic would have redeemed Alice for less than her 900 dollars. That market risk is hers, and the program neither cushions it nor hides it. Bob bought in fairly at the higher share price and still holds 450 shares worth roughly 478 dollars. Maria earned 13.5 shares, about 14 dollars, for running the book. The strategy held custody from the first deposit to the last withdrawal, the manager never touched the vaults with her own key, and the fee she could charge was capped in the bytecode. ## Two honest footnotes NARRATION: -First, the swap router here is a deterministic test stand-in. It mints and burns at a fixed rate with no spread, and its rate matches the Pyth price, which keeps the math clean for teaching. A real deployment would call out to a live venue and the strategy would only trust the one router address it registered at initialization. That registration is checked on every `invest` and `rebalance`. +First, the swap router here is a deterministic test stand-in. It mints and burns at a fixed rate with no spread, and its rate matches the Pyth price, which keeps the math clean for teaching. A real deployment would call out to a live venue, and the strategy would only trust the one router address it registered at initialization. That registration is checked on every `invest` and `rebalance`. -Second, the forty-sixty weights are a target Maria maintains, not an allocation the program enforces per deposit. If you want the strategy to auto-allocate on deposit, that is a feature to add, not something to assume is already there. +Second, the 40/60 weights are a target Maria maintains, not an allocation the program enforces per deposit. Before real money, that is the thing to harden: enforce the weights in-program, timelock any change to the router, and bound per-swap slippage. Those are additions, not assumptions, and they would shrink what depositors must take on trust to almost nothing. That is the whole lifecycle: open, deposit, invest, price in new depositors fairly, rebalance, charge a bounded streaming fee, and redeem in kind. Thanks for watching. From 6cbe7a44aaee09ec28dc36788a8f8b9c2666f4dc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 00:23:09 +0000 Subject: [PATCH 7/8] vault-strategy: curated whitelist, dynamic assets, oracle-bounded slippage Add a Registry plus per-mint WhitelistEntry accounts curated by a protocol authority (separate from managers), binding each approved mint to its official Pyth feed. Strategies grow their portfolio with add_asset, which registers a whitelisted mint at the next index as an AssetConfig PDA and creates its vault; deposit and withdraw validate the complete 0..asset_count set via remaining accounts so NAV and in-kind payouts always cover every asset. Replace caller-supplied swap minimums with an oracle-anchored bound: invest and rebalance compute each leg's minimum output from the Pyth price and a strategy-level max_slippage_bps (capped in code). Also fix the example's clean-checkout build: box the mock-swap-router swap account structs (4096-byte SBF stack overflow) and document the per-manifest build that avoids stripping the router entrypoint. Update README and CHANGELOG; rewrite the test suite (15 tests) for the new API. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01C5vHRAwvmnXhz8tzcq3xHX --- finance/vault-strategy/anchor/CHANGELOG.md | 20 + finance/vault-strategy/anchor/README.md | 293 +-- .../src/instructions/swap_asset_for_usdc.rs | 10 +- .../src/instructions/swap_usdc_for_asset.rs | 10 +- .../programs/vault-strategy/src/error.rs | 34 +- .../src/instructions/add_asset.rs | 90 + .../src/instructions/deposit.rs | 169 +- .../src/instructions/initialize_registry.rs | 30 + .../src/instructions/initialize_strategy.rs | 66 +- .../vault-strategy/src/instructions/invest.rs | 58 +- .../vault-strategy/src/instructions/mod.rs | 6 + .../src/instructions/rebalance.rs | 89 +- .../src/instructions/whitelist_asset.rs | 43 + .../src/instructions/withdraw.rs | 204 +-- .../anchor/programs/vault-strategy/src/lib.rs | 67 +- .../programs/vault-strategy/src/oracle.rs | 112 ++ .../programs/vault-strategy/src/state/mod.rs | 2 + .../vault-strategy/src/state/registry.rs | 25 + .../vault-strategy/src/state/strategy.rs | 66 +- .../vault-strategy/tests/vault_strategy.rs | 1596 +++++++---------- 20 files changed, 1454 insertions(+), 1536 deletions(-) create mode 100644 finance/vault-strategy/anchor/CHANGELOG.md create mode 100644 finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/add_asset.rs create mode 100644 finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_registry.rs create mode 100644 finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/whitelist_asset.rs create mode 100644 finance/vault-strategy/anchor/programs/vault-strategy/src/oracle.rs create mode 100644 finance/vault-strategy/anchor/programs/vault-strategy/src/state/registry.rs diff --git a/finance/vault-strategy/anchor/CHANGELOG.md b/finance/vault-strategy/anchor/CHANGELOG.md new file mode 100644 index 00000000..46cfa360 --- /dev/null +++ b/finance/vault-strategy/anchor/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## Unreleased + +### Added + +- **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`. + +### 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. +- `invest` takes `(usdc_amount)` and `rebalance` takes `(sell_amount, usdc_to_invest)`; per-call minimums are gone. + +### Fixed + +- Boxed the `mock-swap-router` swap account structs, which overflowed the 4096-byte SBF stack frame under current platform-tools. +- Documented the per-manifest build (the workspace build strips the router entrypoint via feature unification). diff --git a/finance/vault-strategy/anchor/README.md b/finance/vault-strategy/anchor/README.md index 436c8c3b..a15c6db3 100644 --- a/finance/vault-strategy/anchor/README.md +++ b/finance/vault-strategy/anchor/README.md @@ -1,8 +1,10 @@ # 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. +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. -The example uses two 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 [tokens](https://solana.com/docs/terminology#token). +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). + +A note on the word **vault**: by the common standard (ERC-4626) a vault holds a single asset. Here a vault is one single-asset [token account](https://solana.com/docs/terminology#token-account), and the whole multi-asset construct is the **strategy**, which owns one vault per asset plus a USDC vault. So "vault strategy" reads literally: a strategy built from vaults. --- @@ -10,7 +12,7 @@ The example uses two stocks as the basket assets: **TSLAx** (Tesla) and **NVDAx* | Program | Description | |---------|-------------| -| `vault-strategy` | Main vault: deposits, share minting, fee accrual, rebalancing, withdrawals | +| `vault-strategy` | Registry/whitelist, strategy creation, asset registration, 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. | --- @@ -19,49 +21,41 @@ The example uses two stocks as the basket assets: **TSLAx** (Tesla) and **NVDAx* ### 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](https://www.investopedia.com/terms/n/nav.asp) is the total value of everything the strategy holds: the USDC vault balance plus each asset vault balance valued at its Pyth price. It prices new deposits fairly, so every depositor pays the same per-share price regardless of when they join. -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. +Because the asset set is dynamic, `deposit` must value *every* asset. The assets live at PDAs indexed `0..asset_count`, and `deposit` re-derives that complete range from the accounts it is given, refusing to run if any asset is missing (`IncompleteAssetAccounts`). This makes it structurally impossible to omit an asset and understate NAV. -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. +Prices come from [Pyth Network](https://pyth.network/) `PriceUpdateV2` accounts. A 60-second staleness window is enforced; zero or negative prices are rejected. ### 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. +A [share](https://www.investopedia.com/terms/s/shares.asp) represents a fraction of the whole strategy. Hold 1% of shares and you own 1% of every 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). +- **First deposit**: shares are issued 1:1 with USDC minor units (initial price of 1 USDC per share). +- **Later deposits**: `shares_to_mint = deposit_usdc × total_shares / NAV`. +- Shares are [SPL tokens](https://solana.com/docs/terminology#token); the share mint's address is a [PDA](https://solana.com/docs/terminology#program-derived-address-pda), so it is deterministic and the strategy PDA is its mint authority. ### 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: +A [management fee](https://www.investopedia.com/terms/m/managementfee.asp), in [basis points](https://www.investopedia.com/terms/b/basispoint.asp) (100 bps = 1% per year), is charged by *minting new shares to the manager*, diluting holders proportionally. This is the common onchain pattern (Yearn, Lido charge fees this way) and differs from a traditional fund, which deducts the fee in cash from assets. ``` fee_shares = total_shares × fee_bps × elapsed_seconds / (10_000 × 31_536_000) ``` -Anyone can call `collect_fees` - it is permissionless. +`collect_fees` is permissionless. The fee is fixed at creation and capped at `MAX_FEE_BPS` (1,000 bps = 10%); there is no setter to raise it later. -### Basket Allocation and Rebalancing +### Weights 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. +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. -### Slippage +### Slippage, bounded by the oracle -[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. +[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%). ### 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. +An [in-kind distribution](https://www.investopedia.com/terms/i/in-kind.asp) returns the underlying assets, not cash. `withdraw` burns shares and pays out a proportional slice of the USDC vault and every asset vault. The user must already hold a token account for each asset; you can sell those on a DEX yourself. --- @@ -71,257 +65,110 @@ An [in-kind distribution](https://www.investopedia.com/terms/i/in-kind.asp) mean | 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)` - -The weights must sum to 10,000 bps, and `fee_bps` must not exceed `MAX_FEE_BPS` (1,000 bps = 10% per year). Because `collect_fees` mints shares to the manager and dilutes every depositor, an uncapped fee would let a manager drain the vault by configuration, so unsafe fees are rejected at creation time (`FeeTooHigh`). - -**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, swap router program pubkey, 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):** +| **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 | -| Account | Change | -|---------|--------| -| `vault_usdc` | −600 USDC | -| `vault_asset_b` (NVDAx) | +3,333,333 base units (3.33 NVDAx @ $180) | -| `router_usdc_treasury` | +600 USDC | +`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. -After both calls the vault holds: ~0 USDC, 1.6 TSLAx, 3.33 NVDAx - all worth ~1,000 USDC at current prices. +### Step 1 - Victor creates the registry and whitelists assets ---- - -### 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:** +`initialize_registry()` creates a `Registry` PDA (`["registry", victor]`) owned by Victor. `whitelist_asset(price_feed)` then creates one `WhitelistEntry` PDA (`["whitelist", registry, mint]`) per approved mint, binding it to its official Pyth feed. Only Victor can do this. This separation is the anti-fraud core: a manager can only ever add assets Victor approved, and the feed comes from the registry, so a manager cannot list a token they mint themselves or pair a real mint with a feed they control. -| 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 | +### Step 2 - Maria initializes the strategy -If either slippage check fails, both legs revert - no partial rebalance. +`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. ---- - -### Step 6 - Alice collects fees +### Step 3 - Maria adds assets -Six months have elapsed. Anyone calls `collect_fees` (it is permissionless). +`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. -**Instruction:** `collect_fees()` - -``` -fee_shares = 2,000,000,000 × 100 bps × 15,768,000 s / (10,000 × 31,536,000 s) ≈ 10,000,000 -``` +### Step 4 - Alice deposits -**Accounts modified:** +`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. -| 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 | +### Step 5 - Maria invests -Bob and Carol are each diluted by ~0.5%. Alice now holds ~0.5% of the vault. +`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. ---- +### Step 6 - Bob deposits at the current share price -### Step 7 - Bob withdraws +Same as step 4. Because shares are priced at NAV, Bob pays the current per-share value and does not dilute Alice's gain. -Bob burns all his shares and receives his proportional slice of the vault in-kind. +### Step 7 - Maria rebalances -**Instruction:** `withdraw(shares_to_burn=1_000_000_000, min_usdc_out=0, min_asset_a_out=0, min_asset_b_out=0)` +`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. -Bob's proportion: 1,000,000,000 / 2,010,000,000 ≈ 49.75% +### Step 8 - Fees accrue -**Accounts modified:** +`collect_fees()` mints time-and-rate-proportional fee shares to Maria, diluting all holders by the fee. -| 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 | +### Step 9 - Alice withdraws in kind -Bob receives TSLAx and NVDAx directly in his own ATAs. He can sell them on a DEX if he wants USDC back. +`withdraw(shares_to_burn, min_usdc_out)`, with each asset's `[asset_config, vault, mint, user_token_account]` as remaining accounts. Alice's shares burn and she receives her proportional slice of USDC and every asset. Amounts floor in the protocol's favour. --- ## 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 (−) | +| 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) -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). +`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). --- ## 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 - -The `Strategy` account stores the router's program pubkey (`swap_router`) at creation time, and `invest` and `rebalance` require the swap router program account they are given to match it (`InvalidSwapRouter`). A manager cannot route vault funds through a program the strategy did not register. - -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. +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. --- -## Account Validation +## What restricts the manager -Every account a caller passes is checked against state the program controls, never trusted: +The strategy PDA holds all assets; no instruction moves a vault's tokens to the manager. The manager's powers are fenced: -- **Mints are bound to the strategy.** `deposit` and `withdraw` enforce `has_one` on `usdc_mint`, `asset_mint_a`, and `asset_mint_b` against the pubkeys stored in the `Strategy` account (`InvalidUsdcMint` / `InvalidAssetMint`). Without this, a caller could pass an unregistered mint whose strategy-owned vault is empty, understating NAV to mint inflated shares on deposit or skewing the proportional payout on withdraw. `invest` and `rebalance` enforce `has_one` on `usdc_mint` and require their asset mints to be one of the two registered basket mints. -- **Vault token accounts are derived, not supplied.** Each vault account must be the associated token account of the strategy PDA for the corresponding bound mint. -- **Price feeds are bound to the strategy.** The Pyth accounts passed to `deposit` must equal the feed pubkeys stored at creation (`InvalidPriceFeed`). -- **The swap router is bound to the strategy.** `invest` and `rebalance` require the router program account to equal the stored `swap_router` (`InvalidSwapRouter`). -- **Config is validated at creation.** Weights must sum to 10,000 bps and the fee is capped at `MAX_FEE_BPS`. - ---- +- **Assets** are limited to mints whitelisted by the registry authority, with the price feed taken from the registry, not the manager. +- **Swaps** go only through the one router registered at creation, and each leg's minimum output is computed from the oracle, not supplied by the manager. +- **The fee** is fixed at creation and capped at 10%, paid only in minted shares. -## 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 onchain 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. +What remains to trust: the honesty of the registered router and registry. With an honest router, the worst a careless manager can do is churn and pay market slippage (which hurts depositors but does not enrich the manager); the manager cannot withdraw principal. --- ## 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) +- Integer arithmetic only; intermediate products use `u128`; multiply before divide. +- All arithmetic uses `checked_*`. Users receive floor division; the protocol keeps the remainder. +- `transfer_checked` carries decimals through every token CPI. --- ## Build and Test ```bash -# Build the vault (requires the Solana toolchain). This also compiles the -# router, but with the vault's `cpi` feature enabled, which strips the -# router's entrypoint and leaves a stub .so: -cargo build-sbf - -# So build the router again on its own to get a deployable .so: +# Build each program on its own. Building the whole workspace at once unifies the +# vault's `cpi` feature into the router build and strips the router's entrypoint, +# leaving a stub .so, so build per-manifest (as `anchor build` does): cargo build-sbf --manifest-path programs/mock-swap-router/Cargo.toml +cargo build-sbf --manifest-path programs/vault-strategy/Cargo.toml # Run tests (LiteSVM, no local validator needed) -cargo test +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) for fast, self-contained program simulation. Both `.so` files are loaded from `target/deploy/`, so build before testing. The suite exercises all six instruction handlers and the rejection paths: slippage limits, unregistered mints on deposit and withdraw, an over-cap management fee, and an unregistered swap router on invest and rebalance. +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. diff --git a/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs b/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs index 0fdcb429..66d33f37 100644 --- a/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs +++ b/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs @@ -25,10 +25,10 @@ pub struct SwapAssetForUsdcAccountConstraints<'info> { pub asset_rate: Account<'info, AssetRate>, #[account(constraint = usdc_mint.key() == router_config.usdc_mint @ RouterError::WrongUsdcMint)] - pub usdc_mint: InterfaceAccount<'info, Mint>, + pub usdc_mint: Box>, #[account(mut)] - pub asset_mint: InterfaceAccount<'info, Mint>, + pub asset_mint: Box>, /// Caller's asset token account - asset tokens are burned from here #[account( @@ -37,7 +37,7 @@ pub struct SwapAssetForUsdcAccountConstraints<'info> { associated_token::authority = caller, associated_token::token_program = token_program )] - pub caller_asset_account: InterfaceAccount<'info, TokenAccount>, + pub caller_asset_account: Box>, /// Caller's USDC account - receives the USDC #[account( @@ -46,7 +46,7 @@ pub struct SwapAssetForUsdcAccountConstraints<'info> { associated_token::authority = caller, associated_token::token_program = token_program )] - pub caller_usdc_account: InterfaceAccount<'info, TokenAccount>, + pub caller_usdc_account: Box>, /// Router's USDC treasury - sends the USDC #[account( @@ -55,7 +55,7 @@ pub struct SwapAssetForUsdcAccountConstraints<'info> { associated_token::authority = router_authority, associated_token::token_program = token_program )] - pub router_usdc_treasury: InterfaceAccount<'info, TokenAccount>, + pub router_usdc_treasury: Box>, /// CHECK: PDA used as treasury authority - validated by seeds constraint #[account( diff --git a/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs b/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs index 54b0b78a..784d16b7 100644 --- a/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs +++ b/finance/vault-strategy/anchor/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs @@ -25,10 +25,10 @@ pub struct SwapUsdcForAssetAccountConstraints<'info> { )] pub asset_rate: Account<'info, AssetRate>, - pub usdc_mint: InterfaceAccount<'info, Mint>, + pub usdc_mint: Box>, #[account(mut)] - pub asset_mint: InterfaceAccount<'info, Mint>, + pub asset_mint: Box>, /// Caller's USDC token account - USDC flows from here to the treasury #[account( @@ -37,7 +37,7 @@ pub struct SwapUsdcForAssetAccountConstraints<'info> { associated_token::authority = caller, associated_token::token_program = token_program )] - pub caller_usdc_account: InterfaceAccount<'info, TokenAccount>, + pub caller_usdc_account: Box>, /// Caller's asset token account - minted asset tokens land here #[account( @@ -46,7 +46,7 @@ pub struct SwapUsdcForAssetAccountConstraints<'info> { associated_token::authority = caller, associated_token::token_program = token_program )] - pub caller_asset_account: InterfaceAccount<'info, TokenAccount>, + pub caller_asset_account: Box>, /// Router's USDC treasury - receives the USDC payment #[account( @@ -55,7 +55,7 @@ pub struct SwapUsdcForAssetAccountConstraints<'info> { associated_token::authority = router_authority, associated_token::token_program = token_program )] - pub router_usdc_treasury: InterfaceAccount<'info, TokenAccount>, + pub router_usdc_treasury: Box>, /// CHECK: PDA used as mint authority - validated by seeds constraint #[account( diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs index 29a29739..34407779 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs @@ -2,18 +2,34 @@ 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("Swap output deviates from the oracle price by more than the allowed slippage")] + SwapSlippageExceeded, + #[msg("Max slippage exceeds the maximum allowed configuration")] + SlippageConfigTooHigh, + #[msg("Asset mint is not part of this strategy")] + AssetNotFound, + #[msg("Asset mint is not whitelisted in the registry")] + AssetNotWhitelisted, + #[msg("Strategy already holds the maximum number of assets")] + TooManyAssets, + #[msg("Asset is already part of this strategy")] + DuplicateAsset, + #[msg("Total target weight would exceed 10000 basis points")] + WeightOverflow, + #[msg("Wrong number of asset accounts supplied for the strategy's assets")] + IncompleteAssetAccounts, + #[msg("An asset account does not match the strategy's registered asset")] + InvalidAssetAccount, + #[msg("Token account could not be read")] + InvalidVaultAccount, + #[msg("Recipient token account is not owned by the withdrawing user")] + InvalidRecipient, + #[msg("Registry does not match the strategy's registered registry")] + InvalidRegistry, #[msg("No time has elapsed since last fee accrual")] NoTimeElapsed, #[msg("Arithmetic overflow")] @@ -24,7 +40,7 @@ pub enum VaultError { ZeroDeposit, #[msg("Total shares are zero - cannot compute proportional withdraw")] ZeroTotalShares, - #[msg("Price feed account does not match the strategy's registered feed")] + #[msg("Price feed account does not match the registered feed")] InvalidPriceFeed, #[msg("Pyth price is zero or negative")] NegativePrice, diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/add_asset.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/add_asset.rs new file mode 100644 index 00000000..ca03a9dd --- /dev/null +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/add_asset.rs @@ -0,0 +1,90 @@ +use anchor_lang::prelude::*; +use anchor_spl::{ + associated_token::AssociatedToken, + token_interface::{Mint, TokenAccount, TokenInterface}, +}; + +use crate::error::VaultError; +use crate::state::{AssetConfig, Registry, Strategy, WhitelistEntry, MAX_ASSETS}; + +#[derive(Accounts)] +pub struct AddAssetAccountConstraints<'info> { + #[account(mut)] + pub manager: Signer<'info>, + + #[account( + mut, + has_one = manager, + has_one = registry @ VaultError::InvalidRegistry, + seeds = [b"strategy", strategy.manager.as_ref()], + bump = strategy.bump + )] + pub strategy: Box>, + + pub registry: Box>, + + pub asset_mint: Box>, + + /// Proof the mint is approved, and the source of its official price feed. + /// Seeds tie it to this registry and this mint; existence means whitelisted. + #[account( + seeds = [b"whitelist", registry.key().as_ref(), asset_mint.key().as_ref()], + bump = whitelist_entry.bump + )] + pub whitelist_entry: Box>, + + #[account( + init, + payer = manager, + space = AssetConfig::DISCRIMINATOR.len() + AssetConfig::INIT_SPACE, + seeds = [b"asset", strategy.key().as_ref(), &[strategy.asset_count]], + bump + )] + pub asset_config: Box>, + + /// Strategy-owned vault for this asset. + #[account( + init, + payer = manager, + associated_token::mint = asset_mint, + associated_token::authority = strategy, + associated_token::token_program = token_program + )] + pub vault_asset: Box>, + + pub associated_token_program: Program<'info, AssociatedToken>, + pub token_program: Interface<'info, TokenInterface>, + pub system_program: Program<'info, System>, +} + +pub fn handle_add_asset( + context: Context, + weight_bps: u16, +) -> Result<()> { + let strategy = &mut context.accounts.strategy; + + require!(strategy.asset_count < MAX_ASSETS, VaultError::TooManyAssets); + + let new_total = (strategy.total_weight_bps as u32) + .checked_add(weight_bps as u32) + .ok_or(VaultError::MathOverflow)?; + require!(new_total <= 10_000, VaultError::WeightOverflow); + + let index = strategy.asset_count; + + context.accounts.asset_config.set_inner(AssetConfig { + strategy: strategy.key(), + index, + mint: context.accounts.asset_mint.key(), + // Copied from the registry entry, never supplied by the manager. + price_feed: context.accounts.whitelist_entry.price_feed, + vault: context.accounts.vault_asset.key(), + weight_bps, + bump: context.bumps.asset_config, + }); + + strategy.asset_count = index.checked_add(1).ok_or(VaultError::MathOverflow)?; + strategy.total_weight_bps = new_total as u16; + + Ok(()) +} diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/deposit.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/deposit.rs index b2de73e5..f4a040c3 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/deposit.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/deposit.rs @@ -7,31 +7,8 @@ use anchor_spl::{ }; 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)) -} +use crate::oracle::{asset_value_in_usdc, load_price, read_token_amount}; +use crate::state::{AssetConfig, Strategy}; #[derive(Accounts)] pub struct DepositAccountConstraints<'info> { @@ -41,8 +18,6 @@ pub struct DepositAccountConstraints<'info> { #[account( mut, has_one = usdc_mint @ VaultError::InvalidUsdcMint, - has_one = asset_mint_a @ VaultError::InvalidAssetMint, - has_one = asset_mint_b @ VaultError::InvalidAssetMint, seeds = [b"strategy", strategy.manager.as_ref()], bump = strategy.bump )] @@ -57,10 +32,6 @@ pub struct DepositAccountConstraints<'info> { pub usdc_mint: Box>, - pub asset_mint_a: Box>, - - pub asset_mint_b: Box>, - #[account( mut, associated_token::mint = usdc_mint, @@ -86,114 +57,67 @@ pub struct DepositAccountConstraints<'info> { )] pub vault_usdc: Box>, - #[account( - associated_token::mint = asset_mint_a, - associated_token::authority = strategy, - associated_token::token_program = token_program - )] - pub vault_asset_a: Box>, - - #[account( - associated_token::mint = asset_mint_b, - associated_token::authority = strategy, - associated_token::token_program = token_program - )] - pub vault_asset_b: Box>, - - /// 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>, + // remaining_accounts: for each asset index 0..asset_count, in order: + // [asset_config, vault, price_feed] } -pub fn handle_deposit( - context: Context, +pub fn handle_deposit<'info>( + context: Context<'info, DepositAccountConstraints<'info>>, 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; + let strategy_key = context.accounts.strategy.key(); + let asset_count = context.accounts.strategy.asset_count as usize; - // 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); + let now = Clock::get()?.unix_timestamp; - // 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()?; + // Net asset value over the complete asset set. The assets are exactly indices + // 0..asset_count, so requiring three accounts per index, in order, each with a + // matching index, makes it impossible to omit an asset and understate NAV. + let remaining = context.remaining_accounts; 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 + remaining.len() == asset_count * 3, + VaultError::IncompleteAssetAccounts ); - // 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)?; + let mut nav: u128 = vault_usdc_amount as u128; + + for i in 0..asset_count { + let config_ai = &remaining[i * 3]; + let vault_ai = &remaining[i * 3 + 1]; + let feed_ai = &remaining[i * 3 + 2]; + + let config = AssetConfig::load_checked(config_ai)?; + require_keys_eq!( + config.strategy, + strategy_key, + VaultError::InvalidAssetAccount + ); + require!(config.index as usize == i, VaultError::InvalidAssetAccount); + require_keys_eq!( + vault_ai.key(), + config.vault, + VaultError::InvalidAssetAccount + ); + + let price = load_price(feed_ai, &config.price_feed, now)?; + let amount = read_token_amount(vault_ai)?; + nav = nav + .checked_add(asset_value_in_usdc(amount, price)?) + .ok_or(VaultError::MathOverflow)?; + } - // shares = usdc_amount * total_shares / nav (floor) - // first deposit (total_shares == 0): 1:1 + // shares = usdc_amount * total_shares / nav (floor); first deposit is 1:1. let shares_to_mint: u64 = if total_shares == 0 { usdc_amount } else { @@ -209,15 +133,10 @@ pub fn handle_deposit( VaultError::SlippageTooHigh ); - // Checks-effects-interactions: update state before CPIs - context.accounts.strategy.total_shares = context - .accounts - .strategy - .total_shares + context.accounts.strategy.total_shares = 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(), @@ -227,9 +146,7 @@ pub fn handle_deposit( 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(), diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_registry.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_registry.rs new file mode 100644 index 00000000..c0479af3 --- /dev/null +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_registry.rs @@ -0,0 +1,30 @@ +use anchor_lang::prelude::*; + +use crate::state::Registry; + +#[derive(Accounts)] +pub struct InitializeRegistryAccountConstraints<'info> { + #[account(mut)] + pub authority: Signer<'info>, + + #[account( + init, + payer = authority, + space = Registry::DISCRIMINATOR.len() + Registry::INIT_SPACE, + seeds = [b"registry", authority.key().as_ref()], + bump + )] + pub registry: Account<'info, Registry>, + + pub system_program: Program<'info, System>, +} + +pub fn handle_initialize_registry( + context: Context, +) -> Result<()> { + context.accounts.registry.set_inner(Registry { + authority: context.accounts.authority.key(), + bump: context.bumps.registry, + }); + Ok(()) +} diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_strategy.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_strategy.rs index fd1b7072..3ebd45a9 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_strategy.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_strategy.rs @@ -5,7 +5,7 @@ use anchor_spl::{ }; use crate::error::VaultError; -use crate::state::Strategy; +use crate::state::{Registry, Strategy}; /// Highest annual management fee a manager may set, in basis points (10%). /// `collect_fees` mints shares to the manager and dilutes every depositor, @@ -13,6 +13,12 @@ use crate::state::Strategy; /// 10% per year is already far above typical fund management fees. pub const MAX_FEE_BPS: u16 = 1_000; +/// Highest slippage tolerance a manager may set, in basis points (10%). +/// invest/rebalance reject a swap whose output deviates from the Pyth price by +/// more than this; capping it stops a manager from setting a tolerance so loose +/// that the bound is meaningless. +pub const MAX_SLIPPAGE_BPS: u16 = 1_000; + #[derive(Accounts)] pub struct InitializeStrategyAccountConstraints<'info> { #[account(mut)] @@ -20,9 +26,8 @@ pub struct InitializeStrategyAccountConstraints<'info> { pub usdc_mint: InterfaceAccount<'info, Mint>, - pub asset_mint_a: InterfaceAccount<'info, Mint>, - - pub asset_mint_b: InterfaceAccount<'info, Mint>, + /// The whitelist this strategy will draw its assets from. + pub registry: Account<'info, Registry>, #[account( init, @@ -31,7 +36,7 @@ pub struct InitializeStrategyAccountConstraints<'info> { seeds = [b"strategy", manager.key().as_ref()], bump )] - pub strategy: Account<'info, Strategy>, + pub strategy: Box>, #[account( init, @@ -43,7 +48,7 @@ pub struct InitializeStrategyAccountConstraints<'info> { seeds = [b"share_mint", strategy.key().as_ref()], bump )] - pub share_mint: InterfaceAccount<'info, Mint>, + pub share_mint: Box>, /// Vault's USDC token account - strategy PDA is the authority #[account( @@ -53,27 +58,7 @@ pub struct InitializeStrategyAccountConstraints<'info> { 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 vault_usdc: Box>, pub associated_token_program: Program<'info, AssociatedToken>, pub token_program: Interface<'info, TokenInterface>, @@ -82,39 +67,30 @@ pub struct InitializeStrategyAccountConstraints<'info> { pub fn handle_initialize_strategy( context: Context, - weight_bps_a: u16, - weight_bps_b: u16, fee_bps: u16, + max_slippage_bps: u16, swap_router: Pubkey, - price_feed_a: Pubkey, - price_feed_b: Pubkey, ) -> Result<()> { + require!(fee_bps <= MAX_FEE_BPS, VaultError::FeeTooHigh); require!( - weight_bps_a - .checked_add(weight_bps_b) - .ok_or(VaultError::InvalidWeights)? - == 10_000, - VaultError::InvalidWeights + max_slippage_bps <= MAX_SLIPPAGE_BPS, + VaultError::SlippageConfigTooHigh ); - require!(fee_bps <= MAX_FEE_BPS, VaultError::FeeTooHigh); - let clock = Clock::get()?; context.accounts.strategy.set_inner(Strategy { manager: context.accounts.manager.key(), + registry: context.accounts.registry.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, + swap_router, fee_bps, + max_slippage_bps, total_shares: 0, last_fee_accrual_timestamp: clock.unix_timestamp, - swap_router, - price_feed_a, - price_feed_b, + asset_count: 0, + total_weight_bps: 0, bump: context.bumps.strategy, }); diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/invest.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/invest.rs index 91a6ff33..cab4add6 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/invest.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/invest.rs @@ -8,7 +8,8 @@ use mock_swap_router::{ }; use crate::error::VaultError; -use crate::state::Strategy; +use crate::oracle::{load_price, PYTH_PRICE_PRECISION}; +use crate::state::{AssetConfig, Strategy}; #[derive(Accounts)] pub struct InvestAccountConstraints<'info> { @@ -24,12 +25,25 @@ pub struct InvestAccountConstraints<'info> { )] pub strategy: Box>, + /// The asset to buy. Validated against its registered config below. + #[account( + constraint = asset_config.strategy == strategy.key() @ VaultError::InvalidAssetAccount, + constraint = asset_config.mint == asset_mint.key() @ VaultError::AssetNotFound, + constraint = asset_config.vault == vault_asset.key() @ VaultError::InvalidAssetAccount, + )] + pub asset_config: Box>, + pub usdc_mint: Box>, - /// The asset mint to buy - must be asset_mint_a or asset_mint_b #[account(mut)] pub asset_mint: Box>, + /// CHECK: Pyth feed - validated against the asset's registered feed + #[account( + constraint = price_feed.key() == asset_config.price_feed @ VaultError::InvalidPriceFeed + )] + pub price_feed: UncheckedAccount<'info>, + #[account( mut, associated_token::mint = usdc_mint, @@ -38,7 +52,6 @@ pub struct InvestAccountConstraints<'info> { )] pub vault_usdc: Box>, - /// Vault's asset token account for the asset being bought #[account( mut, associated_token::mint = asset_mint, @@ -71,22 +84,35 @@ pub struct InvestAccountConstraints<'info> { pub system_program: Program<'info, System>, } -pub fn handle_invest( - context: Context, - usdc_amount: u64, - minimum_asset_out: u64, -) -> Result<()> { +pub fn handle_invest(context: Context, usdc_amount: 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 max_slippage_bps = strategy.max_slippage_bps; + + // Slippage floor anchored to the oracle, not to a manager-supplied number: + // expected_out = usdc_amount * 10^8 / price, then allow it to fall short by at + // most max_slippage_bps. The router rejects any fill below this. + let now = Clock::get()?.unix_timestamp; + let price = load_price( + &context.accounts.price_feed, + &context.accounts.asset_config.price_feed, + now, + )?; + + let expected_out = (usdc_amount as u128) + .checked_mul(PYTH_PRICE_PRECISION) + .ok_or(VaultError::MathOverflow)? + .checked_div(price) + .ok_or(VaultError::MathOverflow)?; + let minimum_asset_out: u64 = expected_out + .checked_mul((10_000 - max_slippage_bps) as u128) + .ok_or(VaultError::MathOverflow)? + .checked_div(10_000) + .ok_or(VaultError::MathOverflow)? + .try_into() + .map_err(|_| VaultError::MathOverflow)?; + let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", manager_key.as_ref(), &[strategy_bump]]]; let cpi_accounts = RouterSwapAccounts { diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/mod.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/mod.rs index 485d6bc7..7def5baa 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/mod.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/mod.rs @@ -1,13 +1,19 @@ +pub mod add_asset; pub mod collect_fees; pub mod deposit; +pub mod initialize_registry; pub mod initialize_strategy; pub mod invest; pub mod rebalance; +pub mod whitelist_asset; pub mod withdraw; +pub use add_asset::*; pub use collect_fees::*; pub use deposit::*; +pub use initialize_registry::*; pub use initialize_strategy::*; pub use invest::*; pub use rebalance::*; +pub use whitelist_asset::*; pub use withdraw::*; diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/rebalance.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/rebalance.rs index ef087342..b94d6eaa 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/rebalance.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/rebalance.rs @@ -9,7 +9,8 @@ use mock_swap_router::{ }; use crate::error::VaultError; -use crate::state::Strategy; +use crate::oracle::{load_price, PYTH_PRICE_PRECISION}; +use crate::state::{AssetConfig, Strategy}; #[derive(Accounts)] pub struct RebalanceAccountConstraints<'info> { @@ -26,15 +27,34 @@ pub struct RebalanceAccountConstraints<'info> { pub usdc_mint: Box>, - /// The basket token being sold #[account(mut)] pub sell_mint: Box>, - /// The basket token being bought #[account(mut)] pub buy_mint: Box>, - /// Vault's token account for the asset being sold + #[account( + constraint = sell_config.strategy == strategy.key() @ VaultError::InvalidAssetAccount, + constraint = sell_config.mint == sell_mint.key() @ VaultError::AssetNotFound, + constraint = sell_config.vault == vault_sell.key() @ VaultError::InvalidAssetAccount, + )] + pub sell_config: Box>, + + #[account( + constraint = buy_config.strategy == strategy.key() @ VaultError::InvalidAssetAccount, + constraint = buy_config.mint == buy_mint.key() @ VaultError::AssetNotFound, + constraint = buy_config.vault == vault_buy.key() @ VaultError::InvalidAssetAccount, + )] + pub buy_config: Box>, + + /// CHECK: Pyth feed - validated against sell asset's registered feed + #[account(constraint = sell_price_feed.key() == sell_config.price_feed @ VaultError::InvalidPriceFeed)] + pub sell_price_feed: UncheckedAccount<'info>, + + /// CHECK: Pyth feed - validated against buy asset's registered feed + #[account(constraint = buy_price_feed.key() == buy_config.price_feed @ VaultError::InvalidPriceFeed)] + pub buy_price_feed: UncheckedAccount<'info>, + #[account( mut, associated_token::mint = sell_mint, @@ -43,7 +63,6 @@ pub struct RebalanceAccountConstraints<'info> { )] pub vault_sell: Box>, - /// Vault's token account for the asset being bought #[account( mut, associated_token::mint = buy_mint, @@ -89,33 +108,61 @@ pub struct RebalanceAccountConstraints<'info> { 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 strategy = &context.accounts.strategy; let manager_key = strategy.manager; let strategy_bump = strategy.bump; + let slip = (10_000 - strategy.max_slippage_bps) as u128; + + let now = Clock::get()?.unix_timestamp; + let price_sell = load_price( + &context.accounts.sell_price_feed, + &context.accounts.sell_config.price_feed, + now, + )?; + let price_buy = load_price( + &context.accounts.buy_price_feed, + &context.accounts.buy_config.price_feed, + now, + )?; + + // Sell leg floor: USDC out must be within slippage of the oracle value of what we sell. + let expected_usdc = (sell_amount as u128) + .checked_mul(price_sell) + .ok_or(VaultError::MathOverflow)? + .checked_div(PYTH_PRICE_PRECISION) + .ok_or(VaultError::MathOverflow)?; + let minimum_usdc_from_sell: u64 = expected_usdc + .checked_mul(slip) + .ok_or(VaultError::MathOverflow)? + .checked_div(10_000) + .ok_or(VaultError::MathOverflow)? + .try_into() + .map_err(|_| VaultError::MathOverflow)?; + + // Buy leg floor: asset out must be within slippage of the oracle-implied amount. + let expected_buy = (usdc_to_invest as u128) + .checked_mul(PYTH_PRICE_PRECISION) + .ok_or(VaultError::MathOverflow)? + .checked_div(price_buy) + .ok_or(VaultError::MathOverflow)?; + let minimum_buy_amount: u64 = expected_buy + .checked_mul(slip) + .ok_or(VaultError::MathOverflow)? + .checked_div(10_000) + .ok_or(VaultError::MathOverflow)? + .try_into() + .map_err(|_| VaultError::MathOverflow)?; + let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", manager_key.as_ref(), &[strategy_bump]]]; - // Step 1: sell basket token → USDC + // 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(), diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/whitelist_asset.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/whitelist_asset.rs new file mode 100644 index 00000000..7436b359 --- /dev/null +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/whitelist_asset.rs @@ -0,0 +1,43 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::Mint; + +use crate::state::{Registry, WhitelistEntry}; + +#[derive(Accounts)] +pub struct WhitelistAssetAccountConstraints<'info> { + #[account(mut)] + pub authority: Signer<'info>, + + #[account( + has_one = authority, + seeds = [b"registry", authority.key().as_ref()], + bump = registry.bump + )] + pub registry: Account<'info, Registry>, + + pub asset_mint: InterfaceAccount<'info, Mint>, + + #[account( + init, + payer = authority, + space = WhitelistEntry::DISCRIMINATOR.len() + WhitelistEntry::INIT_SPACE, + seeds = [b"whitelist", registry.key().as_ref(), asset_mint.key().as_ref()], + bump + )] + pub whitelist_entry: Account<'info, WhitelistEntry>, + + pub system_program: Program<'info, System>, +} + +pub fn handle_whitelist_asset( + context: Context, + price_feed: Pubkey, +) -> Result<()> { + context.accounts.whitelist_entry.set_inner(WhitelistEntry { + registry: context.accounts.registry.key(), + mint: context.accounts.asset_mint.key(), + price_feed, + bump: context.bumps.whitelist_entry, + }); + Ok(()) +} diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/withdraw.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/withdraw.rs index 67c725e5..30b75642 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/withdraw.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/withdraw.rs @@ -7,7 +7,8 @@ use anchor_spl::{ }; use crate::error::VaultError; -use crate::state::Strategy; +use crate::oracle::{read_mint_decimals, read_token_amount, read_token_mint_and_owner}; +use crate::state::{AssetConfig, Strategy}; #[derive(Accounts)] pub struct WithdrawAccountConstraints<'info> { @@ -17,8 +18,6 @@ pub struct WithdrawAccountConstraints<'info> { #[account( mut, has_one = usdc_mint @ VaultError::InvalidUsdcMint, - has_one = asset_mint_a @ VaultError::InvalidAssetMint, - has_one = asset_mint_b @ VaultError::InvalidAssetMint, seeds = [b"strategy", strategy.manager.as_ref()], bump = strategy.bump )] @@ -33,10 +32,6 @@ pub struct WithdrawAccountConstraints<'info> { pub usdc_mint: Box>, - pub asset_mint_a: Box>, - - pub asset_mint_b: Box>, - #[account( mut, associated_token::mint = share_mint, @@ -54,24 +49,6 @@ pub struct WithdrawAccountConstraints<'info> { )] pub user_usdc_account: Box>, - #[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: Box>, - - #[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: Box>, - #[account( mut, associated_token::mint = usdc_mint, @@ -80,137 +57,140 @@ pub struct WithdrawAccountConstraints<'info> { )] pub vault_usdc: Box>, - #[account( - mut, - associated_token::mint = asset_mint_a, - associated_token::authority = strategy, - associated_token::token_program = token_program - )] - pub vault_asset_a: Box>, - - #[account( - mut, - associated_token::mint = asset_mint_b, - associated_token::authority = strategy, - associated_token::token_program = token_program - )] - pub vault_asset_b: Box>, - pub associated_token_program: Program<'info, AssociatedToken>, pub token_program: Interface<'info, TokenInterface>, pub system_program: Program<'info, System>, + // remaining_accounts: for each asset index 0..asset_count, in order: + // [asset_config, vault, mint, user_token_account] + // The user's asset token accounts must already exist. } -pub fn handle_withdraw( - context: Context, +pub fn handle_withdraw<'info>( + context: Context<'info, WithdrawAccountConstraints<'info>>, 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 strategy_key = context.accounts.strategy.key(); + let user_key = context.accounts.user.key(); + let asset_count = context.accounts.strategy.asset_count as usize; + + require!( + context.remaining_accounts.len() == asset_count * 4, + VaultError::IncompleteAssetAccounts + ); let shares_u128 = shares_to_burn as u128; let total_u128 = total_shares as u128; - // Proportional amounts - floor division (user gets floor) + // USDC leg, floored in the protocol's favour. let amount_usdc: u64 = (vault_usdc_amount as u128) .checked_mul(shares_u128) .ok_or(VaultError::MathOverflow)? .checked_div(total_u128) .ok_or(VaultError::MathOverflow)? 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 + // Checks-effects-interactions: shrink supply before any transfer. 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) + // Hoist owned account-info handles for every CPI up front, so the asset loop + // can borrow remaining_accounts without also re-borrowing `context.accounts` + // (Account is invariant over its lifetime, which otherwise fails to unify). + let strategy_info = context.accounts.strategy.to_account_info(); + let share_mint_info = context.accounts.share_mint.to_account_info(); + let usdc_mint_info = context.accounts.usdc_mint.to_account_info(); + let vault_usdc_info = context.accounts.vault_usdc.to_account_info(); + let user_info = context.accounts.user.to_account_info(); + let user_share_info = context.accounts.user_share_account.to_account_info(); + let user_usdc_info = context.accounts.user_usdc_account.to_account_info(); + let token_program_key = context.accounts.token_program.key(); + + // Burn the user's shares. 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(), + mint: share_mint_info, + from: user_share_info, + authority: user_info, }; - let cpi_ctx = CpiContext::new(context.accounts.token_program.key(), burn_accounts); - burn(cpi_ctx, shares_to_burn)?; + burn( + CpiContext::new(token_program_key, burn_accounts), + shares_to_burn, + )?; - // Transfer USDC from vault to user + // USDC payout. 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(), + from: vault_usdc_info, + mint: usdc_mint_info, + to: user_usdc_info, + authority: strategy_info.clone(), }; - 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_checked( + CpiContext::new_with_signer(token_program_key, transfer_accounts, signer_seeds), + 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, + // Each basket asset, paid in kind, proportional to shares burned. + let remaining = context.remaining_accounts; + for i in 0..asset_count { + let config_ai = &remaining[i * 4]; + let vault_ai = &remaining[i * 4 + 1]; + let mint_ai = &remaining[i * 4 + 2]; + let user_ata_ai = &remaining[i * 4 + 3]; + + let config = AssetConfig::load_checked(config_ai)?; + require_keys_eq!( + config.strategy, + strategy_key, + VaultError::InvalidAssetAccount ); - 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, + require!(config.index as usize == i, VaultError::InvalidAssetAccount); + require_keys_eq!( + vault_ai.key(), + config.vault, + VaultError::InvalidAssetAccount ); - transfer_checked(cpi_ctx, amount_b, asset_b_decimals)?; + require_keys_eq!(mint_ai.key(), config.mint, VaultError::InvalidAssetAccount); + + let (recipient_mint, recipient_owner) = read_token_mint_and_owner(user_ata_ai)?; + require_keys_eq!(recipient_owner, user_key, VaultError::InvalidRecipient); + require_keys_eq!(recipient_mint, config.mint, VaultError::InvalidRecipient); + + let vault_balance = read_token_amount(vault_ai)?; + let amount: u64 = (vault_balance as u128) + .checked_mul(shares_u128) + .ok_or(VaultError::MathOverflow)? + .checked_div(total_u128) + .ok_or(VaultError::MathOverflow)? as u64; + + if amount > 0 { + let decimals = read_mint_decimals(mint_ai)?; + let transfer_accounts = TransferChecked { + from: vault_ai.to_account_info(), + mint: mint_ai.to_account_info(), + to: user_ata_ai.to_account_info(), + authority: strategy_info.clone(), + }; + transfer_checked( + CpiContext::new_with_signer(token_program_key, transfer_accounts, signer_seeds), + amount, + decimals, + )?; + } } Ok(()) diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/lib.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/lib.rs index c6f1da0d..21eec1a1 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/lib.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/lib.rs @@ -1,5 +1,6 @@ pub mod error; pub mod instructions; +pub mod oracle; pub mod state; use anchor_lang::prelude::*; @@ -13,75 +14,69 @@ declare_id!("VLT5W7bqhRN4nCdRpXm8UfHRxZd9EuZGqiSAkGHQfGh"); pub mod vault_strategy { use super::*; + /// Create a curated whitelist of assets, owned by `authority` (not a manager). + pub fn initialize_registry( + context: Context, + ) -> Result<()> { + instructions::initialize_registry::handle_initialize_registry(context) + } + + /// Approve a mint and bind it to its official price feed. Registry authority only. + pub fn whitelist_asset( + context: Context, + price_feed: Pubkey, + ) -> Result<()> { + instructions::whitelist_asset::handle_whitelist_asset(context, price_feed) + } + pub fn initialize_strategy( context: Context, - weight_bps_a: u16, - weight_bps_b: u16, fee_bps: u16, + max_slippage_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, + max_slippage_bps, swap_router, - price_feed_a, - price_feed_b, ) } - pub fn deposit( - context: Context, + /// Add a whitelisted asset to the strategy at the next index. Manager only. + pub fn add_asset(context: Context, weight_bps: u16) -> Result<()> { + instructions::add_asset::handle_add_asset(context, weight_bps) + } + + pub fn deposit<'info>( + context: Context<'info, DepositAccountConstraints<'info>>, 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 invest(context: Context, usdc_amount: u64) -> Result<()> { + instructions::invest::handle_invest(context, usdc_amount) } pub fn collect_fees(context: Context) -> Result<()> { instructions::collect_fees::handle_collect_fees(context) } - pub fn withdraw( - context: Context, + pub fn withdraw<'info>( + context: Context<'info, WithdrawAccountConstraints<'info>>, 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, - ) + instructions::withdraw::handle_withdraw(context, shares_to_burn, min_usdc_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, - ) + instructions::rebalance::handle_rebalance(context, sell_amount, usdc_to_invest) } } diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/oracle.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/oracle.rs new file mode 100644 index 00000000..cfa41e11 --- /dev/null +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/oracle.rs @@ -0,0 +1,112 @@ +use anchor_lang::prelude::*; + +use crate::error::VaultError; + +/// Byte offset of `price` (i64) inside a Pyth PriceUpdateV2 account: +/// 8 discriminator + 32 write_authority + 1 verification_level + 32 feed_id = 73 +const PYTH_PRICE_OFFSET: usize = 73; +/// Byte offset of `publish_time` (i64): +/// price(8) + conf(8) + exponent(4) = +20 bytes after price +const PYTH_PUBLISH_TIME_OFFSET: usize = PYTH_PRICE_OFFSET + 8 + 8 + 4; // 93 +/// Pyth USD pairs use exponent -8 (price * 10^-8 = dollars per token). +pub const PYTH_PRICE_PRECISION: u128 = 100_000_000; // 10^8 +/// Prices older than this (seconds) are rejected. +const MAX_PRICE_AGE_SECONDS: i64 = 60; + +/// SPL token account layout: amount is a u64 at bytes 64..72. The base layout is +/// shared by the Classic Token Program and the Token Extensions Program, so this +/// reads either. +const TOKEN_AMOUNT_OFFSET: usize = 64; +/// `owner` Pubkey is at bytes 32..64. +const TOKEN_OWNER_OFFSET: usize = 32; +/// `mint` Pubkey is at bytes 0..32. +const TOKEN_MINT_OFFSET: usize = 0; + +fn read_pyth_raw(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)) +} + +/// Validate a price feed account against the one the strategy registered, then +/// return its positive, fresh price as u128. `now` is the current unix timestamp. +pub fn load_price(price_feed: &AccountInfo, expected_key: &Pubkey, now: i64) -> Result { + require_keys_eq!( + price_feed.key(), + *expected_key, + VaultError::InvalidPriceFeed + ); + + let data = price_feed.try_borrow_data()?; + let (price, publish_time) = read_pyth_raw(&data)?; + + require!(price > 0, VaultError::NegativePrice); + require!( + now.checked_sub(publish_time) + .ok_or(VaultError::MathOverflow)? + <= MAX_PRICE_AGE_SECONDS, + VaultError::StalePriceFeed + ); + + Ok(price as u128) +} + +/// Read the `amount` field of a token account from its raw data. +pub fn read_token_amount(account: &AccountInfo) -> Result { + let data = account.try_borrow_data()?; + if data.len() < TOKEN_AMOUNT_OFFSET + 8 { + return err!(VaultError::InvalidVaultAccount); + } + Ok(u64::from_le_bytes( + data[TOKEN_AMOUNT_OFFSET..TOKEN_AMOUNT_OFFSET + 8] + .try_into() + .map_err(|_| VaultError::InvalidVaultAccount)?, + )) +} + +/// Read the `decimals` byte of a mint account. Offset 44 in the Mint layout +/// (mint_authority option 36 + supply 8), shared by both token programs. +pub fn read_mint_decimals(account: &AccountInfo) -> Result { + let data = account.try_borrow_data()?; + const MINT_DECIMALS_OFFSET: usize = 44; + if data.len() <= MINT_DECIMALS_OFFSET { + return err!(VaultError::InvalidVaultAccount); + } + Ok(data[MINT_DECIMALS_OFFSET]) +} + +/// Read the `mint` and `owner` Pubkeys of a token account from its raw data. +pub fn read_token_mint_and_owner(account: &AccountInfo) -> Result<(Pubkey, Pubkey)> { + let data = account.try_borrow_data()?; + if data.len() < TOKEN_OWNER_OFFSET + 32 { + return err!(VaultError::InvalidVaultAccount); + } + let mint = Pubkey::try_from(&data[TOKEN_MINT_OFFSET..TOKEN_MINT_OFFSET + 32]) + .map_err(|_| VaultError::InvalidVaultAccount)?; + let owner = Pubkey::try_from(&data[TOKEN_OWNER_OFFSET..TOKEN_OWNER_OFFSET + 32]) + .map_err(|_| VaultError::InvalidVaultAccount)?; + Ok((mint, owner)) +} + +/// Value of `amount` token minor units in USDC minor units, given a Pyth price. +/// Both USDC and the basket assets use 6 decimals, so the only scaling is the +/// Pyth exponent: value = amount * price / 10^8. Multiply before divide. +pub fn asset_value_in_usdc(amount: u64, price: u128) -> Result { + (amount as u128) + .checked_mul(price) + .ok_or(VaultError::MathOverflow)? + .checked_div(PYTH_PRICE_PRECISION) + .ok_or(VaultError::MathOverflow) + .map_err(Into::into) +} diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/state/mod.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/state/mod.rs index 781e460e..94ac4523 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/state/mod.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/state/mod.rs @@ -1,3 +1,5 @@ +pub mod registry; pub mod strategy; +pub use registry::*; pub use strategy::*; diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/state/registry.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/state/registry.rs new file mode 100644 index 00000000..9c814637 --- /dev/null +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/state/registry.rs @@ -0,0 +1,25 @@ +use anchor_lang::prelude::*; + +/// A curated set of assets that strategies may hold. Maintained by a protocol +/// authority that is deliberately not the strategy manager: the authority vets +/// which real assets (and which official price feed) are safe, and the manager +/// only chooses among them. This is what stops a manager from listing a token +/// they mint themselves, or pairing a real mint with a feed they control. +#[account] +#[derive(InitSpace)] +pub struct Registry { + pub authority: Pubkey, + pub bump: u8, +} + +/// One approved mint, binding it to its official Pyth PriceUpdateV2 feed. +/// Created only by the registry authority; add_asset copies `price_feed` from +/// here so the manager never supplies the feed. +#[account] +#[derive(InitSpace)] +pub struct WhitelistEntry { + pub registry: Pubkey, + pub mint: Pubkey, + pub price_feed: Pubkey, + pub bump: u8, +} diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/src/state/strategy.rs b/finance/vault-strategy/anchor/programs/vault-strategy/src/state/strategy.rs index f5f9f8ed..ec2db339 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/src/state/strategy.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/src/state/strategy.rs @@ -1,23 +1,69 @@ use anchor_lang::prelude::*; +/// Largest number of basket assets one strategy can hold. Not a storage limit +/// (each asset is its own account); the cap keeps deposit and withdraw, which +/// must reference every asset at once, within transaction account limits. USDC +/// is the base currency, held separately, and does not count against this. +pub const MAX_ASSETS: u8 = 8; + #[account] #[derive(InitSpace)] pub struct Strategy { pub manager: Pubkey, + /// Whitelist this strategy draws assets from. add_asset only accepts mints + /// approved in this registry. + pub registry: 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 swap_router: Pubkey, + /// Annual management fee in basis points (e.g. 100 = 1%). pub fee_bps: u16, + /// Maximum tolerated deviation, in basis points, between a swap's output and + /// the Pyth-implied amount on invest/rebalance. Bounded by MAX_SLIPPAGE_BPS. + pub max_slippage_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 + /// Assets live at PDAs indexed 0..asset_count, so callers can re-derive the + /// complete set and no asset can be silently omitted from a NAV calculation. + pub asset_count: u8, + /// Running sum of every asset's target weight, kept <= 10000. + pub total_weight_bps: u16, + pub bump: u8, +} + +/// One basket asset. Its address is a PDA seeded by the strategy and the asset's +/// index, so the full set is the contiguous range 0..asset_count: any handler +/// computing net asset value re-derives every index and refuses to proceed if an +/// asset account is missing. +#[account] +#[derive(InitSpace)] +pub struct AssetConfig { + pub strategy: Pubkey, + pub index: u8, + pub mint: Pubkey, + /// Pyth PriceUpdateV2 account, copied from the registry whitelist entry at + /// add time so the manager cannot substitute a feed they control. + pub price_feed: Pubkey, + /// Strategy-owned associated token account holding this asset. + pub vault: Pubkey, + /// Target share of the strategy's value in basis points. Advisory: the + /// manager maintains it with invest/rebalance; no handler enforces it on deposit. + pub weight_bps: u16, pub bump: u8, } + +impl AssetConfig { + /// Deserialize an AssetConfig passed via remaining_accounts to an owned value, + /// verifying it is owned by this program and has the right discriminator. + /// Avoids the lifetime invariance of `Account::try_from` on borrowed infos. + pub fn load_checked(account: &AccountInfo) -> Result { + require_keys_eq!( + *account.owner, + crate::ID, + crate::error::VaultError::InvalidAssetAccount + ); + let data = account.try_borrow_data()?; + AssetConfig::try_deserialize(&mut &data[..]) + .map_err(|_| error!(crate::error::VaultError::InvalidAssetAccount)) + } +} diff --git a/finance/vault-strategy/anchor/programs/vault-strategy/tests/vault_strategy.rs b/finance/vault-strategy/anchor/programs/vault-strategy/tests/vault_strategy.rs index 45d568a6..ef9c9c22 100644 --- a/finance/vault-strategy/anchor/programs/vault-strategy/tests/vault_strategy.rs +++ b/finance/vault-strategy/anchor/programs/vault-strategy/tests/vault_strategy.rs @@ -1,7 +1,10 @@ use { anchor_lang::{ - solana_program::{clock::Clock, instruction::Instruction, pubkey::Pubkey, system_program}, - InstructionData, ToAccountMetas, + solana_program::{ + clock::Clock, instruction::AccountMeta, instruction::Instruction, pubkey::Pubkey, + system_program, + }, + AccountDeserialize, InstructionData, ToAccountMetas, }, anchor_spl::token::spl_token, litesvm::LiteSVM, @@ -41,43 +44,53 @@ fn derive_ata(wallet: &Pubkey, mint: &Pubkey) -> Pubkey { 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) +/// Mock PriceUpdateV2 layout (see pyth-solana-receiver-sdk): price i64 at 73, +/// publish_time i64 at 93. Exponent -8. 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(&[0u8; 32]); + data.push(1u8); + data.extend_from_slice(&[0xEFu8; 32]); data.extend_from_slice(&price.to_le_bytes()); - data.extend_from_slice(&100_000u64.to_le_bytes()); // conf + data.extend_from_slice(&100_000u64.to_le_bytes()); 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.extend_from_slice(&(publish_time - 1).to_le_bytes()); + data.extend_from_slice(&price.to_le_bytes()); + data.extend_from_slice(&120_000u64.to_le_bytes()); + data.extend_from_slice(&1u64.to_le_bytes()); data } -/// Fixed publish time matching the test clock -const PUBLISH_TIME: i64 = 1_700_000_000; +fn set_price_feed(svm: &mut LiteSVM, key: Pubkey, price: i64) { + let data = build_mock_price_update_account(price, -8, PUBLISH_TIME); + let rent = svm.minimum_balance_for_rent_exemption(data.len()); + svm.set_account( + key, + SolanaAccount { + lamports: rent, + data, + owner: pyth_receiver_program_id(), + executable: false, + rent_epoch: 0, + }, + ) + .unwrap(); +} -/// All test mints (USDC and the basket assets) use 6 decimals, matching real USDC. +const PUBLISH_TIME: i64 = 1_700_000_000; const TOKEN_DECIMALS: u8 = 6; +const SECONDS_PER_YEAR: i64 = 31_536_000; + +const TSLA_PRICE: i64 = 25_000_000_000; // $250 +const NVDA_PRICE: i64 = 18_000_000_000; // $180 +const TSLA_RATE: u64 = 250; // router usdc per token +const NVDA_RATE: u64 = 180; + +const FEE_BPS: u16 = 100; // 1% +const SLIPPAGE_BPS: u16 = 100; // 1% struct TestContext { svm: LiteSVM, @@ -90,6 +103,9 @@ struct TestContext { nvda_mint: Pubkey, strategy_pda: Pubkey, share_mint_pda: Pubkey, + registry_pda: Pubkey, + whitelist_tsla: Pubkey, + whitelist_nvda: Pubkey, router_config_pda: Pubkey, router_authority_pda: Pubkey, tsla_rate_pda: Pubkey, @@ -102,19 +118,34 @@ struct TestContext { price_feed_nvda: Pubkey, } +impl TestContext { + fn asset_config(&self, index: u8) -> Pubkey { + Pubkey::find_program_address( + &[b"asset", self.strategy_pda.as_ref(), &[index]], + &self.vault_program_id, + ) + .0 + } +} + +/// Mints, router (config + rates + treasury), Pyth feeds, a registry with TSLAx +/// and NVDAx whitelisted, and all derived PDAs. Does not create the strategy. fn setup_full() -> TestContext { let vault_program_id = vault_strategy::id(); let router_program_id = mock_swap_router::id(); let mut svm = LiteSVM::new(); + svm.add_program( + vault_program_id, + include_bytes!("../../../target/deploy/vault_strategy.so"), + ) + .unwrap(); + svm.add_program( + router_program_id, + include_bytes!("../../../target/deploy/mock_swap_router.so"), + ) + .unwrap(); - 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, @@ -126,7 +157,6 @@ fn setup_full() -> TestContext { let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); let manager = create_wallet(&mut svm, 10_000_000_000).unwrap(); - // Create mints with payer as the initial mint authority for all three let usdc_mint = create_token_mint(&mut svm, &payer, TOKEN_DECIMALS, None).unwrap(); let tsla_mint = create_token_mint(&mut svm, &payer, TOKEN_DECIMALS, None).unwrap(); let nvda_mint = create_token_mint(&mut svm, &payer, TOKEN_DECIMALS, None).unwrap(); @@ -134,10 +164,9 @@ fn setup_full() -> TestContext { let (router_authority_pda, _) = Pubkey::find_program_address(&[b"router_authority"], &router_program_id); - // The router pays out swap_usdc_for_asset by minting, so the basket asset - // mints must have router_authority as their mint authority + // The router mints basket assets on swap, so it must hold their mint authority. for basket_mint in [&tsla_mint, &nvda_mint] { - let set_authority_instruction = spl_token::instruction::set_authority( + let ix = spl_token::instruction::set_authority( &spl_token::ID, basket_mint, Some(&router_authority_pda), @@ -146,20 +175,23 @@ fn setup_full() -> TestContext { &[], ) .unwrap(); - send_transaction_from_instructions( - &mut svm, - vec![set_authority_instruction], - &[&payer], - &payer.pubkey(), - ) - .unwrap(); + send_transaction_from_instructions(&mut svm, vec![ix], &[&payer], &payer.pubkey()).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 (registry_pda, _) = + Pubkey::find_program_address(&[b"registry", payer.pubkey().as_ref()], &vault_program_id); + let (whitelist_tsla, _) = Pubkey::find_program_address( + &[b"whitelist", registry_pda.as_ref(), tsla_mint.as_ref()], + &vault_program_id, + ); + let (whitelist_nvda, _) = Pubkey::find_program_address( + &[b"whitelist", registry_pda.as_ref(), nvda_mint.as_ref()], + &vault_program_id, + ); let (router_config_pda, _) = Pubkey::find_program_address(&[b"router_config"], &router_program_id); let (tsla_rate_pda, _) = @@ -167,49 +199,17 @@ fn setup_full() -> TestContext { 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(); + let price_feed_tsla = Keypair::new().pubkey(); + let price_feed_nvda = Keypair::new().pubkey(); + set_price_feed(&mut svm, price_feed_tsla, TSLA_PRICE); + set_price_feed(&mut svm, price_feed_nvda, NVDA_PRICE); - // Step 1: Initialize router + // Router: init, rates, treasury. let init_router_ix = Instruction::new_with_bytes( router_program_id, &mock_swap_router::instruction::InitializeRouter { usdc_mint }.data(), @@ -226,75 +226,80 @@ fn setup_full() -> TestContext { 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( + for (mint, rate, rate_pda) in [ + (tsla_mint, TSLA_RATE, tsla_rate_pda), + (nvda_mint, NVDA_RATE, nvda_rate_pda), + ] { + let ix = Instruction::new_with_bytes( + router_program_id, + &mock_swap_router::instruction::SetRate { + mint, + usdc_per_token: rate, + } + .data(), + mock_swap_router::accounts::SetRateAccountConstraints { + authority: payer.pubkey(), + router_config: router_config_pda, + asset_mint: mint, + usdc_mint, + asset_rate: 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![ix], &[&payer], &payer.pubkey()).unwrap(); + } + + mint_tokens_to_token_account( &mut svm, - vec![set_tsla_rate_ix], - &[&payer], - &payer.pubkey(), + &usdc_mint, + &router_usdc_treasury, + 10_000_000_000u64, + &payer, ) .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 { + // Registry with both basket assets whitelisted, bound to their feeds. + let init_registry_ix = Instruction::new_with_bytes( + vault_program_id, + &vault_strategy::instruction::InitializeRegistry {}.data(), + vault_strategy::accounts::InitializeRegistryAccountConstraints { 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(), + registry: registry_pda, system_program: system_program::id(), } .to_account_metas(None), ); send_transaction_from_instructions( &mut svm, - vec![set_nvda_rate_ix], + vec![init_registry_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(); + for (mint, feed, entry) in [ + (tsla_mint, price_feed_tsla, whitelist_tsla), + (nvda_mint, price_feed_nvda, whitelist_nvda), + ] { + let ix = Instruction::new_with_bytes( + vault_program_id, + &vault_strategy::instruction::WhitelistAsset { price_feed: feed }.data(), + vault_strategy::accounts::WhitelistAssetAccountConstraints { + authority: payer.pubkey(), + registry: registry_pda, + asset_mint: mint, + whitelist_entry: entry, + system_program: system_program::id(), + } + .to_account_metas(None), + ); + send_transaction_from_instructions(&mut svm, vec![ix], &[&payer], &payer.pubkey()).unwrap(); + } TestContext { svm, @@ -307,6 +312,9 @@ fn setup_full() -> TestContext { nvda_mint, strategy_pda, share_mint_pda, + registry_pda, + whitelist_tsla, + whitelist_nvda, router_config_pda, router_authority_pda, tsla_rate_pda, @@ -320,212 +328,166 @@ fn setup_full() -> TestContext { } } -fn build_initialize_strategy_instruction( - ctx: &TestContext, - fee_bps: u16, - swap_router: Pubkey, -) -> Instruction { - Instruction::new_with_bytes( +fn init_strategy(ctx: &mut TestContext, fee_bps: u16, slippage_bps: u16, router: Pubkey) { + let ix = Instruction::new_with_bytes( ctx.vault_program_id, &vault_strategy::instruction::InitializeStrategy { - weight_bps_a: 4000, - weight_bps_b: 6000, fee_bps, - swap_router, - price_feed_a: ctx.price_feed_tsla, - price_feed_b: ctx.price_feed_nvda, + max_slippage_bps: slippage_bps, + swap_router: router, } .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, + registry: ctx.registry_pda, 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), - ) -} - -/// Annual management fee used by the happy-path tests: 100 bps = 1%. -const TEST_FEE_BPS: u16 = 100; - -fn initialize_strategy(ctx: &mut TestContext) { - initialize_strategy_with_router(ctx, ctx.router_program_id); -} - -/// Initialize the strategy with an arbitrary stored swap router, so tests can -/// prove that invest/rebalance reject a router program the strategy did not register. -fn initialize_strategy_with_router(ctx: &mut TestContext, swap_router: Pubkey) { - let init_strategy_ix = build_initialize_strategy_instruction(ctx, TEST_FEE_BPS, swap_router); + ); send_transaction_from_instructions( &mut ctx.svm, - vec![init_strategy_ix], - &[&ctx.payer, &ctx.manager], - &ctx.payer.pubkey(), + vec![ix], + &[&ctx.manager], + &ctx.manager.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( +fn add_asset( + ctx: &mut TestContext, + index: u8, + mint: Pubkey, + whitelist_entry: Pubkey, + vault: Pubkey, + weight_bps: u16, +) -> Result<(), solana_kite::SolanaKiteError> { + let asset_config = ctx.asset_config(index); + let 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(), + &vault_strategy::instruction::AddAsset { weight_bps }.data(), + vault_strategy::accounts::AddAssetAccountConstraints { + manager: ctx.manager.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, + registry: ctx.registry_pda, + asset_mint: mint, + whitelist_entry, + asset_config, + vault_asset: vault, 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(), + vec![ix], + &[&ctx.manager], + &ctx.manager.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"); +/// init strategy + add TSLAx (index 0, 40%) + NVDAx (index 1, 60%). +fn standard_strategy(ctx: &mut TestContext) { + let router = ctx.router_program_id; + init_strategy(ctx, FEE_BPS, SLIPPAGE_BPS, router); + let (tm, wt, vt) = (ctx.tsla_mint, ctx.whitelist_tsla, ctx.vault_tsla); + add_asset(ctx, 0, tm, wt, vt, 4000).unwrap(); + let (nm, wn, vn) = (ctx.nvda_mint, ctx.whitelist_nvda, ctx.vault_nvda); + add_asset(ctx, 1, nm, wn, vn, 6000).unwrap(); +} - 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" - ); +/// remaining_accounts for deposit: [asset_config, vault, price_feed] per asset. +fn deposit_remaining(ctx: &TestContext) -> Vec { + vec![ + AccountMeta::new_readonly(ctx.asset_config(0), false), + AccountMeta::new_readonly(ctx.vault_tsla, false), + AccountMeta::new_readonly(ctx.price_feed_tsla, false), + AccountMeta::new_readonly(ctx.asset_config(1), false), + AccountMeta::new_readonly(ctx.vault_nvda, false), + AccountMeta::new_readonly(ctx.price_feed_nvda, false), + ] } -fn do_deposit(ctx: &mut TestContext, user: &Keypair, usdc_amount: u64) -> Pubkey { +fn do_deposit( + ctx: &mut TestContext, + user: &Keypair, + usdc_amount: u64, + minimum_shares: 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( + let mut metas = vault_strategy::accounts::DepositAccountConstraints { + depositor: user.pubkey(), + strategy: ctx.strategy_pda, + share_mint: ctx.share_mint_pda, + usdc_mint: ctx.usdc_mint, + depositor_usdc_account: user_usdc, + depositor_share_account: user_share, + vault_usdc: ctx.vault_usdc, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None); + metas.extend(deposit_remaining(ctx)); + + let ix = Instruction::new_with_bytes( ctx.vault_program_id, &vault_strategy::instruction::Deposit { usdc_amount, - minimum_shares: 0, + minimum_shares, } .data(), - vault_strategy::accounts::DepositAccountConstraints { - depositor: user.pubkey(), + metas, + ); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[user], &user.pubkey()).unwrap(); + user_share +} + +fn invest_ix( + ctx: &TestContext, + mint: Pubkey, + config: Pubkey, + feed: Pubkey, + vault: Pubkey, + rate: Pubkey, + usdc_amount: u64, +) -> Instruction { + Instruction::new_with_bytes( + ctx.vault_program_id, + &vault_strategy::instruction::Invest { usdc_amount }.data(), + vault_strategy::accounts::InvestAccountConstraints { + manager: ctx.manager.pubkey(), strategy: ctx.strategy_pda, - share_mint: ctx.share_mint_pda, + asset_config: config, 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, + asset_mint: mint, + price_feed: feed, 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, + vault_asset: vault, + asset_rate: rate, + 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![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 +fn fund_user(ctx: &mut TestContext, usdc_amount: u64) -> Keypair { 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(); @@ -533,463 +495,367 @@ fn test_invest() { &mut ctx.svm, &ctx.usdc_mint, &user_usdc, - deposit_amount, + usdc_amount, &ctx.payer, ) .unwrap(); - do_deposit(&mut ctx, &user, deposit_amount); + user +} + +// ---------------------------------------------------------------------------- + +#[test] +fn test_initialize_and_add_assets() { + let mut ctx = setup_full(); + standard_strategy(&mut ctx); + + let account = ctx.svm.get_account(&ctx.strategy_pda).unwrap(); + let strategy = + vault_strategy::state::Strategy::try_deserialize(&mut &account.data[..]).unwrap(); + assert_eq!(strategy.asset_count, 2); + assert_eq!(strategy.total_weight_bps, 10_000); + assert_eq!(strategy.fee_bps, FEE_BPS); + assert_eq!(strategy.max_slippage_bps, SLIPPAGE_BPS); + assert_eq!(strategy.registry, ctx.registry_pda); + + let cfg0 = ctx.svm.get_account(&ctx.asset_config(0)).unwrap(); + let asset0 = vault_strategy::state::AssetConfig::try_deserialize(&mut &cfg0.data[..]).unwrap(); + assert_eq!(asset0.mint, ctx.tsla_mint); + assert_eq!(asset0.price_feed, ctx.price_feed_tsla); + assert_eq!(asset0.vault, ctx.vault_tsla); + assert_eq!(asset0.weight_bps, 4000); +} + +#[test] +fn test_add_asset_rejects_non_whitelisted() { + let mut ctx = setup_full(); + let router = ctx.router_program_id; + init_strategy(&mut ctx, FEE_BPS, SLIPPAGE_BPS, router); + + // A mint that was never whitelisted: its whitelist_entry PDA does not exist. + let rogue_mint = create_token_mint(&mut ctx.svm, &ctx.payer, TOKEN_DECIMALS, None).unwrap(); + let (rogue_entry, _) = Pubkey::find_program_address( + &[b"whitelist", ctx.registry_pda.as_ref(), rogue_mint.as_ref()], + &ctx.vault_program_id, + ); + let rogue_vault = derive_ata(&ctx.strategy_pda, &rogue_mint); + + let result = add_asset(&mut ctx, 0, rogue_mint, rogue_entry, rogue_vault, 5000); + assert!(result.is_err(), "adding a non-whitelisted mint must fail"); +} + +#[test] +fn test_add_asset_rejects_weight_overflow() { + let mut ctx = setup_full(); + let router = ctx.router_program_id; + init_strategy(&mut ctx, FEE_BPS, SLIPPAGE_BPS, router); + let (tm, wt, vt) = (ctx.tsla_mint, ctx.whitelist_tsla, ctx.vault_tsla); + add_asset(&mut ctx, 0, tm, wt, vt, 6000).unwrap(); + let (nm, wn, vn) = (ctx.nvda_mint, ctx.whitelist_nvda, ctx.vault_nvda); + let result = add_asset(&mut ctx, 1, nm, wn, vn, 6000); + assert!(result.is_err(), "weights over 10000 bps must fail"); +} - // 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( +#[test] +fn test_initialize_rejects_excessive_fee() { + let mut ctx = setup_full(); + let excessive = vault_strategy::instructions::initialize_strategy::MAX_FEE_BPS + 1; + let ix = Instruction::new_with_bytes( ctx.vault_program_id, - &vault_strategy::instruction::Invest { - usdc_amount: invest_amount, - minimum_asset_out: 0, + &vault_strategy::instruction::InitializeStrategy { + fee_bps: excessive, + max_slippage_bps: SLIPPAGE_BPS, + swap_router: ctx.router_program_id, } .data(), - vault_strategy::accounts::InvestAccountConstraints { + vault_strategy::accounts::InitializeStrategyAccountConstraints { manager: ctx.manager.pubkey(), - strategy: ctx.strategy_pda, usdc_mint: ctx.usdc_mint, - asset_mint: ctx.tsla_mint, + registry: ctx.registry_pda, + strategy: ctx.strategy_pda, + share_mint: ctx.share_mint_pda, 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( + let r = 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" + vec![ix], + &[&ctx.manager], + &ctx.manager.pubkey(), ); + assert!(r.is_err(), "fee above MAX_FEE_BPS must be rejected"); } #[test] -fn test_deposit_after_invest() { +fn test_initialize_rejects_excessive_slippage() { 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( + let excessive = vault_strategy::instructions::initialize_strategy::MAX_SLIPPAGE_BPS + 1; + let ix = Instruction::new_with_bytes( ctx.vault_program_id, - &vault_strategy::instruction::Invest { - usdc_amount: 4_000_000, - minimum_asset_out: 0, + &vault_strategy::instruction::InitializeStrategy { + fee_bps: FEE_BPS, + max_slippage_bps: excessive, + swap_router: ctx.router_program_id, } .data(), - vault_strategy::accounts::InvestAccountConstraints { + vault_strategy::accounts::InitializeStrategyAccountConstraints { manager: ctx.manager.pubkey(), - strategy: ctx.strategy_pda, usdc_mint: ctx.usdc_mint, - asset_mint: ctx.tsla_mint, + registry: ctx.registry_pda, + strategy: ctx.strategy_pda, + share_mint: ctx.share_mint_pda, 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( + let r = 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"); -} + vec![ix], + &[&ctx.manager], + &ctx.manager.pubkey(), + ); + assert!( + r.is_err(), + "slippage above MAX_SLIPPAGE_BPS must be rejected" + ); +} #[test] -fn test_collect_fees() { +fn test_deposit_first() { let mut ctx = setup_full(); - initialize_strategy(&mut ctx); + standard_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); + let amount = 1_000_000u64; // 1 USDC + let user = fund_user(&mut ctx, amount); + let user_share = do_deposit(&mut ctx, &user, amount, 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), + assert_eq!( + get_token_account_balance(&ctx.svm, &user_share).unwrap(), + amount + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), + amount ); +} +#[test] +fn test_invest() { + let mut ctx = setup_full(); + standard_strategy(&mut ctx); + + let user = fund_user(&mut ctx, 10_000_000); + do_deposit(&mut ctx, &user, 10_000_000, 1); + + let ix = invest_ix( + &ctx, + ctx.tsla_mint, + ctx.asset_config(0), + ctx.price_feed_tsla, + ctx.vault_tsla, + ctx.tsla_rate_pda, + 4_000_000, + ); send_transaction_from_instructions( &mut ctx.svm, - vec![collect_fees_ix], - &[&ctx.payer], - &ctx.payer.pubkey(), + vec![ix], + &[&ctx.manager], + &ctx.manager.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 + // 4 USDC / 250 = 16000 TSLAx + assert_eq!( + get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), + 16_000 + ); assert_eq!( - fee_shares, 10_000_000, - "Annual fee should be 1% of total shares" + get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(), + 6_000_000 ); } #[test] -fn test_withdraw() { +fn test_invest_rejects_slippage() { let mut ctx = setup_full(); - initialize_strategy(&mut ctx); + standard_strategy(&mut ctx); + let user = fund_user(&mut ctx, 10_000_000); + do_deposit(&mut ctx, &user, 10_000_000, 1); - // 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, + // Make the router quote far worse than the oracle: rate 300 vs Pyth-implied 250. + let bad_rate_ix = Instruction::new_with_bytes( + ctx.router_program_id, + &mock_swap_router::instruction::SetRate { + mint: ctx.tsla_mint, + usdc_per_token: 300, } .data(), - vault_strategy::accounts::WithdrawAccountConstraints { - user: user.pubkey(), - strategy: ctx.strategy_pda, - share_mint: ctx.share_mint_pda, + mock_swap_router::accounts::SetRateAccountConstraints { + authority: ctx.payer.pubkey(), + router_config: ctx.router_config_pda, + asset_mint: ctx.tsla_mint, 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, + asset_rate: ctx.tsla_rate_pda, + router_authority: ctx.router_authority_pda, + router_usdc_treasury: ctx.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 ctx.svm, - vec![withdraw_ix], - &[&ctx.payer, &user], + vec![bad_rate_ix], + &[&ctx.payer], &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"); + let ix = invest_ix( + &ctx, + ctx.tsla_mint, + ctx.asset_config(0), + ctx.price_feed_tsla, + ctx.vault_tsla, + ctx.tsla_rate_pda, + 4_000_000, + ); + let r = send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&ctx.manager], + &ctx.manager.pubkey(), + ); + assert!( + r.is_err(), + "swap worse than oracle beyond tolerance must revert" + ); } #[test] -fn test_withdraw_rejects_slippage() { +fn test_invest_rejects_unregistered_router() { 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), + // Register a different router than the deployed mock. + let bogus_router = Pubkey::new_unique(); + init_strategy(&mut ctx, FEE_BPS, SLIPPAGE_BPS, bogus_router); + let (tm, wt, vt) = (ctx.tsla_mint, ctx.whitelist_tsla, ctx.vault_tsla); + add_asset(&mut ctx, 0, tm, wt, vt, 4000).unwrap(); + + let ix = invest_ix( + &ctx, + ctx.tsla_mint, + ctx.asset_config(0), + ctx.price_feed_tsla, + ctx.vault_tsla, + ctx.tsla_rate_pda, + 1_000_000, ); - - let result = send_transaction_from_instructions( + let r = send_transaction_from_instructions( &mut ctx.svm, - vec![withdraw_ix], - &[&ctx.payer, &user], - &ctx.payer.pubkey(), + vec![ix], + &[&ctx.manager], + &ctx.manager.pubkey(), ); assert!( - result.is_err(), - "Withdraw should fail when slippage too high" + r.is_err(), + "invest through an unregistered router must fail" ); } #[test] -fn test_rebalance() { +fn test_deposit_after_invest() { 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( + standard_strategy(&mut ctx); + + // Alice deposits 10 USDC (1:1 -> 10,000,000 shares). + let alice = fund_user(&mut ctx, 10_000_000); + do_deposit(&mut ctx, &alice, 10_000_000, 1); + + // Manager invests 4 USDC into TSLAx. + let ix = invest_ix( + &ctx, + ctx.tsla_mint, + ctx.asset_config(0), + ctx.price_feed_tsla, + ctx.vault_tsla, + ctx.tsla_rate_pda, + 4_000_000, + ); + send_transaction_from_instructions( &mut ctx.svm, - &ctx.usdc_mint, - &user_usdc, - deposit_amount, - &ctx.payer, + vec![ix], + &[&ctx.manager], + &ctx.manager.pubkey(), ) .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), + // NAV unchanged at 10 USDC (6 USDC + 16000 TSLAx * $250 = 6 + 4). Bob deposits 5 USDC -> 5,000,000 shares. + let bob = fund_user(&mut ctx, 5_000_000); + let bob_share = do_deposit(&mut ctx, &bob, 5_000_000, 1); + assert_eq!( + get_token_account_balance(&ctx.svm, &bob_share).unwrap(), + 5_000_000 + ); +} + +#[test] +fn test_rebalance() { + let mut ctx = setup_full(); + standard_strategy(&mut ctx); + let user = fund_user(&mut ctx, 100_000_000); + do_deposit(&mut ctx, &user, 100_000_000, 1); + + // Invest 40 USDC -> TSLAx (160000), 30 USDC -> NVDAx (166666). + let i1 = invest_ix( + &ctx, + ctx.tsla_mint, + ctx.asset_config(0), + ctx.price_feed_tsla, + ctx.vault_tsla, + ctx.tsla_rate_pda, + 40_000_000, ); send_transaction_from_instructions( &mut ctx.svm, - vec![invest_tsla_ix], - &[&ctx.payer, &ctx.manager], - &ctx.payer.pubkey(), + vec![i1], + &[&ctx.manager], + &ctx.manager.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), + let i2 = invest_ix( + &ctx, + ctx.nvda_mint, + ctx.asset_config(1), + ctx.price_feed_nvda, + ctx.vault_nvda, + ctx.nvda_rate_pda, + 30_000_000, ); send_transaction_from_instructions( &mut ctx.svm, - vec![invest_nvda_ix], - &[&ctx.payer, &ctx.manager], - &ctx.payer.pubkey(), + vec![i2], + &[&ctx.manager], + &ctx.manager.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 100_000 TSLAx (vault holds 160_000) → receive - // 25_000_000 USDC (100_000 * 250), then buy NVDAx with that USDC - // → 138_888 NVDAx (25_000_000 / 180, floor) - let sell_amount: u64 = 100_000; - let usdc_from_sell: u64 = sell_amount * 250; // 25_000_000 - let nvda_bought: u64 = usdc_from_sell / 180; // 138_888 - - let rebalance_ix = Instruction::new_with_bytes( + // Sell 100000 TSLAx -> 25 USDC, buy NVDAx with 25 USDC -> 138888. + let 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, + sell_amount: 100_000, + usdc_to_invest: 25_000_000, } .data(), vault_strategy::accounts::RebalanceAccountConstraints { @@ -998,6 +864,10 @@ fn test_rebalance() { usdc_mint: ctx.usdc_mint, sell_mint: ctx.tsla_mint, buy_mint: ctx.nvda_mint, + sell_config: ctx.asset_config(0), + buy_config: ctx.asset_config(1), + sell_price_feed: ctx.price_feed_tsla, + buy_price_feed: ctx.price_feed_nvda, vault_sell: ctx.vault_tsla, vault_buy: ctx.vault_nvda, vault_usdc: ctx.vault_usdc, @@ -1013,359 +883,229 @@ fn test_rebalance() { } .to_account_metas(None), ); - send_transaction_from_instructions( &mut ctx.svm, - vec![rebalance_ix], - &[&ctx.payer, &ctx.manager], - &ctx.payer.pubkey(), + vec![ix], + &[&ctx.manager], + &ctx.manager.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" - ); -} - -fn assert_transaction_fails_with( - result: Result<(), solana_kite::SolanaKiteError>, - expected_error_name: &str, -) { - let error = result.expect_err("transaction should fail"); - let error_text = format!("{error:?}"); - assert!( - error_text.contains(expected_error_name), - "expected failure with {expected_error_name}, got: {error_text}" + get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), + tsla_before - 100_000 ); + assert!(get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap() > nvda_before); } #[test] -fn test_initialize_rejects_excessive_fee() { +fn test_collect_fees() { let mut ctx = setup_full(); + standard_strategy(&mut ctx); - let excessive_fee_bps = vault_strategy::MAX_FEE_BPS + 1; - let init_strategy_ix = - build_initialize_strategy_instruction(&ctx, excessive_fee_bps, ctx.router_program_id); - let result = send_transaction_from_instructions( - &mut ctx.svm, - vec![init_strategy_ix], - &[&ctx.payer, &ctx.manager], - &ctx.payer.pubkey(), - ); - assert_transaction_fails_with(result, "FeeTooHigh"); + let user = fund_user(&mut ctx, 1_000_000_000); // 1000 USDC + do_deposit(&mut ctx, &user, 1_000_000_000, 1); - assert!( - ctx.svm.get_account(&ctx.strategy_pda).is_none(), - "Strategy PDA must not be created when fee_bps exceeds MAX_FEE_BPS" - ); -} - -#[test] -fn test_deposit_rejects_wrong_usdc_mint() { - let mut ctx = setup_full(); - initialize_strategy(&mut ctx); - - // A real but unregistered mint: its strategy-owned vault is empty, so - // accepting it would understate NAV and mint inflated shares. - let junk_mint = create_token_mint(&mut ctx.svm, &ctx.payer, TOKEN_DECIMALS, None).unwrap(); - let junk_vault = - create_associated_token_account(&mut ctx.svm, &ctx.strategy_pda, &junk_mint, &ctx.payer) - .unwrap(); - - let user = create_wallet(&mut ctx.svm, 10_000_000_000).unwrap(); - let deposit_amount: u64 = 1_000_000; - let user_junk = - create_associated_token_account(&mut ctx.svm, &user.pubkey(), &junk_mint, &ctx.payer) - .unwrap(); - mint_tokens_to_token_account( - &mut ctx.svm, - &junk_mint, - &user_junk, - deposit_amount, - &ctx.payer, - ) - .unwrap(); - let user_share = derive_ata(&user.pubkey(), &ctx.share_mint_pda); + // Advance a full year. + let clock = ctx.svm.get_sysvar::(); + ctx.svm.set_sysvar(&Clock { + slot: clock.slot + 1_000_000, + epoch_start_timestamp: clock.epoch_start_timestamp, + epoch: clock.epoch, + leader_schedule_epoch: clock.leader_schedule_epoch, + unix_timestamp: PUBLISH_TIME + SECONDS_PER_YEAR, + }); - let deposit_ix = Instruction::new_with_bytes( + let manager_share = derive_ata(&ctx.manager.pubkey(), &ctx.share_mint_pda); + let ix = Instruction::new_with_bytes( ctx.vault_program_id, - &vault_strategy::instruction::Deposit { - usdc_amount: deposit_amount, - minimum_shares: 0, - } - .data(), - vault_strategy::accounts::DepositAccountConstraints { - depositor: user.pubkey(), + &vault_strategy::instruction::CollectFees {}.data(), + vault_strategy::accounts::CollectFeesAccountConstraints { + manager: ctx.manager.pubkey(), strategy: ctx.strategy_pda, share_mint: ctx.share_mint_pda, - usdc_mint: junk_mint, - asset_mint_a: ctx.tsla_mint, - asset_mint_b: ctx.nvda_mint, - depositor_usdc_account: user_junk, - depositor_share_account: user_share, - vault_usdc: junk_vault, - 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, + 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![ix], &[&ctx.payer], &ctx.payer.pubkey()) + .unwrap(); - let result = send_transaction_from_instructions( - &mut ctx.svm, - vec![deposit_ix], - &[&ctx.payer, &user], - &ctx.payer.pubkey(), + // 1% of 1,000,000,000 = 10,000,000 fee shares. + assert_eq!( + get_token_account_balance(&ctx.svm, &manager_share).unwrap(), + 10_000_000 ); - assert_transaction_fails_with(result, "InvalidUsdcMint"); } -#[test] -fn test_deposit_rejects_wrong_asset_mint() { - let mut ctx = setup_full(); - initialize_strategy(&mut ctx); - - // An unregistered mint passed as asset_mint_a: its empty strategy-owned - // vault would hide the real TSLAx holdings from the NAV calculation. - let junk_mint = create_token_mint(&mut ctx.svm, &ctx.payer, TOKEN_DECIMALS, None).unwrap(); - let junk_vault = - create_associated_token_account(&mut ctx.svm, &ctx.strategy_pda, &junk_mint, &ctx.payer) - .unwrap(); - - let user = create_wallet(&mut ctx.svm, 10_000_000_000).unwrap(); - let deposit_amount: u64 = 1_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 = 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: deposit_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: junk_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: junk_vault, - 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), - ); - - let result = send_transaction_from_instructions( - &mut ctx.svm, - vec![deposit_ix], - &[&ctx.payer, &user], - &ctx.payer.pubkey(), - ); - assert_transaction_fails_with(result, "InvalidAssetMint"); - - // The deposit must not have moved funds or minted shares - let vault_usdc_balance = get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(); - assert_eq!(vault_usdc_balance, 0, "Vault USDC must be untouched"); +fn withdraw_remaining(ctx: &TestContext, user: &Pubkey) -> Vec { + vec![ + AccountMeta::new_readonly(ctx.asset_config(0), false), + AccountMeta::new(ctx.vault_tsla, false), + AccountMeta::new_readonly(ctx.tsla_mint, false), + AccountMeta::new(derive_ata(user, &ctx.tsla_mint), false), + AccountMeta::new_readonly(ctx.asset_config(1), false), + AccountMeta::new(ctx.vault_nvda, false), + AccountMeta::new_readonly(ctx.nvda_mint, false), + AccountMeta::new(derive_ata(user, &ctx.nvda_mint), false), + ] } #[test] -fn test_withdraw_rejects_wrong_asset_mint() { +fn test_withdraw() { let mut ctx = setup_full(); - initialize_strategy(&mut ctx); + standard_strategy(&mut ctx); - // Deposit normally so the user holds shares - 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( + let user = fund_user(&mut ctx, 10_000_000); + let user_share = do_deposit(&mut ctx, &user, 10_000_000, 1); + let shares = get_token_account_balance(&ctx.svm, &user_share).unwrap(); + + // Manager invests 4 USDC into TSLAx so the vault holds a mix. + let ix = invest_ix( + &ctx, + ctx.tsla_mint, + ctx.asset_config(0), + ctx.price_feed_tsla, + ctx.vault_tsla, + ctx.tsla_rate_pda, + 4_000_000, + ); + send_transaction_from_instructions( &mut ctx.svm, - &ctx.usdc_mint, - &user_usdc, - deposit_amount, - &ctx.payer, + vec![ix], + &[&ctx.manager], + &ctx.manager.pubkey(), ) .unwrap(); - let user_share = do_deposit(&mut ctx, &user, deposit_amount); - // An unregistered mint passed as asset_mint_a on withdraw: the empty junk - // vault would replace the real TSLAx vault in the proportional payout. - let junk_mint = create_token_mint(&mut ctx.svm, &ctx.payer, TOKEN_DECIMALS, None).unwrap(); - let junk_vault = - create_associated_token_account(&mut ctx.svm, &ctx.strategy_pda, &junk_mint, &ctx.payer) - .unwrap(); - let user_junk = derive_ata(&user.pubkey(), &junk_mint); - let user_nvda = derive_ata(&user.pubkey(), &ctx.nvda_mint); + // User needs token accounts for each asset paid in kind. + let user_usdc = derive_ata(&user.pubkey(), &ctx.usdc_mint); + create_associated_token_account(&mut ctx.svm, &user.pubkey(), &ctx.tsla_mint, &ctx.payer) + .unwrap(); + create_associated_token_account(&mut ctx.svm, &user.pubkey(), &ctx.nvda_mint, &ctx.payer) + .unwrap(); - let withdraw_ix = Instruction::new_with_bytes( + let mut metas = vault_strategy::accounts::WithdrawAccountConstraints { + user: user.pubkey(), + strategy: ctx.strategy_pda, + share_mint: ctx.share_mint_pda, + usdc_mint: ctx.usdc_mint, + user_share_account: user_share, + user_usdc_account: user_usdc, + vault_usdc: ctx.vault_usdc, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None); + metas.extend(withdraw_remaining(&ctx, &user.pubkey())); + + let ix = Instruction::new_with_bytes( ctx.vault_program_id, &vault_strategy::instruction::Withdraw { - shares_to_burn: deposit_amount, + 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: junk_mint, - asset_mint_b: ctx.nvda_mint, - user_share_account: user_share, - user_usdc_account: user_usdc, - user_asset_a_account: user_junk, - user_asset_b_account: user_nvda, - vault_usdc: ctx.vault_usdc, - vault_asset_a: junk_vault, - 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), + metas, ); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()).unwrap(); - let result = send_transaction_from_instructions( - &mut ctx.svm, - vec![withdraw_ix], - &[&ctx.payer, &user], - &ctx.payer.pubkey(), + // Sole holder withdraws everything: 6 USDC + all 16000 TSLAx back. + assert_eq!( + get_token_account_balance(&ctx.svm, &user_usdc).unwrap(), + 6_000_000 ); - assert_transaction_fails_with(result, "InvalidAssetMint"); - - // Shares must not have been burned and the vault must still hold the USDC - let shares_after = get_token_account_balance(&ctx.svm, &user_share).unwrap(); - assert_eq!(shares_after, deposit_amount, "Shares must be untouched"); - let vault_usdc_balance = get_token_account_balance(&ctx.svm, &ctx.vault_usdc).unwrap(); assert_eq!( - vault_usdc_balance, deposit_amount, - "Vault USDC must be untouched" + get_token_account_balance(&ctx.svm, &derive_ata(&user.pubkey(), &ctx.tsla_mint)).unwrap(), + 16_000 ); } #[test] -fn test_invest_rejects_unregistered_router() { +fn test_withdraw_rejects_slippage() { let mut ctx = setup_full(); + standard_strategy(&mut ctx); + + let user = fund_user(&mut ctx, 10_000_000); + let user_share = do_deposit(&mut ctx, &user, 10_000_000, 1); + let shares = get_token_account_balance(&ctx.svm, &user_share).unwrap(); + + let user_usdc = derive_ata(&user.pubkey(), &ctx.usdc_mint); + create_associated_token_account(&mut ctx.svm, &user.pubkey(), &ctx.tsla_mint, &ctx.payer) + .unwrap(); + create_associated_token_account(&mut ctx.svm, &user.pubkey(), &ctx.nvda_mint, &ctx.payer) + .unwrap(); - // Strategy registers a router that is NOT the deployed mock-swap-router - let registered_router = Pubkey::new_unique(); - initialize_strategy_with_router(&mut ctx, registered_router); + let mut metas = vault_strategy::accounts::WithdrawAccountConstraints { + user: user.pubkey(), + strategy: ctx.strategy_pda, + share_mint: ctx.share_mint_pda, + usdc_mint: ctx.usdc_mint, + user_share_account: user_share, + user_usdc_account: user_usdc, + vault_usdc: ctx.vault_usdc, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None); + metas.extend(withdraw_remaining(&ctx, &user.pubkey())); - let invest_ix = Instruction::new_with_bytes( + let ix = Instruction::new_with_bytes( ctx.vault_program_id, - &vault_strategy::instruction::Invest { - usdc_amount: 1_000_000, - minimum_asset_out: 0, + &vault_strategy::instruction::Withdraw { + shares_to_burn: shares, + min_usdc_out: 10_000_001, // more than available } .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), - ); - - let result = send_transaction_from_instructions( - &mut ctx.svm, - vec![invest_ix], - &[&ctx.payer, &ctx.manager], - &ctx.payer.pubkey(), + metas, ); - assert_transaction_fails_with(result, "InvalidSwapRouter"); + let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); + assert!(r.is_err(), "min_usdc_out above payout must revert"); } #[test] -fn test_rebalance_rejects_unregistered_router() { +fn test_deposit_rejects_incomplete_assets() { let mut ctx = setup_full(); + standard_strategy(&mut ctx); - let registered_router = Pubkey::new_unique(); - initialize_strategy_with_router(&mut ctx, registered_router); + let amount = 1_000_000u64; + let user = fund_user(&mut ctx, amount); + let user_usdc = derive_ata(&user.pubkey(), &ctx.usdc_mint); + let user_share = derive_ata(&user.pubkey(), &ctx.share_mint_pda); - let rebalance_ix = Instruction::new_with_bytes( + // Only one asset's accounts supplied (3) for a two-asset strategy (needs 6). + let mut metas = vault_strategy::accounts::DepositAccountConstraints { + depositor: user.pubkey(), + strategy: ctx.strategy_pda, + share_mint: ctx.share_mint_pda, + usdc_mint: ctx.usdc_mint, + depositor_usdc_account: user_usdc, + depositor_share_account: user_share, + vault_usdc: ctx.vault_usdc, + associated_token_program: ata_program_id(), + token_program: token_program_id(), + system_program: system_program::id(), + } + .to_account_metas(None); + metas.push(AccountMeta::new_readonly(ctx.asset_config(0), false)); + metas.push(AccountMeta::new_readonly(ctx.vault_tsla, false)); + metas.push(AccountMeta::new_readonly(ctx.price_feed_tsla, false)); + + let ix = Instruction::new_with_bytes( ctx.vault_program_id, - &vault_strategy::instruction::Rebalance { - sell_amount: 1, - minimum_usdc_from_sell: 0, - usdc_to_invest: 0, - minimum_buy_amount: 0, + &vault_strategy::instruction::Deposit { + usdc_amount: amount, + minimum_shares: 1, } .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), - ); - - let result = send_transaction_from_instructions( - &mut ctx.svm, - vec![rebalance_ix], - &[&ctx.payer, &ctx.manager], - &ctx.payer.pubkey(), + metas, ); - assert_transaction_fails_with(result, "InvalidSwapRouter"); + let r = send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey()); + assert!(r.is_err(), "incomplete asset accounts must revert"); } From 1cce9e6a3c7a1c49f650aeb3513ade1275e8a8bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 00:26:03 +0000 Subject: [PATCH 8/8] Rewrite vault-strategy script for registry, dynamic assets, oracle slippage Add Victor (registry authority) and the whitelist/add_asset steps, describe the oracle-computed slippage floor in invest/rebalance, correct the PDA wording (a PDA is an off-curve address with no private key, not a public key), and replace the stale two-asset and caller-slippage footnotes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01C5vHRAwvmnXhz8tzcq3xHX --- finance/vault-strategy/VIDEO_SCRIPT.md | 175 +++++++++++++++---------- 1 file changed, 109 insertions(+), 66 deletions(-) diff --git a/finance/vault-strategy/VIDEO_SCRIPT.md b/finance/vault-strategy/VIDEO_SCRIPT.md index 141faaba..c998b671 100644 --- a/finance/vault-strategy/VIDEO_SCRIPT.md +++ b/finance/vault-strategy/VIDEO_SCRIPT.md @@ -1,6 +1,6 @@ # Vault Strategy: a walkthrough -A video script for the `vault-strategy` example. Target runtime is roughly eight minutes at a normal speaking pace. Narration lines are what the presenter says; the indented blocks are what is on screen as a running ledger of onchain state. +A video script for the `vault-strategy` example. Target runtime is roughly nine minutes at a normal speaking pace. Narration lines are what the presenter says; the indented blocks are what is on screen as a running ledger of onchain state. Prices for TSLAx and NVDAx in this script are illustrative and match the rates the example's tests configure. They are not live quotes. USDC (US dollars), TSLAx (Tesla stock) and NVDAx (NVIDIA stock) are real assets; the swap behind the scenes is a deterministic test stand-in, which we will be honest about when we reach it. @@ -10,7 +10,7 @@ NARRATION: Let's build a vault strategy: the onchain equivalent of a mutual fund, or an actively managed ETF. You deposit cash with a manager, you receive shares, the manager invests across several assets and rebalances them over time, and your shares are priced at net asset value: the worth of everything the strategy holds, divided by the shares outstanding. The word net is a finance convention for value after subtracting what a fund owes; this strategy borrows nothing, so its net asset value is simply its holdings. For running the book, the manager earns a fee. -By the end you will have watched someone deposit, the manager invest and rebalance, a fee accrue, and someone redeem, and you will know which instruction handler does each one. The program controls every dollar the whole time: the manager invests the deposits but can never move them to herself, a limit we will pin down precisely. +By the end you will have watched an asset get approved, a strategy get built, someone deposit, the manager invest and rebalance, a fee accrue, and someone redeem, and you will know which instruction handler does each one. The program controls every dollar the whole time: the manager invests the deposits but can never move them to herself, a limit we will pin down precisely. You have seen this shape on Solana, in protocols like Symmetry and Kamino. This is the teaching-sized version. @@ -29,74 +29,108 @@ Custody is the whole game, so let us name the boxes before we move money. First, the word vault, because it gets overloaded. By the common standard a vault holds a single asset: you put one kind of token in, you get shares out. A managed mix of several assets is not one vault; it lives in several vaults, one per asset, and is usually called a basket or a fund. Symmetry calls its multi-asset products baskets. We will keep it simple: a vault is one single-asset token account, and the strategy is the whole construct that owns them. So vault strategy reads literally, a strategy built from vaults. -The center of everything is the `Strategy` account, a PDA derived from the seeds `"strategy"` plus Maria's public key. It is the authority over four accounts: a USDC vault, a TSLAx vault, an NVDAx vault, and the share mint. Each vault is an associated token account owned by the strategy PDA, and holds exactly one asset. The share mint is also created at a PDA, seeds `"share_mint"` plus the strategy address. A PDA is just a public key with no private key behind it, derived from seeds so the program can find it and sign for it; the mint's address is therefore deterministic, one share mint per strategy, and the strategy PDA is its mint and freeze authority. +The center of everything is the `Strategy` account, whose address is a PDA derived from the seeds `"strategy"` plus Maria's public key. A PDA is an address with no private key: it is found deliberately off the signing curve, so no key can sign for it and only the program can, by supplying the seeds. The strategy PDA is the authority over the USDC vault, every asset vault, and the share mint. Each vault is an associated token account owned by the strategy PDA and holds exactly one asset. The share mint's address is also a PDA, seeds `"share_mint"` plus the strategy address, so it is deterministic, one share mint per strategy, with the strategy PDA as its mint authority. -One structural limit to call out now: the program holds exactly two assets, A and B, plus USDC. Not three, not ten thousand. Supporting more would mean storing a list of mints, weights, and vaults and looping over them, which is a real code change, not a setting. +The asset set is not fixed. Each asset the strategy holds gets its own small account, an `AssetConfig`, whose address is a PDA seeded by the strategy and an index: zero, one, two, and so on. That indexing matters later: the assets are exactly the range zero up to the count, so any handler that values the whole strategy can re-derive every one and refuse to run if a single asset account is missing. -What that buys us: only the strategy PDA can sign to move tokens out of those vaults or to mint shares. Maria manages the strategy, but she cannot reach into the vaults with her own keypair. Her powers are exactly three instruction handlers, and we will see the limit on each. +One account sits outside any single strategy: a `Registry`, a curated whitelist of assets that strategies are allowed to hold. We will meet its keeper first. ON SCREEN: ``` +Registry [off curve - PDA, seeds: "registry" + authority] owner = curator, not a manager Strategy [off curve - PDA, seeds: "strategy" + manager] - authority over: vault_usdc, vault_asset_a, vault_asset_b, share_mint + authority over: vault_usdc, every asset vault, share_mint share_mint [off curve - PDA, seeds: "share_mint" + strategy] authority = Strategy PDA -vault_usdc / _a / _b [off curve - ATAs, one asset each] authority = Strategy PDA +AssetConfig #i [off curve - PDA, seeds: "asset" + strategy + index] one per asset +vault_usdc / per-asset vaults [off curve - ATAs, one asset each] authority = Strategy PDA ``` -## Maria opens the strategy +## Victor approves the assets NARRATION: -Maria is the strategy's manager, and she wants to run it and earn the fee. She calls `initialize_strategy`. She sets two target weights, one per asset: forty percent to TSLAx and sixty percent to NVDAx. Weights are written in basis points, so that is 4000 and 6000, and the program requires they sum to 10000, which is one hundred percent. A weight is a target share of the strategy's value, not a token balance and not a count of assets. +Meet Victor. Victor is not a fund manager; he runs the registry, the list of assets any strategy is allowed to hold. His motive is reputational: he is the gatekeeper who vets that an asset is real and has a trustworthy price feed. He calls `initialize_registry` once, then `whitelist_asset` for each approved token, and here is the important part, each whitelist entry binds the mint to its official Pyth price feed. + +Why a separate person at all? Because this is the line that stops fraud. If a manager could add any token to her own strategy, she could mint a worthless token herself, list it, and value it at whatever she liked. And even with a real token, if she could choose its price feed she could point at one she controls. Victor's registry removes both moves: a manager can only ever pick from assets Victor approved, and the price feed comes from Victor's entry, never from the manager. -One honest detail up front, and it is the crux of how much you must trust Maria: those weights are a target she maintains by hand. The program stores them but no handler reads them to force an allocation. Deposits arrive as USDC and sit in the USDC vault until Maria chooses to invest. The 40/60 split is a promise she keeps with `invest` and `rebalance`, not a rule the bytecode enforces on each deposit. +ON SCREEN: + +``` +ADDED - Registry [off curve - PDA] authority: Victor + +ADDED - WhitelistEntry (TSLAx) [seeds: "whitelist" + registry + TSLAx mint] + mint: TSLAx price_feed: +ADDED - WhitelistEntry (NVDAx) [seeds: "whitelist" + registry + NVDAx mint] + mint: NVDAx price_feed: + +TOKEN MOVEMENT: none - approvals only +Fee generated: none +``` -So let us pin down exactly what Maria can and cannot do. She can do three things, all visible in this walkthrough: invest USDC into one of the two registered assets, rebalance from one registered asset into the other, and collect her fee. Every one of those is fenced: +## Maria opens the strategy -- She can only trade the two asset mints registered at creation, and only through the one swap router registered at creation. The program checks the router's address on every call, so she cannot route through a different one later. -- Each swap carries a minimum-out that the transaction specifies, so a bad fill reverts instead of quietly bleeding a vault. -- Her fee is set once at creation, capped at ten percent a year, and paid only in newly minted shares. There is no setter to raise it later. -- There is no instruction anywhere that sends a vault's tokens to the manager. She can direct the assets; she cannot withdraw them. +NARRATION: -What is left to trust, honestly, is the router Maria registered and that she sets sane slippage. With an honest router, the worst a careless manager can do is churn and pay market slippage, which hurts depositors but does not enrich her. She cannot abscond with the principal. That is the same trust boundary as a delegate-managed vault, where one manager trades pooled funds but can never pull them out. +Maria is our portfolio manager, and she wants to run the basket and earn the fee. She calls `initialize_strategy`, binding her strategy to Victor's registry. She sets two numbers and no assets yet: a fee of one hundred basis points, which is one percent a year, and a maximum slippage of one hundred basis points, which we will use when she trades. Both are capped in code, the fee at ten percent and the slippage tolerance at ten percent, and both are fixed here at creation with no setter to change them later. -The fee cap lives in code. `initialize_strategy` rejects any fee above `MAX_FEE_BPS`, ten percent, because the fee is paid by minting new shares, and an uncapped fee would let a manager dilute depositors to nothing by configuration alone. +The fee cap exists because the fee is paid by minting new shares to the manager; an uncapped fee would let a manager dilute depositors to nothing by configuration alone. ON SCREEN: ``` ADDED - Strategy [off curve - PDA] - manager: Maria weight_bps_a: 4000 (TSLAx) weight_bps_b: 6000 (NVDAx) - fee_bps: 100 total_shares: 0 last_fee_accrual_timestamp: now + manager: Maria registry: Victor's registry + fee_bps: 100 max_slippage_bps: 100 total_shares: 0 + asset_count: 0 total_weight_bps: 0 last_fee_accrual_timestamp: now -ADDED - share_mint, vault_usdc, vault_asset_a, vault_asset_b (all empty) +ADDED - share_mint, vault_usdc (empty) TOKEN MOVEMENT: none - setup only Fee generated: none ``` +## Maria adds the two assets + +NARRATION: + +Now Maria builds the basket with `add_asset`, once per asset. Each call names a mint and a target weight, and the program checks that mint against Victor's registry, copies the official price feed from the whitelist entry, creates the asset's vault, and records it at the next index. TSLAx goes in first at index zero with a forty percent weight; NVDAx at index one with sixty percent. The weights are written in basis points and the program keeps their running sum at or below ten thousand, so four thousand plus six thousand is exactly full. + +Two honest notes. The weight is a target Maria maintains by hand with invest and rebalance; the program records it but does not force an allocation on deposit. And if Maria names a token Victor never whitelisted, there is simply no whitelist entry to read, and the call fails. + +ON SCREEN: + +``` +ADDED - AssetConfig #0 [off curve - PDA, seeds: "asset" + strategy + 0] + mint: TSLAx price_feed: weight_bps: 4000 vault: vault_tsla +ADDED - vault_tsla (empty) + +ADDED - AssetConfig #1 [off curve - PDA, seeds: "asset" + strategy + 1] + mint: NVDAx price_feed: weight_bps: 6000 vault: vault_nvda +ADDED - vault_nvda (empty) + +UPDATED - Strategy asset_count: 0 -> 2 total_weight_bps: 0 -> 10000 + +TOKEN MOVEMENT: none - the vaults start empty +Fee generated: none +``` + ## Alice deposits 900 USDC NARRATION: -Alice wants exposure to both stocks without buying and rebalancing them herself, so she calls `deposit` with 900 USDC. `deposit` is permissionless: any user can call it, not just the manager. This is buying into the strategy. +Alice wants exposure to both stocks without buying and rebalancing them herself, so she calls `deposit` with 900 USDC. `deposit` is permissionless: any user can call it. This is buying into the strategy. -The handler prices her shares against net asset value, the total worth of the strategy. It reads both Pyth feeds straight from the raw account bytes at fixed offsets, checks each price is positive and no more than sixty seconds stale, and computes net asset value as the USDC vault balance plus each asset vault balance times its price. The strategy is empty, so net asset value is zero, and the first deposit is defined as one to one. Alice gets 900 shares. Shares carry six decimals, so under the hood that is 900 million minor units, but think of it as 900 shares worth a dollar each. +The handler prices her shares against net asset value. It walks the complete asset set, index zero then index one, reading each vault's balance and each Pyth price, and it will not proceed unless every asset's accounts are present, so nothing can be hidden from the valuation. The strategy is empty, so net asset value is zero, and the first deposit is defined as one to one. Alice gets 900 shares. Shares carry six decimals, so under the hood that is 900 million minor units, but think of it as 900 shares worth a dollar each. Checks, effects, interactions: the handler raises `total_shares` first, then pulls her USDC into the USDC vault, then mints her the shares with the strategy PDA signing. ON SCREEN: ``` -UPDATED - Strategy - total_shares: 0 -> 900,000,000 - -UPDATED - vault_usdc [authority = Strategy PDA] - balance: 0 -> 900 USDC - -UPDATED - Alice share ATA - balance: 0 -> 900 shares +UPDATED - Strategy total_shares: 0 -> 900,000,000 +UPDATED - vault_usdc 0 -> 900 USDC +UPDATED - Alice share ATA 0 -> 900 shares TOKEN MOVEMENT: Alice USDC ATA -> vault_usdc 900 USDC (deposit) @@ -109,25 +143,24 @@ Fee generated: none - deposits do not accrue fees NARRATION: -Now Maria earns her title. She calls `invest` twice. `invest` is manager-only; the account constraints require her signature via `has_one = manager`. +Now Maria earns her title. She calls `invest` twice, manager-only. It hands the swap to the registered router, which for this example is a deterministic mock: at a fixed rate it mints the asset into the matching vault and takes the USDC. First, 360 dollars into TSLAx at 250 dollars a share, so the TSLAx vault receives 1.44 TSLAx. Then 540 dollars into NVDAx at 180 dollars a share, so the NVDAx vault receives exactly 3 NVDAx. That is the 40/60 split, by hand. -`invest` does not hold a price of its own. It makes a cross-program call into the swap router, which for this example is a deterministic mock: at a fixed rate it mints the asset into the matching vault and takes the USDC. First, 360 dollars into TSLAx at 250 dollars a share, so the TSLAx vault receives 1.44 TSLAx. Then 540 dollars into NVDAx at 180 dollars a share, so the NVDAx vault receives exactly 3 NVDAx. That is the 40/60 split, by hand. - -The strategy PDA signs both swaps, because the USDC is leaving the USDC vault, which only the PDA controls. +The slippage guard is the part worth watching. Maria does not get to hand in a minimum, the program computes one. It reads the asset's Pyth price, works out how much the swap should return, and refuses anything more than her one percent tolerance below that. A bad or manipulated quote reverts instead of quietly draining the vault. The strategy PDA signs the swap, because the USDC leaves a vault only it controls. ON SCREEN: ``` UPDATED - vault_usdc 540 USDC -> 0 USDC (across both invests) -UPDATED - vault_asset_a 0 -> 1.44 TSLAx -UPDATED - vault_asset_b 0 -> 3.0 NVDAx +UPDATED - vault_tsla 0 -> 1.44 TSLAx +UPDATED - vault_nvda 0 -> 3.0 NVDAx TOKEN MOVEMENT (invest #1): vault_usdc -> router treasury 360 USDC - router -> vault_asset_a 1.44 TSLAx (router mints; 360 / 250) + router -> vault_tsla 1.44 TSLAx (router mints; 360 / 250) + minimum out: computed from Pyth (>= 1.4256 TSLAx at 1% tolerance), not supplied by Maria TOKEN MOVEMENT (invest #2): vault_usdc -> router treasury 540 USDC - router -> vault_asset_b 3.0 NVDAx (router mints; 540 / 180) + router -> vault_nvda 3.0 NVDAx (router mints; 540 / 180) Net asset value now: 0 + 1.44 x 250 + 3.0 x 180 = 360 + 540 = 900 USDC Fee generated: none @@ -146,11 +179,9 @@ ON SCREEN: ``` Net asset value before Bob: 0 + 1.44 x 250 + 3.0 x 200 = 360 + 600 = 960 USDC -UPDATED - Strategy - total_shares: 900,000,000 -> 1,350,000,000 - -UPDATED - vault_usdc 0 -> 480 USDC -UPDATED - Bob share ATA 0 -> 450 shares +UPDATED - Strategy total_shares: 900,000,000 -> 1,350,000,000 +UPDATED - vault_usdc 0 -> 480 USDC +UPDATED - Bob share ATA 0 -> 450 shares TOKEN MOVEMENT: Bob USDC ATA -> vault_usdc 480 USDC @@ -163,20 +194,21 @@ Fee generated: none NARRATION: -NVIDIA's run pushed the holdings away from 40/60, so Maria calls `rebalance`. One handler, two swaps, both signed by the strategy PDA: it sells one asset for USDC, then spends that USDC on the other. +NVIDIA's run pushed the holdings away from 40/60, so Maria calls `rebalance`. One handler, two swaps, both signed by the strategy PDA: it sells one asset for USDC, then spends that USDC on the other. Each leg carries the same oracle-computed floor as invest, so neither can be filled at a bad price. -She sells 0.36 TSLAx, receiving 90 dollars, then buys 0.5 NVDAx with that same 90 dollars. Both legs name a minimum out, so a bad rate would revert rather than silently lose value. The USDC vault nets to zero change across the two legs; the strategy just shifts weight from Tesla into NVIDIA. +She sells 0.36 TSLAx, receiving 90 dollars, then buys 0.5 NVDAx with that same 90 dollars. The USDC vault nets to zero change across the two legs; the strategy just shifts weight from Tesla into NVIDIA. ON SCREEN: ``` -UPDATED - vault_asset_a 1.44 TSLAx -> 1.08 TSLAx (sold 0.36) -UPDATED - vault_asset_b 3.0 NVDAx -> 3.5 NVDAx (bought 0.5) -UPDATED - vault_usdc 480 USDC -> 480 USDC (+90 then -90) +UPDATED - vault_tsla 1.44 TSLAx -> 1.08 TSLAx (sold 0.36) +UPDATED - vault_nvda 3.0 NVDAx -> 3.5 NVDAx (bought 0.5) +UPDATED - vault_usdc 480 USDC -> 480 USDC (+90 then -90) TOKEN MOVEMENT: - sell leg: vault_asset_a -> router (burned) 0.36 TSLAx; router treasury -> vault_usdc 90 USDC - buy leg: vault_usdc -> router treasury 90 USDC; router -> vault_asset_b 0.5 NVDAx + sell leg: vault_tsla -> router (burned) 0.36 TSLAx; router treasury -> vault_usdc 90 USDC + buy leg: vault_usdc -> router treasury 90 USDC; router -> vault_nvda 0.5 NVDAx + both legs: minimum out computed from each asset's Pyth price Net asset value: 480 + 1.08 x 250 + 3.5 x 200 = 480 + 270 + 700 = 1,450 USDC Fee generated: none - rebalance moves assets, it does not charge a fee @@ -188,7 +220,7 @@ NARRATION: Maria calls `collect_fees`. This is a streaming management fee, and the mechanism is worth dwelling on, because it is the opposite of the offchain world. A traditional fund deducts its expense ratio from fund assets, selling holdings to pay the manager in cash, which lowers net asset value. This program touches no vault at all. It mints new shares to the manager, proportional to time elapsed and the fee rate. Over a full year at one percent, that is one percent of the share supply, 13.5 shares, minted to Maria. -Same economics, different lever. New shares with no new assets behind them make every existing share a slightly thinner slice, so the dilution, spread across all holders, is how Alice and Bob pay the fee. Minting fee shares is the common onchain pattern: Yearn and Lido both charge their fees this way rather than skimming assets. And it is honest to say there is no performance fee here, only this management fee on assets under management, bounded by the cap from creation. +Same economics, different lever. New shares with no new assets behind them make every existing share a slightly thinner slice, so the dilution, spread across all holders, is how Alice and Bob pay the fee. Minting fee shares is the common onchain pattern: Yearn and Lido both charge their fees this way rather than skimming assets. And there is no performance fee here, only this management fee on assets under management, bounded by the cap from creation. ON SCREEN: @@ -197,11 +229,9 @@ elapsed: 1 year (illustrative) fee_shares = total_shares x fee_bps x elapsed / (10,000 x seconds_per_year) = 1,350,000,000 x 100 x 1yr / (10,000 x 1yr) = 13,500,000 (13.5 shares) -UPDATED - Strategy - total_shares: 1,350,000,000 -> 1,363,500,000 - last_fee_accrual_timestamp: updated - -UPDATED - Maria share ATA 0 -> 13.5 shares +UPDATED - Strategy total_shares: 1,350,000,000 -> 1,363,500,000 + last_fee_accrual_timestamp: updated +UPDATED - Maria share ATA 0 -> 13.5 shares TOKEN MOVEMENT: share_mint -> Maria share ATA 13.5 shares (minted, Strategy PDA signs) @@ -212,7 +242,7 @@ Fee generated: 13.5 shares to the manager; all other holders diluted ~1% NARRATION: -Alice calls `withdraw` and burns all 900 of her shares. Here is the part people miss: withdrawal is in kind and proportional. She does not get cash. She gets her exact fraction of every balance the strategy holds, across all three vaults: USDC, TSLAx, and NVDAx alike. It is the same move an ETF makes when it redeems in kind, handing back the underlying holdings instead of cash. +Alice calls `withdraw` and burns all 900 of her shares. Here is the part people miss: withdrawal is in kind and proportional. She does not get cash. She gets her exact fraction of every balance the strategy holds, across the USDC vault and both asset vaults. It is the same move an ETF makes when it redeems in kind, handing back the underlying holdings instead of cash. Just like deposit, the handler insists on seeing every asset, so her slice is computed against the whole strategy. Her fraction is 900 shares out of the 1,363.5 that now exist. The handler floors each amount in the protocol's favor, so any rounding dust stays with the remaining holders. @@ -222,36 +252,49 @@ ON SCREEN: Alice fraction = 900,000,000 / 1,363,500,000 amount_usdc = 480,000,000 x 900,000,000 / 1,363,500,000 = 316,831,683 (316.83 USDC, floor) -amount_a = 1,080,000 x 900,000,000 / 1,363,500,000 = 712,871 (0.712871 TSLAx, floor) -amount_b = 3,500,000 x 900,000,000 / 1,363,500,000 = 2,310,231 (2.310231 NVDAx, floor) +amount_tsla = 1,080,000 x 900,000,000 / 1,363,500,000 = 712,871 (0.712871 TSLAx, floor) +amount_nvda = 3,500,000 x 900,000,000 / 1,363,500,000 = 2,310,231 (2.310231 NVDAx, floor) UPDATED - Strategy total_shares: 1,363,500,000 -> 463,500,000 UPDATED - Alice share ATA 900 shares -> 0 (burned) TOKEN MOVEMENT: share_mint burns 900 shares from Alice - vault_usdc -> Alice 316.83 USDC - vault_asset_a -> Alice 0.712871 TSLAx - vault_asset_b -> Alice 2.310231 NVDAx + vault_usdc -> Alice 316.83 USDC + vault_tsla -> Alice 0.712871 TSLAx + vault_nvda -> Alice 2.310231 NVDAx Alice payout value @ 250 / 200 = 316.83 + 0.712871 x 250 + 2.310231 x 200 = about 957.10 USDC Fee generated: none - withdrawals do not accrue fees ``` +## What restricts Maria + +NARRATION: + +We promised to pin down what the manager can and cannot do, so here it is, now that you have seen each power in action. Maria can add assets, invest, rebalance, and collect her fee. Every one is fenced: + +- She can only add assets Victor whitelisted, and each asset's price feed is copied from Victor's registry, not chosen by her. +- Her swaps go only through the one router registered at creation, and every swap's floor is computed from the oracle, not supplied by her. +- Her fee is fixed at creation, capped at ten percent, and paid only in newly minted shares. +- No instruction anywhere sends a vault's tokens to the manager. She directs the assets; she cannot withdraw them. + +What is left to trust, honestly, is the router and registry the strategy was pointed at. With an honest router the worst a careless manager can do is churn and pay market slippage, which hurts depositors but does not enrich her. She cannot abscond with the principal. + ## Reconcile, and where everyone ended up NARRATION: Let us check the books. USDC into the USDC vault was 900 from Alice plus 480 from Bob, 1,380 total. The invests sent 900 to the router; rebalance was a wash. That leaves 480 in the USDC vault, and after Alice's withdrawal, 163.17 remains. Tokens in equal tokens out. -So: Alice came in with 900 dollars, rode NVIDIA up, paid her share of a one percent fee through dilution, and left with about 957 dollars of assets, in kind. The strategy passes returns through in both directions: had NVIDIA fallen instead of risen, the same arithmetic would have redeemed Alice for less than her 900 dollars. That market risk is hers, and the program neither cushions it nor hides it. Bob bought in fairly at the higher share price and still holds 450 shares worth roughly 478 dollars. Maria earned 13.5 shares, about 14 dollars, for running the book. The strategy held custody from the first deposit to the last withdrawal, the manager never touched the vaults with her own key, and the fee she could charge was capped in the bytecode. +So: Alice came in with 900 dollars, rode NVIDIA up, paid her share of a one percent fee through dilution, and left with about 957 dollars of assets, in kind. The strategy passes returns through in both directions: had NVIDIA fallen instead of risen, the same arithmetic would have redeemed Alice for less than her 900 dollars. That market risk is hers, and the program neither cushions it nor hides it. Bob bought in fairly at the higher share price and still holds 450 shares worth roughly 478 dollars. Maria earned 13.5 shares, about 14 dollars, for running the book. Victor's only role was the guest list. The strategy held custody from the first deposit to the last withdrawal, the manager never touched the vaults with her own key, and the fee she could charge was capped in the bytecode. ## Two honest footnotes NARRATION: -First, the swap router here is a deterministic test stand-in. It mints and burns at a fixed rate with no spread, and its rate matches the Pyth price, which keeps the math clean for teaching. A real deployment would call out to a live venue, and the strategy would only trust the one router address it registered at initialization. That registration is checked on every `invest` and `rebalance`. +First, the swap router here is a deterministic test stand-in. It mints and burns at a fixed rate with no spread, and its rate matches the Pyth price, which keeps the math clean for teaching. A real deployment would call out to a live venue, and the strategy would only trust the one router address it registered at creation. That registration is checked on every invest and rebalance. -Second, the 40/60 weights are a target Maria maintains, not an allocation the program enforces per deposit. Before real money, that is the thing to harden: enforce the weights in-program, timelock any change to the router, and bound per-swap slippage. Those are additions, not assumptions, and they would shrink what depositors must take on trust to almost nothing. +Second, two limits worth naming. The weights are a target Maria maintains, not an allocation the program enforces on each deposit; enforcing them in-program is a reasonable thing to add. And the basket is add-only in this version, you can grow it but not prune it, because the assets are addressed by a contiguous index and removing one would leave a gap. Both are clean extensions, not assumptions baked in. -That is the whole lifecycle: open, deposit, invest, price in new depositors fairly, rebalance, charge a bounded streaming fee, and redeem in kind. Thanks for watching. +That is the whole lifecycle: approve, open, add assets, deposit, invest, price in new depositors fairly, rebalance, charge a bounded streaming fee, and redeem in kind. Thanks for watching.