You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
vault-strategy: require full allocation before deposits, drop invest
A strategy now accepts deposits only once its target weights sum to exactly
10,000 bps. deposit reverts with StrategyNotFullyAllocated otherwise, so a
strategy is either still being configured or fully allocated and live, and
every accepted deposit is fully invested (bar sub-cent rounding dust). There
is no idle-cash mode. add_asset and set_weight keep the <= 10,000 cap, so the
manager can still pass through intermediate states while reconfiguring.
Remove invest. Once deposits fully deploy and rebalance covers every
asset-to-asset move (including liquidating a retired asset), a "deploy idle
USDC" handler has no honest job and contradicts the full-allocation invariant.
The router CPI stays; deposit and rebalance still use it.
Tests: add test_deposit_rejects_underallocated and the retire-then-reallocate
path in test_set_weight_retire; move router-mismatch coverage onto deposit;
drop the invest tests. 19 tests pass under cargo build-sbf + cargo test.
Update README and CHANGELOG.
Copy file name to clipboardExpand all lines: finance/vault-strategy/anchor/CHANGELOG.md
+5-5Lines changed: 5 additions & 5 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -6,16 +6,16 @@
6
6
7
7
-**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`.
8
8
-**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.
9
-
-**Oracle-bounded slippage.**`deposit`, `invest`, and `rebalance` compute each swap's minimum output from the Pyth price and a strategy-level `max_slippage_bps` (capped at `MAX_SLIPPAGE_BPS` = 10%), instead of trusting a caller-supplied minimum. Set at creation via `initialize_strategy`.
10
-
-**Immediate deployment on deposit.**`deposit` swaps each depositor's USDC into the basket at its target weights through the registered router in the same transaction. The USDC vault no longer holds idle deposits; only the unallocated remainder (when the weights sum below 10,000) stays as USDC.
11
-
-**Retirable assets.**`set_weight(weight_bps)` changes an asset's target weight after creation, including setting it to zero to retire it. The asset's index is preserved, so the `0..asset_count` range the valuation handlers depend on stays contiguous.
9
+
-**Oracle-bounded slippage.**`deposit` and `rebalance` compute each swap's minimum output from the Pyth price and a strategy-level `max_slippage_bps` (capped at `MAX_SLIPPAGE_BPS` = 10%), instead of trusting a caller-supplied minimum. Set at creation via `initialize_strategy`.
10
+
-**Full-allocation invariant with immediate deployment.**A strategy accepts deposits only once its weights sum to exactly 10,000 bps (`deposit`reverts with `StrategyNotFullyAllocated` otherwise). `deposit` then swaps each depositor's USDC into the basket at its target weights through the registered router in the same transaction, so every deposit is fully invested (bar sub-cent rounding dust) and the USDC vault holds no idle cash.
11
+
-**Retirable assets.**`set_weight(weight_bps)` changes an asset's target weight after creation, including setting it to zero to retire it (reassign that weight to another asset to reach 100% and reopen deposits; `rebalance` liquidates the retired holdings). The asset's index is preserved, so the `0..asset_count` range the valuation handlers depend on stays contiguous.
12
12
13
13
### Changed
14
14
15
15
-`initialize_strategy` now takes `(index, fee_bps, max_slippage_bps, swap_router)` and binds the strategy to a registry; the strategy PDA is seeded by a caller-chosen index (`["strategy", index]`) rather than the manager's key, with the manager kept as a stored field. Weights and price feeds move to `add_asset`.
16
-
-`deposit` takes each asset's `[asset_config, vault, mint, rate, price_feed]` plus the router accounts, validates the complete `0..asset_count` set for NAV, and deploys the deposit at the target weights.
16
+
-`deposit` takes each asset's `[asset_config, vault, mint, rate, price_feed]` plus the router accounts, validates the complete `0..asset_count` set for NAV, requires the strategy to be fully allocated, and deploys the deposit at the target weights.
17
17
-`withdraw` takes each asset's `[asset_config, vault, mint, user_token_account]` and pays out every asset in kind over the complete `0..asset_count` set.
18
-
-`invest` takes `(usdc_amount)` and `rebalance` takes `(sell_amount, usdc_to_invest)`; per-call minimums are gone.
18
+
-`rebalance` takes `(sell_amount, usdc_to_invest)`; per-call minimums are gone.
Each asset carries a target **weight** in basis points (e.g. 40% TSLAx, 60% NVDAx); the running sum is kept at or below 10,000. The weights are enforced where it matters most: `deposit`deploys each depositor's USDC straight into the basket at those weights, so money is never left sitting idle by default. When the weights sum below 10,000 (for example after an asset is retired), the unallocated slice stays in the USDC vault for the manager to place with `invest`.
50
+
Each asset carries a target **weight** in basis points (e.g. 40% TSLAx, 60% NVDAx). A strategy accepts deposits only once its weights sum to exactly 10,000 (`add_asset` and `set_weight` keep the running sum at or below 10,000; `deposit`requires it to equal 10,000, else `StrategyNotFullyAllocated`). So a strategy is either still being configured or fully allocated and live, and `deposit` deploys each depositor's USDC straight into the basket at those weights, fully invested bar sub-cent rounding dust. There is no idle-cash mode.
51
51
52
52
[Rebalancing](https://www.investopedia.com/terms/r/rebalancing.asp) handles the drift that prices create after a deposit: `rebalance` sells an over-weight asset for USDC and buys an under-weight one in a single atomic instruction. `set_weight` changes a target after creation, including setting it to zero to **retire** an asset: deposits stop allocating to it, the manager sells its holdings out with `rebalance`, and the now-empty vault keeps its index so the contiguous `0..asset_count` range stays intact (the index is never reused).
53
53
54
54
### Slippage, bounded by the oracle
55
55
56
-
[Slippage](https://www.investopedia.com/terms/s/slippage.asp) is the gap between the expected and the realized amount of a swap. Rather than trust a manager-supplied minimum, `deposit`, `invest`, and `rebalance` compute the floor themselves from the Pyth price and the strategy's `max_slippage_bps`: a swap whose output falls more than that tolerance below the oracle-implied amount reverts. `max_slippage_bps` is set at creation and capped at `MAX_SLIPPAGE_BPS` (1,000 bps = 10%).
56
+
[Slippage](https://www.investopedia.com/terms/s/slippage.asp) is the gap between the expected and the realized amount of a swap. Rather than trust a manager-supplied minimum, `deposit` 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%).
57
57
58
58
### In-Kind Withdrawal
59
59
@@ -72,39 +72,35 @@ An [in-kind distribution](https://www.investopedia.com/terms/i/in-kind.asp) retu
72
72
73
73
`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.
74
74
75
-
### Step 1 - Victor creates the registry and whitelists assets
75
+
### Victor creates the registry and whitelists assets
76
76
77
77
`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.
78
78
79
-
### Step 2 - Maria initializes the strategy
79
+
### Maria initializes the strategy
80
80
81
81
`initialize_strategy(index=0, fee_bps=100, max_slippage_bps=100, swap_router)` creates the `Strategy` PDA (`["strategy", 0]`), the share mint, and the USDC vault, binding the strategy to Victor's registry. The strategy is addressed by a caller-chosen index (`"strategy" + 0`, `"strategy" + 1`, …) rather than the manager's key. No assets yet.
82
82
83
-
### Step 3 - Maria adds assets
83
+
### Maria adds assets
84
84
85
-
`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.
85
+
`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. Deposits stay closed until the weights sum to exactly 10,000.
86
86
87
-
### Step 4 - Alice deposits, and her money is deployed at once
87
+
### Alice deposits, and her money is deployed at once
88
88
89
-
`deposit(usdc_amount, minimum_shares)`, with each asset's `[asset_config, vault, mint, rate, price_feed]` passed as remaining accounts, plus the router accounts. The handler values every asset for NAV (first deposit is 1:1), mints shares to Alice, then deploys her USDC across the basket at its target weights through the router, each leg carrying the same oracle slippage floor`invest` uses. With the weights at 40/60, a 900 USDC deposit lands as 1.44 TSLAx and 3.0 NVDAx with no idle USDC.
89
+
`deposit(usdc_amount, minimum_shares)`, with each asset's `[asset_config, vault, mint, rate, price_feed]` passed as remaining accounts, plus the router accounts. The handler requires the strategy to be fully allocated, values every asset for NAV (first deposit is 1:1), mints shares to Alice, then deploys her USDC across the basket at its target weights through the router, each leg under an oracle slippage floor. With the weights at 40/60, a 900 USDC deposit lands as 1.44 TSLAx and 3.0 NVDAx with no idle USDC.
90
90
91
-
### Step 5 - Maria invests idle USDC (when there is any)
91
+
### Bob deposits at the current share price
92
92
93
-
`invest(usdc_amount)` for one registered asset, passing its `asset_config` and `price_feed`. Because `deposit` already deploys at the target weights, the USDC vault is normally empty; `invest` is the tool for the leftover cases, such as the unallocated remainder when the weights sum below 10,000, or the proceeds of a retired asset. The handler reads the Pyth price, computes the minimum acceptable output, and CPIs the router; a fill worse than the bound reverts.
93
+
Same as Alice's deposit. Because shares are priced at NAV, Bob pays the current per-share value and does not dilute Alice's gain; his USDC is deployed at the target weights too.
94
94
95
-
### Step 6 - Bob deposits at the current share price
95
+
### Maria rebalances
96
96
97
-
Same as step 4. Because shares are priced at NAV, Bob pays the current per-share value and does not dilute Alice's gain; his USDC is deployed at the target weights too.
97
+
A price move pushes the basket off target. `rebalance(sell_amount, usdc_to_invest)` sells the over-weight asset for USDC and buys the under-weight one, both legs bounded against their Pyth prices, in one atomic instruction. `set_weight(weight_bps)` changes a target between rebalances, or retires an asset by setting it to zero (then reassign that weight to another asset to reach 100% again, and `rebalance` liquidates the retired holdings).
98
98
99
-
### Step 7 - Maria rebalances
100
-
101
-
A price move pushes the basket off target. `rebalance(sell_amount, usdc_to_invest)` sells the over-weight asset for USDC and buys the under-weight one, both legs bounded against their Pyth prices, in one atomic instruction. `set_weight(weight_bps)` changes a target between rebalances, or retires an asset by setting it to zero.
102
-
103
-
### Step 8 - Fees accrue
99
+
### Fees accrue
104
100
105
101
`collect_fees()` mints time-and-rate-proportional fee shares to Maria, diluting all holders by the fee.
106
102
107
-
### Step 9 - Alice withdraws in kind
103
+
### Alice withdraws in kind
108
104
109
105
`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.
110
106
@@ -118,7 +114,7 @@ A price move pushes the basket off target. `rebalance(sell_amount, usdc_to_inves
118
114
119
115
## Mock Swap Router vs Production
120
116
121
-
The `mock-swap-router` exists only for testing: it stores a `usdc_per_token` rate per asset, holds the basket mints' authority, and mints/burns to simulate swaps. The `Strategy` stores the router program pubkey at creation, and `deposit`, `invest`, and `rebalance` require the router account to match it (`InvalidSwapRouter`). In production, replace the router CPIs with [Jupiter](https://jup.ag); the strategy PDA still signs.
117
+
The `mock-swap-router` exists only for testing: it stores a `usdc_per_token` rate per asset, holds the basket mints' authority, and mints/burns to simulate swaps. The `Strategy` stores the router program pubkey at creation, and `deposit` and `rebalance` require the router account to match it (`InvalidSwapRouter`). In production, replace the router CPIs with [Jupiter](https://jup.ag); the strategy PDA still signs.
cargo test --manifest-path programs/vault-strategy/Cargo.toml
156
152
```
157
153
158
-
Tests live in `programs/vault-strategy/tests/vault_strategy.rs` and use [LiteSVM](https://github.com/LiteSVM/litesvm). Both `.so` files are loaded from `target/deploy/`, so build before testing. The suite covers the full lifecycle end to end (deposit with auto-deployment, a price move, rebalance back to target, a second depositor priced at the new NAV, a year's fee, in-kind withdrawal) plus focused tests for each handler, retiring an asset with `set_weight`, and the rejection paths: non-whitelisted asset, weight overflow, over-cap fee and slippage, oracle-bounded slippage on both deposit and invest, non-manager `set_weight`, unregistered router, and incomplete asset accounts on deposit.
154
+
Tests live in `programs/vault-strategy/tests/vault_strategy.rs` and use [LiteSVM](https://github.com/LiteSVM/litesvm). Both `.so` files are loaded from `target/deploy/`, so build before testing. The suite covers the full lifecycle end to end (deposit with auto-deployment, a price move, rebalance back to target, a second depositor priced at the new NAV, a year's fee, in-kind withdrawal), retiring an asset with `set_weight` and reallocating to reopen deposits, and the rejection paths: non-whitelisted asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage, an under-allocated strategy, non-manager `set_weight`, unregistered router, and incomplete asset accounts on deposit.
0 commit comments