Skip to content

Commit 0b47509

Browse files
committed
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.
1 parent 1397994 commit 0b47509

10 files changed

Lines changed: 84 additions & 310 deletions

File tree

finance/vault-strategy/anchor/CHANGELOG.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@
66

77
- **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`.
88
- **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.
1212

1313
### Changed
1414

1515
- `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.
1717
- `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.
1919

2020
### Fixed
2121

finance/vault-strategy/anchor/README.md

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,13 @@ fee_shares = total_shares × fee_bps × elapsed_seconds / (10_000 × 31_536_000)
4747

4848
### Weights and Rebalancing
4949

50-
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.
5151

5252
[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).
5353

5454
### Slippage, bounded by the oracle
5555

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%).
5757

5858
### In-Kind Withdrawal
5959

@@ -72,39 +72,35 @@ An [in-kind distribution](https://www.investopedia.com/terms/i/in-kind.asp) retu
7272

7373
`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.
7474

75-
### Step 1 - Victor creates the registry and whitelists assets
75+
### Victor creates the registry and whitelists assets
7676

7777
`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.
7878

79-
### Step 2 - Maria initializes the strategy
79+
### Maria initializes the strategy
8080

8181
`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.
8282

83-
### Step 3 - Maria adds assets
83+
### Maria adds assets
8484

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.
8686

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

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.
9090

91-
### Step 5 - Maria invests idle USDC (when there is any)
91+
### Bob deposits at the current share price
9292

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.
9494

95-
### Step 6 - Bob deposits at the current share price
95+
### Maria rebalances
9696

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).
9898

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
104100

105101
`collect_fees()` mints time-and-rate-proportional fee shares to Maria, diluting all holders by the fee.
106102

107-
### Step 9 - Alice withdraws in kind
103+
### Alice withdraws in kind
108104

109105
`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.
110106

@@ -118,7 +114,7 @@ A price move pushes the basket off target. `rebalance(sell_amount, usdc_to_inves
118114

119115
## Mock Swap Router vs Production
120116

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.
122118

123119
---
124120

@@ -155,4 +151,4 @@ cargo build-sbf --manifest-path programs/vault-strategy/Cargo.toml
155151
cargo test --manifest-path programs/vault-strategy/Cargo.toml
156152
```
157153

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.

finance/vault-strategy/anchor/programs/vault-strategy/src/error.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ pub enum VaultError {
2020
DuplicateAsset,
2121
#[msg("Total target weight would exceed 10000 basis points")]
2222
WeightOverflow,
23+
#[msg("Strategy weights must sum to 100% before it can accept deposits")]
24+
StrategyNotFullyAllocated,
2325
#[msg("Wrong number of asset accounts supplied for the strategy's assets")]
2426
IncompleteAssetAccounts,
2527
#[msg("An asset account does not match the strategy's registered asset")]

finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/deposit.rs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,17 +83,23 @@ pub struct DepositAccountConstraints<'info> {
8383
}
8484

8585
/// Deposit USDC, receive shares priced at net asset value, and immediately deploy
86-
/// the deposit into the basket at its target weights. The USDC does not sit idle:
87-
/// for each asset the handler swaps `usdc_amount * weight_bps / 10000` through the
88-
/// registered router, so a depositor's money is invested in the same transaction
89-
/// they put it in. Any unallocated remainder (when the weights sum below 10000,
90-
/// e.g. after an asset is retired) stays in the USDC vault for the manager to deploy.
86+
/// the deposit into the basket at its target weights. The strategy must be fully
87+
/// allocated first: the weights sum to exactly 10000, so every deposit is fully
88+
/// invested. For each asset the handler swaps `usdc_amount * weight_bps / 10000`
89+
/// through the registered router, so a depositor's money is invested in the same
90+
/// transaction they put it in (only sub-cent rounding dust can remain as USDC).
9191
pub fn handle_deposit<'info>(
9292
context: Context<'info, DepositAccountConstraints<'info>>,
9393
usdc_amount: u64,
9494
minimum_shares: u64,
9595
) -> Result<()> {
9696
require!(usdc_amount > 0, VaultError::ZeroDeposit);
97+
// A strategy accepts deposits only once its weights sum to 100%, so a deposit is
98+
// always fully invested. A half-configured or under-allocated basket is closed.
99+
require!(
100+
context.accounts.strategy.total_weight_bps == 10_000,
101+
VaultError::StrategyNotFullyAllocated
102+
);
97103

98104
let vault_usdc_amount = context.accounts.vault_usdc.amount;
99105
let total_shares = context.accounts.strategy.total_shares;
@@ -165,9 +171,9 @@ pub fn handle_deposit<'info>(
165171
let signer_seeds: &[&[&[u8]]] = &[&[b"strategy", index_bytes.as_ref(), &[strategy_bump]]];
166172

167173
// Deploy the deposit across the basket at its target weights. Each leg swaps a
168-
// weight-sized slice of the deposit through the router, with the same
169-
// oracle-computed slippage floor `invest` uses. The strategy PDA signs, since
170-
// the USDC leaves a vault only it controls.
174+
// weight-sized slice of the deposit through the router, under an oracle-computed
175+
// slippage floor. The strategy PDA signs, since the USDC leaves a vault only it
176+
// controls.
171177
for index in 0..asset_count {
172178
let config_account = &remaining[index * 5];
173179
let vault_account = &remaining[index * 5 + 1];

finance/vault-strategy/anchor/programs/vault-strategy/src/instructions/initialize_strategy.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use crate::state::{Registry, Strategy};
1414
pub const MAX_FEE_BPS: u16 = 1_000;
1515

1616
/// Highest slippage tolerance a manager may set, in basis points (10%).
17-
/// invest/rebalance reject a swap whose output deviates from the Pyth price by
17+
/// deposit/rebalance reject a swap whose output deviates from the Pyth price by
1818
/// more than this; capping it stops a manager from setting a tolerance so loose
1919
/// that the bound is meaningless.
2020
pub const MAX_SLIPPAGE_BPS: u16 = 1_000;

0 commit comments

Comments
 (0)