From 9fa5f86956fdf829d5ef2f9b113ee4cafd32e534 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Thu, 1 Oct 2026 20:33:50 +0000 Subject: [PATCH] Managed fund: value assets by their decimals, and let anyone rebalance Valuation assumed every asset had USDC's six decimals and every Pyth feed an exponent of -8: value = amount * price / 10^8, with nothing checking either. An eight-decimal asset was valued 100 times too high, so a later depositor bought almost no shares; Pyth's US equity feeds use exponent -5, a further factor of 1,000. load_price now reads the exponent, AssetConfig records the mint's decimals and Fund the USDC mint's, and deposit and rebalance value and size swaps by 10^(usdc_decimals + exponent - asset_decimals). The mock router's usdc_per_token is now USDC minor units per whole token. rebalance(sell_amount, usdc_to_invest) let the manager choose both legs, so a manager could trade a balanced fund back and forth and bleed it through slippage to whoever filled the trades. rebalance(sell_index, buy_index) is now permissionless and sizes its own trade: it values every asset, requires the asset sold to be above its target by a threshold fixed at creation (100..=2,000 bps, no setter) and the asset bought to be below its own, and trades the smaller gap, spending only the sale's proceeds. A fund at its targets has no trade to make. Anchor v2, Anchor v1 and Quasar change together, with tests for churn, the threshold, the buy side, retired assets, donations and mixed decimals, two new Kani harnesses, and the web apps' IDL, client and rebalance panel. From quicknode/solana-book review feedback on the Managed Fund chapter. Claude-Session: https://claude.ai/code/session_01Jw5mz4PJSs6GbyMY1zNeEr --- CHANGELOG.md | 15 + finance/managed-fund/anchor-v1/CHANGELOG.md | 5 + finance/managed-fund/anchor-v1/PRODUCT.md | 7 +- finance/managed-fund/anchor-v1/README.md | 17 +- finance/managed-fund/anchor-v1/app/README.md | 9 +- .../anchor-v1/app/scripts/verify-client.mjs | 26 +- .../app/src/components/ActionTicket.tsx | 4 +- .../app/src/components/Allocation.tsx | 13 +- .../app/src/components/ManagerView.tsx | 153 ++-- .../anchor-v1/app/src/hooks/useFund.ts | 11 +- .../anchor-v1/app/src/idl/managedFund.ts | 6 + .../anchor-v1/app/src/idl/managed_fund.json | 179 ++--- .../anchor-v1/app/src/lib/amounts.ts | 11 +- .../anchor-v1/app/src/preview.tsx | 10 +- .../anchor-v1/app/src/solana/config.ts | 3 +- .../anchor-v1/app/src/solana/format.ts | 5 + .../anchor-v1/app/src/solana/fund.ts | 119 ++- .../anchor-v1/app/src/solana/instructions.ts | 38 +- .../anchor-v1/app/src/solana/pyth.ts | 6 +- .../anchor-v1/app/src/views/InvestorView.tsx | 7 + .../programs/managed-fund/src/error.rs | 8 +- .../src/instructions/add_asset.rs | 1 + .../managed-fund/src/instructions/deposit.rs | 20 +- .../src/instructions/initialize_fund.rs | 17 + .../src/instructions/rebalance.rs | 315 ++++---- .../src/instructions/set_weight.rs | 9 +- .../programs/managed-fund/src/lib.rs | 15 +- .../programs/managed-fund/src/oracle.rs | 97 ++- .../programs/managed-fund/src/state/fund.rs | 15 +- .../managed-fund/tests/managed_fund.rs | 625 +++++++++++---- .../src/instructions/swap_asset_for_usdc.rs | 12 +- .../src/instructions/swap_usdc_for_asset.rs | 12 +- .../mock-swap-router/src/state/asset_rate.rs | 6 +- finance/managed-fund/anchor/CHANGELOG.md | 5 + finance/managed-fund/anchor/PRODUCT.md | 7 +- finance/managed-fund/anchor/README.md | 17 +- finance/managed-fund/anchor/app/README.md | 9 +- .../anchor/app/scripts/verify-client.mjs | 26 +- .../app/src/components/ActionTicket.tsx | 4 +- .../anchor/app/src/components/Allocation.tsx | 13 +- .../anchor/app/src/components/ManagerView.tsx | 153 ++-- .../anchor/app/src/hooks/useFund.ts | 11 +- .../anchor/app/src/idl/managedFund.ts | 6 + .../anchor/app/src/idl/managed_fund.json | 179 ++--- .../anchor/app/src/lib/amounts.ts | 11 +- .../managed-fund/anchor/app/src/preview.tsx | 10 +- .../anchor/app/src/solana/config.ts | 3 +- .../anchor/app/src/solana/format.ts | 5 + .../anchor/app/src/solana/fund.ts | 119 ++- .../anchor/app/src/solana/instructions.ts | 38 +- .../anchor/app/src/solana/pyth.ts | 6 +- .../anchor/app/src/views/InvestorView.tsx | 7 + .../anchor/programs/managed-fund/src/error.rs | 8 +- .../src/instructions/add_asset.rs | 1 + .../managed-fund/src/instructions/deposit.rs | 20 +- .../src/instructions/initialize_fund.rs | 17 + .../src/instructions/rebalance.rs | 300 ++++---- .../src/instructions/set_weight.rs | 9 +- .../anchor/programs/managed-fund/src/lib.rs | 11 +- .../programs/managed-fund/src/oracle.rs | 97 ++- .../programs/managed-fund/src/state/fund.rs | 15 +- .../managed-fund/tests/managed_fund.rs | 625 +++++++++++---- .../src/instructions/swap_asset_for_usdc.rs | 12 +- .../src/instructions/swap_usdc_for_asset.rs | 12 +- .../mock-swap-router/src/state/asset_rate.rs | 6 +- finance/managed-fund/kani-proofs/src/lib.rs | 155 ++++ finance/managed-fund/quasar/CHANGELOG.md | 24 + finance/managed-fund/quasar/README.md | 31 +- .../quasar/managed-fund/src/errors.rs | 6 + .../src/instructions/add_asset.rs | 1 + .../managed-fund/src/instructions/deposit.rs | 22 +- .../src/instructions/initialize_fund.rs | 17 + .../src/instructions/rebalance.rs | 309 +++++--- .../src/instructions/set_weight.rs | 9 +- .../quasar/managed-fund/src/lib.rs | 21 +- .../quasar/managed-fund/src/oracle.rs | 88 ++- .../quasar/managed-fund/src/state/fund.rs | 18 + .../quasar/managed-fund/src/tests.rs | 712 +++++++++++++++++- .../src/instructions/swap_asset_for_usdc.rs | 8 +- .../src/instructions/swap_usdc_for_asset.rs | 8 +- .../quasar/mock-swap-router/src/state/mod.rs | 7 +- .../quasar/mock-swap-router/src/tests.rs | 5 +- 82 files changed, 3677 insertions(+), 1322 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddd0f7262..c29d16cf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this repository are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2026-10-01] - Managed Fund: decimal-aware valuation and permissionless rebalancing + +### Fixed + +- `finance/managed-fund` valued every asset as if it had six decimals and a + Pyth exponent of −8. It now reads each feed's exponent and records each + mint's decimals, in Anchor v2, Anchor v1 and Quasar. + +### Changed + +- `finance/managed-fund`'s `rebalance` is permissionless and computes its own + trade from oracle prices and target weights, once drift passes a threshold + fixed at fund creation, so no caller, the manager included, can churn the + fund. See the example's changelogs. + ## [2026-09-28] - Token Fundraiser is renamed Fundraiser Contributors to the fundraiser receive no token, only a refund if the target is diff --git a/finance/managed-fund/anchor-v1/CHANGELOG.md b/finance/managed-fund/anchor-v1/CHANGELOG.md index a303322e1..7d8b0b84c 100644 --- a/finance/managed-fund/anchor-v1/CHANGELOG.md +++ b/finance/managed-fund/anchor-v1/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026-10-01 + +- **Valuation scales by each asset's decimals and each feed's exponent.** The fund valued an asset as `amount × price / 10⁸`, which is right only when the asset has USDC's 6 decimals and its Pyth feed has exponent −8, and nothing checked either. An eight-decimal asset would have been valued 100 times too high, so a later depositor would have bought almost no shares; Pyth's US equity feeds use exponent −5, a further factor of 1,000. `load_price` now reads the exponent (offset 89) and returns an `OraclePrice`, `AssetConfig` records the mint's `decimals` and `Fund` the USDC mint's `usdc_decimals`, and `deposit` and `rebalance` value and size swaps with `asset_value_in_usdc` and `usdc_to_asset_amount`, which scale by `10^(usdc_decimals + exponent − asset_decimals)`. `PYTH_PRICE_PRECISION` is removed. The mock router's `usdc_per_token` is now USDC minor units per whole token, and its swaps scale by the asset mint's decimals. Tested by `test_valuation_scales_by_decimals_and_exponent`, which runs the story with an eight-decimal TSLAx on an exponent −5 feed. +- **Anyone may rebalance, and the program sizes the trade.** `rebalance(sell_amount, usdc_to_invest)`, manager-only, is now `rebalance(sell_index, buy_index)`, signed by any `caller`, with the same five remaining accounts per asset as `deposit`. It values every asset and requires the asset sold to sit above its target by at least the fund's threshold (new `DriftBelowThreshold`; a retired asset may always be sold) and the asset bought to sit below its target (new `NotUnderweight`). It trades the smaller of the two gaps and spends only what the sale brought in. Before, the manager chose both amounts, so they could trade a balanced fund back and forth and bleed it through slippage, to the benefit of whoever filled the trades. A fund at its targets now has no trade to make. `initialize_fund` takes a `rebalance_threshold_bps`, between `MIN_REBALANCE_THRESHOLD_BPS` (100) and `MAX_REBALANCE_THRESHOLD_BPS` (2,000) (new `RebalanceThresholdOutOfRange`), stored on `Fund` with no setter. Tested by `test_rebalance_refuses_fund_at_target`, `test_rebalance_refuses_drift_below_threshold`, `test_rebalance_cannot_churn`, `test_rebalance_refuses_buying_overweight_asset`, `test_rebalance_rejects_incomplete_assets`, `test_rebalance_sells_retired_asset`, `test_rebalance_ignores_donations` (replacing `test_rebalance_cannot_spend_donated_usdc`) and `test_initialize_rejects_threshold_out_of_range`. The Kani proofs gain `proof_rebalance_trade_never_overshoots` and `proof_sell_amount_never_worth_more_than_trade`. The web app's IDL, instruction builders and rebalance panel follow. + ## 2026-09-28 - **Renamed from Vault Strategy to Managed Fund.** The example moved from `finance/vault-strategy` to `finance/managed-fund` and now uses the finance term for the product. The program crate is `managed-fund` (library `managed_fund`), the `Strategy` account is `Fund`, `initialize_strategy` is `initialize_fund`, the `StrategyNotFullyAllocated` error is `FundNotFullyAllocated`, and the fund PDA's seed is `"fund"` instead of `"strategy"`, so fund addresses change. The `initialize_fund` instruction and `Fund` account have new Anchor discriminators, and the web app's IDL and client follow. `vault` still means only a single-asset token account the fund owns. The program ID and the program's behavior are unchanged. diff --git a/finance/managed-fund/anchor-v1/PRODUCT.md b/finance/managed-fund/anchor-v1/PRODUCT.md index 1be37f405..7043668cf 100644 --- a/finance/managed-fund/anchor-v1/PRODUCT.md +++ b/finance/managed-fund/anchor-v1/PRODUCT.md @@ -11,7 +11,7 @@ web The frontend serves two roles first; a third exists in the program and is secondary for now. - **Depositor (primary):** a retail user who deposits USDC into a manager-run fund, receives shares representing proportional ownership of the whole basket, watches their position's value track the portfolio, and withdraws their slice in kind when they choose. Highest-volume, lowest-expertise surface. -- **Manager (primary):** a power user who creates and operates a fund — registers curator-approved assets, sets target weights, rebalances as prices drift, and collects the management fee. An operations surface where correctness and confirmation matter more than reach. +- **Manager (primary):** a power user who creates and operates a fund — registers curator-approved assets, sets target weights, and collects the management fee. An operations surface where correctness and confirmation matter more than reach. - **Curator (secondary, not yet in scope):** governs the approved-asset registry that bounds what managers may hold. Low-frequency, high-trust. Recorded so future work does not treat the manager as the only privileged role. ## Product Purpose @@ -35,14 +35,15 @@ A **multi-asset** fund built from single-asset vaults, transparently priced on-c Program instructions the frontend can surface: - **Registry / curator:** `initialize_registry`, `approve_asset` (whitelist of assets managers may use). -- **Manager:** `initialize_fund`, `add_asset`, `set_weight` (including set-to-zero to retire an asset), `rebalance`, `collect_fees`. +- **Manager:** `initialize_fund` (fee, slippage tolerance, and rebalance threshold, all fixed at creation), `add_asset`, `set_weight` (including set-to-zero to retire an asset), `collect_fees`. +- **Anyone:** `rebalance(sell_index, buy_index)`. The program sizes the trade from oracle prices and target weights, and refuses unless the asset sold is over its target by at least the fund's threshold and the asset bought is under its own (`DriftBelowThreshold`, `NotUnderweight`). - **Depositor:** `deposit` (USDC → minted shares), `withdraw` (burn shares → in-kind slice of every vault). Rules the UI must respect and reflect: - Deposits are accepted only when target weights sum to **exactly 10,000 bps**; a fund is either still being configured or fully allocated and live (`FundNotFullyAllocated` otherwise). - Shares: first deposit is 1:1 with USDC minor units; later deposits mint `deposit_usdc × total_shares / NAV`. Share mint is a PDA owned by the fund PDA. -- NAV and withdrawals use the fund's recorded holdings (`usdc_holdings`, `asset_holdings`), not vault token balances. Tokens sent straight to a vault are not part of the fund and should not be shown as fund value. A deposit too small to buy any of an asset is rejected (`DepositTooSmall`); a rebalance cannot sell or spend more than the recorded holdings (`InsufficientHoldings`). +- NAV and withdrawals use the fund's recorded holdings (`usdc_holdings`, `asset_holdings`), not vault token balances. Tokens sent straight to a vault are not part of the fund and should not be shown as fund value. A deposit too small to buy any of an asset is rejected (`DepositTooSmall`); a rebalance cannot sell more than the recorded holdings (`InsufficientHoldings`), spends only what its sale brought in, and values assets from the recorded holdings, so a donation cannot force one. - Management fee is charged by minting new shares to the manager (dilution), fixed at creation, capped at `MAX_FEE_BPS` = 1,000 bps (10%), no setter to raise it. `collect_fees` is permissionless. - Slippage floors are computed on-chain from the Pyth price and `max_slippage_bps` (capped at 1,000 bps); a manager-supplied minimum is not trusted. - `MAX_ASSETS` = 16. `deposit` re-derives the full `0..asset_count` PDA range and refuses to run if any asset account is missing (`IncompleteAssetAccounts`), so NAV can't be understated. diff --git a/finance/managed-fund/anchor-v1/README.md b/finance/managed-fund/anchor-v1/README.md index db964f09c..14152021b 100644 --- a/finance/managed-fund/anchor-v1/README.md +++ b/finance/managed-fund/anchor-v1/README.md @@ -58,7 +58,7 @@ fee_shares = total_shares × fee_bps × elapsed_seconds / (10_000 × 31_536_000) Each asset carries a target **weight** in basis points (e.g. 40% TSLAx, 60% NVDAx). A fund 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 `FundNotFullyAllocated`). So a fund 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. -[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). +[Rebalancing](https://www.investopedia.com/terms/r/rebalancing.asp) handles the drift that prices create after a deposit: `rebalance(sell_index, buy_index)` sells an over-weight asset for USDC and buys an under-weight one in a single atomic instruction. **Anyone may call it, and the program, not the caller, sizes the trade.** It values every asset (the same five accounts per asset as `deposit`), and requires the asset sold to sit above its target by at least the fund's `rebalance_threshold_bps` of the fund's value (`DriftBelowThreshold`) and the asset bought to sit below its own (`NotUnderweight`). It then trades the smaller of the two gaps, so neither asset ends past its target, and spends only what the sale brought in. A fund at its targets has no trade to make, so nobody, the manager included, can churn it: trade it back and forth to bleed value through slippage. The threshold is set at creation between `MIN_REBALANCE_THRESHOLD_BPS` (100, one percentage point) and `MAX_REBALANCE_THRESHOLD_BPS` (2,000) and has no setter (`RebalanceThresholdOutOfRange`). `set_weight` changes a target after creation, including setting it to zero to **retire** an asset: deposits stop allocating to it, a rebalance may sell all of it whatever the threshold, and the now-empty vault keeps its index so the contiguous `0..asset_count` range stays intact (the index is never reused). ### Slippage, bounded by the oracle @@ -101,9 +101,9 @@ An [in-kind distribution](https://www.investopedia.com/terms/i/in-kind.asp) retu 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. -### Maria rebalances +### Anyone rebalances -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). +A price move pushes the basket off target by more than the fund's threshold. Anyone calls `rebalance(sell_index, buy_index)`, naming the over-weight asset and an under-weight one; the program computes how much to sell from the Pyth prices and the target weights, and both legs are bounded against their Pyth prices, in one atomic instruction. `set_weight(weight_bps)` is how Maria steers the fund: it changes a target, or retires an asset by setting it to zero (then reassign that weight to another asset to reach 100% again, and `rebalance` sells the retired holdings). ### Fees accrue @@ -117,13 +117,13 @@ A price move pushes the basket off target. `rebalance(sell_amount, usdc_to_inves ## Oracle Integration (Pyth) -`PriceUpdateV2` price (i64) is read at byte offset 73, `publish_time` at 93 and `posted_slot` (u64) at 125, directly from account bytes to avoid borsh version incompatibility with Anchor. Under Alpenglow each leader sets the Clock's `unix_timestamp`, which may advance by at most twice the slot time elapsed since the parent block, so after a halt the timestamp trails real time and a price published just before the halt can still look fresh by seconds. `load_price` therefore also requires `posted_slot` to be after the `LastRestartSlot` sysvar's slot (0 means the cluster has never restarted), pausing deposits and rebalances until Pyth posts again. 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). +`PriceUpdateV2` price (i64) is read at byte offset 73, `publish_time` at 93 and `posted_slot` (u64) at 125, directly from account bytes to avoid borsh version incompatibility with Anchor. Under Alpenglow each leader sets the Clock's `unix_timestamp`, which may advance by at most twice the slot time elapsed since the parent block, so after a halt the timestamp trails real time and a price published just before the halt can still look fresh by seconds. `load_price` therefore also requires `posted_slot` to be after the `LastRestartSlot` sysvar's slot (0 means the cluster has never restarted), pausing deposits and rebalances until Pyth posts again. The exponent (i32) is read at offset 89 rather than assumed: Pyth's crypto USD feeds use −8 and its US equity feeds −5. Each asset's mint decimals are recorded in its `AssetConfig` when it is added, and the USDC mint's in the `Fund` at creation, so value in USDC minor units is `amount × price × 10^(usdc_decimals + exponent − asset_decimals)`: `amount × price / 10⁸` only when the asset has USDC's 6 decimals and the feed's exponent is −8. 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 a `usdc_per_token` rate per asset, holds the basket mints' authority, and mints/burns to simulate swaps. Its single `router_config` account (`["router_config"]`) is both its state and its signer: it owns the USDC treasury, is the mint authority of every basket mint, and signs the router's token CPIs with its own seeds, the same way the fund PDA does for the vaults. The `Fund` 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 fund PDA still signs. +The `mock-swap-router` exists only for testing: it stores a `usdc_per_token` rate per asset (USDC minor units per whole token, scaled by the asset mint's decimals on each swap), holds the basket mints' authority, and mints/burns to simulate swaps. Its single `router_config` account (`["router_config"]`) is both its state and its signer: it owns the USDC treasury, is the mint authority of every basket mint, and signs the router's token CPIs with its own seeds, the same way the fund PDA does for the vaults. The `Fund` 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 fund PDA still signs. --- @@ -133,9 +133,10 @@ The fund PDA holds all assets; no instruction moves a vault's tokens to the mana - **Assets** are limited to mints approved 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. +- **Rebalancing** is sized by the program from the oracle and the target weights, and only once drift passes a threshold fixed at creation. The manager chooses the weights, not the trades. - **The fee** is fixed at creation and capped at 10%, paid only in minted shares. -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. +What remains to trust: the honesty of the registered router and registry. The manager cannot withdraw principal, and cannot churn the fund through the router either: no caller chooses a trade's size or can trade a fund sitting at its targets. --- @@ -160,7 +161,7 @@ cargo build-sbf --manifest-path programs/managed-fund/Cargo.toml cargo test --manifest-path programs/managed-fund/Cargo.toml ``` -Tests live in `programs/managed-fund/tests/managed_fund.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: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage, an under-allocated fund, non-manager `set_weight`, unregistered router, and incomplete asset accounts on deposit. `test_full_lifecycle` checks after every step that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the first-depositor attack (a one-minor-unit deposit, a 1,000 USDC transfer straight into the USDC vault, then a 1,000 USDC deposit with no `minimum_shares` floor) and checks the victim gets exactly the shares they would have got without the donation. `test_deposit_rejects_leg_that_buys_nothing` and `test_rebalance_cannot_spend_donated_usdc` pin the other two guards. +Tests live in `programs/managed-fund/tests/managed_fund.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: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage, an under-allocated fund, non-manager `set_weight`, unregistered router, and incomplete asset accounts on deposit and rebalance. The rebalance tests sign as a stranger, since anyone may call it: `test_rebalance_refuses_fund_at_target`, `test_rebalance_refuses_drift_below_threshold` and `test_rebalance_cannot_churn` check that a fund at its targets, or within its threshold, or just rebalanced, cannot be traded; `test_rebalance_refuses_buying_overweight_asset`, `test_rebalance_sells_retired_asset` and `test_initialize_rejects_threshold_out_of_range` cover the rest of its rules. `test_valuation_scales_by_decimals_and_exponent` runs the story with an eight-decimal TSLAx priced by an exponent −5 feed and gets the same share counts. `test_full_lifecycle` checks after every step that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the first-depositor attack (a one-minor-unit deposit, a 1,000 USDC transfer straight into the USDC vault, then a 1,000 USDC deposit with no `minimum_shares` floor) and checks the victim gets exactly the shares they would have got without the donation. `test_deposit_rejects_leg_that_buys_nothing` and `test_rebalance_ignores_donations` pin the other two guards: the second checks that donated tokens can neither force a rebalance nor be spent by one. ## FAQ @@ -174,4 +175,4 @@ Shares are priced at the fund's net asset value: the total value of its recorded ### How does the manager operate the fund? -`rebalance` trades the vaults back to their target weights as prices drift, `set_weight` reweights or retires an asset, and a management fee accrues over time and is collected with `collect_fees`. +`set_weight` reweights or retires an asset, `rebalance`, which anyone may call, trades the vaults back to those weights once prices drift past the fund's threshold, and a management fee accrues over time and is collected with `collect_fees`. diff --git a/finance/managed-fund/anchor-v1/app/README.md b/finance/managed-fund/anchor-v1/app/README.md index 6ab21b8c0..be08bbff0 100644 --- a/finance/managed-fund/anchor-v1/app/README.md +++ b/finance/managed-fund/anchor-v1/app/README.md @@ -2,8 +2,10 @@ A small, data-forward frontend for the [`managed-fund`](../README.md) Solana program: an educational demo of a manager-run investment fund. Investors deposit USDC and hold -shares priced at net asset value; a manager allocates the pooled USDC across a basket and -rebalances it. Every number on screen is a real on-chain account read. +shares priced at net asset value; a manager sets the basket's target weights, and anyone can +rebalance it once an asset drifts past the fund's threshold (the program sizes the trade, so +the caller only picks which asset to sell and which to buy). Every number on screen is a real +on-chain account read. Stack: **Vite + React + TypeScript + Tailwind**, `@solana/wallet-adapter`, `@coral-xyz/anchor`. Target cluster: **devnet**. @@ -54,7 +56,8 @@ so the client is **verified offline** (`pnpm verify`) rather than against a live To bring the demo up you need `solana` + `anchor` (or `cargo build-sbf`) to: 1. Build & deploy both programs to devnet (you'll get **new** program ids — put them in `.env.local`). -2. Create a 6-decimal mock USDC + the basket mints; init the router, set rates, fund its treasury. +2. Create a 6-decimal mock USDC + the basket mints; init the router, set rates (USDC minor units + per whole token, e.g. `250_000_000` for $250), fund its treasury. 3. Create a registry, approve the basket assets (bound to Pyth `PriceUpdateV2` feeds), init a fund, and add the assets to 100% weight. diff --git a/finance/managed-fund/anchor-v1/app/scripts/verify-client.mjs b/finance/managed-fund/anchor-v1/app/scripts/verify-client.mjs index 5ac087ce4..a7c646021 100644 --- a/finance/managed-fund/anchor-v1/app/scripts/verify-client.mjs +++ b/finance/managed-fund/anchor-v1/app/scripts/verify-client.mjs @@ -94,9 +94,11 @@ if (program) { registry: PublicKey.default, shareMint: PublicKey.default, usdcMint: PublicKey.default, + usdcDecimals: 6, swapRouter: PublicKey.default, feeBps: 100, maxSlippageBps: 250, + rebalanceThresholdBps: 500, totalShares: new BN("1350000000"), usdcHoldings: new BN("1000"), assetHoldings: Array.from({ length: 16 }, (_, i) => new BN(i === 1 ? 2880000 : 0)), @@ -111,7 +113,9 @@ if (program) { const good = decoded.index.toString() === "7" && decoded.feeBps === 100 && + decoded.usdcDecimals === 6 && decoded.maxSlippageBps === 250 && + decoded.rebalanceThresholdBps === 500 && decoded.assetCount === 2 && decoded.totalWeightBps === 10000 && decoded.totalShares.toString() === "1350000000" && @@ -121,9 +125,29 @@ if (program) { } catch (e) { fail("Fund round-trip", e.message); } + + // 5. Round-trip an AssetConfig, whose decimals the client valuation depends on. + try { + const sample = { + fund: PublicKey.default, + index: 1, + mint: PublicKey.default, + decimals: 8, + priceFeed: PublicKey.default, + vault: PublicKey.default, + weightBps: 4000, + bump: 253, + }; + const encoded = await program.coder.accounts.encode("assetConfig", sample); + const decoded = program.coder.accounts.decode("assetConfig", encoded); + const good = decoded.index === 1 && decoded.decimals === 8 && decoded.weightBps === 4000 && decoded.bump === 253; + good ? ok("AssetConfig encode/decode round-trip") : fail("AssetConfig round-trip", JSON.stringify(decoded)); + } catch (e) { + fail("AssetConfig round-trip", e.message); + } } -// 5. Derive the key PDAs (sanity + reference values). +// 6. Derive the key PDAs (sanity + reference values). console.log("PDAs (index 0)"); const u64le = (n) => { const b = Buffer.alloc(8); diff --git a/finance/managed-fund/anchor-v1/app/src/components/ActionTicket.tsx b/finance/managed-fund/anchor-v1/app/src/components/ActionTicket.tsx index b4b22895f..dc1beaa7a 100644 --- a/finance/managed-fund/anchor-v1/app/src/components/ActionTicket.tsx +++ b/finance/managed-fund/anchor-v1/app/src/components/ActionTicket.tsx @@ -183,7 +183,9 @@ export function ActionTicket({ view, connected, walletUsdc, position, onDeposit, #{leg.index} · {shortAddress(leg.mint)} - {formatUnits(leg.amountMinor, 6, 6)} + + {formatUnits(leg.amountMinor, leg.decimals, leg.decimals)} + ))}
diff --git a/finance/managed-fund/anchor-v1/app/src/components/Allocation.tsx b/finance/managed-fund/anchor-v1/app/src/components/Allocation.tsx index cd13853cf..4312153b3 100644 --- a/finance/managed-fund/anchor-v1/app/src/components/Allocation.tsx +++ b/finance/managed-fund/anchor-v1/app/src/components/Allocation.tsx @@ -1,4 +1,4 @@ -import { formatBps, formatRatioPct, formatUnits, formatUsdc } from "../solana/format"; +import { formatBps, formatPrice, formatRatioPct, formatUnits, formatUsdc } from "../solana/format"; import type { FundView } from "../solana/fund"; import { Addr } from "./atoms"; @@ -40,8 +40,8 @@ function buildSegments(view: FundView): Segment[] { return segments; } -function priceUsd(price: bigint | null): string { - return price === null ? "—" : `$${formatUnits(price, 8, 2)}`; +function priceUsd(price: bigint | null, exponent: number | null): string { + return price === null || exponent === null ? "—" : `$${formatPrice(price, exponent)}`; } export function AllocationPanel({ view }: { view: FundView }) { @@ -54,7 +54,8 @@ export function AllocationPanel({ view }: { view: FundView }) {

Allocation

- fee {formatBps(s.feeBps)} / yr · slippage {formatBps(s.maxSlippageBps)} + fee {formatBps(s.feeBps)} / yr · slippage {formatBps(s.maxSlippageBps)} · rebalance threshold{" "} + {formatBps(s.rebalanceThresholdBps)}
@@ -106,9 +107,9 @@ export function AllocationPanel({ view }: { view: FundView }) { {a.actualWeight === null ? "—" : formatRatioPct(a.actualWeight)} - {formatUnits(a.vaultAmount, 6, 6)} + {formatUnits(a.vaultAmount, a.decimals, a.decimals)} - {priceUsd(a.price)} + {priceUsd(a.price, a.exponent)} {a.stale && stale} diff --git a/finance/managed-fund/anchor-v1/app/src/components/ManagerView.tsx b/finance/managed-fund/anchor-v1/app/src/components/ManagerView.tsx index 54f630f3c..9272baa5f 100644 --- a/finance/managed-fund/anchor-v1/app/src/components/ManagerView.tsx +++ b/finance/managed-fund/anchor-v1/app/src/components/ManagerView.tsx @@ -2,20 +2,21 @@ import { useConnection } from "@solana/wallet-adapter-react"; import { PublicKey } from "@solana/web3.js"; import { useEffect, useMemo, useState } from "react"; import type { FundState } from "../hooks/useFund"; -import { parseAmount, parsePercentToBps, toAmountInput } from "../lib/amounts"; +import { parsePercentToBps } from "../lib/amounts"; import { describeError } from "../lib/tx"; import { BPS_DENOMINATOR, FUND_INDEX, MAX_ASSETS, MAX_FEE_BPS, + MAX_REBALANCE_THRESHOLD_BPS, MAX_SLIPPAGE_BPS, - PYTH_PRICE_PRECISION, + MIN_REBALANCE_THRESHOLD_BPS, ROUTER_PROGRAM_ID, USDC_MINT, } from "../solana/config"; import { formatBps, formatShares, formatUnits, formatUsdc, shortAddress } from "../solana/format"; -import type { FundView } from "../solana/fund"; +import { type AssetView, assetDrift, type FundView, rebalancePlan } from "../solana/fund"; import { approvedAssetPda } from "../solana/pdas"; import { Button, Panel, Select, StatusLine, TextField, type TxStatus } from "./atoms"; @@ -58,12 +59,38 @@ const assetOptions = (view: FundView) => // ---- Rebalance ------------------------------------------------------------- -function RebalancePanel({ view, onRebalance }: { view: FundView; onRebalance: FundState["rebalance"] }) { +function driftLabel(view: FundView, a: AssetView): { text: string; tone: string } { + const d = assetDrift(view, a); + if (!d) return { text: "unpriced", tone: "text-loss" }; + const threshold = view.account!.rebalanceThresholdBps; + const pct = `${d.driftBps >= 0 ? "+" : ""}${formatBps(d.driftBps)}`; + if (d.driftUsdc > 0n) { + const sellable = a.weightBps === 0 || d.driftBps >= threshold; + return { text: `${pct} over${sellable ? " · can sell" : ""}`, tone: sellable ? "text-accent" : "text-muted" }; + } + if (d.driftUsdc < 0n) return { text: `${pct} under · can buy`, tone: "text-gain" }; + return { text: "on target", tone: "text-faint" }; +} + +/** Index of the asset furthest over (sign 1) or under (sign -1) its target, or null. */ +function extreme(view: FundView, sign: 1 | -1): number | null { + let best: number | null = null; + let bestDrift = 0n; + for (const a of view.assets) { + const d = assetDrift(view, a); + if (d && d.driftUsdc * BigInt(sign) > bestDrift) { + best = a.index; + bestDrift = d.driftUsdc * BigInt(sign); + } + } + return best; +} + +/** Rebalance is permissionless: the program sizes the trade, so the caller only names the pair. */ +export function RebalancePanel({ view, onRebalance }: { view: FundView; onRebalance: FundState["rebalance"] }) { const { status, busy, run } = useAction(); - const [sell, setSell] = useState("0"); - const [buy, setBuy] = useState(view.assets.length > 1 ? "1" : "0"); - const [sellInput, setSellInput] = useState(""); - const [usdcInput, setUsdcInput] = useState(""); + const [sell, setSell] = useState(String(extreme(view, 1) ?? 0)); + const [buy, setBuy] = useState(String(extreme(view, -1) ?? (view.assets.length > 1 ? 1 : 0))); if (view.assets.length < 2) { return ( @@ -75,78 +102,47 @@ function RebalancePanel({ view, onRebalance }: { view: FundView; onRebalance: Fu ); } + const s = view.account!; const sellIdx = Number(sell); const buyIdx = Number(buy); const sellAsset = view.assets[sellIdx]; const buyAsset = view.assets[buyIdx]; - const sellMinor = parseAmount(sellInput, 6); - const usdcMinor = parseAmount(usdcInput, 6); - const impliedUsdc = - sellMinor !== null && sellAsset.price ? (sellMinor * sellAsset.price) / PYTH_PRICE_PRECISION : null; - const legStale = sellAsset.price === null || sellAsset.stale || buyAsset.price === null || buyAsset.stale; - - const block = - sellIdx === buyIdx - ? "Choose two different assets." - : legStale - ? "An oracle price is stale or missing — the rebalance would revert on-chain." - : sellMinor !== null && sellMinor > sellAsset.vaultAmount - ? "Sell amount exceeds the vault balance." - : null; - const ready = - sellIdx !== buyIdx && - !legStale && - sellMinor !== null && - sellMinor > 0n && - sellMinor <= sellAsset.vaultAmount && - usdcMinor !== null && - usdcMinor > 0n; + const plan = rebalancePlan(view, sellIdx, buyIdx); return ( - + +
+ {view.assets.map((a) => { + const label = driftLabel(view, a); + return ( +
+ + #{a.index} · {shortAddress(a.mint)} · target {formatBps(a.weightBps, 0)} + + {label.text} +
+ ); + })} +
- +
- setSellInput(toAmountInput(sellAsset.vaultAmount))} - className="tabular-nums transition-colors hover:text-accent" - > - Vault {formatUnits(sellAsset.vaultAmount, 6, 4)} · Max - - } - /> - setUsdcInput(toAmountInput(impliedUsdc))} - className="tabular-nums transition-colors hover:text-accent" - > - Oracle ≈ {formatUsdc(impliedUsdc)} · Use - - ) : ( - "enter sell amount" - ) - } - /> - {block &&

{block}

} + {plan.block ? ( +

{plan.block}

+ ) : ( +

+ Sells ≈ {formatUnits(plan.sellAmount, sellAsset.decimals, 4)} of # + {sellAsset.index} (≈ ${formatUsdc(plan.tradeUsdc)}) and buys #{buyAsset.index} with the proceeds. +

+ )} +

+ The program sizes the trade from the oracle: the smaller of the sell asset's excess and the buy asset's + shortfall, so neither ends up past its target. +

@@ -324,7 +320,8 @@ export function ManagerView({ fund }: { fund: FundState }) { Manager console — operating fund #{view.index.toString()} - fee {formatBps(view.account!.feeBps)} · fixed at creation, no setter + fee {formatBps(view.account!.feeBps)} · rebalance threshold {formatBps(view.account!.rebalanceThresholdBps)} · + fixed at creation, no setter
@@ -346,12 +343,14 @@ export function CreateFundForm({ fund }: { fund: FundState }) { const [router, setRouter] = useState(ROUTER_PROGRAM_ID.toBase58()); const [feePct, setFeePct] = useState("1"); const [slipPct, setSlipPct] = useState("1"); + const [thresholdPct, setThresholdPct] = useState("5"); const registryPk = tryPubkey(registry); const usdcPk = tryPubkey(usdc); const routerPk = tryPubkey(router); const feeBps = parsePercentToBps(feePct); const slipBps = parsePercentToBps(slipPct); + const thresholdBps = parsePercentToBps(thresholdPct); const block = !fund.connected ? "Connect a wallet to create the fund." @@ -365,7 +364,11 @@ export function CreateFundForm({ fund }: { fund: FundState }) { ? `Fee must be 0–${MAX_FEE_BPS / 100}%.` : slipBps === null || slipBps > MAX_SLIPPAGE_BPS ? `Max slippage must be 0–${MAX_SLIPPAGE_BPS / 100}%.` - : null; + : thresholdBps === null || + thresholdBps < MIN_REBALANCE_THRESHOLD_BPS || + thresholdBps > MAX_REBALANCE_THRESHOLD_BPS + ? `Rebalance threshold must be ${MIN_REBALANCE_THRESHOLD_BPS / 100}–${MAX_REBALANCE_THRESHOLD_BPS / 100}%.` + : null; const ready = !block; return ( @@ -396,9 +399,10 @@ export function CreateFundForm({ fund }: { fund: FundState }) { placeholder="Base58…" invalid={router.trim() !== "" && !routerPk} /> -
+
+
{block && fund.connected &&

{block}

}
diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/src/error.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/src/error.rs index 1f7f9d53b..d0cb49a2b 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/src/error.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/src/error.rs @@ -46,7 +46,7 @@ pub enum FundError { NegativePrice, #[msg("Pyth price feed is stale")] StalePriceFeed, - #[msg("Sell and buy mints must be different")] + #[msg("Sell and buy assets must be different")] SameMint, #[msg("USDC mint does not match the fund's registered USDC mint")] InvalidUsdcMint, @@ -60,4 +60,10 @@ pub enum FundError { DepositTooSmall, #[msg("Rebalance spends more than the fund's recorded holdings")] InsufficientHoldings, + #[msg("Rebalance threshold is outside the allowed range")] + RebalanceThresholdOutOfRange, + #[msg("The asset to sell is not far enough above its target weight to rebalance")] + DriftBelowThreshold, + #[msg("The asset to buy is not below its target weight")] + NotUnderweight, } diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/add_asset.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/add_asset.rs index 5aafd303b..cb2520eb7 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/add_asset.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/add_asset.rs @@ -76,6 +76,7 @@ pub fn handle_add_asset( fund: fund.key(), index, mint: context.accounts.asset_mint.key(), + decimals: context.accounts.asset_mint.decimals, // Copied from the registry entry, never supplied by the manager. price_feed: context.accounts.approved_asset.price_feed, vault: context.accounts.vault_asset.key(), diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/deposit.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/deposit.rs index 050306eed..bdbe2135d 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/deposit.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/deposit.rs @@ -8,7 +8,7 @@ use anchor_spl::{ use mock_swap_router::cpi::accounts::SwapUsdcForAssetAccountConstraints as RouterSwapAccounts; use crate::error::FundError; -use crate::oracle::{asset_value_in_usdc, load_price, read_token_amount, PYTH_PRICE_PRECISION}; +use crate::oracle::{asset_value_in_usdc, load_price, read_token_amount, usdc_to_asset_amount}; use crate::state::{AssetConfig, Fund}; #[derive(Accounts)] @@ -144,7 +144,12 @@ pub fn handle_deposit<'info>( let price = load_price(feed_account, &config.price_feed, now)?; let amount = asset_holdings[index]; nav = nav - .checked_add(asset_value_in_usdc(amount, price)?) + .checked_add(asset_value_in_usdc( + amount as u128, + price, + config.decimals, + usdc_decimals, + )?) .ok_or(FundError::MathOverflow)?; } @@ -214,14 +219,11 @@ pub fn handle_deposit<'info>( continue; } - // Slippage floor anchored to the oracle: expected_out = deploy_usdc * 10^8 / - // price, allowed to fall short by at most max_slippage_bps. + // Slippage floor anchored to the oracle: expected_out is what deploy_usdc + // buys at the Pyth price, allowed to fall short by at most max_slippage_bps. let price = load_price(feed_account, &config.price_feed, now)?; - let expected_out = (deploy_usdc as u128) - .checked_mul(PYTH_PRICE_PRECISION) - .ok_or(FundError::MathOverflow)? - .checked_div(price) - .ok_or(FundError::MathOverflow)?; + let expected_out = + usdc_to_asset_amount(deploy_usdc as u128, price, config.decimals, usdc_decimals)?; let minimum_asset_out: u64 = expected_out .checked_mul((10_000 - max_slippage_bps) as u128) .ok_or(FundError::MathOverflow)? diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/initialize_fund.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/initialize_fund.rs index cd97b5a23..756dd79a6 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/initialize_fund.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/initialize_fund.rs @@ -19,6 +19,15 @@ pub const MAX_FEE_BPS: u16 = 1_000; /// that the bound is meaningless. pub const MAX_SLIPPAGE_BPS: u16 = 1_000; +/// Lowest rebalance threshold a manager may set, in basis points of the fund's +/// value (one percentage point). Every rebalance pays slippage, so a threshold +/// near zero would let anyone trade the fund on every small price move. +pub const MIN_REBALANCE_THRESHOLD_BPS: u16 = 100; + +/// Highest rebalance threshold a manager may set (twenty percentage points). +/// Past that, the target weights stop describing what the fund holds. +pub const MAX_REBALANCE_THRESHOLD_BPS: u16 = 2_000; + #[derive(Accounts)] #[instruction(index: u64)] pub struct InitializeFundAccountConstraints<'info> { @@ -71,6 +80,7 @@ pub fn handle_initialize_fund( index: u64, fee_bps: u16, max_slippage_bps: u16, + rebalance_threshold_bps: u16, swap_router: Pubkey, ) -> Result<()> { require!(fee_bps <= MAX_FEE_BPS, FundError::FeeTooHigh); @@ -78,6 +88,11 @@ pub fn handle_initialize_fund( max_slippage_bps <= MAX_SLIPPAGE_BPS, FundError::SlippageConfigTooHigh ); + require!( + (MIN_REBALANCE_THRESHOLD_BPS..=MAX_REBALANCE_THRESHOLD_BPS) + .contains(&rebalance_threshold_bps), + FundError::RebalanceThresholdOutOfRange + ); let clock = Clock::get()?; @@ -87,9 +102,11 @@ pub fn handle_initialize_fund( registry: context.accounts.registry.key(), share_mint: context.accounts.share_mint.key(), usdc_mint: context.accounts.usdc_mint.key(), + usdc_decimals: context.accounts.usdc_mint.decimals, swap_router, fee_bps, max_slippage_bps, + rebalance_threshold_bps, total_shares: 0, usdc_holdings: 0, asset_holdings: [0; MAX_ASSETS as usize], diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/rebalance.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/rebalance.rs index 1c0d9c223..ac30d48a4 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/rebalance.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/rebalance.rs @@ -3,22 +3,26 @@ use anchor_spl::{ associated_token::AssociatedToken, token_interface::{Mint, TokenAccount, TokenInterface}, }; -use mock_swap_router::{ - cpi::accounts::SwapAssetForUsdcAccountConstraints as RouterSellAccounts, - cpi::accounts::SwapUsdcForAssetAccountConstraints as RouterBuyAccounts, state::AssetRate, +use mock_swap_router::cpi::accounts::{ + SwapAssetForUsdcAccountConstraints as RouterSellAccounts, + SwapUsdcForAssetAccountConstraints as RouterBuyAccounts, }; use crate::error::FundError; -use crate::oracle::{load_price, read_token_amount, PYTH_PRICE_PRECISION}; -use crate::state::{AssetConfig, Fund}; +use crate::oracle::{ + asset_value_in_usdc, load_price, read_token_amount, usdc_to_asset_amount, OraclePrice, +}; +use crate::state::{AssetConfig, Fund, MAX_ASSETS}; #[derive(Accounts)] pub struct RebalanceAccountConstraints<'info> { - pub manager: Signer<'info>, + /// Anyone. The program computes the trade from the oracle and the target + /// weights, so the caller chooses nothing but which pair to restore, and + /// pays the transaction fee. + pub caller: Signer<'info>, #[account( mut, - has_one = manager, has_one = usdc_mint @ FundError::InvalidUsdcMint, seeds = [b"fund", fund.index.to_le_bytes().as_ref()], bump = fund.bump @@ -27,50 +31,6 @@ pub struct RebalanceAccountConstraints<'info> { pub usdc_mint: Box>, - #[account(mut)] - pub sell_mint: Box>, - - #[account(mut)] - pub buy_mint: Box>, - - #[account( - constraint = sell_config.fund == fund.key() @ FundError::InvalidAssetAccount, - constraint = sell_config.mint == sell_mint.key() @ FundError::AssetNotFound, - constraint = sell_config.vault == vault_sell.key() @ FundError::InvalidAssetAccount, - )] - pub sell_config: Box>, - - #[account( - constraint = buy_config.fund == fund.key() @ FundError::InvalidAssetAccount, - constraint = buy_config.mint == buy_mint.key() @ FundError::AssetNotFound, - constraint = buy_config.vault == vault_buy.key() @ FundError::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 @ FundError::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 @ FundError::InvalidPriceFeed)] - pub buy_price_feed: UncheckedAccount<'info>, - - #[account( - mut, - associated_token::mint = sell_mint, - associated_token::authority = fund, - associated_token::token_program = token_program - )] - pub vault_sell: Box>, - - #[account( - mut, - associated_token::mint = buy_mint, - associated_token::authority = fund, - associated_token::token_program = token_program - )] - pub vault_buy: Box>, - #[account( mut, associated_token::mint = usdc_mint, @@ -79,10 +39,6 @@ pub struct RebalanceAccountConstraints<'info> { )] pub vault_usdc: Box>, - pub sell_rate: Account<'info, AssetRate>, - - pub buy_rate: Account<'info, AssetRate>, - /// CHECK: Router config PDA #[account(mut)] pub router_config: UncheckedAccount<'info>, @@ -99,89 +55,171 @@ pub struct RebalanceAccountConstraints<'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, asset_mint, asset_rate, price_feed] + // the same layout as deposit. Every asset is valued, so the targets are + // shares of the whole fund. } -pub fn handle_rebalance( - context: Context, - sell_amount: u64, - usdc_to_invest: u64, +/// Restore one pair of assets toward their target weights. The asset at +/// `sell_index` must sit above its target by at least the fund's +/// `rebalance_threshold_bps` of the fund's value (or be retired, at weight zero, +/// and still held); the asset at `buy_index` must sit below its target. The +/// trade is the smaller of the two gaps, so it never pushes either asset past +/// its target, and a second call straight after finds nothing to do. That is +/// what lets anyone call it: no caller can choose a trade size, and none can +/// trade a fund that has not drifted. +pub fn handle_rebalance<'info>( + context: Context<'info, RebalanceAccountConstraints<'info>>, + sell_index: u8, + buy_index: u8, ) -> Result<()> { - require!( - context.accounts.sell_mint.key() != context.accounts.buy_mint.key(), - FundError::SameMint - ); + require!(sell_index != buy_index, FundError::SameMint); let fund = &context.accounts.fund; + let fund_key = fund.key(); let fund_index = fund.index; let fund_bump = fund.bump; let slip = (10_000 - fund.max_slippage_bps) as u128; + let threshold_bps = fund.rebalance_threshold_bps as u128; + let usdc_decimals = fund.usdc_decimals; + let asset_count = fund.asset_count as usize; + // Recorded holdings, never vault balances: tokens donated into a vault + // cannot make an asset look over its target and force a trade. + let mut usdc_holdings = fund.usdc_holdings; + let mut asset_holdings = fund.asset_holdings; + + let sell = sell_index as usize; + let buy = buy_index as usize; + require!( + sell < asset_count && buy < asset_count, + FundError::InvalidAssetAccount + ); + + let remaining = context.remaining_accounts; + require!( + remaining.len() == asset_count * 5, + FundError::IncompleteAssetAccounts + ); 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(FundError::MathOverflow)? - .checked_div(PYTH_PRICE_PRECISION) - .ok_or(FundError::MathOverflow)?; - let minimum_usdc_from_sell: u64 = expected_usdc - .checked_mul(slip) - .ok_or(FundError::MathOverflow)? - .checked_div(10_000) - .ok_or(FundError::MathOverflow)? - .try_into() - .map_err(|_| FundError::MathOverflow)?; + // Value every asset, exactly as deposit does. + let mut nav: u128 = usdc_holdings as u128; + let mut values = [0u128; MAX_ASSETS as usize]; + let mut sell_asset: Option<(AssetConfig, OraclePrice)> = None; + let mut buy_asset: Option<(AssetConfig, OraclePrice)> = None; + for index in 0..asset_count { + let config = AssetConfig::load_checked(&remaining[index * 5])?; + require_keys_eq!(config.fund, fund_key, FundError::InvalidAssetAccount); + require!( + config.index as usize == index, + FundError::InvalidAssetAccount + ); + require_keys_eq!( + remaining[index * 5 + 1].key(), + config.vault, + FundError::InvalidAssetAccount + ); + require_keys_eq!( + remaining[index * 5 + 2].key(), + config.mint, + FundError::InvalidAssetAccount + ); + + let price = load_price(&remaining[index * 5 + 4], &config.price_feed, now)?; + values[index] = asset_value_in_usdc( + asset_holdings[index] as u128, + price, + config.decimals, + usdc_decimals, + )?; + nav = nav + .checked_add(values[index]) + .ok_or(FundError::MathOverflow)?; + + if index == sell { + sell_asset = Some((config, price)); + } else if index == buy { + buy_asset = Some((config, price)); + } + } + let (sell_config, sell_price) = sell_asset.ok_or(FundError::InvalidAssetAccount)?; + let (buy_config, buy_price) = buy_asset.ok_or(FundError::InvalidAssetAccount)?; + + let target = |weight_bps: u16| -> Result { + nav.checked_mul(weight_bps as u128) + .ok_or(FundError::MathOverflow)? + .checked_div(10_000) + .ok_or(FundError::MathOverflow.into()) + }; - // 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(FundError::MathOverflow)? - .checked_div(price_buy) - .ok_or(FundError::MathOverflow)?; - let minimum_buy_amount: u64 = expected_buy - .checked_mul(slip) + // The sell side: how far above its target the asset sits. A retired asset + // may always be sold down; any other only once the gap reaches the + // threshold, so the fund does not trade on every small price move. + let excess = values[sell].saturating_sub(target(sell_config.weight_bps)?); + let threshold_value = nav + .checked_mul(threshold_bps) .ok_or(FundError::MathOverflow)? .checked_div(10_000) - .ok_or(FundError::MathOverflow)? - .try_into() - .map_err(|_| FundError::MathOverflow)?; - - // Rebalancing may only trade what the program has accounted for: tokens - // donated into a vault are outside the fund, so they can be neither sold nor - // spent. The legs below record what each swap actually moved. - let sell_index = context.accounts.sell_config.index as usize; - let buy_index = context.accounts.buy_config.index as usize; - let mut usdc_holdings = context.accounts.fund.usdc_holdings; - let mut asset_holdings = context.accounts.fund.asset_holdings; + .ok_or(FundError::MathOverflow)?; + require!( + excess > 0 && (sell_config.weight_bps == 0 || excess >= threshold_value), + FundError::DriftBelowThreshold + ); + + // The buy side: how far below its target the asset sits. + let shortfall = target(buy_config.weight_bps)?.saturating_sub(values[buy]); + require!(shortfall > 0, FundError::NotUnderweight); + + // Trade the smaller gap, so neither asset ends up past its target. + let trade_value = excess.min(shortfall); + let sell_amount: u64 = + usdc_to_asset_amount(trade_value, sell_price, sell_config.decimals, usdc_decimals)? + .try_into() + .map_err(|_| FundError::MathOverflow)?; + require!(sell_amount > 0, FundError::DriftBelowThreshold); require!( - sell_amount <= asset_holdings[sell_index], + sell_amount <= asset_holdings[sell], FundError::InsufficientHoldings ); + // Sell leg floor: USDC out within slippage of the oracle value of what is sold. + let minimum_usdc_from_sell: u64 = asset_value_in_usdc( + sell_amount as u128, + sell_price, + sell_config.decimals, + usdc_decimals, + )? + .checked_mul(slip) + .ok_or(FundError::MathOverflow)? + .checked_div(10_000) + .ok_or(FundError::MathOverflow)? + .try_into() + .map_err(|_| FundError::MathOverflow)?; + let index_bytes = fund_index.to_le_bytes(); let signer_seeds: &[&[&[u8]]] = &[&[b"fund", index_bytes.as_ref(), &[fund_bump]]]; - let sell_before = read_token_amount(&context.accounts.vault_sell.to_account_info())?; - let usdc_before_sell = read_token_amount(&context.accounts.vault_usdc.to_account_info())?; + let vault_sell = &remaining[sell * 5 + 1]; + let mint_sell = &remaining[sell * 5 + 2]; + let rate_sell = &remaining[sell * 5 + 3]; + let vault_buy = &remaining[buy * 5 + 1]; + let mint_buy = &remaining[buy * 5 + 2]; + let rate_buy = &remaining[buy * 5 + 3]; + let vault_usdc_info = context.accounts.vault_usdc.to_account_info(); + + let sell_before = read_token_amount(vault_sell)?; + let usdc_before_sell = read_token_amount(&vault_usdc_info)?; - // Step 1: sell basket token -> USDC + // Step 1: sell the over-weight asset for USDC. let sell_cpi_accounts = RouterSellAccounts { caller: context.accounts.fund.to_account_info(), router_config: context.accounts.router_config.to_account_info(), - asset_rate: context.accounts.sell_rate.to_account_info(), + asset_rate: rate_sell.clone(), usdc_mint: context.accounts.usdc_mint.to_account_info(), - asset_mint: context.accounts.sell_mint.to_account_info(), - caller_asset_account: context.accounts.vault_sell.to_account_info(), + asset_mint: mint_sell.clone(), + caller_asset_account: vault_sell.clone(), caller_usdc_account: context.accounts.vault_usdc.to_account_info(), router_usdc_treasury: context.accounts.router_usdc_treasury.to_account_info(), associated_token_program: context.accounts.associated_token_program.to_account_info(), @@ -198,36 +236,48 @@ pub fn handle_rebalance( minimum_usdc_from_sell, )?; + // Record what the swap actually moved, measured on the vaults. let sold = sell_before - .checked_sub(read_token_amount( - &context.accounts.vault_sell.to_account_info(), - )?) + .checked_sub(read_token_amount(vault_sell)?) .ok_or(FundError::MathOverflow)?; - let usdc_after_sell = read_token_amount(&context.accounts.vault_usdc.to_account_info())?; + let usdc_after_sell = read_token_amount(&vault_usdc_info)?; let usdc_received = usdc_after_sell .checked_sub(usdc_before_sell) .ok_or(FundError::MathOverflow)?; - asset_holdings[sell_index] = asset_holdings[sell_index] + asset_holdings[sell] = asset_holdings[sell] .checked_sub(sold) .ok_or(FundError::InsufficientHoldings)?; usdc_holdings = usdc_holdings .checked_add(usdc_received) .ok_or(FundError::MathOverflow)?; - require!( - usdc_to_invest <= usdc_holdings, - FundError::InsufficientHoldings - ); - let buy_before = read_token_amount(&context.accounts.vault_buy.to_account_info())?; - // Step 2: buy basket token with USDC + // Step 2: spend exactly what the sale brought in on the under-weight asset. + // Donated USDC in the vault is never spent, because only the sale's proceeds + // are invested. + let usdc_to_invest = usdc_received; + let minimum_buy_amount: u64 = usdc_to_asset_amount( + usdc_to_invest as u128, + buy_price, + buy_config.decimals, + usdc_decimals, + )? + .checked_mul(slip) + .ok_or(FundError::MathOverflow)? + .checked_div(10_000) + .ok_or(FundError::MathOverflow)? + .try_into() + .map_err(|_| FundError::MathOverflow)?; + + let buy_before = read_token_amount(vault_buy)?; + let buy_cpi_accounts = RouterBuyAccounts { caller: context.accounts.fund.to_account_info(), router_config: context.accounts.router_config.to_account_info(), - asset_rate: context.accounts.buy_rate.to_account_info(), + asset_rate: rate_buy.clone(), usdc_mint: context.accounts.usdc_mint.to_account_info(), - asset_mint: context.accounts.buy_mint.to_account_info(), + asset_mint: mint_buy.clone(), caller_usdc_account: context.accounts.vault_usdc.to_account_info(), - caller_asset_account: context.accounts.vault_buy.to_account_info(), + caller_asset_account: vault_buy.clone(), router_usdc_treasury: context.accounts.router_usdc_treasury.to_account_info(), associated_token_program: context.accounts.associated_token_program.to_account_info(), token_program: context.accounts.token_program.to_account_info(), @@ -243,24 +293,21 @@ pub fn handle_rebalance( minimum_buy_amount, )?; - let bought = read_token_amount(&context.accounts.vault_buy.to_account_info())? + let bought = read_token_amount(vault_buy)? .checked_sub(buy_before) .ok_or(FundError::MathOverflow)?; let usdc_spent = usdc_after_sell - .checked_sub(read_token_amount( - &context.accounts.vault_usdc.to_account_info(), - )?) + .checked_sub(read_token_amount(&vault_usdc_info)?) .ok_or(FundError::MathOverflow)?; - asset_holdings[buy_index] = asset_holdings[buy_index] + asset_holdings[buy] = asset_holdings[buy] .checked_add(bought) .ok_or(FundError::MathOverflow)?; usdc_holdings = usdc_holdings .checked_sub(usdc_spent) .ok_or(FundError::InsufficientHoldings)?; - let fund = &mut context.accounts.fund; - fund.usdc_holdings = usdc_holdings; - fund.asset_holdings = asset_holdings; + context.accounts.fund.usdc_holdings = usdc_holdings; + context.accounts.fund.asset_holdings = asset_holdings; Ok(()) } diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/set_weight.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/set_weight.rs index 00aa21774..7c21e6afb 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/set_weight.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/src/instructions/set_weight.rs @@ -23,10 +23,11 @@ pub struct SetWeightAccountConstraints<'info> { } /// Change an asset's target weight. Setting it to zero retires the asset: deposits -/// stop allocating to it, and the manager sells its holdings out with `rebalance`, -/// leaving an empty vault at the asset's index. The index is never reused, so the -/// contiguous 0..asset_count range the valuation handlers depend on stays intact. -/// Funds do not move here; this only edits the target the manager trades toward. +/// stop allocating to it, and `rebalance` may sell all of its holdings whatever the +/// fund's threshold, leaving an empty vault at the asset's index. The index is never +/// reused, so the contiguous 0..asset_count range the valuation handlers depend on +/// stays intact. Funds do not move here; this only edits the target that +/// `rebalance` trades toward. pub fn handle_set_weight( context: Context, weight_bps: u16, diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/src/lib.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/src/lib.rs index b5b982655..8cfeedb21 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/src/lib.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/src/lib.rs @@ -37,6 +37,7 @@ pub mod managed_fund { index: u64, fee_bps: u16, max_slippage_bps: u16, + rebalance_threshold_bps: u16, swap_router: Pubkey, ) -> Result<()> { instructions::initialize_fund::handle_initialize_fund( @@ -44,6 +45,7 @@ pub mod managed_fund { index, fee_bps, max_slippage_bps, + rebalance_threshold_bps, swap_router, ) } @@ -81,11 +83,14 @@ pub mod managed_fund { instructions::withdraw::handle_withdraw(context, shares_to_burn, min_usdc_out) } - pub fn rebalance( - context: Context, - sell_amount: u64, - usdc_to_invest: u64, + /// Sell an asset that has drifted above its target weight and buy one below + /// its target. Anyone may call it: the program computes the trade, so the + /// caller chooses only which pair to restore. + pub fn rebalance<'info>( + context: Context<'info, RebalanceAccountConstraints<'info>>, + sell_index: u8, + buy_index: u8, ) -> Result<()> { - instructions::rebalance::handle_rebalance(context, sell_amount, usdc_to_invest) + instructions::rebalance::handle_rebalance(context, sell_index, buy_index) } } diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/src/oracle.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/src/oracle.rs index 589d8ee8f..9f9a438af 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/src/oracle.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/src/oracle.rs @@ -6,14 +6,14 @@ use crate::error::FundError; /// 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 `exponent` (i32): price(8) + conf(8) = +16 bytes after price. +const PYTH_EXPONENT_OFFSET: usize = PYTH_PRICE_OFFSET + 8 + 8; // 89 /// 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 /// Byte offset of `posted_slot` (u64), the slot the update was posted in: /// publish_time(8) + prev_publish_time(8) + ema_price(8) + ema_conf(8) = +32 bytes const PYTH_POSTED_SLOT_OFFSET: usize = PYTH_PUBLISH_TIME_OFFSET + 8 + 8 + 8 + 8; // 125 -/// 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; @@ -26,8 +26,17 @@ const TOKEN_OWNER_OFFSET: usize = 32; /// `mint` Pubkey is at bytes 0..32. const TOKEN_MINT_OFFSET: usize = 0; -/// Returns `(price, publish_time, posted_slot)`. -fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i64, u64)> { +/// A positive Pyth price: `price * 10^exponent` dollars per whole token. +/// Crypto USD feeds use exponent -8 and US equity feeds -5, so the exponent is +/// read from the feed rather than assumed. +#[derive(Clone, Copy)] +pub struct OraclePrice { + pub price: u128, + pub exponent: i32, +} + +/// Returns `(price, exponent, publish_time, posted_slot)`. +fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i32, i64, u64)> { if account_data.len() < PYTH_POSTED_SLOT_OFFSET + 8 { return err!(FundError::InvalidPriceFeed); } @@ -36,6 +45,11 @@ fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i64, u64)> { .try_into() .map_err(|_| FundError::InvalidPriceFeed)?, ); + let exponent = i32::from_le_bytes( + account_data[PYTH_EXPONENT_OFFSET..PYTH_EXPONENT_OFFSET + 4] + .try_into() + .map_err(|_| FundError::InvalidPriceFeed)?, + ); let publish_time = i64::from_le_bytes( account_data[PYTH_PUBLISH_TIME_OFFSET..PYTH_PUBLISH_TIME_OFFSET + 8] .try_into() @@ -46,17 +60,21 @@ fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i64, u64)> { .try_into() .map_err(|_| FundError::InvalidPriceFeed)?, ); - Ok((price, publish_time, posted_slot)) + Ok((price, exponent, publish_time, posted_slot)) } /// Validate a price feed account against the one the fund registered, then -/// return its positive, fresh price as u128. `now` is the current unix timestamp. +/// return its positive, fresh price. `now` is the current unix timestamp. /// A price posted at or before the last cluster restart is rejected too. -pub fn load_price(price_feed: &AccountInfo, expected_key: &Pubkey, now: i64) -> Result { +pub fn load_price( + price_feed: &AccountInfo, + expected_key: &Pubkey, + now: i64, +) -> Result { require_keys_eq!(price_feed.key(), *expected_key, FundError::InvalidPriceFeed); let data = price_feed.try_borrow_data()?; - let (price, publish_time, posted_slot) = read_pyth_raw(&data)?; + let (price, exponent, publish_time, posted_slot) = read_pyth_raw(&data)?; require!(price > 0, FundError::NegativePrice); require!( @@ -80,7 +98,10 @@ pub fn load_price(price_feed: &AccountInfo, expected_key: &Pubkey, now: i64) -> FundError::PricePredatesRestart ); - Ok(price as u128) + Ok(OraclePrice { + price: price as u128, + exponent, + }) } /// Read the `amount` field of a token account from its raw data. @@ -120,14 +141,54 @@ pub fn read_token_mint_and_owner(account: &AccountInfo) -> Result<(Pubkey, Pubke 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) +/// `numerator * 10^power / denominator`, floored, for a power of either sign: +/// a negative power divides by `10^-power` instead. Multiplies before dividing. +fn mul_pow10_div(numerator: u128, power: i32, denominator: u128) -> Result { + let scale = 10u128 + .checked_pow(power.unsigned_abs()) + .ok_or(FundError::MathOverflow)?; + let (numerator, denominator) = if power >= 0 { + (numerator.checked_mul(scale), Some(denominator)) + } else { + (Some(numerator), denominator.checked_mul(scale)) + }; + numerator .ok_or(FundError::MathOverflow)? - .checked_div(PYTH_PRICE_PRECISION) - .ok_or(FundError::MathOverflow) - .map_err(Into::into) + .checked_div(denominator.ok_or(FundError::MathOverflow)?) + .ok_or(FundError::MathOverflow.into()) +} + +/// Value of `amount` asset minor units in USDC minor units. The asset has +/// `asset_decimals`, USDC has `usdc_decimals`, and a whole asset is worth +/// `price * 10^exponent` dollars, so +/// value = amount * price * 10^(usdc_decimals + exponent - asset_decimals). +/// With six-decimal USDC, an eight-decimal asset and an exponent of -8, that is +/// amount * price / 10^10. Floored. +pub fn asset_value_in_usdc( + amount: u128, + price: OraclePrice, + asset_decimals: u8, + usdc_decimals: u8, +) -> Result { + let power = usdc_decimals as i32 + price.exponent - asset_decimals as i32; + mul_pow10_div( + amount + .checked_mul(price.price) + .ok_or(FundError::MathOverflow)?, + power, + 1, + ) +} + +/// The inverse of `asset_value_in_usdc`: how many asset minor units +/// `usdc_amount` USDC minor units buys at the oracle price, +/// usdc_amount * 10^(asset_decimals - exponent - usdc_decimals) / price. Floored. +pub fn usdc_to_asset_amount( + usdc_amount: u128, + price: OraclePrice, + asset_decimals: u8, + usdc_decimals: u8, +) -> Result { + let power = asset_decimals as i32 - price.exponent - usdc_decimals as i32; + mul_pow10_div(usdc_amount, power, price.price) } diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/src/state/fund.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/src/state/fund.rs index 3db0aabdf..aa65c5fcc 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/src/state/fund.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/src/state/fund.rs @@ -26,12 +26,20 @@ pub struct Fund { pub registry: Pubkey, pub share_mint: Pubkey, pub usdc_mint: Pubkey, + /// The USDC mint's decimals, read from the mint at creation. Valuation + /// scales every asset into these minor units. + pub usdc_decimals: u8, 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 deposit/rebalance. Bounded by MAX_SLIPPAGE_BPS. pub max_slippage_bps: u16, + /// How far, in basis points of the fund's value, an asset must sit above its + /// target weight before `rebalance` may sell it. Set at creation within + /// MIN/MAX_REBALANCE_THRESHOLD_BPS and never changed, so nobody can lower it + /// later to trade the fund on every small price move. + pub rebalance_threshold_bps: u16, pub total_shares: u64, /// USDC the program has accounted for in the USDC vault: deposits in, swap /// spending and withdrawals out. Share prices and payouts use this, never the @@ -61,14 +69,17 @@ pub struct AssetConfig { pub fund: Pubkey, pub index: u8, pub mint: Pubkey, + /// The asset mint's decimals, read from the mint when the asset is added. + /// Valuation scales by them, so a basket can mix assets of any precision. + pub decimals: u8, /// Pyth PriceUpdateV2 account, copied from the registry's ApprovedAsset at /// add time so the manager cannot substitute a feed they control. pub price_feed: Pubkey, /// Fund-owned associated token account holding this asset. pub vault: Pubkey, /// Target share of the fund's value in basis points. deposit deploys at these - /// weights (the sum across assets must reach 10000 before deposits open), and the - /// manager maintains them against price drift with rebalance. + /// weights (the sum across assets must reach 10000 before deposits open), and + /// rebalance restores them once prices drift past the fund's threshold. pub weight_bps: u16, pub bump: u8, } diff --git a/finance/managed-fund/anchor-v1/programs/managed-fund/tests/managed_fund.rs b/finance/managed-fund/anchor-v1/programs/managed-fund/tests/managed_fund.rs index ff2055617..4b876ad69 100644 --- a/finance/managed-fund/anchor-v1/programs/managed-fund/tests/managed_fund.rs +++ b/finance/managed-fund/anchor-v1/programs/managed-fund/tests/managed_fund.rs @@ -77,7 +77,12 @@ fn set_price_feed(svm: &mut LiteSVM, key: Pubkey, price: i64) { /// Write a Pyth feed as if Pyth posted it in `posted_slot`. fn set_price_feed_posted_at(svm: &mut LiteSVM, key: Pubkey, price: i64, posted_slot: u64) { - let data = build_mock_price_update_account(price, -8, PUBLISH_TIME, posted_slot); + write_price_feed(svm, key, price, -8, posted_slot); +} + +/// Write a Pyth feed with its own exponent: Pyth's US equity feeds use -5. +fn write_price_feed(svm: &mut LiteSVM, key: Pubkey, price: i64, exponent: i32, posted_slot: u64) { + let data = build_mock_price_update_account(price, exponent, PUBLISH_TIME, posted_slot); let rent = svm.minimum_balance_for_rent_exemption(data.len()); svm.set_account( key, @@ -98,11 +103,12 @@ 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 TSLA_RATE: u64 = 250_000_000; // router USDC minor units per whole token +const NVDA_RATE: u64 = 180_000_000; const FEE_BPS: u16 = 100; // 1% const SLIPPAGE_BPS: u16 = 100; // 1% +const REBALANCE_THRESHOLD_BPS: u16 = 200; // two percentage points const FUND_INDEX: u64 = 0; // fund PDA seed: "fund" + 0 struct TestContext { @@ -143,6 +149,11 @@ impl TestContext { /// Mints, router (config + rates + treasury), Pyth feeds, a registry with TSLAx /// and NVDAx approved, and all derived PDAs. Does not create the fund. fn setup_full() -> TestContext { + setup_with_tsla_decimals(TOKEN_DECIMALS) +} + +/// `setup_full`, with TSLAx minted at `tsla_decimals` instead of six. +fn setup_with_tsla_decimals(tsla_decimals: u8) -> TestContext { let fund_program_id = managed_fund::id(); let router_program_id = mock_swap_router::id(); @@ -175,7 +186,7 @@ fn setup_full() -> TestContext { let manager = create_wallet(&mut svm, 10_000_000_000).unwrap(); 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 tsla_mint = create_token_mint(&mut svm, &payer, tsla_decimals, None).unwrap(); let nvda_mint = create_token_mint(&mut svm, &payer, TOKEN_DECIMALS, None).unwrap(); let (router_config_pda, _) = @@ -343,13 +354,20 @@ fn setup_full() -> TestContext { } } -fn init_fund(ctx: &mut TestContext, fee_bps: u16, slippage_bps: u16, router: Pubkey) { - let ix = Instruction::new_with_bytes( +fn initialize_fund_instruction( + ctx: &TestContext, + fee_bps: u16, + max_slippage_bps: u16, + rebalance_threshold_bps: u16, + router: Pubkey, +) -> Instruction { + Instruction::new_with_bytes( ctx.fund_program_id, &managed_fund::instruction::InitializeFund { index: FUND_INDEX, fee_bps, - max_slippage_bps: slippage_bps, + max_slippage_bps, + rebalance_threshold_bps, swap_router: router, } .data(), @@ -365,7 +383,12 @@ fn init_fund(ctx: &mut TestContext, fee_bps: u16, slippage_bps: u16, router: Pub system_program: system_program::id(), } .to_account_metas(None), - ); + ) +} + +fn init_fund(ctx: &mut TestContext, fee_bps: u16, slippage_bps: u16, router: Pubkey) { + let ix = + initialize_fund_instruction(ctx, fee_bps, slippage_bps, REBALANCE_THRESHOLD_BPS, router); send_transaction_from_instructions( &mut ctx.svm, vec![ix], @@ -656,76 +679,74 @@ fn read_asset_config(ctx: &TestContext, index: u8) -> managed_fund::state::Asset managed_fund::state::AssetConfig::try_deserialize(&mut &account.data[..]).unwrap() } -/// (mint, asset_config, price_feed, vault, rate_pda) for an asset in the two-asset -/// standard fund: index 0 is TSLAx, index 1 is NVDAx. -fn asset_accounts(ctx: &TestContext, index: u8) -> (Pubkey, Pubkey, Pubkey, Pubkey, Pubkey) { - match index { - 0 => ( - ctx.tsla_mint, - ctx.asset_config(0), - ctx.price_feed_tsla, - ctx.vault_tsla, - ctx.tsla_rate_pda, - ), - 1 => ( - ctx.nvda_mint, - ctx.asset_config(1), - ctx.price_feed_nvda, - ctx.vault_nvda, - ctx.nvda_rate_pda, - ), - _ => panic!("unknown asset index {index}"), - } +/// A rebalance of the standard fund, signed by `caller`: the program computes +/// the trade, so the caller names only the pair. The remaining accounts are the +/// same five per asset that a deposit takes. +fn rebalance_instruction( + ctx: &TestContext, + caller: &Keypair, + sell_index: u8, + buy_index: u8, +) -> Instruction { + rebalance_instruction_with(ctx, caller, sell_index, buy_index, deposit_remaining(ctx)) } -fn do_rebalance( - ctx: &mut TestContext, +fn rebalance_instruction_with( + ctx: &TestContext, + caller: &Keypair, sell_index: u8, buy_index: u8, - sell_amount: u64, - usdc_to_invest: u64, -) { - let (sell_mint, sell_config, sell_feed, vault_sell, sell_rate) = - asset_accounts(ctx, sell_index); - let (buy_mint, buy_config, buy_feed, vault_buy, buy_rate) = asset_accounts(ctx, buy_index); - let ix = Instruction::new_with_bytes( + remaining: Vec, +) -> Instruction { + let mut metas = managed_fund::accounts::RebalanceAccountConstraints { + caller: caller.pubkey(), + fund: ctx.fund_pda, + usdc_mint: ctx.usdc_mint, + vault_usdc: ctx.vault_usdc, + router_config: ctx.router_config_pda, + router_usdc_treasury: ctx.router_usdc_treasury, + 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); + metas.extend(remaining); + Instruction::new_with_bytes( ctx.fund_program_id, &managed_fund::instruction::Rebalance { - sell_amount, - usdc_to_invest, + sell_index, + buy_index, } .data(), - managed_fund::accounts::RebalanceAccountConstraints { - manager: ctx.manager.pubkey(), - fund: ctx.fund_pda, - usdc_mint: ctx.usdc_mint, - sell_mint, - buy_mint, - sell_config, - buy_config, - sell_price_feed: sell_feed, - buy_price_feed: buy_feed, - vault_sell, - vault_buy, - vault_usdc: ctx.vault_usdc, - sell_rate, - buy_rate, - router_config: ctx.router_config_pda, - router_usdc_treasury: ctx.router_usdc_treasury, - 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![ix], - &[&ctx.manager], - &ctx.manager.pubkey(), + metas, ) - .unwrap(); +} + +/// Rebalance the standard fund, signed by a fresh wallet that is neither the +/// manager nor a depositor. +fn try_rebalance( + ctx: &mut TestContext, + sell_index: u8, + buy_index: u8, +) -> Result<(), solana_kite::SolanaKiteError> { + let stranger = create_wallet(&mut ctx.svm, 1_000_000_000).unwrap(); + let ix = rebalance_instruction(ctx, &stranger, sell_index, buy_index); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&stranger], &stranger.pubkey()) +} + +fn do_rebalance(ctx: &mut TestContext, sell_index: u8, buy_index: u8) { + try_rebalance(ctx, sell_index, buy_index).unwrap(); +} + +/// Assert that a transaction failed with one of the program's errors. +fn assert_program_error( + result: Result<(), solana_kite::SolanaKiteError>, + error: FundError, + why: &str, +) { + let err = format!("{:?}", result.expect_err(why)); + assert!(err.contains(&program_error(error)), "{why}: {err}"); } fn advance_one_year(ctx: &mut TestContext) { @@ -896,6 +917,7 @@ fn test_initialize_rejects_excessive_fee() { index: FUND_INDEX, fee_bps: excessive, max_slippage_bps: SLIPPAGE_BPS, + rebalance_threshold_bps: REBALANCE_THRESHOLD_BPS, swap_router: ctx.router_program_id, } .data(), @@ -931,6 +953,7 @@ fn test_initialize_rejects_excessive_slippage() { index: FUND_INDEX, fee_bps: FEE_BPS, max_slippage_bps: excessive, + rebalance_threshold_bps: REBALANCE_THRESHOLD_BPS, swap_router: ctx.router_program_id, } .data(), @@ -1028,7 +1051,7 @@ fn test_deposit_rejects_slippage() { // Router rate for TSLAx far worse than the oracle: a deposit's TSLAx deploy leg // must revert, taking the whole deposit with it. let (tsla_mint, tsla_rate_pda) = (ctx.tsla_mint, ctx.tsla_rate_pda); - set_router_rate(&mut ctx, tsla_mint, 300, tsla_rate_pda); + set_router_rate(&mut ctx, tsla_mint, 300_000_000, tsla_rate_pda); let user = fund_user(&mut ctx, 10_000_000); let ix = deposit_instruction(&ctx, &user, 10_000_000, 1, deposit_remaining(&ctx)); @@ -1125,7 +1148,7 @@ fn test_deposit_fair_pricing() { ); // NVDAx rises 180 -> 200. NAV rises to 0 + 1.44*250 + 3.0*200 = 960 USDC. - set_nvda_price(&mut ctx, 20_000_000_000, 200); + set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); // Bob deposits 480 USDC at the higher NAV: shares = 480 * 900 / 960 = 450,000,000. // He pays today's price, so he does not dilute Alice's gain. @@ -1155,10 +1178,13 @@ fn test_rebalance() { do_deposit(&mut ctx, &alice, 900_000_000, 1); // NVDAx rises 180 -> 200, pushing the basket to 37.5 / 62.5 by value. - set_nvda_price(&mut ctx, 20_000_000_000, 200); + set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); - // Rebalance back toward 40/60: sell 0.12 NVDAx for 24 USDC, buy 0.096 TSLAx with it. - do_rebalance(&mut ctx, 1, 0, 120_000, 24_000_000); + // A stranger, neither the manager nor a depositor, rebalances. The program + // computes the trade back to 40/60: NVDAx is $24 over its $576 target and + // TSLAx $24 under its $384 target, so it sells 0.12 NVDAx for 24 USDC and + // buys 0.096 TSLAx with it. + do_rebalance(&mut ctx, 1, 0); // 1.44 + 0.096 = 1.536 TSLAx; 3.0 - 0.12 = 2.88 NVDAx. Now 384 / 576 = 40 / 60. // The USDC vault nets to zero across the two legs. @@ -1459,8 +1485,8 @@ fn test_full_lifecycle() { assert_holdings_match_vaults(&ctx); // NVDAx 180 -> 200; basket drifts to 37.5 / 62.5. Rebalance back to 40/60. - set_nvda_price(&mut ctx, 20_000_000_000, 200); - do_rebalance(&mut ctx, 1, 0, 120_000, 24_000_000); + set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); + do_rebalance(&mut ctx, 1, 0); assert_holdings_match_vaults(&ctx); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), @@ -1620,79 +1646,327 @@ fn test_deposit_rejects_leg_that_buys_nothing() { assert_eq!(read_fund(&ctx).asset_holdings[0], 4_000); } -/// Donated USDC is outside the fund, so a rebalance cannot spend it: the buy leg -/// may spend at most the recorded USDC, which after the sell leg is what the -/// sale brought in. +/// Transfer `amount` of `mint` from the donor's token account straight into a +/// fund vault, never calling a handler. +fn donate_token( + ctx: &mut TestContext, + donor: &Keypair, + mint: &Pubkey, + vault: &Pubkey, + amount: u64, +) { + let ix = spl_token::instruction::transfer( + &spl_token::ID, + &derive_ata(&donor.pubkey(), mint), + vault, + &donor.pubkey(), + &[], + amount, + ) + .unwrap(); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[donor], &donor.pubkey()).unwrap(); +} + +/// A fund sitting at its target weights has nothing to rebalance, in either +/// direction, so nobody can trade it. #[test] -fn test_rebalance_cannot_spend_donated_usdc() { +fn test_rebalance_refuses_fund_at_target() { let mut ctx = setup_full(); standard_fund(&mut ctx); + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); - // Alice's 900 USDC is fully deployed; the recorded USDC is zero. + assert_program_error( + try_rebalance(&mut ctx, 1, 0), + FundError::DriftBelowThreshold, + "a fund at target must not sell NVDAx", + ); + assert_program_error( + try_rebalance(&mut ctx, 0, 1), + FundError::DriftBelowThreshold, + "a fund at target must not sell TSLAx", + ); +} + +/// Drift smaller than the fund's threshold is not worth the slippage of a +/// trade, so the rebalance is refused. +#[test] +fn test_rebalance_refuses_drift_below_threshold() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); let alice = fund_user(&mut ctx, 900_000_000); do_deposit(&mut ctx, &alice, 900_000_000, 1); - assert_eq!(read_fund(&ctx).usdc_holdings, 0); - let donor = fund_user(&mut ctx, 100_000_000); - donate_usdc(&mut ctx, &donor, 100_000_000); - - // Selling 0.1 NVDAx at $180 brings in 18 USDC; investing 50 would need 32 USDC - // of the donation. - let (sell_mint, sell_config, sell_feed, vault_sell, sell_rate) = asset_accounts(&ctx, 1); - let (buy_mint, buy_config, buy_feed, vault_buy, buy_rate) = asset_accounts(&ctx, 0); - let rebalance = |sell_amount: u64, usdc_to_invest: u64| { - Instruction::new_with_bytes( - ctx.fund_program_id, - &managed_fund::instruction::Rebalance { - sell_amount, - usdc_to_invest, - } - .data(), - managed_fund::accounts::RebalanceAccountConstraints { - manager: ctx.manager.pubkey(), - fund: ctx.fund_pda, - usdc_mint: ctx.usdc_mint, - sell_mint, - buy_mint, - sell_config, - buy_config, - sell_price_feed: sell_feed, - buy_price_feed: buy_feed, - vault_sell, - vault_buy, - vault_usdc: ctx.vault_usdc, - sell_rate, - buy_rate, - router_config: ctx.router_config_pda, - router_usdc_treasury: ctx.router_usdc_treasury, - 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 too_much = rebalance(100_000, 50_000_000); + // NVDAx 180 -> 185: 555 of a 915 fund is 60.66%, 0.66 points over its 60% + // target and under the two-point threshold. + set_nvda_price(&mut ctx, 18_500_000_000, 185_000_000); + assert_program_error( + try_rebalance(&mut ctx, 1, 0), + FundError::DriftBelowThreshold, + "drift under the threshold must not trade", + ); + assert_eq!(read_fund(&ctx).asset_holdings[1], 3_000_000); +} + +/// Churn: once a rebalance has restored the weights, calling it again, in +/// either direction, finds nothing to do. Nobody can trade the fund back and +/// forth to bleed it through slippage. +#[test] +fn test_rebalance_cannot_churn() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); + + set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); + do_rebalance(&mut ctx, 1, 0); + let after_first = read_fund(&ctx); + + ctx.svm.expire_blockhash(); + assert_program_error( + try_rebalance(&mut ctx, 1, 0), + FundError::DriftBelowThreshold, + "a second rebalance must find nothing to sell", + ); + assert_program_error( + try_rebalance(&mut ctx, 0, 1), + FundError::DriftBelowThreshold, + "rebalancing back the other way must be refused", + ); + let after_retries = read_fund(&ctx); + assert_eq!(after_retries.asset_holdings, after_first.asset_holdings); + assert_eq!(after_retries.usdc_holdings, after_first.usdc_holdings); +} + +/// A third basket asset the router can trade: its own mint (the router holds +/// the mint authority), router rate, Pyth feed, and registry approval. +struct ExtraAsset { + mint: Pubkey, + approved: Pubkey, + vault: Pubkey, + rate_pda: Pubkey, + feed: Pubkey, +} + +fn create_routable_asset(ctx: &mut TestContext, price: i64, rate: u64) -> ExtraAsset { + let mint = create_token_mint(&mut ctx.svm, &ctx.payer, TOKEN_DECIMALS, None).unwrap(); + let ix = spl_token::instruction::set_authority( + &spl_token::ID, + &mint, + Some(&ctx.router_config_pda), + spl_token::instruction::AuthorityType::MintTokens, + &ctx.payer.pubkey(), + &[], + ) + .unwrap(); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&ctx.payer], &ctx.payer.pubkey()) + .unwrap(); + let (rate_pda, _) = + Pubkey::find_program_address(&[b"rate", mint.as_ref()], &ctx.router_program_id); + set_router_rate(ctx, mint, rate, rate_pda); + let feed = Keypair::new().pubkey(); + set_price_feed(&mut ctx.svm, feed, price); + let (approved, _) = Pubkey::find_program_address( + &[b"approved_asset", ctx.registry_pda.as_ref(), mint.as_ref()], + &ctx.fund_program_id, + ); + let ix = Instruction::new_with_bytes( + ctx.fund_program_id, + &managed_fund::instruction::ApproveAsset { price_feed: feed }.data(), + managed_fund::accounts::ApproveAssetAccountConstraints { + authority: ctx.payer.pubkey(), + registry: ctx.registry_pda, + asset_mint: mint, + approved_asset: approved, + 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 vault = derive_ata(&ctx.fund_pda, &mint); + ExtraAsset { + mint, + approved, + vault, + rate_pda, + feed, + } +} + +/// The buy side must be under its target: a rebalance cannot pour the sale into +/// an asset already at or over its weight. Two assets cannot show this, since +/// when one is over its target the other is under by the same amount, so the +/// fund here holds a third, at 40/40/20. +#[test] +fn test_rebalance_refuses_buying_overweight_asset() { + let mut ctx = setup_full(); + let router = ctx.router_program_id; + init_fund(&mut ctx, FEE_BPS, SLIPPAGE_BPS, router); + let third = create_routable_asset(&mut ctx, 10_000_000_000, 100_000_000); // $100 + let (tm, wt, vt) = (ctx.tsla_mint, ctx.approved_tsla, ctx.vault_tsla); + add_asset(&mut ctx, 0, tm, wt, vt, 4000).unwrap(); + let (nm, wn, vn) = (ctx.nvda_mint, ctx.approved_nvda, ctx.vault_nvda); + add_asset(&mut ctx, 1, nm, wn, vn, 4000).unwrap(); + add_asset(&mut ctx, 2, third.mint, third.approved, third.vault, 2000).unwrap(); + + let mut remaining = deposit_remaining(&ctx); + remaining.extend(asset_deposit_metas( + ctx.asset_config(2), + third.vault, + third.mint, + third.rate_pda, + third.feed, + )); + let alice = fund_user(&mut ctx, 1_000_000_000); + let ix = deposit_instruction(&ctx, &alice, 1_000_000_000, 1, remaining.clone()); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&alice], &alice.pubkey()).unwrap(); + + // NVDAx and the third asset both rise 30%. NVDAx is about $520 of $1,180 + // against a $472 target; the third asset $260 against $236; TSLAx $400 + // against $472. NVDAx is over by more than the threshold, the third asset + // is over too, and only TSLAx is under. + set_nvda_price(&mut ctx, 23_400_000_000, 234_000_000); + set_price_feed(&mut ctx.svm, third.feed, 13_000_000_000); + set_router_rate(&mut ctx, third.mint, 130_000_000, third.rate_pda); + + let stranger = create_wallet(&mut ctx.svm, 1_000_000_000).unwrap(); + let into_third = rebalance_instruction_with(&ctx, &stranger, 1, 2, remaining.clone()); let r = send_transaction_from_instructions( &mut ctx.svm, - vec![too_much], - &[&ctx.manager], - &ctx.manager.pubkey(), + vec![into_third], + &[&stranger], + &stranger.pubkey(), ); - let err = format!( - "{:?}", - r.expect_err("a rebalance must not spend donated USDC") + assert_program_error( + r, + FundError::NotUnderweight, + "an over-weight asset must not be bought", ); - assert!( - err.contains(&program_error(FundError::InsufficientHoldings)), - "{err}" + + // Naming the same asset on both sides is refused outright. + let into_itself = rebalance_instruction_with(&ctx, &stranger, 1, 1, remaining.clone()); + let r = send_transaction_from_instructions( + &mut ctx.svm, + vec![into_itself], + &[&stranger], + &stranger.pubkey(), + ); + assert_program_error( + r, + FundError::SameMint, + "an asset must not be sold into itself", ); - // Investing only what the sale brought in goes through, and the donation is - // still outside the recorded holdings. + // Selling NVDAx into TSLAx, the asset that is under, goes through. + let into_tsla = rebalance_instruction_with(&ctx, &stranger, 1, 0, remaining); + send_transaction_from_instructions( + &mut ctx.svm, + vec![into_tsla], + &[&stranger], + &stranger.pubkey(), + ) + .unwrap(); + assert!(read_fund(&ctx).asset_holdings[0] > 1_600_000); +} + +/// A rebalance values every asset, so it needs every asset's accounts, as a +/// deposit does. +#[test] +fn test_rebalance_rejects_incomplete_assets() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); + set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); + + let stranger = create_wallet(&mut ctx.svm, 1_000_000_000).unwrap(); + let mut ix = rebalance_instruction(&ctx, &stranger, 1, 0); + ix.accounts.truncate(ix.accounts.len() - 5); + let r = send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&stranger], + &stranger.pubkey(), + ); + assert_program_error( + r, + FundError::IncompleteAssetAccounts, + "a rebalance missing an asset must revert", + ); +} + +/// Setting a weight to zero retires an asset, and a rebalance then sells all of +/// it, however little is left: a retired asset needs no threshold, because +/// selling it to zero is a trade that can happen only once. +#[test] +fn test_rebalance_sells_retired_asset() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); + + // Maria retires NVDAx and moves its weight to TSLAx. + set_weight(&mut ctx, 1, 0).unwrap(); + set_weight(&mut ctx, 0, 10_000).unwrap(); + + // NVDAx falls to $1, so the 3 NVDAx left are $3 of a $363 fund: under one + // percentage point, below the two-point threshold. + set_nvda_price(&mut ctx, 100_000_000, 1_000_000); + do_rebalance(&mut ctx, 1, 0); + + // All 3 NVDAx sold for 3 USDC, which bought 0.012 TSLAx. + let fund = read_fund(&ctx); + assert_eq!(fund.asset_holdings[1], 0); + assert_eq!(fund.asset_holdings[0], 1_452_000); + assert_eq!(fund.usdc_holdings, 0); + assert_holdings_match_vaults(&ctx); + ctx.svm.expire_blockhash(); - do_rebalance(&mut ctx, 1, 0, 100_000, 18_000_000); + assert_program_error( + try_rebalance(&mut ctx, 1, 0), + FundError::DriftBelowThreshold, + "a retired asset already sold has nothing left to sell", + ); +} + +/// Donations can neither force a rebalance nor pay for one. Donated NVDAx is not +/// recorded, so it cannot push NVDAx over its target; donated USDC is never +/// spent, because the buy leg invests only what the sale brought in. +#[test] +fn test_rebalance_ignores_donations() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); + + // The donor gets NVDAx the way anyone can: deposit, then withdraw in kind. + let donor = fund_user(&mut ctx, 200_000_000); + let donor_share = do_deposit(&mut ctx, &donor, 100_000_000, 1); + let donor_shares = get_token_account_balance(&ctx.svm, &donor_share).unwrap(); + do_withdraw(&mut ctx, &donor, donor_shares, 0); + let donated_nvda = + get_token_account_balance(&ctx.svm, &derive_ata(&donor.pubkey(), &ctx.nvda_mint)).unwrap(); + assert!(donated_nvda > 0); + let (nvda_mint, vault_nvda, usdc_mint, vault_usdc) = + (ctx.nvda_mint, ctx.vault_nvda, ctx.usdc_mint, ctx.vault_usdc); + donate_token(&mut ctx, &donor, &nvda_mint, &vault_nvda, donated_nvda); + donate_token(&mut ctx, &donor, &usdc_mint, &vault_usdc, 100_000_000); + + // Counted, the donated NVDAx would put NVDAx far over its target. Recorded + // holdings are still 40/60, so there is nothing to rebalance. + assert_program_error( + try_rebalance(&mut ctx, 1, 0), + FundError::DriftBelowThreshold, + "donated NVDAx must not force a trade", + ); + + // A real price move does drift the fund, and the rebalance spends only + // what its sale brought in: the donated USDC is still in the vault, outside + // the recorded holdings. + set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); + ctx.svm.expire_blockhash(); + do_rebalance(&mut ctx, 1, 0); let fund = read_fund(&ctx); assert_eq!(fund.usdc_holdings, 0); assert_eq!( @@ -1700,7 +1974,86 @@ fn test_rebalance_cannot_spend_donated_usdc() { 100_000_000 ); assert_eq!( - fund.asset_holdings[1], + fund.asset_holdings[1] + donated_nvda, get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap() ); } + +/// The threshold is fixed at creation, within bounds: a manager cannot set it +/// near zero, where the fund would trade on every small move, or so high that +/// the target weights stop describing the fund. +#[test] +fn test_initialize_rejects_threshold_out_of_range() { + use managed_fund::instructions::initialize_fund::{ + MAX_REBALANCE_THRESHOLD_BPS, MIN_REBALANCE_THRESHOLD_BPS, + }; + let mut ctx = setup_full(); + let router = ctx.router_program_id; + for threshold in [ + 0, + MIN_REBALANCE_THRESHOLD_BPS - 1, + MAX_REBALANCE_THRESHOLD_BPS + 1, + ] { + let ix = initialize_fund_instruction(&ctx, FEE_BPS, SLIPPAGE_BPS, threshold, router); + let r = send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&ctx.manager], + &ctx.manager.pubkey(), + ); + assert_program_error( + r, + FundError::RebalanceThresholdOutOfRange, + "a threshold outside the bounds must be rejected", + ); + ctx.svm.expire_blockhash(); + } + init_fund(&mut ctx, FEE_BPS, SLIPPAGE_BPS, router); + assert_eq!( + read_fund(&ctx).rebalance_threshold_bps, + REBALANCE_THRESHOLD_BPS + ); +} + +/// Valuation scales by each asset's decimals and each feed's exponent. TSLAx +/// here has eight decimals and a Pyth equity feed with exponent -5, while USDC +/// and NVDAx keep six decimals and NVDAx's feed keeps -8. Assuming six decimals +/// and -8 would value Alice's 1.44 TSLAx at $360 * 100 * 1,000, and Bob's +/// deposit would buy almost no shares. Scaled correctly, every figure matches +/// the six-decimal story. +#[test] +fn test_valuation_scales_by_decimals_and_exponent() { + let mut ctx = setup_with_tsla_decimals(8); + write_price_feed(&mut ctx.svm, ctx.price_feed_tsla, 25_000_000, -5, 1); // $250 + standard_fund(&mut ctx); + assert_eq!(read_asset_config(&ctx, 0).decimals, 8); + assert_eq!(read_asset_config(&ctx, 1).decimals, 6); + + // Alice's 900 USDC deploys to 1.44 TSLAx (eight decimals) and 3 NVDAx. + let alice = fund_user(&mut ctx, 900_000_000); + let alice_share = do_deposit(&mut ctx, &alice, 900_000_000, 1); + assert_eq!( + get_token_account_balance(&ctx.svm, &alice_share).unwrap(), + 900_000_000 + ); + assert_eq!(read_fund(&ctx).asset_holdings[0], 144_000_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 3_000_000); + + // NVDAx to $200. The rebalance computes its trade in the same units: 0.12 + // NVDAx sold for 24 USDC, which buys 0.096 TSLAx, back to 40/60. + set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); + do_rebalance(&mut ctx, 1, 0); + assert_eq!(read_fund(&ctx).asset_holdings[0], 153_600_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 2_880_000); + + // The fund is worth $960, so Bob's 480 USDC buys 450 shares. + let bob = fund_user(&mut ctx, 480_000_000); + let bob_share = do_deposit(&mut ctx, &bob, 480_000_000, 1); + assert_eq!( + get_token_account_balance(&ctx.svm, &bob_share).unwrap(), + 450_000_000 + ); + assert_eq!(read_fund(&ctx).asset_holdings[0], 230_400_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 4_320_000); + assert_holdings_match_vaults(&ctx); +} diff --git a/finance/managed-fund/anchor-v1/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs b/finance/managed-fund/anchor-v1/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs index b036ba2f0..78e07e321 100644 --- a/finance/managed-fund/anchor-v1/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs +++ b/finance/managed-fund/anchor-v1/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs @@ -73,10 +73,18 @@ pub fn handle_swap_asset_for_usdc( require!(rate > 0, RouterError::ZeroRate); require!(asset_amount_in > 0, RouterError::ZeroAmount); - // usdc_out = asset_amount_in * rate (u128 intermediate, program gets ceil on sell) + // usdc_out = asset_amount_in * rate / 10^asset_decimals (u128 intermediate, + // caller gets the floor) + let one_token = 10u128 + .checked_pow(context.accounts.asset_mint.decimals as u32) + .ok_or(RouterError::MathOverflow)?; let usdc_out: u64 = (asset_amount_in as u128) .checked_mul(rate as u128) - .ok_or(RouterError::MathOverflow)? as u64; + .ok_or(RouterError::MathOverflow)? + .checked_div(one_token) + .ok_or(RouterError::MathOverflow)? + .try_into() + .map_err(|_| RouterError::MathOverflow)?; require!(usdc_out >= minimum_usdc_out, RouterError::SlippageExceeded); diff --git a/finance/managed-fund/anchor-v1/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs b/finance/managed-fund/anchor-v1/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs index 093c850f9..a37b69c8e 100644 --- a/finance/managed-fund/anchor-v1/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs +++ b/finance/managed-fund/anchor-v1/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs @@ -72,12 +72,18 @@ pub fn handle_swap_usdc_for_asset( let rate = context.accounts.asset_rate.usdc_per_token; require!(rate > 0, RouterError::ZeroRate); - // asset_out = usdc_amount_in / rate (u128 intermediate, user gets floor) + // asset_out = usdc_amount_in * 10^asset_decimals / rate (u128 intermediate, + // caller gets the floor) + let one_token = 10u128 + .checked_pow(context.accounts.asset_mint.decimals as u32) + .ok_or(RouterError::MathOverflow)?; let asset_out: u64 = (usdc_amount_in as u128) - .checked_mul(1u128) + .checked_mul(one_token) .ok_or(RouterError::MathOverflow)? .checked_div(rate as u128) - .ok_or(RouterError::MathOverflow)? as u64; + .ok_or(RouterError::MathOverflow)? + .try_into() + .map_err(|_| RouterError::MathOverflow)?; require!( asset_out >= minimum_asset_out, diff --git a/finance/managed-fund/anchor-v1/programs/mock-swap-router/src/state/asset_rate.rs b/finance/managed-fund/anchor-v1/programs/mock-swap-router/src/state/asset_rate.rs index 332ebfa8b..8de50308f 100644 --- a/finance/managed-fund/anchor-v1/programs/mock-swap-router/src/state/asset_rate.rs +++ b/finance/managed-fund/anchor-v1/programs/mock-swap-router/src/state/asset_rate.rs @@ -4,9 +4,9 @@ use anchor_lang::prelude::*; #[derive(InitSpace)] pub struct AssetRate { pub mint: Pubkey, - /// USDC base units per token base unit. - /// e.g. 250 means 1 token base unit = 250 USDC base units - /// (so 1.0 TSLAx = $250 when both have 6 decimals) + /// USDC base units per whole token, e.g. 250_000_000 means 1.0 TSLAx = $250 + /// with six-decimal USDC. The swaps scale by the asset mint's decimals, so the + /// rate means the same thing whatever precision the asset has. pub usdc_per_token: u64, pub bump: u8, } diff --git a/finance/managed-fund/anchor/CHANGELOG.md b/finance/managed-fund/anchor/CHANGELOG.md index a303322e1..7d8b0b84c 100644 --- a/finance/managed-fund/anchor/CHANGELOG.md +++ b/finance/managed-fund/anchor/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026-10-01 + +- **Valuation scales by each asset's decimals and each feed's exponent.** The fund valued an asset as `amount × price / 10⁸`, which is right only when the asset has USDC's 6 decimals and its Pyth feed has exponent −8, and nothing checked either. An eight-decimal asset would have been valued 100 times too high, so a later depositor would have bought almost no shares; Pyth's US equity feeds use exponent −5, a further factor of 1,000. `load_price` now reads the exponent (offset 89) and returns an `OraclePrice`, `AssetConfig` records the mint's `decimals` and `Fund` the USDC mint's `usdc_decimals`, and `deposit` and `rebalance` value and size swaps with `asset_value_in_usdc` and `usdc_to_asset_amount`, which scale by `10^(usdc_decimals + exponent − asset_decimals)`. `PYTH_PRICE_PRECISION` is removed. The mock router's `usdc_per_token` is now USDC minor units per whole token, and its swaps scale by the asset mint's decimals. Tested by `test_valuation_scales_by_decimals_and_exponent`, which runs the story with an eight-decimal TSLAx on an exponent −5 feed. +- **Anyone may rebalance, and the program sizes the trade.** `rebalance(sell_amount, usdc_to_invest)`, manager-only, is now `rebalance(sell_index, buy_index)`, signed by any `caller`, with the same five remaining accounts per asset as `deposit`. It values every asset and requires the asset sold to sit above its target by at least the fund's threshold (new `DriftBelowThreshold`; a retired asset may always be sold) and the asset bought to sit below its target (new `NotUnderweight`). It trades the smaller of the two gaps and spends only what the sale brought in. Before, the manager chose both amounts, so they could trade a balanced fund back and forth and bleed it through slippage, to the benefit of whoever filled the trades. A fund at its targets now has no trade to make. `initialize_fund` takes a `rebalance_threshold_bps`, between `MIN_REBALANCE_THRESHOLD_BPS` (100) and `MAX_REBALANCE_THRESHOLD_BPS` (2,000) (new `RebalanceThresholdOutOfRange`), stored on `Fund` with no setter. Tested by `test_rebalance_refuses_fund_at_target`, `test_rebalance_refuses_drift_below_threshold`, `test_rebalance_cannot_churn`, `test_rebalance_refuses_buying_overweight_asset`, `test_rebalance_rejects_incomplete_assets`, `test_rebalance_sells_retired_asset`, `test_rebalance_ignores_donations` (replacing `test_rebalance_cannot_spend_donated_usdc`) and `test_initialize_rejects_threshold_out_of_range`. The Kani proofs gain `proof_rebalance_trade_never_overshoots` and `proof_sell_amount_never_worth_more_than_trade`. The web app's IDL, instruction builders and rebalance panel follow. + ## 2026-09-28 - **Renamed from Vault Strategy to Managed Fund.** The example moved from `finance/vault-strategy` to `finance/managed-fund` and now uses the finance term for the product. The program crate is `managed-fund` (library `managed_fund`), the `Strategy` account is `Fund`, `initialize_strategy` is `initialize_fund`, the `StrategyNotFullyAllocated` error is `FundNotFullyAllocated`, and the fund PDA's seed is `"fund"` instead of `"strategy"`, so fund addresses change. The `initialize_fund` instruction and `Fund` account have new Anchor discriminators, and the web app's IDL and client follow. `vault` still means only a single-asset token account the fund owns. The program ID and the program's behavior are unchanged. diff --git a/finance/managed-fund/anchor/PRODUCT.md b/finance/managed-fund/anchor/PRODUCT.md index 1be37f405..7043668cf 100644 --- a/finance/managed-fund/anchor/PRODUCT.md +++ b/finance/managed-fund/anchor/PRODUCT.md @@ -11,7 +11,7 @@ web The frontend serves two roles first; a third exists in the program and is secondary for now. - **Depositor (primary):** a retail user who deposits USDC into a manager-run fund, receives shares representing proportional ownership of the whole basket, watches their position's value track the portfolio, and withdraws their slice in kind when they choose. Highest-volume, lowest-expertise surface. -- **Manager (primary):** a power user who creates and operates a fund — registers curator-approved assets, sets target weights, rebalances as prices drift, and collects the management fee. An operations surface where correctness and confirmation matter more than reach. +- **Manager (primary):** a power user who creates and operates a fund — registers curator-approved assets, sets target weights, and collects the management fee. An operations surface where correctness and confirmation matter more than reach. - **Curator (secondary, not yet in scope):** governs the approved-asset registry that bounds what managers may hold. Low-frequency, high-trust. Recorded so future work does not treat the manager as the only privileged role. ## Product Purpose @@ -35,14 +35,15 @@ A **multi-asset** fund built from single-asset vaults, transparently priced on-c Program instructions the frontend can surface: - **Registry / curator:** `initialize_registry`, `approve_asset` (whitelist of assets managers may use). -- **Manager:** `initialize_fund`, `add_asset`, `set_weight` (including set-to-zero to retire an asset), `rebalance`, `collect_fees`. +- **Manager:** `initialize_fund` (fee, slippage tolerance, and rebalance threshold, all fixed at creation), `add_asset`, `set_weight` (including set-to-zero to retire an asset), `collect_fees`. +- **Anyone:** `rebalance(sell_index, buy_index)`. The program sizes the trade from oracle prices and target weights, and refuses unless the asset sold is over its target by at least the fund's threshold and the asset bought is under its own (`DriftBelowThreshold`, `NotUnderweight`). - **Depositor:** `deposit` (USDC → minted shares), `withdraw` (burn shares → in-kind slice of every vault). Rules the UI must respect and reflect: - Deposits are accepted only when target weights sum to **exactly 10,000 bps**; a fund is either still being configured or fully allocated and live (`FundNotFullyAllocated` otherwise). - Shares: first deposit is 1:1 with USDC minor units; later deposits mint `deposit_usdc × total_shares / NAV`. Share mint is a PDA owned by the fund PDA. -- NAV and withdrawals use the fund's recorded holdings (`usdc_holdings`, `asset_holdings`), not vault token balances. Tokens sent straight to a vault are not part of the fund and should not be shown as fund value. A deposit too small to buy any of an asset is rejected (`DepositTooSmall`); a rebalance cannot sell or spend more than the recorded holdings (`InsufficientHoldings`). +- NAV and withdrawals use the fund's recorded holdings (`usdc_holdings`, `asset_holdings`), not vault token balances. Tokens sent straight to a vault are not part of the fund and should not be shown as fund value. A deposit too small to buy any of an asset is rejected (`DepositTooSmall`); a rebalance cannot sell more than the recorded holdings (`InsufficientHoldings`), spends only what its sale brought in, and values assets from the recorded holdings, so a donation cannot force one. - Management fee is charged by minting new shares to the manager (dilution), fixed at creation, capped at `MAX_FEE_BPS` = 1,000 bps (10%), no setter to raise it. `collect_fees` is permissionless. - Slippage floors are computed on-chain from the Pyth price and `max_slippage_bps` (capped at 1,000 bps); a manager-supplied minimum is not trusted. - `MAX_ASSETS` = 16. `deposit` re-derives the full `0..asset_count` PDA range and refuses to run if any asset account is missing (`IncompleteAssetAccounts`), so NAV can't be understated. diff --git a/finance/managed-fund/anchor/README.md b/finance/managed-fund/anchor/README.md index d64e1d0b3..062ef522e 100644 --- a/finance/managed-fund/anchor/README.md +++ b/finance/managed-fund/anchor/README.md @@ -58,7 +58,7 @@ fee_shares = total_shares × fee_bps × elapsed_seconds / (10_000 × 31_536_000) Each asset carries a target **weight** in basis points (e.g. 40% TSLAx, 60% NVDAx). A fund 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 `FundNotFullyAllocated`). So a fund 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. -[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). +[Rebalancing](https://www.investopedia.com/terms/r/rebalancing.asp) handles the drift that prices create after a deposit: `rebalance(sell_index, buy_index)` sells an over-weight asset for USDC and buys an under-weight one in a single atomic instruction. **Anyone may call it, and the program, not the caller, sizes the trade.** It values every asset (the same five accounts per asset as `deposit`), and requires the asset sold to sit above its target by at least the fund's `rebalance_threshold_bps` of the fund's value (`DriftBelowThreshold`) and the asset bought to sit below its own (`NotUnderweight`). It then trades the smaller of the two gaps, so neither asset ends past its target, and spends only what the sale brought in. A fund at its targets has no trade to make, so nobody, the manager included, can churn it: trade it back and forth to bleed value through slippage. The threshold is set at creation between `MIN_REBALANCE_THRESHOLD_BPS` (100, one percentage point) and `MAX_REBALANCE_THRESHOLD_BPS` (2,000) and has no setter (`RebalanceThresholdOutOfRange`). `set_weight` changes a target after creation, including setting it to zero to **retire** an asset: deposits stop allocating to it, a rebalance may sell all of it whatever the threshold, and the now-empty vault keeps its index so the contiguous `0..asset_count` range stays intact (the index is never reused). ### Slippage, bounded by the oracle @@ -101,9 +101,9 @@ An [in-kind distribution](https://www.investopedia.com/terms/i/in-kind.asp) retu 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. -### Maria rebalances +### Anyone rebalances -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). +A price move pushes the basket off target by more than the fund's threshold. Anyone calls `rebalance(sell_index, buy_index)`, naming the over-weight asset and an under-weight one; the program computes how much to sell from the Pyth prices and the target weights, and both legs are bounded against their Pyth prices, in one atomic instruction. `set_weight(weight_bps)` is how Maria steers the fund: it changes a target, or retires an asset by setting it to zero (then reassign that weight to another asset to reach 100% again, and `rebalance` sells the retired holdings). ### Fees accrue @@ -117,13 +117,13 @@ A price move pushes the basket off target. `rebalance(sell_amount, usdc_to_inves ## Oracle Integration (Pyth) -`PriceUpdateV2` price (i64) is read at byte offset 73, `publish_time` at 93 and `posted_slot` (u64) at 125, directly from account bytes to avoid borsh version incompatibility with Anchor. Under Alpenglow each leader sets the Clock's `unix_timestamp`, which may advance by at most twice the slot time elapsed since the parent block, so after a halt the timestamp trails real time and a price published just before the halt can still look fresh by seconds. `load_price` therefore also requires `posted_slot` to be after the `LastRestartSlot` sysvar's slot (0 means the cluster has never restarted), pausing deposits and rebalances until Pyth posts again. 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). +`PriceUpdateV2` price (i64) is read at byte offset 73, `publish_time` at 93 and `posted_slot` (u64) at 125, directly from account bytes to avoid borsh version incompatibility with Anchor. Under Alpenglow each leader sets the Clock's `unix_timestamp`, which may advance by at most twice the slot time elapsed since the parent block, so after a halt the timestamp trails real time and a price published just before the halt can still look fresh by seconds. `load_price` therefore also requires `posted_slot` to be after the `LastRestartSlot` sysvar's slot (0 means the cluster has never restarted), pausing deposits and rebalances until Pyth posts again. The exponent (i32) is read at offset 89 rather than assumed: Pyth's crypto USD feeds use −8 and its US equity feeds −5. Each asset's mint decimals are recorded in its `AssetConfig` when it is added, and the USDC mint's in the `Fund` at creation, so value in USDC minor units is `amount × price × 10^(usdc_decimals + exponent − asset_decimals)`: `amount × price / 10⁸` only when the asset has USDC's 6 decimals and the feed's exponent is −8. 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 a `usdc_per_token` rate per asset, holds the basket mints' authority, and mints/burns to simulate swaps. Its single `router_config` account (`["router_config"]`) is both its state and its signer: it owns the USDC treasury, is the mint authority of every basket mint, and signs the router's token CPIs with its own seeds, the same way the fund PDA does for the vaults. The `Fund` 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 fund PDA still signs. +The `mock-swap-router` exists only for testing: it stores a `usdc_per_token` rate per asset (USDC minor units per whole token, scaled by the asset mint's decimals on each swap), holds the basket mints' authority, and mints/burns to simulate swaps. Its single `router_config` account (`["router_config"]`) is both its state and its signer: it owns the USDC treasury, is the mint authority of every basket mint, and signs the router's token CPIs with its own seeds, the same way the fund PDA does for the vaults. The `Fund` 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 fund PDA still signs. --- @@ -133,9 +133,10 @@ The fund PDA holds all assets; no instruction moves a vault's tokens to the mana - **Assets** are limited to mints approved 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. +- **Rebalancing** is sized by the program from the oracle and the target weights, and only once drift passes a threshold fixed at creation. The manager chooses the weights, not the trades. - **The fee** is fixed at creation and capped at 10%, paid only in minted shares. -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. +What remains to trust: the honesty of the registered router and registry. The manager cannot withdraw principal, and cannot churn the fund through the router either: no caller chooses a trade's size or can trade a fund sitting at its targets. --- @@ -160,7 +161,7 @@ cargo build-sbf --manifest-path programs/managed-fund/Cargo.toml cargo test --manifest-path programs/managed-fund/Cargo.toml ``` -Tests live in `programs/managed-fund/tests/managed_fund.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: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage, an under-allocated fund, non-manager `set_weight`, unregistered router, and incomplete asset accounts on deposit. `test_full_lifecycle` checks after every step that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the first-depositor attack (a one-minor-unit deposit, a 1,000 USDC transfer straight into the USDC vault, then a 1,000 USDC deposit with no `minimum_shares` floor) and checks the victim gets exactly the shares they would have got without the donation. `test_deposit_rejects_leg_that_buys_nothing` and `test_rebalance_cannot_spend_donated_usdc` pin the other two guards. +Tests live in `programs/managed-fund/tests/managed_fund.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: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage, an under-allocated fund, non-manager `set_weight`, unregistered router, and incomplete asset accounts on deposit and rebalance. The rebalance tests sign as a stranger, since anyone may call it: `test_rebalance_refuses_fund_at_target`, `test_rebalance_refuses_drift_below_threshold` and `test_rebalance_cannot_churn` check that a fund at its targets, or within its threshold, or just rebalanced, cannot be traded; `test_rebalance_refuses_buying_overweight_asset`, `test_rebalance_sells_retired_asset` and `test_initialize_rejects_threshold_out_of_range` cover the rest of its rules. `test_valuation_scales_by_decimals_and_exponent` runs the story with an eight-decimal TSLAx priced by an exponent −5 feed and gets the same share counts. `test_full_lifecycle` checks after every step that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the first-depositor attack (a one-minor-unit deposit, a 1,000 USDC transfer straight into the USDC vault, then a 1,000 USDC deposit with no `minimum_shares` floor) and checks the victim gets exactly the shares they would have got without the donation. `test_deposit_rejects_leg_that_buys_nothing` and `test_rebalance_ignores_donations` pin the other two guards: the second checks that donated tokens can neither force a rebalance nor be spent by one. ## FAQ @@ -174,4 +175,4 @@ Shares are priced at the fund's net asset value: the total value of its recorded ### How does the manager operate the fund? -`rebalance` trades the vaults back to their target weights as prices drift, `set_weight` reweights or retires an asset, and a management fee accrues over time and is collected with `collect_fees`. +`set_weight` reweights or retires an asset, `rebalance`, which anyone may call, trades the vaults back to those weights once prices drift past the fund's threshold, and a management fee accrues over time and is collected with `collect_fees`. diff --git a/finance/managed-fund/anchor/app/README.md b/finance/managed-fund/anchor/app/README.md index 54a4d57e9..1e6a51b27 100644 --- a/finance/managed-fund/anchor/app/README.md +++ b/finance/managed-fund/anchor/app/README.md @@ -2,8 +2,10 @@ A small, data-forward frontend for the [`managed-fund`](../README.md) Solana program: an educational demo of a manager-run investment fund. Investors deposit USDC and hold -shares priced at net asset value; a manager allocates the pooled USDC across a basket and -rebalances it. Every number on screen is a real on-chain account read. +shares priced at net asset value; a manager sets the basket's target weights, and anyone can +rebalance it once an asset drifts past the fund's threshold (the program sizes the trade, so +the caller only picks which asset to sell and which to buy). Every number on screen is a real +on-chain account read. Stack: **Vite + React + TypeScript + Tailwind**, `@solana/wallet-adapter`, `@coral-xyz/anchor`. Target cluster: **devnet**. @@ -54,7 +56,8 @@ so the client is **verified offline** (`pnpm verify`) rather than against a live To bring the demo up you need `solana` + `anchor` (or `cargo build-sbf`) to: 1. Build & deploy both programs to devnet (you'll get **new** program ids — put them in `.env.local`). -2. Create a 6-decimal mock USDC + the basket mints; init the router, set rates, fund its treasury. +2. Create a 6-decimal mock USDC + the basket mints; init the router, set rates (USDC minor units + per whole token, e.g. `250_000_000` for $250), fund its treasury. 3. Create a registry, approve the basket assets (bound to Pyth `PriceUpdateV2` feeds), init a fund, and add the assets to 100% weight. diff --git a/finance/managed-fund/anchor/app/scripts/verify-client.mjs b/finance/managed-fund/anchor/app/scripts/verify-client.mjs index 5ac087ce4..a7c646021 100644 --- a/finance/managed-fund/anchor/app/scripts/verify-client.mjs +++ b/finance/managed-fund/anchor/app/scripts/verify-client.mjs @@ -94,9 +94,11 @@ if (program) { registry: PublicKey.default, shareMint: PublicKey.default, usdcMint: PublicKey.default, + usdcDecimals: 6, swapRouter: PublicKey.default, feeBps: 100, maxSlippageBps: 250, + rebalanceThresholdBps: 500, totalShares: new BN("1350000000"), usdcHoldings: new BN("1000"), assetHoldings: Array.from({ length: 16 }, (_, i) => new BN(i === 1 ? 2880000 : 0)), @@ -111,7 +113,9 @@ if (program) { const good = decoded.index.toString() === "7" && decoded.feeBps === 100 && + decoded.usdcDecimals === 6 && decoded.maxSlippageBps === 250 && + decoded.rebalanceThresholdBps === 500 && decoded.assetCount === 2 && decoded.totalWeightBps === 10000 && decoded.totalShares.toString() === "1350000000" && @@ -121,9 +125,29 @@ if (program) { } catch (e) { fail("Fund round-trip", e.message); } + + // 5. Round-trip an AssetConfig, whose decimals the client valuation depends on. + try { + const sample = { + fund: PublicKey.default, + index: 1, + mint: PublicKey.default, + decimals: 8, + priceFeed: PublicKey.default, + vault: PublicKey.default, + weightBps: 4000, + bump: 253, + }; + const encoded = await program.coder.accounts.encode("assetConfig", sample); + const decoded = program.coder.accounts.decode("assetConfig", encoded); + const good = decoded.index === 1 && decoded.decimals === 8 && decoded.weightBps === 4000 && decoded.bump === 253; + good ? ok("AssetConfig encode/decode round-trip") : fail("AssetConfig round-trip", JSON.stringify(decoded)); + } catch (e) { + fail("AssetConfig round-trip", e.message); + } } -// 5. Derive the key PDAs (sanity + reference values). +// 6. Derive the key PDAs (sanity + reference values). console.log("PDAs (index 0)"); const u64le = (n) => { const b = Buffer.alloc(8); diff --git a/finance/managed-fund/anchor/app/src/components/ActionTicket.tsx b/finance/managed-fund/anchor/app/src/components/ActionTicket.tsx index b4b22895f..dc1beaa7a 100644 --- a/finance/managed-fund/anchor/app/src/components/ActionTicket.tsx +++ b/finance/managed-fund/anchor/app/src/components/ActionTicket.tsx @@ -183,7 +183,9 @@ export function ActionTicket({ view, connected, walletUsdc, position, onDeposit, #{leg.index} · {shortAddress(leg.mint)} - {formatUnits(leg.amountMinor, 6, 6)} + + {formatUnits(leg.amountMinor, leg.decimals, leg.decimals)} + ))}
diff --git a/finance/managed-fund/anchor/app/src/components/Allocation.tsx b/finance/managed-fund/anchor/app/src/components/Allocation.tsx index cd13853cf..4312153b3 100644 --- a/finance/managed-fund/anchor/app/src/components/Allocation.tsx +++ b/finance/managed-fund/anchor/app/src/components/Allocation.tsx @@ -1,4 +1,4 @@ -import { formatBps, formatRatioPct, formatUnits, formatUsdc } from "../solana/format"; +import { formatBps, formatPrice, formatRatioPct, formatUnits, formatUsdc } from "../solana/format"; import type { FundView } from "../solana/fund"; import { Addr } from "./atoms"; @@ -40,8 +40,8 @@ function buildSegments(view: FundView): Segment[] { return segments; } -function priceUsd(price: bigint | null): string { - return price === null ? "—" : `$${formatUnits(price, 8, 2)}`; +function priceUsd(price: bigint | null, exponent: number | null): string { + return price === null || exponent === null ? "—" : `$${formatPrice(price, exponent)}`; } export function AllocationPanel({ view }: { view: FundView }) { @@ -54,7 +54,8 @@ export function AllocationPanel({ view }: { view: FundView }) {

Allocation

- fee {formatBps(s.feeBps)} / yr · slippage {formatBps(s.maxSlippageBps)} + fee {formatBps(s.feeBps)} / yr · slippage {formatBps(s.maxSlippageBps)} · rebalance threshold{" "} + {formatBps(s.rebalanceThresholdBps)}
@@ -106,9 +107,9 @@ export function AllocationPanel({ view }: { view: FundView }) { {a.actualWeight === null ? "—" : formatRatioPct(a.actualWeight)} - {formatUnits(a.vaultAmount, 6, 6)} + {formatUnits(a.vaultAmount, a.decimals, a.decimals)} - {priceUsd(a.price)} + {priceUsd(a.price, a.exponent)} {a.stale && stale} diff --git a/finance/managed-fund/anchor/app/src/components/ManagerView.tsx b/finance/managed-fund/anchor/app/src/components/ManagerView.tsx index 54f630f3c..9272baa5f 100644 --- a/finance/managed-fund/anchor/app/src/components/ManagerView.tsx +++ b/finance/managed-fund/anchor/app/src/components/ManagerView.tsx @@ -2,20 +2,21 @@ import { useConnection } from "@solana/wallet-adapter-react"; import { PublicKey } from "@solana/web3.js"; import { useEffect, useMemo, useState } from "react"; import type { FundState } from "../hooks/useFund"; -import { parseAmount, parsePercentToBps, toAmountInput } from "../lib/amounts"; +import { parsePercentToBps } from "../lib/amounts"; import { describeError } from "../lib/tx"; import { BPS_DENOMINATOR, FUND_INDEX, MAX_ASSETS, MAX_FEE_BPS, + MAX_REBALANCE_THRESHOLD_BPS, MAX_SLIPPAGE_BPS, - PYTH_PRICE_PRECISION, + MIN_REBALANCE_THRESHOLD_BPS, ROUTER_PROGRAM_ID, USDC_MINT, } from "../solana/config"; import { formatBps, formatShares, formatUnits, formatUsdc, shortAddress } from "../solana/format"; -import type { FundView } from "../solana/fund"; +import { type AssetView, assetDrift, type FundView, rebalancePlan } from "../solana/fund"; import { approvedAssetPda } from "../solana/pdas"; import { Button, Panel, Select, StatusLine, TextField, type TxStatus } from "./atoms"; @@ -58,12 +59,38 @@ const assetOptions = (view: FundView) => // ---- Rebalance ------------------------------------------------------------- -function RebalancePanel({ view, onRebalance }: { view: FundView; onRebalance: FundState["rebalance"] }) { +function driftLabel(view: FundView, a: AssetView): { text: string; tone: string } { + const d = assetDrift(view, a); + if (!d) return { text: "unpriced", tone: "text-loss" }; + const threshold = view.account!.rebalanceThresholdBps; + const pct = `${d.driftBps >= 0 ? "+" : ""}${formatBps(d.driftBps)}`; + if (d.driftUsdc > 0n) { + const sellable = a.weightBps === 0 || d.driftBps >= threshold; + return { text: `${pct} over${sellable ? " · can sell" : ""}`, tone: sellable ? "text-accent" : "text-muted" }; + } + if (d.driftUsdc < 0n) return { text: `${pct} under · can buy`, tone: "text-gain" }; + return { text: "on target", tone: "text-faint" }; +} + +/** Index of the asset furthest over (sign 1) or under (sign -1) its target, or null. */ +function extreme(view: FundView, sign: 1 | -1): number | null { + let best: number | null = null; + let bestDrift = 0n; + for (const a of view.assets) { + const d = assetDrift(view, a); + if (d && d.driftUsdc * BigInt(sign) > bestDrift) { + best = a.index; + bestDrift = d.driftUsdc * BigInt(sign); + } + } + return best; +} + +/** Rebalance is permissionless: the program sizes the trade, so the caller only names the pair. */ +export function RebalancePanel({ view, onRebalance }: { view: FundView; onRebalance: FundState["rebalance"] }) { const { status, busy, run } = useAction(); - const [sell, setSell] = useState("0"); - const [buy, setBuy] = useState(view.assets.length > 1 ? "1" : "0"); - const [sellInput, setSellInput] = useState(""); - const [usdcInput, setUsdcInput] = useState(""); + const [sell, setSell] = useState(String(extreme(view, 1) ?? 0)); + const [buy, setBuy] = useState(String(extreme(view, -1) ?? (view.assets.length > 1 ? 1 : 0))); if (view.assets.length < 2) { return ( @@ -75,78 +102,47 @@ function RebalancePanel({ view, onRebalance }: { view: FundView; onRebalance: Fu ); } + const s = view.account!; const sellIdx = Number(sell); const buyIdx = Number(buy); const sellAsset = view.assets[sellIdx]; const buyAsset = view.assets[buyIdx]; - const sellMinor = parseAmount(sellInput, 6); - const usdcMinor = parseAmount(usdcInput, 6); - const impliedUsdc = - sellMinor !== null && sellAsset.price ? (sellMinor * sellAsset.price) / PYTH_PRICE_PRECISION : null; - const legStale = sellAsset.price === null || sellAsset.stale || buyAsset.price === null || buyAsset.stale; - - const block = - sellIdx === buyIdx - ? "Choose two different assets." - : legStale - ? "An oracle price is stale or missing — the rebalance would revert on-chain." - : sellMinor !== null && sellMinor > sellAsset.vaultAmount - ? "Sell amount exceeds the vault balance." - : null; - const ready = - sellIdx !== buyIdx && - !legStale && - sellMinor !== null && - sellMinor > 0n && - sellMinor <= sellAsset.vaultAmount && - usdcMinor !== null && - usdcMinor > 0n; + const plan = rebalancePlan(view, sellIdx, buyIdx); return ( - + +
+ {view.assets.map((a) => { + const label = driftLabel(view, a); + return ( +
+ + #{a.index} · {shortAddress(a.mint)} · target {formatBps(a.weightBps, 0)} + + {label.text} +
+ ); + })} +
- +
- setSellInput(toAmountInput(sellAsset.vaultAmount))} - className="tabular-nums transition-colors hover:text-accent" - > - Vault {formatUnits(sellAsset.vaultAmount, 6, 4)} · Max - - } - /> - setUsdcInput(toAmountInput(impliedUsdc))} - className="tabular-nums transition-colors hover:text-accent" - > - Oracle ≈ {formatUsdc(impliedUsdc)} · Use - - ) : ( - "enter sell amount" - ) - } - /> - {block &&

{block}

} + {plan.block ? ( +

{plan.block}

+ ) : ( +

+ Sells ≈ {formatUnits(plan.sellAmount, sellAsset.decimals, 4)} of # + {sellAsset.index} (≈ ${formatUsdc(plan.tradeUsdc)}) and buys #{buyAsset.index} with the proceeds. +

+ )} +

+ The program sizes the trade from the oracle: the smaller of the sell asset's excess and the buy asset's + shortfall, so neither ends up past its target. +

@@ -324,7 +320,8 @@ export function ManagerView({ fund }: { fund: FundState }) { Manager console — operating fund #{view.index.toString()} - fee {formatBps(view.account!.feeBps)} · fixed at creation, no setter + fee {formatBps(view.account!.feeBps)} · rebalance threshold {formatBps(view.account!.rebalanceThresholdBps)} · + fixed at creation, no setter
@@ -346,12 +343,14 @@ export function CreateFundForm({ fund }: { fund: FundState }) { const [router, setRouter] = useState(ROUTER_PROGRAM_ID.toBase58()); const [feePct, setFeePct] = useState("1"); const [slipPct, setSlipPct] = useState("1"); + const [thresholdPct, setThresholdPct] = useState("5"); const registryPk = tryPubkey(registry); const usdcPk = tryPubkey(usdc); const routerPk = tryPubkey(router); const feeBps = parsePercentToBps(feePct); const slipBps = parsePercentToBps(slipPct); + const thresholdBps = parsePercentToBps(thresholdPct); const block = !fund.connected ? "Connect a wallet to create the fund." @@ -365,7 +364,11 @@ export function CreateFundForm({ fund }: { fund: FundState }) { ? `Fee must be 0–${MAX_FEE_BPS / 100}%.` : slipBps === null || slipBps > MAX_SLIPPAGE_BPS ? `Max slippage must be 0–${MAX_SLIPPAGE_BPS / 100}%.` - : null; + : thresholdBps === null || + thresholdBps < MIN_REBALANCE_THRESHOLD_BPS || + thresholdBps > MAX_REBALANCE_THRESHOLD_BPS + ? `Rebalance threshold must be ${MIN_REBALANCE_THRESHOLD_BPS / 100}–${MAX_REBALANCE_THRESHOLD_BPS / 100}%.` + : null; const ready = !block; return ( @@ -396,9 +399,10 @@ export function CreateFundForm({ fund }: { fund: FundState }) { placeholder="Base58…" invalid={router.trim() !== "" && !routerPk} /> -
+
+
{block && fund.connected &&

{block}

}
diff --git a/finance/managed-fund/anchor/programs/managed-fund/src/error.rs b/finance/managed-fund/anchor/programs/managed-fund/src/error.rs index 1f7f9d53b..d0cb49a2b 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/error.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/error.rs @@ -46,7 +46,7 @@ pub enum FundError { NegativePrice, #[msg("Pyth price feed is stale")] StalePriceFeed, - #[msg("Sell and buy mints must be different")] + #[msg("Sell and buy assets must be different")] SameMint, #[msg("USDC mint does not match the fund's registered USDC mint")] InvalidUsdcMint, @@ -60,4 +60,10 @@ pub enum FundError { DepositTooSmall, #[msg("Rebalance spends more than the fund's recorded holdings")] InsufficientHoldings, + #[msg("Rebalance threshold is outside the allowed range")] + RebalanceThresholdOutOfRange, + #[msg("The asset to sell is not far enough above its target weight to rebalance")] + DriftBelowThreshold, + #[msg("The asset to buy is not below its target weight")] + NotUnderweight, } diff --git a/finance/managed-fund/anchor/programs/managed-fund/src/instructions/add_asset.rs b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/add_asset.rs index aba758619..d6b405ab4 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/instructions/add_asset.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/add_asset.rs @@ -75,6 +75,7 @@ pub fn handle_add_asset( fund: *fund.address(), index, mint: *context.accounts.asset_mint.address(), + decimals: context.accounts.asset_mint.decimals(), // Copied from the registry entry, never supplied by the manager. price_feed: context.accounts.approved_asset.price_feed, vault: *context.accounts.vault_asset.address(), diff --git a/finance/managed-fund/anchor/programs/managed-fund/src/instructions/deposit.rs b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/deposit.rs index 2e71d4e1d..dc4afb773 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/instructions/deposit.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/deposit.rs @@ -8,7 +8,7 @@ use anchor_spl::{ use mock_swap_router::cpi::accounts::SwapUsdcForAssetAccountConstraints as RouterSwapAccounts; use crate::error::FundError; -use crate::oracle::{asset_value_in_usdc, load_price, read_token_amount, PYTH_PRICE_PRECISION}; +use crate::oracle::{asset_value_in_usdc, load_price, read_token_amount, usdc_to_asset_amount}; use crate::state::{AssetConfig, Fund}; #[derive(Accounts)] @@ -145,7 +145,12 @@ pub fn handle_deposit( let price = load_price(feed_account, &config.price_feed, now)?; let amount = asset_holdings[index]; nav = nav - .checked_add(asset_value_in_usdc(amount, price)?) + .checked_add(asset_value_in_usdc( + amount as u128, + price, + config.decimals, + usdc_decimals, + )?) .ok_or(FundError::MathOverflow)?; } @@ -221,14 +226,11 @@ pub fn handle_deposit( continue; } - // Slippage floor anchored to the oracle: expected_out = deploy_usdc * 10^8 / - // price, allowed to fall short by at most max_slippage_bps. + // Slippage floor anchored to the oracle: expected_out is what deploy_usdc + // buys at the Pyth price, allowed to fall short by at most max_slippage_bps. let price = load_price(feed_account, &config.price_feed, now)?; - let expected_out = (deploy_usdc as u128) - .checked_mul(PYTH_PRICE_PRECISION) - .ok_or(FundError::MathOverflow)? - .checked_div(price) - .ok_or(FundError::MathOverflow)?; + let expected_out = + usdc_to_asset_amount(deploy_usdc as u128, price, config.decimals, usdc_decimals)?; let minimum_asset_out: u64 = expected_out .checked_mul((10_000 - max_slippage_bps) as u128) .ok_or(FundError::MathOverflow)? diff --git a/finance/managed-fund/anchor/programs/managed-fund/src/instructions/initialize_fund.rs b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/initialize_fund.rs index feb5465a9..810e6ab36 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/instructions/initialize_fund.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/initialize_fund.rs @@ -25,6 +25,15 @@ pub const MAX_FEE_BPS: u16 = 1_000; /// that the bound is meaningless. pub const MAX_SLIPPAGE_BPS: u16 = 1_000; +/// Lowest rebalance threshold a manager may set, in basis points of the fund's +/// value (one percentage point). Every rebalance pays slippage, so a threshold +/// near zero would let anyone trade the fund on every small price move. +pub const MIN_REBALANCE_THRESHOLD_BPS: u16 = 100; + +/// Highest rebalance threshold a manager may set (twenty percentage points). +/// Past that, the target weights stop describing what the fund holds. +pub const MAX_REBALANCE_THRESHOLD_BPS: u16 = 2_000; + #[derive(Accounts)] #[instruction(index: u64)] pub struct InitializeFundAccountConstraints { @@ -77,6 +86,7 @@ pub fn handle_initialize_fund( index: u64, fee_bps: u16, max_slippage_bps: u16, + rebalance_threshold_bps: u16, swap_router: Address, ) -> Result<()> { require!(fee_bps <= MAX_FEE_BPS, FundError::FeeTooHigh); @@ -84,6 +94,11 @@ pub fn handle_initialize_fund( max_slippage_bps <= MAX_SLIPPAGE_BPS, FundError::SlippageConfigTooHigh ); + require!( + (MIN_REBALANCE_THRESHOLD_BPS..=MAX_REBALANCE_THRESHOLD_BPS) + .contains(&rebalance_threshold_bps), + FundError::RebalanceThresholdOutOfRange + ); let clock = Clock::get()?; @@ -93,9 +108,11 @@ pub fn handle_initialize_fund( registry: *context.accounts.registry.address(), share_mint: *context.accounts.share_mint.address(), usdc_mint: *context.accounts.usdc_mint.address(), + usdc_decimals: context.accounts.usdc_mint.decimals(), swap_router, fee_bps, max_slippage_bps, + rebalance_threshold_bps, total_shares: 0, usdc_holdings: 0, asset_holdings: [0; MAX_ASSETS as usize], diff --git a/finance/managed-fund/anchor/programs/managed-fund/src/instructions/rebalance.rs b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/rebalance.rs index 191ed0e49..f30aba4e9 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/instructions/rebalance.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/rebalance.rs @@ -3,19 +3,23 @@ use anchor_spl::{ associated_token::AssociatedToken, token_interface::{Mint, TokenAccount, TokenInterface}, }; -use mock_swap_router::{ - cpi::accounts::SwapAssetForUsdcAccountConstraints as RouterSellAccounts, - cpi::accounts::SwapUsdcForAssetAccountConstraints as RouterBuyAccounts, state::AssetRate, +use mock_swap_router::cpi::accounts::{ + SwapAssetForUsdcAccountConstraints as RouterSellAccounts, + SwapUsdcForAssetAccountConstraints as RouterBuyAccounts, }; use crate::error::FundError; -use crate::oracle::{load_price, PYTH_PRICE_PRECISION}; -use crate::state::{AssetConfig, Fund}; +use crate::oracle::{ + asset_value_in_usdc, load_price, read_token_amount, usdc_to_asset_amount, OraclePrice, +}; +use crate::state::{AssetConfig, Fund, MAX_ASSETS}; #[derive(Accounts)] pub struct RebalanceAccountConstraints { - #[account(address = fund.manager)] - pub manager: Signer, + /// Anyone. The program computes the trade from the oracle and the target + /// weights, so the caller chooses nothing but which pair to restore, and + /// pays the transaction fee. + pub caller: Signer, #[account( mut, @@ -27,50 +31,6 @@ pub struct RebalanceAccountConstraints { #[account(address = fund.usdc_mint @ FundError::InvalidUsdcMint)] pub usdc_mint: Box>, - #[account(mut)] - pub sell_mint: Box>, - - #[account(mut)] - pub buy_mint: Box>, - - #[account( - constraint = sell_config.fund == *fund.address() @ FundError::InvalidAssetAccount, - constraint = sell_config.mint == *sell_mint.address() @ FundError::AssetNotFound, - constraint = sell_config.vault == *vault_sell.address() @ FundError::InvalidAssetAccount, - )] - pub sell_config: Box>, - - #[account( - constraint = buy_config.fund == *fund.address() @ FundError::InvalidAssetAccount, - constraint = buy_config.mint == *buy_mint.address() @ FundError::AssetNotFound, - constraint = buy_config.vault == *vault_buy.address() @ FundError::InvalidAssetAccount, - )] - pub buy_config: Box>, - - /// CHECK: Pyth feed - validated against sell asset's registered feed - #[account(constraint = *sell_price_feed.address() == sell_config.price_feed @ FundError::InvalidPriceFeed)] - pub sell_price_feed: UncheckedAccount, - - /// CHECK: Pyth feed - validated against buy asset's registered feed - #[account(constraint = *buy_price_feed.address() == buy_config.price_feed @ FundError::InvalidPriceFeed)] - pub buy_price_feed: UncheckedAccount, - - #[account( - mut, - associated_token::mint = sell_mint, - associated_token::authority = fund, - associated_token::token_program = token_program - )] - pub vault_sell: Box>, - - #[account( - mut, - associated_token::mint = buy_mint, - associated_token::authority = fund, - associated_token::token_program = token_program - )] - pub vault_buy: Box>, - #[account( mut, associated_token::mint = usdc_mint, @@ -79,10 +39,6 @@ pub struct RebalanceAccountConstraints { )] pub vault_usdc: Box>, - pub sell_rate: BorshAccount, - - pub buy_rate: BorshAccount, - /// CHECK: Router config PDA #[account(mut)] pub router_config: UncheckedAccount, @@ -100,94 +56,175 @@ pub struct RebalanceAccountConstraints { pub associated_token_program: Program, pub token_program: Interface<'static, TokenInterface>, pub system_program: Program, + // remaining_accounts: for each asset index 0..asset_count, in order: + // [asset_config, vault, asset_mint, asset_rate, price_feed] + // the same layout as deposit. Every asset is valued, so the targets are + // shares of the whole fund. } +/// Restore one pair of assets toward their target weights. The asset at +/// `sell_index` must sit above its target by at least the fund's +/// `rebalance_threshold_bps` of the fund's value (or be retired, at weight zero, +/// and still held); the asset at `buy_index` must sit below its target. The +/// trade is the smaller of the two gaps, so it never pushes either asset past +/// its target, and a second call straight after finds nothing to do. That is +/// what lets anyone call it: no caller can choose a trade size, and none can +/// trade a fund that has not drifted. pub fn handle_rebalance( context: &mut Context, - sell_amount: u64, - usdc_to_invest: u64, + sell_index: u8, + buy_index: u8, ) -> Result<()> { - require!( - context.accounts.sell_mint.address() != context.accounts.buy_mint.address(), - FundError::SameMint - ); + require!(sell_index != buy_index, FundError::SameMint); let fund = &context.accounts.fund; + let fund_key = *fund.address(); let fund_index = fund.index; let fund_bump = fund.bump; let slip = (10_000 - fund.max_slippage_bps) as u128; + let threshold_bps = fund.rebalance_threshold_bps as u128; + let usdc_decimals = fund.usdc_decimals; + let asset_count = fund.asset_count as usize; + // Recorded holdings, never vault balances: tokens donated into a vault + // cannot make an asset look over its target and force a trade. + let mut usdc_holdings = fund.usdc_holdings; + let mut asset_holdings = fund.asset_holdings; + + let sell = sell_index as usize; + let buy = buy_index as usize; + require!( + sell < asset_count && buy < asset_count, + FundError::InvalidAssetAccount + ); + + let remaining = context.remaining_accounts()?; + require!( + remaining.len() == asset_count * 5, + FundError::IncompleteAssetAccounts + ); 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(FundError::MathOverflow)? - .checked_div(PYTH_PRICE_PRECISION) - .ok_or(FundError::MathOverflow)?; - let minimum_usdc_from_sell: u64 = expected_usdc - .checked_mul(slip) - .ok_or(FundError::MathOverflow)? - .checked_div(10_000) - .ok_or(FundError::MathOverflow)? - .try_into() - .map_err(|_| FundError::MathOverflow)?; + // Value every asset, exactly as deposit does. + let mut nav: u128 = usdc_holdings as u128; + let mut values = [0u128; MAX_ASSETS as usize]; + let mut sell_asset: Option<(AssetConfig, OraclePrice)> = None; + let mut buy_asset: Option<(AssetConfig, OraclePrice)> = None; + for index in 0..asset_count { + let config = AssetConfig::load_checked(&remaining[index * 5])?; + require_keys_eq!(config.fund, fund_key, FundError::InvalidAssetAccount); + require!( + config.index as usize == index, + FundError::InvalidAssetAccount + ); + require_keys_eq!( + *remaining[index * 5 + 1].address(), + config.vault, + FundError::InvalidAssetAccount + ); + require_keys_eq!( + *remaining[index * 5 + 2].address(), + config.mint, + FundError::InvalidAssetAccount + ); + + let price = load_price(&remaining[index * 5 + 4], &config.price_feed, now)?; + values[index] = asset_value_in_usdc( + asset_holdings[index] as u128, + price, + config.decimals, + usdc_decimals, + )?; + nav = nav + .checked_add(values[index]) + .ok_or(FundError::MathOverflow)?; + + if index == sell { + sell_asset = Some((config, price)); + } else if index == buy { + buy_asset = Some((config, price)); + } + } + let (sell_config, sell_price) = sell_asset.ok_or(FundError::InvalidAssetAccount)?; + let (buy_config, buy_price) = buy_asset.ok_or(FundError::InvalidAssetAccount)?; + + let target = |weight_bps: u16| -> Result { + nav.checked_mul(weight_bps as u128) + .ok_or(FundError::MathOverflow)? + .checked_div(10_000) + .ok_or(FundError::MathOverflow.into()) + }; - // 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(FundError::MathOverflow)? - .checked_div(price_buy) - .ok_or(FundError::MathOverflow)?; - let minimum_buy_amount: u64 = expected_buy - .checked_mul(slip) + // The sell side: how far above its target the asset sits. A retired asset + // may always be sold down; any other only once the gap reaches the + // threshold, so the fund does not trade on every small price move. + let excess = values[sell].saturating_sub(target(sell_config.weight_bps)?); + let threshold_value = nav + .checked_mul(threshold_bps) .ok_or(FundError::MathOverflow)? .checked_div(10_000) - .ok_or(FundError::MathOverflow)? - .try_into() - .map_err(|_| FundError::MathOverflow)?; - - // Rebalancing may only trade what the program has accounted for: tokens - // donated into a vault are outside the fund, so they can be neither sold nor - // spent. The legs below record what each swap actually moved. - let sell_index = context.accounts.sell_config.index as usize; - let buy_index = context.accounts.buy_config.index as usize; - let mut usdc_holdings = context.accounts.fund.usdc_holdings; - let mut asset_holdings = context.accounts.fund.asset_holdings; + .ok_or(FundError::MathOverflow)?; + require!( + excess > 0 && (sell_config.weight_bps == 0 || excess >= threshold_value), + FundError::DriftBelowThreshold + ); + + // The buy side: how far below its target the asset sits. + let shortfall = target(buy_config.weight_bps)?.saturating_sub(values[buy]); + require!(shortfall > 0, FundError::NotUnderweight); + + // Trade the smaller gap, so neither asset ends up past its target. + let trade_value = excess.min(shortfall); + let sell_amount: u64 = + usdc_to_asset_amount(trade_value, sell_price, sell_config.decimals, usdc_decimals)? + .try_into() + .map_err(|_| FundError::MathOverflow)?; + require!(sell_amount > 0, FundError::DriftBelowThreshold); require!( - sell_amount <= asset_holdings[sell_index], + sell_amount <= asset_holdings[sell], FundError::InsufficientHoldings ); + // Sell leg floor: USDC out within slippage of the oracle value of what is sold. + let minimum_usdc_from_sell: u64 = asset_value_in_usdc( + sell_amount as u128, + sell_price, + sell_config.decimals, + usdc_decimals, + )? + .checked_mul(slip) + .ok_or(FundError::MathOverflow)? + .checked_div(10_000) + .ok_or(FundError::MathOverflow)? + .try_into() + .map_err(|_| FundError::MathOverflow)?; + let index_bytes = fund_index.to_le_bytes(); let signer_seeds: &[&[&[u8]]] = &[&[b"fund", index_bytes.as_ref(), &[fund_bump]]]; - // `fund` signs the CPI(s) below. It is a data account holding a live + // `fund` signs the CPIs below. It is a data account holding a live // borrow on its buffer, which the runtime would reject when the CPI borrows // the same account, so hand the borrow back for the duration. context.accounts.fund.release_borrow()?; - let sell_before = context.accounts.vault_sell.amount(); + let mut vault_sell = remaining[sell * 5 + 1]; + let mut mint_sell = remaining[sell * 5 + 2]; + let rate_sell = &remaining[sell * 5 + 3]; + let mut vault_buy = remaining[buy * 5 + 1]; + let mut mint_buy = remaining[buy * 5 + 2]; + let rate_buy = &remaining[buy * 5 + 3]; + + let sell_before = read_token_amount(&vault_sell)?; let usdc_before_sell = context.accounts.vault_usdc.amount(); - // Step 1: sell basket token -> USDC + // Step 1: sell the over-weight asset for USDC. let sell_cpi_accounts = RouterSellAccounts { caller: context.accounts.fund.to_cpi_handle(), router_config: context.accounts.router_config.cpi_handle(), - asset_rate: context.accounts.sell_rate.cpi_handle(), + asset_rate: CpiHandle::readonly(rate_sell), usdc_mint: context.accounts.usdc_mint.to_cpi_handle(), - asset_mint: context.accounts.sell_mint.to_cpi_handle_mut(), - caller_asset_account: context.accounts.vault_sell.to_cpi_handle_mut(), + asset_mint: CpiHandleMut::writable(&mut mint_sell), + caller_asset_account: CpiHandleMut::writable(&mut vault_sell), caller_usdc_account: context.accounts.vault_usdc.to_cpi_handle_mut(), router_usdc_treasury: context.accounts.router_usdc_treasury.cpi_handle_mut(), associated_token_program: context.accounts.associated_token_program.cpi_handle(), @@ -204,34 +241,48 @@ pub fn handle_rebalance( minimum_usdc_from_sell, )?; + // Record what the swap actually moved, measured on the vaults. let sold = sell_before - .checked_sub(context.accounts.vault_sell.amount()) + .checked_sub(read_token_amount(&vault_sell)?) .ok_or(FundError::MathOverflow)?; let usdc_after_sell = context.accounts.vault_usdc.amount(); let usdc_received = usdc_after_sell .checked_sub(usdc_before_sell) .ok_or(FundError::MathOverflow)?; - asset_holdings[sell_index] = asset_holdings[sell_index] + asset_holdings[sell] = asset_holdings[sell] .checked_sub(sold) .ok_or(FundError::InsufficientHoldings)?; usdc_holdings = usdc_holdings .checked_add(usdc_received) .ok_or(FundError::MathOverflow)?; - require!( - usdc_to_invest <= usdc_holdings, - FundError::InsufficientHoldings - ); - let buy_before = context.accounts.vault_buy.amount(); - // Step 2: buy basket token with USDC + // Step 2: spend exactly what the sale brought in on the under-weight asset. + // Donated USDC in the vault is never spent, because only the sale's proceeds + // are invested. + let usdc_to_invest = usdc_received; + let minimum_buy_amount: u64 = usdc_to_asset_amount( + usdc_to_invest as u128, + buy_price, + buy_config.decimals, + usdc_decimals, + )? + .checked_mul(slip) + .ok_or(FundError::MathOverflow)? + .checked_div(10_000) + .ok_or(FundError::MathOverflow)? + .try_into() + .map_err(|_| FundError::MathOverflow)?; + + let buy_before = read_token_amount(&vault_buy)?; + let buy_cpi_accounts = RouterBuyAccounts { caller: context.accounts.fund.to_cpi_handle(), router_config: context.accounts.router_config.cpi_handle(), - asset_rate: context.accounts.buy_rate.cpi_handle(), + asset_rate: CpiHandle::readonly(rate_buy), usdc_mint: context.accounts.usdc_mint.to_cpi_handle(), - asset_mint: context.accounts.buy_mint.to_cpi_handle_mut(), + asset_mint: CpiHandleMut::writable(&mut mint_buy), caller_usdc_account: context.accounts.vault_usdc.to_cpi_handle_mut(), - caller_asset_account: context.accounts.vault_buy.to_cpi_handle_mut(), + caller_asset_account: CpiHandleMut::writable(&mut vault_buy), router_usdc_treasury: context.accounts.router_usdc_treasury.cpi_handle_mut(), associated_token_program: context.accounts.associated_token_program.cpi_handle(), token_program: context.accounts.token_program.cpi_handle(), @@ -247,16 +298,13 @@ pub fn handle_rebalance( minimum_buy_amount, )?; - let bought = context - .accounts - .vault_buy - .amount() + let bought = read_token_amount(&vault_buy)? .checked_sub(buy_before) .ok_or(FundError::MathOverflow)?; let usdc_spent = usdc_after_sell .checked_sub(context.accounts.vault_usdc.amount()) .ok_or(FundError::MathOverflow)?; - asset_holdings[buy_index] = asset_holdings[buy_index] + asset_holdings[buy] = asset_holdings[buy] .checked_add(bought) .ok_or(FundError::MathOverflow)?; usdc_holdings = usdc_holdings diff --git a/finance/managed-fund/anchor/programs/managed-fund/src/instructions/set_weight.rs b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/set_weight.rs index 135b6432d..c2ee5312e 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/instructions/set_weight.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/instructions/set_weight.rs @@ -23,10 +23,11 @@ pub struct SetWeightAccountConstraints { } /// Change an asset's target weight. Setting it to zero retires the asset: deposits -/// stop allocating to it, and the manager sells its holdings out with `rebalance`, -/// leaving an empty vault at the asset's index. The index is never reused, so the -/// contiguous 0..asset_count range the valuation handlers depend on stays intact. -/// Funds do not move here; this only edits the target the manager trades toward. +/// stop allocating to it, and `rebalance` may sell all of its holdings whatever the +/// fund's threshold, leaving an empty vault at the asset's index. The index is never +/// reused, so the contiguous 0..asset_count range the valuation handlers depend on +/// stays intact. Funds do not move here; this only edits the target that +/// `rebalance` trades toward. pub fn handle_set_weight( context: &mut Context, weight_bps: u16, diff --git a/finance/managed-fund/anchor/programs/managed-fund/src/lib.rs b/finance/managed-fund/anchor/programs/managed-fund/src/lib.rs index f3027bc2f..ac2e31c23 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/lib.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/lib.rs @@ -38,6 +38,7 @@ pub mod managed_fund { index: u64, fee_bps: u16, max_slippage_bps: u16, + rebalance_threshold_bps: u16, swap_router: Address, ) -> Result<()> { instructions::initialize_fund::handle_initialize_fund( @@ -45,6 +46,7 @@ pub mod managed_fund { index, fee_bps, max_slippage_bps, + rebalance_threshold_bps, swap_router, ) } @@ -85,11 +87,14 @@ pub mod managed_fund { instructions::withdraw::handle_withdraw(context, shares_to_burn, min_usdc_out) } + /// Sell an asset that has drifted above its target weight and buy one below + /// its target. Anyone may call it: the program computes the trade, so the + /// caller chooses only which pair to restore. pub fn rebalance( context: &mut Context, - sell_amount: u64, - usdc_to_invest: u64, + sell_index: u8, + buy_index: u8, ) -> Result<()> { - instructions::rebalance::handle_rebalance(context, sell_amount, usdc_to_invest) + instructions::rebalance::handle_rebalance(context, sell_index, buy_index) } } diff --git a/finance/managed-fund/anchor/programs/managed-fund/src/oracle.rs b/finance/managed-fund/anchor/programs/managed-fund/src/oracle.rs index 6222f63fb..7c503c381 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/oracle.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/oracle.rs @@ -5,14 +5,14 @@ use crate::error::FundError; /// 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 `exponent` (i32): price(8) + conf(8) = +16 bytes after price. +const PYTH_EXPONENT_OFFSET: usize = PYTH_PRICE_OFFSET + 8 + 8; // 89 /// 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 /// Byte offset of `posted_slot` (u64), the slot the update was posted in: /// publish_time(8) + prev_publish_time(8) + ema_price(8) + ema_conf(8) = +32 bytes const PYTH_POSTED_SLOT_OFFSET: usize = PYTH_PUBLISH_TIME_OFFSET + 8 + 8 + 8 + 8; // 125 -/// 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; @@ -25,8 +25,17 @@ const TOKEN_OWNER_OFFSET: usize = 32; /// `mint` Address is at bytes 0..32. const TOKEN_MINT_OFFSET: usize = 0; -/// Returns `(price, publish_time, posted_slot)`. -fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i64, u64)> { +/// A positive Pyth price: `price * 10^exponent` dollars per whole token. +/// Crypto USD feeds use exponent -8 and US equity feeds -5, so the exponent is +/// read from the feed rather than assumed. +#[derive(Clone, Copy)] +pub struct OraclePrice { + pub price: u128, + pub exponent: i32, +} + +/// Returns `(price, exponent, publish_time, posted_slot)`. +fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i32, i64, u64)> { if account_data.len() < PYTH_POSTED_SLOT_OFFSET + 8 { return err!(FundError::InvalidPriceFeed); } @@ -35,6 +44,11 @@ fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i64, u64)> { .try_into() .map_err(|_| FundError::InvalidPriceFeed)?, ); + let exponent = i32::from_le_bytes( + account_data[PYTH_EXPONENT_OFFSET..PYTH_EXPONENT_OFFSET + 4] + .try_into() + .map_err(|_| FundError::InvalidPriceFeed)?, + ); let publish_time = i64::from_le_bytes( account_data[PYTH_PUBLISH_TIME_OFFSET..PYTH_PUBLISH_TIME_OFFSET + 8] .try_into() @@ -45,13 +59,17 @@ fn read_pyth_raw(account_data: &[u8]) -> Result<(i64, i64, u64)> { .try_into() .map_err(|_| FundError::InvalidPriceFeed)?, ); - Ok((price, publish_time, posted_slot)) + Ok((price, exponent, publish_time, posted_slot)) } /// Validate a price feed account against the one the fund registered, then -/// return its positive, fresh price as u128. `now` is the current unix timestamp. +/// return its positive, fresh price. `now` is the current unix timestamp. /// A price posted at or before the last cluster restart is rejected too. -pub fn load_price(price_feed: &AccountView, expected_key: &Address, now: i64) -> Result { +pub fn load_price( + price_feed: &AccountView, + expected_key: &Address, + now: i64, +) -> Result { require_keys_eq!( *price_feed.address(), *expected_key, @@ -59,7 +77,7 @@ pub fn load_price(price_feed: &AccountView, expected_key: &Address, now: i64) -> ); let data = price_feed.try_borrow_data()?; - let (price, publish_time, posted_slot) = read_pyth_raw(&data)?; + let (price, exponent, publish_time, posted_slot) = read_pyth_raw(&data)?; require!(price > 0, FundError::NegativePrice); require!( @@ -83,7 +101,10 @@ pub fn load_price(price_feed: &AccountView, expected_key: &Address, now: i64) -> FundError::PricePredatesRestart ); - Ok(price as u128) + Ok(OraclePrice { + price: price as u128, + exponent, + }) } /// Read the `amount` field of a token account from its raw data. @@ -123,14 +144,54 @@ pub fn read_token_mint_and_owner(account: &AccountView) -> Result<(Address, Addr 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) +/// `numerator * 10^power / denominator`, floored, for a power of either sign: +/// a negative power divides by `10^-power` instead. Multiplies before dividing. +fn mul_pow10_div(numerator: u128, power: i32, denominator: u128) -> Result { + let scale = 10u128 + .checked_pow(power.unsigned_abs()) + .ok_or(FundError::MathOverflow)?; + let (numerator, denominator) = if power >= 0 { + (numerator.checked_mul(scale), Some(denominator)) + } else { + (Some(numerator), denominator.checked_mul(scale)) + }; + numerator .ok_or(FundError::MathOverflow)? - .checked_div(PYTH_PRICE_PRECISION) - .ok_or(FundError::MathOverflow) - .map_err(Into::into) + .checked_div(denominator.ok_or(FundError::MathOverflow)?) + .ok_or(FundError::MathOverflow.into()) +} + +/// Value of `amount` asset minor units in USDC minor units. The asset has +/// `asset_decimals`, USDC has `usdc_decimals`, and a whole asset is worth +/// `price * 10^exponent` dollars, so +/// value = amount * price * 10^(usdc_decimals + exponent - asset_decimals). +/// With six-decimal USDC, an eight-decimal asset and an exponent of -8, that is +/// amount * price / 10^10. Floored. +pub fn asset_value_in_usdc( + amount: u128, + price: OraclePrice, + asset_decimals: u8, + usdc_decimals: u8, +) -> Result { + let power = usdc_decimals as i32 + price.exponent - asset_decimals as i32; + mul_pow10_div( + amount + .checked_mul(price.price) + .ok_or(FundError::MathOverflow)?, + power, + 1, + ) +} + +/// The inverse of `asset_value_in_usdc`: how many asset minor units +/// `usdc_amount` USDC minor units buys at the oracle price, +/// usdc_amount * 10^(asset_decimals - exponent - usdc_decimals) / price. Floored. +pub fn usdc_to_asset_amount( + usdc_amount: u128, + price: OraclePrice, + asset_decimals: u8, + usdc_decimals: u8, +) -> Result { + let power = asset_decimals as i32 - price.exponent - usdc_decimals as i32; + mul_pow10_div(usdc_amount, power, price.price) } diff --git a/finance/managed-fund/anchor/programs/managed-fund/src/state/fund.rs b/finance/managed-fund/anchor/programs/managed-fund/src/state/fund.rs index f0cdfe0fb..ca1fcfedb 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/src/state/fund.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/src/state/fund.rs @@ -26,12 +26,20 @@ pub struct Fund { pub registry: Address, pub share_mint: Address, pub usdc_mint: Address, + /// The USDC mint's decimals, read from the mint at creation. Valuation + /// scales every asset into these minor units. + pub usdc_decimals: u8, pub swap_router: Address, /// 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 deposit/rebalance. Bounded by MAX_SLIPPAGE_BPS. pub max_slippage_bps: u16, + /// How far, in basis points of the fund's value, an asset must sit above its + /// target weight before `rebalance` may sell it. Set at creation within + /// MIN/MAX_REBALANCE_THRESHOLD_BPS and never changed, so nobody can lower it + /// later to trade the fund on every small price move. + pub rebalance_threshold_bps: u16, pub total_shares: u64, /// USDC the program has accounted for in the USDC vault: deposits in, swap /// spending and withdrawals out. Share prices and payouts use this, never the @@ -61,14 +69,17 @@ pub struct AssetConfig { pub fund: Address, pub index: u8, pub mint: Address, + /// The asset mint's decimals, read from the mint when the asset is added. + /// Valuation scales by them, so a basket can mix assets of any precision. + pub decimals: u8, /// Pyth PriceUpdateV2 account, copied from the registry's ApprovedAsset at /// add time so the manager cannot substitute a feed they control. pub price_feed: Address, /// Fund-owned associated token account holding this asset. pub vault: Address, /// Target share of the fund's value in basis points. deposit deploys at these - /// weights (the sum across assets must reach 10000 before deposits open), and the - /// manager maintains them against price drift with rebalance. + /// weights (the sum across assets must reach 10000 before deposits open), and + /// rebalance restores them once prices drift past the fund's threshold. pub weight_bps: u16, pub bump: u8, } diff --git a/finance/managed-fund/anchor/programs/managed-fund/tests/managed_fund.rs b/finance/managed-fund/anchor/programs/managed-fund/tests/managed_fund.rs index 34b2289e1..d92e39f75 100644 --- a/finance/managed-fund/anchor/programs/managed-fund/tests/managed_fund.rs +++ b/finance/managed-fund/anchor/programs/managed-fund/tests/managed_fund.rs @@ -74,7 +74,12 @@ fn set_price_feed(svm: &mut LiteSVM, key: Address, price: i64) { /// Write a Pyth feed as if Pyth posted it in `posted_slot`. fn set_price_feed_posted_at(svm: &mut LiteSVM, key: Address, price: i64, posted_slot: u64) { - let data = build_mock_price_update_account(price, -8, PUBLISH_TIME, posted_slot); + write_price_feed(svm, key, price, -8, posted_slot); +} + +/// Write a Pyth feed with its own exponent: Pyth's US equity feeds use -5. +fn write_price_feed(svm: &mut LiteSVM, key: Address, price: i64, exponent: i32, posted_slot: u64) { + let data = build_mock_price_update_account(price, exponent, PUBLISH_TIME, posted_slot); let rent = svm.minimum_balance_for_rent_exemption(data.len()); svm.set_account( key, @@ -95,11 +100,12 @@ 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 TSLA_RATE: u64 = 250_000_000; // router USDC minor units per whole token +const NVDA_RATE: u64 = 180_000_000; const FEE_BPS: u16 = 100; // 1% const SLIPPAGE_BPS: u16 = 100; // 1% +const REBALANCE_THRESHOLD_BPS: u16 = 200; // two percentage points const FUND_INDEX: u64 = 0; // fund PDA seed: "fund" + 0 struct TestContext { @@ -140,6 +146,11 @@ impl TestContext { /// Mints, router (config + rates + treasury), Pyth feeds, a registry with TSLAx /// and NVDAx approved, and all derived PDAs. Does not create the fund. fn setup_full() -> TestContext { + setup_with_tsla_decimals(TOKEN_DECIMALS) +} + +/// `setup_full`, with TSLAx minted at `tsla_decimals` instead of six. +fn setup_with_tsla_decimals(tsla_decimals: u8) -> TestContext { let fund_program_id = managed_fund::id(); let router_program_id = mock_swap_router::id(); @@ -172,7 +183,7 @@ fn setup_full() -> TestContext { let manager = create_wallet(&mut svm, 10_000_000_000).unwrap(); 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 tsla_mint = create_token_mint(&mut svm, &payer, tsla_decimals, None).unwrap(); let nvda_mint = create_token_mint(&mut svm, &payer, TOKEN_DECIMALS, None).unwrap(); let (router_config_pda, _) = @@ -340,13 +351,20 @@ fn setup_full() -> TestContext { } } -fn init_fund(ctx: &mut TestContext, fee_bps: u16, slippage_bps: u16, router: Address) { - let ix = Instruction::new_with_bytes( +fn initialize_fund_instruction( + ctx: &TestContext, + fee_bps: u16, + max_slippage_bps: u16, + rebalance_threshold_bps: u16, + router: Address, +) -> Instruction { + Instruction::new_with_bytes( ctx.fund_program_id, &managed_fund::instruction::InitializeFund { index: FUND_INDEX, fee_bps, - max_slippage_bps: slippage_bps, + max_slippage_bps, + rebalance_threshold_bps, swap_router: router, } .data(), @@ -362,7 +380,12 @@ fn init_fund(ctx: &mut TestContext, fee_bps: u16, slippage_bps: u16, router: Add system_program: system_program::ID, } .to_account_metas(None), - ); + ) +} + +fn init_fund(ctx: &mut TestContext, fee_bps: u16, slippage_bps: u16, router: Address) { + let ix = + initialize_fund_instruction(ctx, fee_bps, slippage_bps, REBALANCE_THRESHOLD_BPS, router); send_transaction_from_instructions( &mut ctx.svm, vec![ix], @@ -653,76 +676,74 @@ fn read_asset_config(ctx: &TestContext, index: u8) -> managed_fund::state::Asset managed_fund::state::AssetConfig::try_deserialize(&mut &account.data[..]).unwrap() } -/// (mint, asset_config, price_feed, vault, rate_pda) for an asset in the two-asset -/// standard fund: index 0 is TSLAx, index 1 is NVDAx. -fn asset_accounts(ctx: &TestContext, index: u8) -> (Address, Address, Address, Address, Address) { - match index { - 0 => ( - ctx.tsla_mint, - ctx.asset_config(0), - ctx.price_feed_tsla, - ctx.vault_tsla, - ctx.tsla_rate_pda, - ), - 1 => ( - ctx.nvda_mint, - ctx.asset_config(1), - ctx.price_feed_nvda, - ctx.vault_nvda, - ctx.nvda_rate_pda, - ), - _ => panic!("unknown asset index {index}"), - } +/// A rebalance of the standard fund, signed by `caller`: the program computes +/// the trade, so the caller names only the pair. The remaining accounts are the +/// same five per asset that a deposit takes. +fn rebalance_instruction( + ctx: &TestContext, + caller: &Keypair, + sell_index: u8, + buy_index: u8, +) -> Instruction { + rebalance_instruction_with(ctx, caller, sell_index, buy_index, deposit_remaining(ctx)) } -fn do_rebalance( - ctx: &mut TestContext, +fn rebalance_instruction_with( + ctx: &TestContext, + caller: &Keypair, sell_index: u8, buy_index: u8, - sell_amount: u64, - usdc_to_invest: u64, -) { - let (sell_mint, sell_config, sell_feed, vault_sell, sell_rate) = - asset_accounts(ctx, sell_index); - let (buy_mint, buy_config, buy_feed, vault_buy, buy_rate) = asset_accounts(ctx, buy_index); - let ix = Instruction::new_with_bytes( + remaining: Vec, +) -> Instruction { + let mut metas = managed_fund::accounts::RebalanceAccountConstraints { + caller: caller.pubkey(), + fund: ctx.fund_pda, + usdc_mint: ctx.usdc_mint, + vault_usdc: ctx.vault_usdc, + router_config: ctx.router_config_pda, + router_usdc_treasury: ctx.router_usdc_treasury, + 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); + metas.extend(remaining); + Instruction::new_with_bytes( ctx.fund_program_id, &managed_fund::instruction::Rebalance { - sell_amount, - usdc_to_invest, + sell_index, + buy_index, } .data(), - managed_fund::accounts::RebalanceAccountConstraints { - manager: ctx.manager.pubkey(), - fund: ctx.fund_pda, - usdc_mint: ctx.usdc_mint, - sell_mint, - buy_mint, - sell_config, - buy_config, - sell_price_feed: sell_feed, - buy_price_feed: buy_feed, - vault_sell, - vault_buy, - vault_usdc: ctx.vault_usdc, - sell_rate, - buy_rate, - router_config: ctx.router_config_pda, - router_usdc_treasury: ctx.router_usdc_treasury, - 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![ix], - &[&ctx.manager], - &ctx.manager.pubkey(), + metas, ) - .unwrap(); +} + +/// Rebalance the standard fund, signed by a fresh wallet that is neither the +/// manager nor a depositor. +fn try_rebalance( + ctx: &mut TestContext, + sell_index: u8, + buy_index: u8, +) -> Result<(), solana_kite::SolanaKiteError> { + let stranger = create_wallet(&mut ctx.svm, 1_000_000_000).unwrap(); + let ix = rebalance_instruction(ctx, &stranger, sell_index, buy_index); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&stranger], &stranger.pubkey()) +} + +fn do_rebalance(ctx: &mut TestContext, sell_index: u8, buy_index: u8) { + try_rebalance(ctx, sell_index, buy_index).unwrap(); +} + +/// Assert that a transaction failed with one of the program's errors. +fn assert_program_error( + result: Result<(), solana_kite::SolanaKiteError>, + error: FundError, + why: &str, +) { + let err = format!("{:?}", result.expect_err(why)); + assert!(err.contains(&program_error(error)), "{why}: {err}"); } fn advance_one_year(ctx: &mut TestContext) { @@ -893,6 +914,7 @@ fn test_initialize_rejects_excessive_fee() { index: FUND_INDEX, fee_bps: excessive, max_slippage_bps: SLIPPAGE_BPS, + rebalance_threshold_bps: REBALANCE_THRESHOLD_BPS, swap_router: ctx.router_program_id, } .data(), @@ -928,6 +950,7 @@ fn test_initialize_rejects_excessive_slippage() { index: FUND_INDEX, fee_bps: FEE_BPS, max_slippage_bps: excessive, + rebalance_threshold_bps: REBALANCE_THRESHOLD_BPS, swap_router: ctx.router_program_id, } .data(), @@ -1025,7 +1048,7 @@ fn test_deposit_rejects_slippage() { // Router rate for TSLAx far worse than the oracle: a deposit's TSLAx deploy leg // must revert, taking the whole deposit with it. let (tsla_mint, tsla_rate_pda) = (ctx.tsla_mint, ctx.tsla_rate_pda); - set_router_rate(&mut ctx, tsla_mint, 300, tsla_rate_pda); + set_router_rate(&mut ctx, tsla_mint, 300_000_000, tsla_rate_pda); let user = fund_user(&mut ctx, 10_000_000); let ix = deposit_instruction(&ctx, &user, 10_000_000, 1, deposit_remaining(&ctx)); @@ -1122,7 +1145,7 @@ fn test_deposit_fair_pricing() { ); // NVDAx rises 180 -> 200. NAV rises to 0 + 1.44*250 + 3.0*200 = 960 USDC. - set_nvda_price(&mut ctx, 20_000_000_000, 200); + set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); // Bob deposits 480 USDC at the higher NAV: shares = 480 * 900 / 960 = 450,000,000. // He pays today's price, so he does not dilute Alice's gain. @@ -1152,10 +1175,13 @@ fn test_rebalance() { do_deposit(&mut ctx, &alice, 900_000_000, 1); // NVDAx rises 180 -> 200, pushing the basket to 37.5 / 62.5 by value. - set_nvda_price(&mut ctx, 20_000_000_000, 200); + set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); - // Rebalance back toward 40/60: sell 0.12 NVDAx for 24 USDC, buy 0.096 TSLAx with it. - do_rebalance(&mut ctx, 1, 0, 120_000, 24_000_000); + // A stranger, neither the manager nor a depositor, rebalances. The program + // computes the trade back to 40/60: NVDAx is $24 over its $576 target and + // TSLAx $24 under its $384 target, so it sells 0.12 NVDAx for 24 USDC and + // buys 0.096 TSLAx with it. + do_rebalance(&mut ctx, 1, 0); // 1.44 + 0.096 = 1.536 TSLAx; 3.0 - 0.12 = 2.88 NVDAx. Now 384 / 576 = 40 / 60. // The USDC vault nets to zero across the two legs. @@ -1456,8 +1482,8 @@ fn test_full_lifecycle() { assert_holdings_match_vaults(&ctx); // NVDAx 180 -> 200; basket drifts to 37.5 / 62.5. Rebalance back to 40/60. - set_nvda_price(&mut ctx, 20_000_000_000, 200); - do_rebalance(&mut ctx, 1, 0, 120_000, 24_000_000); + set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); + do_rebalance(&mut ctx, 1, 0); assert_holdings_match_vaults(&ctx); assert_eq!( get_token_account_balance(&ctx.svm, &ctx.vault_tsla).unwrap(), @@ -1617,79 +1643,327 @@ fn test_deposit_rejects_leg_that_buys_nothing() { assert_eq!(read_fund(&ctx).asset_holdings[0], 4_000); } -/// Donated USDC is outside the fund, so a rebalance cannot spend it: the buy leg -/// may spend at most the recorded USDC, which after the sell leg is what the -/// sale brought in. +/// Transfer `amount` of `mint` from the donor's token account straight into a +/// fund vault, never calling a handler. +fn donate_token( + ctx: &mut TestContext, + donor: &Keypair, + mint: &Address, + vault: &Address, + amount: u64, +) { + let ix = spl_token::instruction::transfer( + &spl_token::ID, + &derive_ata(&donor.pubkey(), mint), + vault, + &donor.pubkey(), + &[], + amount, + ) + .unwrap(); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[donor], &donor.pubkey()).unwrap(); +} + +/// A fund sitting at its target weights has nothing to rebalance, in either +/// direction, so nobody can trade it. #[test] -fn test_rebalance_cannot_spend_donated_usdc() { +fn test_rebalance_refuses_fund_at_target() { let mut ctx = setup_full(); standard_fund(&mut ctx); + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); - // Alice's 900 USDC is fully deployed; the recorded USDC is zero. + assert_program_error( + try_rebalance(&mut ctx, 1, 0), + FundError::DriftBelowThreshold, + "a fund at target must not sell NVDAx", + ); + assert_program_error( + try_rebalance(&mut ctx, 0, 1), + FundError::DriftBelowThreshold, + "a fund at target must not sell TSLAx", + ); +} + +/// Drift smaller than the fund's threshold is not worth the slippage of a +/// trade, so the rebalance is refused. +#[test] +fn test_rebalance_refuses_drift_below_threshold() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); let alice = fund_user(&mut ctx, 900_000_000); do_deposit(&mut ctx, &alice, 900_000_000, 1); - assert_eq!(read_fund(&ctx).usdc_holdings, 0); - let donor = fund_user(&mut ctx, 100_000_000); - donate_usdc(&mut ctx, &donor, 100_000_000); - - // Selling 0.1 NVDAx at $180 brings in 18 USDC; investing 50 would need 32 USDC - // of the donation. - let (sell_mint, sell_config, sell_feed, vault_sell, sell_rate) = asset_accounts(&ctx, 1); - let (buy_mint, buy_config, buy_feed, vault_buy, buy_rate) = asset_accounts(&ctx, 0); - let rebalance = |sell_amount: u64, usdc_to_invest: u64| { - Instruction::new_with_bytes( - ctx.fund_program_id, - &managed_fund::instruction::Rebalance { - sell_amount, - usdc_to_invest, - } - .data(), - managed_fund::accounts::RebalanceAccountConstraints { - manager: ctx.manager.pubkey(), - fund: ctx.fund_pda, - usdc_mint: ctx.usdc_mint, - sell_mint, - buy_mint, - sell_config, - buy_config, - sell_price_feed: sell_feed, - buy_price_feed: buy_feed, - vault_sell, - vault_buy, - vault_usdc: ctx.vault_usdc, - sell_rate, - buy_rate, - router_config: ctx.router_config_pda, - router_usdc_treasury: ctx.router_usdc_treasury, - 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 too_much = rebalance(100_000, 50_000_000); + // NVDAx 180 -> 185: 555 of a 915 fund is 60.66%, 0.66 points over its 60% + // target and under the two-point threshold. + set_nvda_price(&mut ctx, 18_500_000_000, 185_000_000); + assert_program_error( + try_rebalance(&mut ctx, 1, 0), + FundError::DriftBelowThreshold, + "drift under the threshold must not trade", + ); + assert_eq!(read_fund(&ctx).asset_holdings[1], 3_000_000); +} + +/// Churn: once a rebalance has restored the weights, calling it again, in +/// either direction, finds nothing to do. Nobody can trade the fund back and +/// forth to bleed it through slippage. +#[test] +fn test_rebalance_cannot_churn() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); + + set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); + do_rebalance(&mut ctx, 1, 0); + let after_first = read_fund(&ctx); + + ctx.svm.expire_blockhash(); + assert_program_error( + try_rebalance(&mut ctx, 1, 0), + FundError::DriftBelowThreshold, + "a second rebalance must find nothing to sell", + ); + assert_program_error( + try_rebalance(&mut ctx, 0, 1), + FundError::DriftBelowThreshold, + "rebalancing back the other way must be refused", + ); + let after_retries = read_fund(&ctx); + assert_eq!(after_retries.asset_holdings, after_first.asset_holdings); + assert_eq!(after_retries.usdc_holdings, after_first.usdc_holdings); +} + +/// A third basket asset the router can trade: its own mint (the router holds +/// the mint authority), router rate, Pyth feed, and registry approval. +struct ExtraAsset { + mint: Address, + approved: Address, + vault: Address, + rate_pda: Address, + feed: Address, +} + +fn create_routable_asset(ctx: &mut TestContext, price: i64, rate: u64) -> ExtraAsset { + let mint = create_token_mint(&mut ctx.svm, &ctx.payer, TOKEN_DECIMALS, None).unwrap(); + let ix = spl_token::instruction::set_authority( + &spl_token::ID, + &mint, + Some(&ctx.router_config_pda), + spl_token::instruction::AuthorityType::MintTokens, + &ctx.payer.pubkey(), + &[], + ) + .unwrap(); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&ctx.payer], &ctx.payer.pubkey()) + .unwrap(); + let (rate_pda, _) = + Address::find_program_address(&[b"rate", mint.as_ref()], &ctx.router_program_id); + set_router_rate(ctx, mint, rate, rate_pda); + let feed = Keypair::new().pubkey(); + set_price_feed(&mut ctx.svm, feed, price); + let (approved, _) = Address::find_program_address( + &[b"approved_asset", ctx.registry_pda.as_ref(), mint.as_ref()], + &ctx.fund_program_id, + ); + let ix = Instruction::new_with_bytes( + ctx.fund_program_id, + &managed_fund::instruction::ApproveAsset { price_feed: feed }.data(), + managed_fund::accounts::ApproveAssetAccountConstraints { + authority: ctx.payer.pubkey(), + registry: ctx.registry_pda, + asset_mint: mint, + approved_asset: approved, + 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 vault = derive_ata(&ctx.fund_pda, &mint); + ExtraAsset { + mint, + approved, + vault, + rate_pda, + feed, + } +} + +/// The buy side must be under its target: a rebalance cannot pour the sale into +/// an asset already at or over its weight. Two assets cannot show this, since +/// when one is over its target the other is under by the same amount, so the +/// fund here holds a third, at 40/40/20. +#[test] +fn test_rebalance_refuses_buying_overweight_asset() { + let mut ctx = setup_full(); + let router = ctx.router_program_id; + init_fund(&mut ctx, FEE_BPS, SLIPPAGE_BPS, router); + let third = create_routable_asset(&mut ctx, 10_000_000_000, 100_000_000); // $100 + let (tm, wt, vt) = (ctx.tsla_mint, ctx.approved_tsla, ctx.vault_tsla); + add_asset(&mut ctx, 0, tm, wt, vt, 4000).unwrap(); + let (nm, wn, vn) = (ctx.nvda_mint, ctx.approved_nvda, ctx.vault_nvda); + add_asset(&mut ctx, 1, nm, wn, vn, 4000).unwrap(); + add_asset(&mut ctx, 2, third.mint, third.approved, third.vault, 2000).unwrap(); + + let mut remaining = deposit_remaining(&ctx); + remaining.extend(asset_deposit_metas( + ctx.asset_config(2), + third.vault, + third.mint, + third.rate_pda, + third.feed, + )); + let alice = fund_user(&mut ctx, 1_000_000_000); + let ix = deposit_instruction(&ctx, &alice, 1_000_000_000, 1, remaining.clone()); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&alice], &alice.pubkey()).unwrap(); + + // NVDAx and the third asset both rise 30%. NVDAx is about $520 of $1,180 + // against a $472 target; the third asset $260 against $236; TSLAx $400 + // against $472. NVDAx is over by more than the threshold, the third asset + // is over too, and only TSLAx is under. + set_nvda_price(&mut ctx, 23_400_000_000, 234_000_000); + set_price_feed(&mut ctx.svm, third.feed, 13_000_000_000); + set_router_rate(&mut ctx, third.mint, 130_000_000, third.rate_pda); + + let stranger = create_wallet(&mut ctx.svm, 1_000_000_000).unwrap(); + let into_third = rebalance_instruction_with(&ctx, &stranger, 1, 2, remaining.clone()); let r = send_transaction_from_instructions( &mut ctx.svm, - vec![too_much], - &[&ctx.manager], - &ctx.manager.pubkey(), + vec![into_third], + &[&stranger], + &stranger.pubkey(), ); - let err = format!( - "{:?}", - r.expect_err("a rebalance must not spend donated USDC") + assert_program_error( + r, + FundError::NotUnderweight, + "an over-weight asset must not be bought", ); - assert!( - err.contains(&program_error(FundError::InsufficientHoldings)), - "{err}" + + // Naming the same asset on both sides is refused outright. + let into_itself = rebalance_instruction_with(&ctx, &stranger, 1, 1, remaining.clone()); + let r = send_transaction_from_instructions( + &mut ctx.svm, + vec![into_itself], + &[&stranger], + &stranger.pubkey(), + ); + assert_program_error( + r, + FundError::SameMint, + "an asset must not be sold into itself", ); - // Investing only what the sale brought in goes through, and the donation is - // still outside the recorded holdings. + // Selling NVDAx into TSLAx, the asset that is under, goes through. + let into_tsla = rebalance_instruction_with(&ctx, &stranger, 1, 0, remaining); + send_transaction_from_instructions( + &mut ctx.svm, + vec![into_tsla], + &[&stranger], + &stranger.pubkey(), + ) + .unwrap(); + assert!(read_fund(&ctx).asset_holdings[0] > 1_600_000); +} + +/// A rebalance values every asset, so it needs every asset's accounts, as a +/// deposit does. +#[test] +fn test_rebalance_rejects_incomplete_assets() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); + set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); + + let stranger = create_wallet(&mut ctx.svm, 1_000_000_000).unwrap(); + let mut ix = rebalance_instruction(&ctx, &stranger, 1, 0); + ix.accounts.truncate(ix.accounts.len() - 5); + let r = send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&stranger], + &stranger.pubkey(), + ); + assert_program_error( + r, + FundError::IncompleteAssetAccounts, + "a rebalance missing an asset must revert", + ); +} + +/// Setting a weight to zero retires an asset, and a rebalance then sells all of +/// it, however little is left: a retired asset needs no threshold, because +/// selling it to zero is a trade that can happen only once. +#[test] +fn test_rebalance_sells_retired_asset() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); + + // Maria retires NVDAx and moves its weight to TSLAx. + set_weight(&mut ctx, 1, 0).unwrap(); + set_weight(&mut ctx, 0, 10_000).unwrap(); + + // NVDAx falls to $1, so the 3 NVDAx left are $3 of a $363 fund: under one + // percentage point, below the two-point threshold. + set_nvda_price(&mut ctx, 100_000_000, 1_000_000); + do_rebalance(&mut ctx, 1, 0); + + // All 3 NVDAx sold for 3 USDC, which bought 0.012 TSLAx. + let fund = read_fund(&ctx); + assert_eq!(fund.asset_holdings[1], 0); + assert_eq!(fund.asset_holdings[0], 1_452_000); + assert_eq!(fund.usdc_holdings, 0); + assert_holdings_match_vaults(&ctx); + ctx.svm.expire_blockhash(); - do_rebalance(&mut ctx, 1, 0, 100_000, 18_000_000); + assert_program_error( + try_rebalance(&mut ctx, 1, 0), + FundError::DriftBelowThreshold, + "a retired asset already sold has nothing left to sell", + ); +} + +/// Donations can neither force a rebalance nor pay for one. Donated NVDAx is not +/// recorded, so it cannot push NVDAx over its target; donated USDC is never +/// spent, because the buy leg invests only what the sale brought in. +#[test] +fn test_rebalance_ignores_donations() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + let alice = fund_user(&mut ctx, 900_000_000); + do_deposit(&mut ctx, &alice, 900_000_000, 1); + + // The donor gets NVDAx the way anyone can: deposit, then withdraw in kind. + let donor = fund_user(&mut ctx, 200_000_000); + let donor_share = do_deposit(&mut ctx, &donor, 100_000_000, 1); + let donor_shares = get_token_account_balance(&ctx.svm, &donor_share).unwrap(); + do_withdraw(&mut ctx, &donor, donor_shares, 0); + let donated_nvda = + get_token_account_balance(&ctx.svm, &derive_ata(&donor.pubkey(), &ctx.nvda_mint)).unwrap(); + assert!(donated_nvda > 0); + let (nvda_mint, vault_nvda, usdc_mint, vault_usdc) = + (ctx.nvda_mint, ctx.vault_nvda, ctx.usdc_mint, ctx.vault_usdc); + donate_token(&mut ctx, &donor, &nvda_mint, &vault_nvda, donated_nvda); + donate_token(&mut ctx, &donor, &usdc_mint, &vault_usdc, 100_000_000); + + // Counted, the donated NVDAx would put NVDAx far over its target. Recorded + // holdings are still 40/60, so there is nothing to rebalance. + assert_program_error( + try_rebalance(&mut ctx, 1, 0), + FundError::DriftBelowThreshold, + "donated NVDAx must not force a trade", + ); + + // A real price move does drift the fund, and the rebalance spends only + // what its sale brought in: the donated USDC is still in the vault, outside + // the recorded holdings. + set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); + ctx.svm.expire_blockhash(); + do_rebalance(&mut ctx, 1, 0); let fund = read_fund(&ctx); assert_eq!(fund.usdc_holdings, 0); assert_eq!( @@ -1697,7 +1971,86 @@ fn test_rebalance_cannot_spend_donated_usdc() { 100_000_000 ); assert_eq!( - fund.asset_holdings[1], + fund.asset_holdings[1] + donated_nvda, get_token_account_balance(&ctx.svm, &ctx.vault_nvda).unwrap() ); } + +/// The threshold is fixed at creation, within bounds: a manager cannot set it +/// near zero, where the fund would trade on every small move, or so high that +/// the target weights stop describing the fund. +#[test] +fn test_initialize_rejects_threshold_out_of_range() { + use managed_fund::instructions::initialize_fund::{ + MAX_REBALANCE_THRESHOLD_BPS, MIN_REBALANCE_THRESHOLD_BPS, + }; + let mut ctx = setup_full(); + let router = ctx.router_program_id; + for threshold in [ + 0, + MIN_REBALANCE_THRESHOLD_BPS - 1, + MAX_REBALANCE_THRESHOLD_BPS + 1, + ] { + let ix = initialize_fund_instruction(&ctx, FEE_BPS, SLIPPAGE_BPS, threshold, router); + let r = send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&ctx.manager], + &ctx.manager.pubkey(), + ); + assert_program_error( + r, + FundError::RebalanceThresholdOutOfRange, + "a threshold outside the bounds must be rejected", + ); + ctx.svm.expire_blockhash(); + } + init_fund(&mut ctx, FEE_BPS, SLIPPAGE_BPS, router); + assert_eq!( + read_fund(&ctx).rebalance_threshold_bps, + REBALANCE_THRESHOLD_BPS + ); +} + +/// Valuation scales by each asset's decimals and each feed's exponent. TSLAx +/// here has eight decimals and a Pyth equity feed with exponent -5, while USDC +/// and NVDAx keep six decimals and NVDAx's feed keeps -8. Assuming six decimals +/// and -8 would value Alice's 1.44 TSLAx at $360 * 100 * 1,000, and Bob's +/// deposit would buy almost no shares. Scaled correctly, every figure matches +/// the six-decimal story. +#[test] +fn test_valuation_scales_by_decimals_and_exponent() { + let mut ctx = setup_with_tsla_decimals(8); + write_price_feed(&mut ctx.svm, ctx.price_feed_tsla, 25_000_000, -5, 1); // $250 + standard_fund(&mut ctx); + assert_eq!(read_asset_config(&ctx, 0).decimals, 8); + assert_eq!(read_asset_config(&ctx, 1).decimals, 6); + + // Alice's 900 USDC deploys to 1.44 TSLAx (eight decimals) and 3 NVDAx. + let alice = fund_user(&mut ctx, 900_000_000); + let alice_share = do_deposit(&mut ctx, &alice, 900_000_000, 1); + assert_eq!( + get_token_account_balance(&ctx.svm, &alice_share).unwrap(), + 900_000_000 + ); + assert_eq!(read_fund(&ctx).asset_holdings[0], 144_000_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 3_000_000); + + // NVDAx to $200. The rebalance computes its trade in the same units: 0.12 + // NVDAx sold for 24 USDC, which buys 0.096 TSLAx, back to 40/60. + set_nvda_price(&mut ctx, 20_000_000_000, 200_000_000); + do_rebalance(&mut ctx, 1, 0); + assert_eq!(read_fund(&ctx).asset_holdings[0], 153_600_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 2_880_000); + + // The fund is worth $960, so Bob's 480 USDC buys 450 shares. + let bob = fund_user(&mut ctx, 480_000_000); + let bob_share = do_deposit(&mut ctx, &bob, 480_000_000, 1); + assert_eq!( + get_token_account_balance(&ctx.svm, &bob_share).unwrap(), + 450_000_000 + ); + assert_eq!(read_fund(&ctx).asset_holdings[0], 230_400_000); + assert_eq!(read_fund(&ctx).asset_holdings[1], 4_320_000); + assert_holdings_match_vaults(&ctx); +} diff --git a/finance/managed-fund/anchor/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs b/finance/managed-fund/anchor/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs index 5294f0043..937c73bf9 100644 --- a/finance/managed-fund/anchor/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs +++ b/finance/managed-fund/anchor/programs/mock-swap-router/src/instructions/swap_asset_for_usdc.rs @@ -73,10 +73,18 @@ pub fn handle_swap_asset_for_usdc( require!(rate > 0, RouterError::ZeroRate); require!(asset_amount_in > 0, RouterError::ZeroAmount); - // usdc_out = asset_amount_in * rate (u128 intermediate, program gets ceil on sell) + // usdc_out = asset_amount_in * rate / 10^asset_decimals (u128 intermediate, + // caller gets the floor) + let one_token = 10u128 + .checked_pow(context.accounts.asset_mint.decimals() as u32) + .ok_or(RouterError::MathOverflow)?; let usdc_out: u64 = (asset_amount_in as u128) .checked_mul(rate as u128) - .ok_or(RouterError::MathOverflow)? as u64; + .ok_or(RouterError::MathOverflow)? + .checked_div(one_token) + .ok_or(RouterError::MathOverflow)? + .try_into() + .map_err(|_| RouterError::MathOverflow)?; require!(usdc_out >= minimum_usdc_out, RouterError::SlippageExceeded); diff --git a/finance/managed-fund/anchor/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs b/finance/managed-fund/anchor/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs index be211e8c6..7aa8eeec1 100644 --- a/finance/managed-fund/anchor/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs +++ b/finance/managed-fund/anchor/programs/mock-swap-router/src/instructions/swap_usdc_for_asset.rs @@ -72,12 +72,18 @@ pub fn handle_swap_usdc_for_asset( let rate = context.accounts.asset_rate.usdc_per_token; require!(rate > 0, RouterError::ZeroRate); - // asset_out = usdc_amount_in / rate (u128 intermediate, user gets floor) + // asset_out = usdc_amount_in * 10^asset_decimals / rate (u128 intermediate, + // caller gets the floor) + let one_token = 10u128 + .checked_pow(context.accounts.asset_mint.decimals() as u32) + .ok_or(RouterError::MathOverflow)?; let asset_out: u64 = (usdc_amount_in as u128) - .checked_mul(1u128) + .checked_mul(one_token) .ok_or(RouterError::MathOverflow)? .checked_div(rate as u128) - .ok_or(RouterError::MathOverflow)? as u64; + .ok_or(RouterError::MathOverflow)? + .try_into() + .map_err(|_| RouterError::MathOverflow)?; require!( asset_out >= minimum_asset_out, diff --git a/finance/managed-fund/anchor/programs/mock-swap-router/src/state/asset_rate.rs b/finance/managed-fund/anchor/programs/mock-swap-router/src/state/asset_rate.rs index 003a23dab..520fb3487 100644 --- a/finance/managed-fund/anchor/programs/mock-swap-router/src/state/asset_rate.rs +++ b/finance/managed-fund/anchor/programs/mock-swap-router/src/state/asset_rate.rs @@ -4,9 +4,9 @@ use anchor_lang::prelude::*; #[derive(InitSpace)] pub struct AssetRate { pub mint: Address, - /// USDC base units per token base unit. - /// e.g. 250 means 1 token base unit = 250 USDC base units - /// (so 1.0 TSLAx = $250 when both have 6 decimals) + /// USDC base units per whole token, e.g. 250_000_000 means 1.0 TSLAx = $250 + /// with six-decimal USDC. The swaps scale by the asset mint's decimals, so the + /// rate means the same thing whatever precision the asset has. pub usdc_per_token: u64, pub bump: u8, } diff --git a/finance/managed-fund/kani-proofs/src/lib.rs b/finance/managed-fund/kani-proofs/src/lib.rs index 638eb9e7b..1feb1216f 100644 --- a/finance/managed-fund/kani-proofs/src/lib.rs +++ b/finance/managed-fund/kani-proofs/src/lib.rs @@ -21,6 +21,12 @@ //! what that buys: payouts never exceed the real balance, and a donation cannot //! dilute the next depositor. //! +//! Valuation converts between an asset's minor units and USDC minor units +//! through the asset's decimals and the Pyth feed's exponent, and `rebalance` +//! sizes its trade from those values. The harnesses check that the conversion +//! never sells more than the trade it was asked for, and that a rebalance +//! trade never pushes either asset past its target. +//! //! Nonlinear 128-bit harnesses use bounded model checking (small symbolic //! inputs), as percolator does; the share identities are scale-invariant. @@ -55,6 +61,61 @@ pub fn deposit_shares(usdc_amount: u64, total_shares: u64, nav: u64) -> Option Option { + let scale = 10u128.checked_pow(power.unsigned_abs())?; + if power >= 0 { + numerator.checked_mul(scale)?.checked_div(denominator) + } else { + numerator.checked_div(denominator.checked_mul(scale)?) + } +} + +/// USDC minor units that `amount` asset minor units are worth: +/// `amount * price * 10^(usdc_decimals + exponent - asset_decimals)`, floored +/// (`asset_value_in_usdc`). +pub fn asset_value_in_usdc( + amount: u128, + price: u128, + exponent: i32, + asset_decimals: u8, + usdc_decimals: u8, +) -> Option { + let power = usdc_decimals as i32 + exponent - asset_decimals as i32; + mul_pow10_div(amount.checked_mul(price)?, power, 1) +} + +/// Asset minor units that `usdc_amount` USDC minor units buys at the oracle +/// price, floored (`usdc_to_asset_amount`). +pub fn usdc_to_asset_amount( + usdc_amount: u128, + price: u128, + exponent: i32, + asset_decimals: u8, + usdc_decimals: u8, +) -> Option { + let power = asset_decimals as i32 - exponent - usdc_decimals as i32; + mul_pow10_div(usdc_amount, power, price) +} + +/// The value `handle_rebalance` trades: the smaller of how far the sell asset +/// sits above its target and how far the buy asset sits below its own. +/// `None` when either gap is zero, where the handler refuses. +pub fn rebalance_trade_value( + sell_value: u128, + sell_target: u128, + buy_value: u128, + buy_target: u128, +) -> Option { + let excess = sell_value.saturating_sub(sell_target); + let shortfall = buy_target.saturating_sub(buy_value); + if excess == 0 || shortfall == 0 { + return None; + } + Some(excess.min(shortfall)) +} + // =========================================================================== // 1. Withdrawal solvency // =========================================================================== @@ -223,6 +284,72 @@ fn proof_fee_shares_bounded_by_supply() { assert!(fee_shares <= total_shares as u128); // <= 100%/year dilution } +// =========================================================================== +// 6. Unit conversion never sells more than the trade +// =========================================================================== + +/// `rebalance` converts its trade value into an amount of the sell asset, then +/// floors the sale's minimum output by valuing that amount back. Across every +/// combination of decimals and exponent in range, the amount sold is never +/// worth more than the trade value, so flooring cannot make a rebalance sell +/// past the asset's target. +#[cfg(kani)] +#[kani::proof] +#[kani::solver(cadical)] +fn proof_sell_amount_never_worth_more_than_trade() { + let trade: u128 = kani::any(); + let price: u128 = kani::any(); + let exponent: i32 = kani::any(); + let asset_decimals: u8 = kani::any(); + let usdc_decimals: u8 = kani::any(); + + kani::assume(trade <= 255); + kani::assume(price >= 1 && price <= 255); + kani::assume(exponent >= -3 && exponent <= 0); + kani::assume(asset_decimals <= 3 && usdc_decimals <= 3); + + let amount = usdc_to_asset_amount(trade, price, exponent, asset_decimals, usdc_decimals) + .expect("computes"); + let worth = asset_value_in_usdc(amount, price, exponent, asset_decimals, usdc_decimals) + .expect("computes"); + assert!(worth <= trade); +} + +// =========================================================================== +// 7. A rebalance trade never overshoots a target +// =========================================================================== + +/// The trade is the smaller of the two gaps, so after it the sell asset is +/// still at or above its target and the buy asset at or below its own, valued +/// at the same prices. A rebalance therefore only ever closes gaps, and once it +/// has closed one there is nothing left to trade on that side: a caller cannot +/// trade the fund back and forth. +#[cfg(kani)] +#[kani::proof] +fn proof_rebalance_trade_never_overshoots() { + let sell_value: u128 = kani::any(); + let sell_target: u128 = kani::any(); + let buy_value: u128 = kani::any(); + let buy_target: u128 = kani::any(); + + kani::assume(sell_value <= u64::MAX as u128 && sell_target <= u64::MAX as u128); + kani::assume(buy_value <= u64::MAX as u128 && buy_target <= u64::MAX as u128); + + if let Some(trade) = rebalance_trade_value(sell_value, sell_target, buy_value, buy_target) { + assert!(trade > 0); + assert!(sell_value - trade >= sell_target); + assert!(buy_value + trade <= buy_target); + // Valued again, at least one side has no gap left. + let again = rebalance_trade_value( + sell_value - trade, + sell_target, + buy_value + trade, + buy_target, + ); + assert!(again.is_none()); + } +} + // =========================================================================== // Plain unit tests. // =========================================================================== @@ -255,6 +382,34 @@ mod tests { assert_eq!(back, 1_000_000_000); } + #[test] + fn valuation_scales_by_decimals_and_exponent() { + // 1.44 TSLAx at eight decimals, $250 on a Pyth equity feed (exponent -5), + // is 360 USDC. + assert_eq!( + asset_value_in_usdc(144_000_000, 25_000_000, -5, 8, 6).unwrap(), + 360_000_000 + ); + // 3 NVDAx at six decimals, $180 at exponent -8, is 540 USDC. + assert_eq!( + asset_value_in_usdc(3_000_000, 18_000_000_000, -8, 6, 6).unwrap(), + 540_000_000 + ); + // 24 USDC buys 0.096 TSLAx at eight decimals. + assert_eq!( + usdc_to_asset_amount(24_000_000, 25_000_000, -5, 8, 6).unwrap(), + 9_600_000 + ); + } + + #[test] + fn rebalance_trades_the_smaller_gap() { + // The book's rebalance: NVDAx $24 over its $576 target, TSLAx $24 under. + assert_eq!(rebalance_trade_value(600, 576, 360, 384), Some(24)); + // A fund at target has nothing to trade. + assert_eq!(rebalance_trade_value(576, 576, 384, 384), None); + } + #[test] fn round_trip_not_profitable() { let minted = deposit_shares(100, 200, 150).unwrap(); diff --git a/finance/managed-fund/quasar/CHANGELOG.md b/finance/managed-fund/quasar/CHANGELOG.md index f0aef53a0..5d4c13957 100644 --- a/finance/managed-fund/quasar/CHANGELOG.md +++ b/finance/managed-fund/quasar/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [2026-10-01] + +### Fixed + +- Valuation scales by each asset's decimals and each Pyth feed's exponent. It + assumed six decimals and an exponent of −8, so an eight-decimal asset was + valued 100 times too high. `load_price` reads the exponent at offset 89, + `AssetConfig` records `decimals` and `Fund` records `usdc_decimals`, and + `deposit` and `rebalance` use `asset_value_in_usdc` and + `usdc_to_asset_amount`. The mock router's `usdc_per_token` is now USDC minor + units per whole token. Tested by + `test_valuation_scales_by_decimals_and_exponent`. + +### Changed + +- `rebalance(sell_index, buy_index)` is permissionless and sizes its own trade + from oracle prices and target weights, once the asset sold is above its + target by the fund's `rebalance_threshold_bps`, a new `initialize_fund` + argument bounded to 100..=2,000 with no setter. New errors + `RebalanceThresholdOutOfRange`, `DriftBelowThreshold` and `NotUnderweight`. + Before, the manager chose both legs' amounts and could churn a balanced fund + through slippage. Tested by `test_rebalance_cannot_churn` and the other + `test_rebalance_*` tests. + ## [2026-09-28] ### Changed diff --git a/finance/managed-fund/quasar/README.md b/finance/managed-fund/quasar/README.md index de9cdbd16..97254fcfc 100644 --- a/finance/managed-fund/quasar/README.md +++ b/finance/managed-fund/quasar/README.md @@ -54,16 +54,27 @@ themselves or pair a real mint with a feed they control. cannot inflate the share price, the first-depositor attack. - `withdraw` burns shares and pays out a proportional slice of the USDC vault and every asset vault, in kind. -- `rebalance` lets the manager sell one asset for USDC and buy another with it, - keeping holdings near their targets as prices drift. Both legs are floored to - the oracle price so a bad swap route reverts, and neither can sell or spend - more than the recorded holdings (`InsufficientHoldings`). +- `rebalance(sell_index, buy_index)` sells one asset for USDC and buys another + with it, restoring the target weights as prices drift. Anyone may call it, + because the program sizes the trade: it values every asset (the same five + accounts per asset as `deposit`), requires the asset sold to sit above its + target by at least the fund's `rebalance_threshold_bps` of the fund's value + (`DriftBelowThreshold`; a retired asset may always be sold) and the asset + bought to sit below its own (`NotUnderweight`), and trades the smaller of the + two gaps. A fund at its targets has no trade to make, so nobody can churn it. + The threshold is set at creation between 100 and 2,000 basis points + (`RebalanceThresholdOutOfRange`) and never changes. Both legs are floored to + the oracle price so a bad swap route reverts, and the buy leg spends only what + the sale brought in. - `collect_fees` accrues the time-based management fee by minting fresh shares to the manager, diluting holders at the configured annual rate. Every swap and rebalance leg is bounded by the registered price feed: the program computes the oracle-implied output and rejects any swap that falls short -by more than the fund's slippage tolerance. +by more than the fund's slippage tolerance. Values scale by each asset's mint +decimals, recorded when the asset is added, and by the exponent each Pyth feed +reports (−8 for crypto USD feeds, −5 for US equities), so a basket can mix +assets of any precision. ## Accounts and PDAs @@ -90,7 +101,7 @@ withdraw), in index order. - Deposited USDC and every asset sit in program-owned vaults whose authority is the Fund PDA; only the deployed program can move them, and it does so only along deposit, withdraw, and rebalance. There is no manager path to withdraw - holdings, only to trade them within the basket or collect the configured fee. + holdings, nor to choose a trade: rebalancing is sized by the program. - The share supply is updated before any mint or burn (checks-effects- interactions), and value computations use u128 intermediates with checked arithmetic, flooring in the fund's favour. @@ -143,7 +154,12 @@ and a USDC-for-asset swap. The fund suite (`managed-fund/src/tests.rs`) drives the manager setup (registry, approve asset, fund, add asset) and a two-program deposit that deploys USDC into the basket through the router CPI, asserting share minting, vault balances, and treasury flow. A second deposit test shows a price -posted before a cluster restart is rejected until Pyth posts again. +posted before a cluster restart is rejected until Pyth posts again. The +rebalance tests sign as a stranger and check that a fund at its targets, within +its threshold, or just rebalanced cannot be traded +(`test_rebalance_cannot_churn` and its neighbors), and +`test_valuation_scales_by_decimals_and_exponent` runs the story with an +eight-decimal asset on an exponent −5 feed. ## Extending @@ -151,4 +167,3 @@ posted before a cluster restart is rejected until Pyth posts again. an Address Lookup Table for the larger account list. - A real AMM or aggregator in place of the mock router. - Deposit and withdraw fees in addition to the time-based management fee. -- Rebalance automation driven by weight drift beyond a threshold. diff --git a/finance/managed-fund/quasar/managed-fund/src/errors.rs b/finance/managed-fund/quasar/managed-fund/src/errors.rs index 11179b1a5..f991ebecc 100644 --- a/finance/managed-fund/quasar/managed-fund/src/errors.rs +++ b/finance/managed-fund/quasar/managed-fund/src/errors.rs @@ -34,4 +34,10 @@ pub enum FundError { DepositTooSmall, /// A rebalance would sell or spend more than the recorded holdings. InsufficientHoldings, + /// Rebalance threshold is outside the allowed range. + RebalanceThresholdOutOfRange, + /// The asset to sell is not far enough above its target weight to rebalance. + DriftBelowThreshold, + /// The asset to buy is not below its target weight. + NotUnderweight, } diff --git a/finance/managed-fund/quasar/managed-fund/src/instructions/add_asset.rs b/finance/managed-fund/quasar/managed-fund/src/instructions/add_asset.rs index 77dbf5122..a85bdfbdf 100644 --- a/finance/managed-fund/quasar/managed-fund/src/instructions/add_asset.rs +++ b/finance/managed-fund/quasar/managed-fund/src/instructions/add_asset.rs @@ -72,6 +72,7 @@ pub fn handle_add_asset( fund: *accounts.fund.address(), index, mint: *accounts.asset_mint.address(), + decimals: accounts.asset_mint.decimals, // Copied from the registry entry, never supplied by the manager. price_feed: accounts.approved_asset.price_feed, vault: *accounts.vault_asset.address(), diff --git a/finance/managed-fund/quasar/managed-fund/src/instructions/deposit.rs b/finance/managed-fund/quasar/managed-fund/src/instructions/deposit.rs index ab8e2e8d0..dbd5b145b 100644 --- a/finance/managed-fund/quasar/managed-fund/src/instructions/deposit.rs +++ b/finance/managed-fund/quasar/managed-fund/src/instructions/deposit.rs @@ -5,7 +5,7 @@ use quasar_lang::sysvars::Sysvar as _; use quasar_spl::prelude::*; use crate::errors::FundError; -use crate::oracle::{asset_value_in_usdc, load_price, read_token_amount, PYTH_PRICE_PRECISION}; +use crate::oracle::{asset_value_in_usdc, load_price, read_token_amount, usdc_to_asset_amount}; use crate::state::{ load_asset_config, read_asset_holdings, snapshot_fund, write_asset_holdings, Fund, ShareMintPda, UsdcVaultPda, FUND_SEED, @@ -62,7 +62,7 @@ pub struct DepositAccountConstraints { pub system_program: Program, } -fn get_view<'a>( +pub(crate) fn get_view<'a>( remaining: &RemainingAccounts<'a>, index: usize, ) -> Result { @@ -138,7 +138,12 @@ pub fn handle_deposit( let price = load_price(&feed_view, &config.price_feed, now)?; nav = nav - .checked_add(asset_value_in_usdc(amount, price)?) + .checked_add(asset_value_in_usdc( + amount as u128, + price, + config.decimals, + usdc_decimals, + )?) .ok_or(FundError::MathOverflow)?; } @@ -217,14 +222,11 @@ pub fn handle_deposit( continue; } - // Oracle-anchored floor: expected_out = deploy_usdc * 10^8 / price, - // allowed to fall short by at most max_slippage_bps. + // Oracle-anchored floor: expected_out is what deploy_usdc buys at the + // Pyth price, allowed to fall short by at most max_slippage_bps. let price = load_price(&feed_view, &config.price_feed, now)?; - let expected_out = (deploy_usdc as u128) - .checked_mul(PYTH_PRICE_PRECISION) - .ok_or(FundError::MathOverflow)? - .checked_div(price) - .ok_or(FundError::MathOverflow)?; + let expected_out = + usdc_to_asset_amount(deploy_usdc as u128, price, config.decimals, usdc_decimals)?; let minimum_asset_out: u64 = expected_out .checked_mul((10_000 - max_slippage_bps) as u128) .ok_or(FundError::MathOverflow)? diff --git a/finance/managed-fund/quasar/managed-fund/src/instructions/initialize_fund.rs b/finance/managed-fund/quasar/managed-fund/src/instructions/initialize_fund.rs index 9c4430552..3c1719f48 100644 --- a/finance/managed-fund/quasar/managed-fund/src/instructions/initialize_fund.rs +++ b/finance/managed-fund/quasar/managed-fund/src/instructions/initialize_fund.rs @@ -14,6 +14,15 @@ pub const MAX_FEE_BPS: u16 = 1_000; /// oracle-anchored swap bound in deposit/rebalance can be made. pub const MAX_SLIPPAGE_BPS: u16 = 1_000; +/// Lowest rebalance threshold a manager may set, in basis points of the fund's +/// value (one percentage point). Every rebalance pays slippage, so a threshold +/// near zero would let anyone trade the fund on every small price move. +pub const MIN_REBALANCE_THRESHOLD_BPS: u16 = 100; + +/// Highest rebalance threshold a manager may set (twenty percentage points). +/// Past that, the target weights stop describing what the fund holds. +pub const MAX_REBALANCE_THRESHOLD_BPS: u16 = 2_000; + #[derive(Accounts)] #[instruction(index: u64)] pub struct InitializeFundAccountConstraints { @@ -54,6 +63,7 @@ pub fn handle_initialize_fund( index: u64, fee_bps: u16, max_slippage_bps: u16, + rebalance_threshold_bps: u16, swap_router: Address, bumps: &InitializeFundAccountConstraintsBumps, ) -> Result<(), ProgramError> { @@ -62,6 +72,11 @@ pub fn handle_initialize_fund( max_slippage_bps <= MAX_SLIPPAGE_BPS, FundError::SlippageConfigTooHigh ); + require!( + (MIN_REBALANCE_THRESHOLD_BPS..=MAX_REBALANCE_THRESHOLD_BPS) + .contains(&rebalance_threshold_bps), + FundError::RebalanceThresholdOutOfRange + ); let now = i64::from(Clock::get()?.unix_timestamp); @@ -71,9 +86,11 @@ pub fn handle_initialize_fund( registry: *accounts.registry.address(), share_mint: *accounts.share_mint.address(), usdc_mint: *accounts.usdc_mint.address(), + usdc_decimals: accounts.usdc_mint.decimals, swap_router, fee_bps, max_slippage_bps, + rebalance_threshold_bps, total_shares: 0, usdc_holdings: 0, asset_holdings: [0; ASSET_HOLDINGS_BYTES], diff --git a/finance/managed-fund/quasar/managed-fund/src/instructions/rebalance.rs b/finance/managed-fund/quasar/managed-fund/src/instructions/rebalance.rs index b24f08a2a..87a7903c8 100644 --- a/finance/managed-fund/quasar/managed-fund/src/instructions/rebalance.rs +++ b/finance/managed-fund/quasar/managed-fund/src/instructions/rebalance.rs @@ -1,59 +1,46 @@ use quasar_lang::cpi::Seed; use quasar_lang::prelude::*; +use quasar_lang::remaining::RemainingAccounts; use quasar_lang::sysvars::Sysvar as _; use quasar_spl::prelude::*; use crate::errors::FundError; -use crate::oracle::{load_price, read_token_amount, PYTH_PRICE_PRECISION}; +use crate::instructions::deposit::get_view; +use crate::oracle::{ + asset_value_in_usdc, load_price, read_token_amount, usdc_to_asset_amount, OraclePrice, +}; use crate::state::{ - read_asset_holdings, snapshot_fund, write_asset_holdings, AssetConfig, Fund, FUND_SEED, + load_asset_config, read_asset_holdings, snapshot_fund, write_asset_holdings, AssetConfigView, + Fund, UsdcVaultPda, FUND_SEED, MAX_ASSETS, }; const ROUTER_SWAP_USDC_FOR_ASSET: u8 = 2; const ROUTER_SWAP_ASSET_FOR_USDC: u8 = 3; const SWAP_ACCOUNTS: usize = 9; const SWAP_DATA_LEN: usize = 17; +/// remaining_accounts arrive as, per asset index 0..asset_count: +/// [asset_config, vault, asset_mint, asset_rate, price_feed] +const ACCOUNTS_PER_ASSET: usize = 5; #[derive(Accounts)] pub struct RebalanceAccountConstraints { - pub manager: Signer, + /// Anyone. The program computes the trade from the oracle and the target + /// weights, so the caller chooses nothing but which pair to restore, and + /// pays the transaction fee. + pub caller: Signer, #[account( mut, address = Fund::seeds(fund.index.into()), - has_one(manager), has_one(usdc_mint) @ FundError::InvalidUsdcMint, )] pub fund: Account, pub usdc_mint: Account, - #[account(mut)] - pub sell_mint: Account, - #[account(mut)] - pub buy_mint: Account, - - #[account(address = AssetConfig::seeds(fund.address(), sell_config.index))] - pub sell_config: Account, - #[account(address = AssetConfig::seeds(fund.address(), buy_config.index))] - pub buy_config: Account, - - /// Pyth feed - validated against the sell asset's registered feed. - pub sell_price_feed: UncheckedAccount, - /// Pyth feed - validated against the buy asset's registered feed. - pub buy_price_feed: UncheckedAccount, - - #[account(mut)] - pub vault_sell: Account, - #[account(mut)] - pub vault_buy: Account, - #[account(mut, address = crate::state::UsdcVaultPda::seeds(fund.address()))] + #[account(mut, address = UsdcVaultPda::seeds(fund.address()))] pub vault_usdc: InterfaceAccount, - /// Router rate accounts for the sell and buy assets. - pub sell_rate: UncheckedAccount, - pub buy_rate: UncheckedAccount, - /// Router config PDA; it owns the treasury and signs the router's token CPIs. #[account(mut)] pub router_config: UncheckedAccount, @@ -63,103 +50,163 @@ pub struct RebalanceAccountConstraints { pub token_program: Program, pub system_program: Program, + // remaining_accounts: for each asset index 0..asset_count, in order: + // [asset_config, vault, asset_mint, asset_rate, price_feed] + // the same layout as deposit. Every asset is valued, so the targets are + // shares of the whole fund. } +/// Restore one pair of assets toward their target weights. The asset at +/// `sell_index` must sit above its target by at least the fund's +/// `rebalance_threshold_bps` of the fund's value (or be retired, at weight zero, +/// and still held); the asset at `buy_index` must sit below its target. The +/// trade is the smaller of the two gaps, so it never pushes either asset past +/// its target, and a second call straight after finds nothing to do. That is +/// what lets anyone call it: no caller can choose a trade size, and none can +/// trade a fund that has not drifted. #[inline(always)] pub fn handle_rebalance( accounts: &mut RebalanceAccountConstraints, - sell_amount: u64, - usdc_to_invest: u64, + remaining: RemainingAccounts<'_>, + sell_index: u8, + buy_index: u8, ) -> Result<(), ProgramError> { - require!( - *accounts.sell_mint.address() != *accounts.buy_mint.address(), - FundError::SameMint - ); - - // Bind each config to its declared mint and vault. - require_keys_eq!( - accounts.sell_config.mint, - *accounts.sell_mint.address(), - FundError::AssetNotFound - ); - require_keys_eq!( - accounts.sell_config.vault, - *accounts.vault_sell.address(), - FundError::InvalidAssetAccount - ); - require_keys_eq!( - accounts.buy_config.mint, - *accounts.buy_mint.address(), - FundError::AssetNotFound - ); - require_keys_eq!( - accounts.buy_config.vault, - *accounts.vault_buy.address(), - FundError::InvalidAssetAccount - ); + require!(sell_index != buy_index, FundError::SameMint); + let fund_key = *accounts.fund.address(); let fund_index = u64::from(accounts.fund.index); let fund_bump = accounts.fund.bump; let slip = (10_000 - u16::from(accounts.fund.max_slippage_bps)) as u128; + let threshold_bps = u16::from(accounts.fund.rebalance_threshold_bps) as u128; + let usdc_decimals = accounts.fund.usdc_decimals; + let asset_count = accounts.fund.asset_count as usize; let router_program_addr = *accounts.swap_router_program.to_account_view().address(); require_keys_eq!( router_program_addr, accounts.fund.swap_router, FundError::InvalidSwapRouter ); + // Recorded holdings, never vault balances: tokens donated into a vault + // cannot make an asset look over its target and force a trade. + let mut fund = snapshot_fund(&accounts.fund); + let mut asset_holdings = read_asset_holdings(&fund.asset_holdings); + + let sell = sell_index as usize; + let buy = buy_index as usize; + require!( + sell < asset_count && buy < asset_count, + FundError::InvalidAssetAccount + ); + + // Exactly five accounts per asset - no more, no less - so no asset can be + // omitted from the valuation. + require!( + remaining + .get(asset_count * ACCOUNTS_PER_ASSET - 1)? + .is_some() + && remaining.get(asset_count * ACCOUNTS_PER_ASSET)?.is_none(), + FundError::IncompleteAssetAccounts + ); let now = i64::from(Clock::get()?.unix_timestamp); - let price_sell = load_price( - accounts.sell_price_feed.to_account_view(), - &accounts.sell_config.price_feed, - now, - )?; - let price_buy = load_price( - accounts.buy_price_feed.to_account_view(), - &accounts.buy_config.price_feed, - now, - )?; - - // Sell leg floor: USDC out within slippage of the oracle value of what we sell. - let expected_usdc = (sell_amount as u128) - .checked_mul(price_sell) - .ok_or(FundError::MathOverflow)? - .checked_div(PYTH_PRICE_PRECISION) - .ok_or(FundError::MathOverflow)?; - let minimum_usdc_from_sell: u64 = expected_usdc - .checked_mul(slip) - .ok_or(FundError::MathOverflow)? - .checked_div(10_000) - .ok_or(FundError::MathOverflow)? - .try_into() - .map_err(|_| FundError::MathOverflow)?; - // Buy leg floor: asset out within slippage of the oracle-implied amount. - let expected_buy = (usdc_to_invest as u128) - .checked_mul(PYTH_PRICE_PRECISION) - .ok_or(FundError::MathOverflow)? - .checked_div(price_buy) - .ok_or(FundError::MathOverflow)?; - let minimum_buy_amount: u64 = expected_buy - .checked_mul(slip) + // Value every asset, exactly as deposit does. + let mut nav: u128 = fund.usdc_holdings as u128; + let mut values = [0u128; MAX_ASSETS as usize]; + let mut sell_asset: Option<(AssetConfigView, OraclePrice)> = None; + let mut buy_asset: Option<(AssetConfigView, OraclePrice)> = None; + for (index, value) in values.iter_mut().enumerate().take(asset_count) { + let config_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET)?; + let vault_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET + 1)?; + let mint_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET + 2)?; + let feed_view = get_view(&remaining, index * ACCOUNTS_PER_ASSET + 4)?; + + let config = load_asset_config(&config_view)?; + require_keys_eq!(config.fund, fund_key, FundError::InvalidAssetAccount); + require!( + config.index as usize == index, + FundError::InvalidAssetAccount + ); + require_keys_eq!( + *vault_view.address(), + config.vault, + FundError::InvalidAssetAccount + ); + require_keys_eq!( + *mint_view.address(), + config.mint, + FundError::InvalidAssetAccount + ); + + let price = load_price(&feed_view, &config.price_feed, now)?; + *value = asset_value_in_usdc( + asset_holdings[index] as u128, + price, + config.decimals, + usdc_decimals, + )?; + nav = nav.checked_add(*value).ok_or(FundError::MathOverflow)?; + + if index == sell { + sell_asset = Some((config, price)); + } else if index == buy { + buy_asset = Some((config, price)); + } + } + let (sell_config, sell_price) = sell_asset.ok_or(FundError::InvalidAssetAccount)?; + let (buy_config, buy_price) = buy_asset.ok_or(FundError::InvalidAssetAccount)?; + + let target = |weight_bps: u16| -> Result { + nav.checked_mul(weight_bps as u128) + .ok_or(FundError::MathOverflow)? + .checked_div(10_000) + .ok_or_else(|| FundError::MathOverflow.into()) + }; + + // The sell side: how far above its target the asset sits. A retired asset + // may always be sold down; any other only once the gap reaches the + // threshold, so the fund does not trade on every small price move. + let excess = values[sell].saturating_sub(target(sell_config.weight_bps)?); + let threshold_value = nav + .checked_mul(threshold_bps) .ok_or(FundError::MathOverflow)? .checked_div(10_000) - .ok_or(FundError::MathOverflow)? - .try_into() - .map_err(|_| FundError::MathOverflow)?; - - // Rebalancing may only trade what the program has accounted for: tokens - // donated into a vault are outside the fund, so they can be neither sold nor - // spent. The legs below record what each swap actually moved. - let sell_index = accounts.sell_config.index as usize; - let buy_index = accounts.buy_config.index as usize; - let mut fund = snapshot_fund(&accounts.fund); - let mut asset_holdings = read_asset_holdings(&fund.asset_holdings); + .ok_or(FundError::MathOverflow)?; require!( - sell_amount <= asset_holdings[sell_index], + excess > 0 && (sell_config.weight_bps == 0 || excess >= threshold_value), + FundError::DriftBelowThreshold + ); + + // The buy side: how far below its target the asset sits. + let shortfall = target(buy_config.weight_bps)?.saturating_sub(values[buy]); + require!(shortfall > 0, FundError::NotUnderweight); + + // Trade the smaller gap, so neither asset ends up past its target. + let trade_value = excess.min(shortfall); + let sell_amount: u64 = + usdc_to_asset_amount(trade_value, sell_price, sell_config.decimals, usdc_decimals)? + .try_into() + .map_err(|_| FundError::MathOverflow)?; + require!(sell_amount > 0, FundError::DriftBelowThreshold); + require!( + sell_amount <= asset_holdings[sell], FundError::InsufficientHoldings ); + // Sell leg floor: USDC out within slippage of the oracle value of what is sold. + let minimum_usdc_from_sell: u64 = asset_value_in_usdc( + sell_amount as u128, + sell_price, + sell_config.decimals, + usdc_decimals, + )? + .checked_mul(slip) + .ok_or(FundError::MathOverflow)? + .checked_div(10_000) + .ok_or(FundError::MathOverflow)? + .try_into() + .map_err(|_| FundError::MathOverflow)?; + let index_bytes = fund_index.to_le_bytes(); let bump = [fund_bump]; let seeds = [ @@ -168,7 +215,14 @@ pub fn handle_rebalance( Seed::from(bump.as_ref()), ]; - // Step 1: sell the basket token for USDC. Router `swap_asset_for_usdc` + let vault_sell = get_view(&remaining, sell * ACCOUNTS_PER_ASSET + 1)?; + let mint_sell = get_view(&remaining, sell * ACCOUNTS_PER_ASSET + 2)?; + let rate_sell = get_view(&remaining, sell * ACCOUNTS_PER_ASSET + 3)?; + let vault_buy = get_view(&remaining, buy * ACCOUNTS_PER_ASSET + 1)?; + let mint_buy = get_view(&remaining, buy * ACCOUNTS_PER_ASSET + 2)?; + let rate_buy = get_view(&remaining, buy * ACCOUNTS_PER_ASSET + 3)?; + + // Step 1: sell the over-weight asset for USDC. Router `swap_asset_for_usdc` // order: caller, router_config, asset_rate, usdc_mint, asset_mint, // caller_asset_account, caller_usdc_account, router_usdc_treasury, // token_program. @@ -179,41 +233,54 @@ pub fn handle_rebalance( let mut sell_cpi = CpiDynamic::::new(&router_program_addr); sell_cpi.push_account(accounts.fund.to_account_view(), true, false)?; sell_cpi.push_account(accounts.router_config.to_account_view(), false, false)?; - sell_cpi.push_account(accounts.sell_rate.to_account_view(), false, false)?; + sell_cpi.push_account(&rate_sell, false, false)?; sell_cpi.push_account(accounts.usdc_mint.to_account_view(), false, false)?; - sell_cpi.push_account(accounts.sell_mint.to_account_view(), false, true)?; - sell_cpi.push_account(accounts.vault_sell.to_account_view(), false, true)?; + sell_cpi.push_account(&mint_sell, false, true)?; + sell_cpi.push_account(&vault_sell, false, true)?; sell_cpi.push_account(accounts.vault_usdc.to_account_view(), false, true)?; sell_cpi.push_account(accounts.router_usdc_treasury.to_account_view(), false, true)?; sell_cpi.push_account(accounts.token_program.to_account_view(), false, false)?; sell_cpi.set_data(&sell_data)?; - let sell_before = read_token_amount(accounts.vault_sell.to_account_view())?; + let sell_before = read_token_amount(&vault_sell)?; let usdc_before_sell = read_token_amount(accounts.vault_usdc.to_account_view())?; sell_cpi.invoke_signed(&seeds)?; + // Record what the swap actually moved, measured on the vaults. let sold = sell_before - .checked_sub(read_token_amount(accounts.vault_sell.to_account_view())?) + .checked_sub(read_token_amount(&vault_sell)?) .ok_or(FundError::MathOverflow)?; let usdc_after_sell = read_token_amount(accounts.vault_usdc.to_account_view())?; let usdc_received = usdc_after_sell .checked_sub(usdc_before_sell) .ok_or(FundError::MathOverflow)?; - asset_holdings[sell_index] = asset_holdings[sell_index] + asset_holdings[sell] = asset_holdings[sell] .checked_sub(sold) .ok_or(FundError::InsufficientHoldings)?; fund.usdc_holdings = fund .usdc_holdings .checked_add(usdc_received) .ok_or(FundError::MathOverflow)?; - require!( - usdc_to_invest <= fund.usdc_holdings, - FundError::InsufficientHoldings - ); - // Step 2: buy the basket token with USDC. Router `swap_usdc_for_asset` - // order: caller, router_config, asset_rate, usdc_mint, asset_mint, - // caller_usdc_account, caller_asset_account, router_usdc_treasury, - // token_program. + // Step 2: spend exactly what the sale brought in on the under-weight asset. + // Donated USDC in the vault is never spent, because only the sale's proceeds + // are invested. + let usdc_to_invest = usdc_received; + let minimum_buy_amount: u64 = usdc_to_asset_amount( + usdc_to_invest as u128, + buy_price, + buy_config.decimals, + usdc_decimals, + )? + .checked_mul(slip) + .ok_or(FundError::MathOverflow)? + .checked_div(10_000) + .ok_or(FundError::MathOverflow)? + .try_into() + .map_err(|_| FundError::MathOverflow)?; + + // Router `swap_usdc_for_asset` order: caller, router_config, asset_rate, + // usdc_mint, asset_mint, caller_usdc_account, caller_asset_account, + // router_usdc_treasury, token_program. let mut buy_data = [0u8; SWAP_DATA_LEN]; buy_data[0] = ROUTER_SWAP_USDC_FOR_ASSET; buy_data[1..9].copy_from_slice(&usdc_to_invest.to_le_bytes()); @@ -221,24 +288,24 @@ pub fn handle_rebalance( let mut buy_cpi = CpiDynamic::::new(&router_program_addr); buy_cpi.push_account(accounts.fund.to_account_view(), true, false)?; buy_cpi.push_account(accounts.router_config.to_account_view(), false, false)?; - buy_cpi.push_account(accounts.buy_rate.to_account_view(), false, false)?; + buy_cpi.push_account(&rate_buy, false, false)?; buy_cpi.push_account(accounts.usdc_mint.to_account_view(), false, false)?; - buy_cpi.push_account(accounts.buy_mint.to_account_view(), false, true)?; + buy_cpi.push_account(&mint_buy, false, true)?; buy_cpi.push_account(accounts.vault_usdc.to_account_view(), false, true)?; - buy_cpi.push_account(accounts.vault_buy.to_account_view(), false, true)?; + buy_cpi.push_account(&vault_buy, false, true)?; buy_cpi.push_account(accounts.router_usdc_treasury.to_account_view(), false, true)?; buy_cpi.push_account(accounts.token_program.to_account_view(), false, false)?; buy_cpi.set_data(&buy_data)?; - let buy_before = read_token_amount(accounts.vault_buy.to_account_view())?; + let buy_before = read_token_amount(&vault_buy)?; buy_cpi.invoke_signed(&seeds)?; - let bought = read_token_amount(accounts.vault_buy.to_account_view())? + let bought = read_token_amount(&vault_buy)? .checked_sub(buy_before) .ok_or(FundError::MathOverflow)?; let usdc_spent = usdc_after_sell .checked_sub(read_token_amount(accounts.vault_usdc.to_account_view())?) .ok_or(FundError::MathOverflow)?; - asset_holdings[buy_index] = asset_holdings[buy_index] + asset_holdings[buy] = asset_holdings[buy] .checked_add(bought) .ok_or(FundError::MathOverflow)?; fund.usdc_holdings = fund diff --git a/finance/managed-fund/quasar/managed-fund/src/instructions/set_weight.rs b/finance/managed-fund/quasar/managed-fund/src/instructions/set_weight.rs index 8198fae2d..abbcd25b9 100644 --- a/finance/managed-fund/quasar/managed-fund/src/instructions/set_weight.rs +++ b/finance/managed-fund/quasar/managed-fund/src/instructions/set_weight.rs @@ -18,10 +18,10 @@ pub struct SetWeightAccountConstraints { } /// Change an asset's target weight. Setting it to zero retires the asset: -/// deposits stop allocating to it and the manager sells its holdings out with -/// `rebalance`, leaving an empty vault at the asset's index. The index is never -/// reused, so the contiguous `0..asset_count` range the valuation handlers -/// depend on stays intact. Funds do not move here. +/// deposits stop allocating to it and `rebalance` may sell all of its holdings +/// whatever the fund's threshold, leaving an empty vault at the asset's index. +/// The index is never reused, so the contiguous `0..asset_count` range the +/// valuation handlers depend on stays intact. Funds do not move here. #[inline(always)] pub fn handle_set_weight( accounts: &mut SetWeightAccountConstraints, @@ -41,6 +41,7 @@ pub fn handle_set_weight( fund: accounts.asset_config.fund, index: accounts.asset_config.index, mint: accounts.asset_config.mint, + decimals: accounts.asset_config.decimals, price_feed: accounts.asset_config.price_feed, vault: accounts.asset_config.vault, weight_bps: old_weight, diff --git a/finance/managed-fund/quasar/managed-fund/src/lib.rs b/finance/managed-fund/quasar/managed-fund/src/lib.rs index d0e6397be..695566f7e 100644 --- a/finance/managed-fund/quasar/managed-fund/src/lib.rs +++ b/finance/managed-fund/quasar/managed-fund/src/lib.rs @@ -51,6 +51,7 @@ mod quasar_managed_fund { index: u64, fee_bps: u16, max_slippage_bps: u16, + rebalance_threshold_bps: u16, swap_router: Address, ) -> Result<(), ProgramError> { instructions::initialize_fund::handle_initialize_fund( @@ -58,6 +59,7 @@ mod quasar_managed_fund { index, fee_bps, max_slippage_bps, + rebalance_threshold_bps, swap_router, &ctx.bumps, ) @@ -122,14 +124,21 @@ mod quasar_managed_fund { ) } - /// Sell one basket asset for USDC and buy another with it, keeping the - /// basket near its target weights. Manager only. + /// Sell an asset that has drifted above its target weight and buy one below + /// its target. Anyone may call it: the program computes the trade, so the + /// caller chooses only which pair to restore. #[instruction(discriminator = 8)] pub fn rebalance( - ctx: Ctx, - sell_amount: u64, - usdc_to_invest: u64, + ctx: CtxWithRemaining, + sell_index: u8, + buy_index: u8, ) -> Result<(), ProgramError> { - instructions::rebalance::handle_rebalance(&mut ctx.accounts, sell_amount, usdc_to_invest) + let remaining = ctx.remaining_accounts(); + instructions::rebalance::handle_rebalance( + &mut ctx.accounts, + remaining, + sell_index, + buy_index, + ) } } diff --git a/finance/managed-fund/quasar/managed-fund/src/oracle.rs b/finance/managed-fund/quasar/managed-fund/src/oracle.rs index 602ec94ff..386d02a0a 100644 --- a/finance/managed-fund/quasar/managed-fund/src/oracle.rs +++ b/finance/managed-fund/quasar/managed-fund/src/oracle.rs @@ -5,13 +5,13 @@ use crate::{errors::FundError, last_restart::LastRestartSlot}; // 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) after price. +/// Byte offset of `exponent` (i32): price(8) + conf(8) = +16 bytes after price. +const PYTH_EXPONENT_OFFSET: usize = PYTH_PRICE_OFFSET + 8 + 8; // 89 +/// Byte offset of `publish_time` (i64): price(8) + conf(8) + exponent(4) after price. const PYTH_PUBLISH_TIME_OFFSET: usize = PYTH_PRICE_OFFSET + 8 + 8 + 4; // 93 /// Byte offset of `posted_slot` (u64), the slot the update was posted in: /// publish_time(8) + prev_publish_time(8) + ema_price(8) + ema_conf(8) after publish_time. const PYTH_POSTED_SLOT_OFFSET: usize = PYTH_PUBLISH_TIME_OFFSET + 8 + 8 + 8 + 8; // 125 -/// 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; @@ -38,6 +38,14 @@ fn read_i64(data: &[u8], offset: usize) -> Result { Ok(i64::from_le_bytes(bytes)) } +fn read_i32(data: &[u8], offset: usize) -> Result { + let bytes: [u8; 4] = data + .get(offset..offset + 4) + .and_then(|slice| slice.try_into().ok()) + .ok_or(FundError::InvalidPriceFeed)?; + Ok(i32::from_le_bytes(bytes)) +} + fn read_u64(data: &[u8], offset: usize) -> Result { let bytes: [u8; 8] = data .get(offset..offset + 8) @@ -46,14 +54,23 @@ fn read_u64(data: &[u8], offset: usize) -> Result { Ok(u64::from_le_bytes(bytes)) } +/// A positive Pyth price: `price * 10^exponent` dollars per whole token. +/// Crypto USD feeds use exponent -8 and US equity feeds -5, so the exponent is +/// read from the feed rather than assumed. +#[derive(Clone, Copy)] +pub struct OraclePrice { + pub price: u128, + pub exponent: i32, +} + /// Validate a price feed account against the one the fund registered, then -/// return its positive, fresh price as u128. `now` is the current unix timestamp. +/// return its positive, fresh price. `now` is the current unix timestamp. /// A price posted at or before the last cluster restart is rejected too. pub fn load_price( price_feed: &AccountView, expected_key: &Address, now: i64, -) -> Result { +) -> Result { if price_feed.address() != expected_key { return Err(FundError::InvalidPriceFeed.into()); } @@ -63,6 +80,7 @@ pub fn load_price( return Err(FundError::InvalidPriceFeed.into()); } let price = read_i64(data, PYTH_PRICE_OFFSET)?; + let exponent = read_i32(data, PYTH_EXPONENT_OFFSET)?; let publish_time = read_i64(data, PYTH_PUBLISH_TIME_OFFSET)?; let posted_slot = read_u64(data, PYTH_POSTED_SLOT_OFFSET)?; @@ -88,7 +106,10 @@ pub fn load_price( FundError::PricePredatesRestart ); - Ok(price as u128) + Ok(OraclePrice { + price: price as u128, + exponent, + }) } /// Read the `amount` field of a token account from its raw data. @@ -124,13 +145,54 @@ pub fn read_token_mint_and_owner( Ok((Address::from(mint), Address::from(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) +/// `numerator * 10^power / denominator`, floored, for a power of either sign: +/// a negative power divides by `10^-power` instead. Multiplies before dividing. +fn mul_pow10_div(numerator: u128, power: i32, denominator: u128) -> Result { + let scale = 10u128 + .checked_pow(power.unsigned_abs()) + .ok_or(FundError::MathOverflow)?; + let (numerator, denominator) = if power >= 0 { + (numerator.checked_mul(scale), Some(denominator)) + } else { + (Some(numerator), denominator.checked_mul(scale)) + }; + numerator .ok_or(FundError::MathOverflow)? - .checked_div(PYTH_PRICE_PRECISION) + .checked_div(denominator.ok_or(FundError::MathOverflow)?) .ok_or_else(|| FundError::MathOverflow.into()) } + +/// Value of `amount` asset minor units in USDC minor units. The asset has +/// `asset_decimals`, USDC has `usdc_decimals`, and a whole asset is worth +/// `price * 10^exponent` dollars, so +/// value = amount * price * 10^(usdc_decimals + exponent - asset_decimals). +/// With six-decimal USDC, an eight-decimal asset and an exponent of -8, that is +/// amount * price / 10^10. Floored. +pub fn asset_value_in_usdc( + amount: u128, + price: OraclePrice, + asset_decimals: u8, + usdc_decimals: u8, +) -> Result { + let power = usdc_decimals as i32 + price.exponent - asset_decimals as i32; + mul_pow10_div( + amount + .checked_mul(price.price) + .ok_or(FundError::MathOverflow)?, + power, + 1, + ) +} + +/// The inverse of `asset_value_in_usdc`: how many asset minor units +/// `usdc_amount` USDC minor units buys at the oracle price, +/// usdc_amount * 10^(asset_decimals - exponent - usdc_decimals) / price. Floored. +pub fn usdc_to_asset_amount( + usdc_amount: u128, + price: OraclePrice, + asset_decimals: u8, + usdc_decimals: u8, +) -> Result { + let power = asset_decimals as i32 - price.exponent - usdc_decimals as i32; + mul_pow10_div(usdc_amount, power, price.price) +} diff --git a/finance/managed-fund/quasar/managed-fund/src/state/fund.rs b/finance/managed-fund/quasar/managed-fund/src/state/fund.rs index 90cc8e541..f337b2d86 100644 --- a/finance/managed-fund/quasar/managed-fund/src/state/fund.rs +++ b/finance/managed-fund/quasar/managed-fund/src/state/fund.rs @@ -47,9 +47,17 @@ pub struct Fund { pub registry: Address, pub share_mint: Address, pub usdc_mint: Address, + /// The USDC mint's decimals, read from the mint at creation. Valuation + /// scales every asset into these minor units. + pub usdc_decimals: u8, pub swap_router: Address, pub fee_bps: u16, pub max_slippage_bps: u16, + /// How far, in basis points of the fund's value, an asset must sit above its + /// target weight before `rebalance` may sell it. Set at creation within + /// MIN/MAX_REBALANCE_THRESHOLD_BPS and never changed, so nobody can lower it + /// later to trade the fund on every small price move. + pub rebalance_threshold_bps: u16, pub total_shares: u64, /// USDC the program has accounted for in the USDC vault: deposits in, swap /// spending and withdrawals out. Share prices and payouts use this, never the @@ -78,11 +86,17 @@ pub struct AssetConfig { pub fund: Address, pub index: u8, pub mint: Address, + /// The asset mint's decimals, read from the mint when the asset is added. + /// Valuation scales by them, so a basket can mix assets of any precision. + pub decimals: u8, /// Price feed account, copied from the registry's ApprovedAsset at add time /// so the manager cannot substitute a feed they control. pub price_feed: Address, /// Fund-owned token account holding this asset. pub vault: Address, + /// Target share of the fund's value in basis points. deposit deploys at these + /// weights (the sum across assets must reach 10000 before deposits open), and + /// rebalance restores them once prices drift past the fund's threshold. pub weight_bps: u16, pub bump: u8, } @@ -109,9 +123,11 @@ pub fn snapshot_fund(fund: &Account) -> FundInner { registry: fund.registry, share_mint: fund.share_mint, usdc_mint: fund.usdc_mint, + usdc_decimals: fund.usdc_decimals, swap_router: fund.swap_router, fee_bps: u16::from(fund.fee_bps), max_slippage_bps: u16::from(fund.max_slippage_bps), + rebalance_threshold_bps: u16::from(fund.rebalance_threshold_bps), total_shares: u64::from(fund.total_shares), usdc_holdings: u64::from(fund.usdc_holdings), asset_holdings: fund.asset_holdings, @@ -128,6 +144,7 @@ pub struct AssetConfigView { pub fund: Address, pub index: u8, pub mint: Address, + pub decimals: u8, pub price_feed: Address, pub vault: Address, pub weight_bps: u16, @@ -142,6 +159,7 @@ pub fn load_asset_config(view: &AccountView) -> Result Pubkey { } // A Pyth PriceUpdateV2-shaped account: `price` (i64) at offset 73, -// `publish_time` (i64) at offset 93, `posted_slot` (u64) at offset 125. The -// program reads only those three fields. Posted at slot 1. +// `exponent` (i32) at offset 89, `publish_time` (i64) at offset 93, +// `posted_slot` (u64) at offset 125. The program reads only those four fields. +// Posted at slot 1, with the -8 exponent of Pyth's crypto USD feeds. fn add_pyth_feed(test: &mut Test, price: i64, publish_time: i64) { add_pyth_feed_posted_at(test, price, publish_time, 1); } // The same feed, as if Pyth posted it in `posted_slot`. fn add_pyth_feed_posted_at(test: &mut Test, price: i64, publish_time: i64, posted_slot: u64) { + write_price_feed(test, PRICE_FEED, price, -8, publish_time, posted_slot); +} + +/// Write a Pyth feed with its own exponent: Pyth's US equity feeds use -5. +fn write_price_feed( + test: &mut Test, + feed: Pubkey, + price: i64, + exponent: i32, + publish_time: i64, + posted_slot: u64, +) { let mut data = vec![0u8; 200]; data[73..81].copy_from_slice(&price.to_le_bytes()); + data[89..93].copy_from_slice(&exponent.to_le_bytes()); data[93..101].copy_from_slice(&publish_time.to_le_bytes()); data[125..133].copy_from_slice(&posted_slot.to_le_bytes()); - test.set_account(Account::new(PRICE_FEED, FEED_OWNER, 1_000_000, data)); + test.set_account(Account::new(feed, FEED_OWNER, 1_000_000, data)); } /// Pin the LastRestartSlot sysvar account, simulating a cluster restart at @@ -150,6 +179,7 @@ fn setup_fund(test: &mut Test, asset_mint_authority: Pubkey) { index: FUND_INDEX, fee_bps: FEE_BPS, max_slippage_bps: MAX_SLIPPAGE_BPS, + rebalance_threshold_bps: REBALANCE_THRESHOLD_BPS, swap_router: router_id(), }) .succeeds(); @@ -182,8 +212,9 @@ fn fund_setup_records_the_basket(test: &mut Test) { ); } -// Asset priced 250 USDC/token: Pyth price = 250 * 10^8 so -// asset_value = amount * price / 10^8 gives 250 USDC per token base unit. +// Asset priced 250 USDC/token: Pyth price = 250 * 10^8 at exponent -8, so with +// six-decimal USDC and asset, asset_value = amount * price / 10^8 gives 250 USDC +// minor units per asset minor unit. const PYTH_PRICE: i64 = 250 * 100_000_000; const DEPOSIT: u64 = 1_000; @@ -191,12 +222,7 @@ const DEPOSIT: u64 = 1_000; /// initialize the router with the asset's rate. Leaves the Pyth feed to the /// caller. fn setup_deposit(test: &mut Test) -> Pdas { - // Runtime read (NOT include_bytes!): quasar-test auto-loads only this - // program's .so; the sibling router program is added explicitly. - let router_elf = - std::fs::read("../mock-swap-router/target/deploy/quasar_mock_swap_router.so").unwrap(); - test.add(Program::new(router_id(), &router_elf)); - test.warp_to_timestamp(NOW); + load_router(test); // The router config account is the asset mint's mint authority, so the // router can mint it on swap. @@ -213,44 +239,67 @@ fn setup_deposit(test: &mut Test) -> Pdas { ); test.add(TokenAccount::new(w.share_mint, DEPOSITOR).at(DEPOSITOR_SHARE)); - // Initialize the router and set the asset's rate (hand-built: the router's - // builders live in the sibling crate). - let rent_id: Pubkey = "SysvarRent111111111111111111111111111111111" + initialize_router(test); + set_router_rate(test, ASSET_MINT, RATE); + + w +} + +/// Load the router program and pin the clock (the router's own builders live +/// in the sibling crate, so its instructions here are hand-built). +fn load_router(test: &mut Test) { + // Runtime read (NOT include_bytes!): quasar-test auto-loads only this + // program's .so; the sibling router program is added explicitly. + let router_elf = + std::fs::read("../mock-swap-router/target/deploy/quasar_mock_swap_router.so").unwrap(); + test.add(Program::new(router_id(), &router_elf)); + test.warp_to_timestamp(NOW); +} + +fn rent_id() -> Pubkey { + "SysvarRent111111111111111111111111111111111" .parse() - .unwrap(); + .unwrap() +} + +/// Initialize the router with `USDC_MINT` as its USDC. +fn initialize_router(test: &mut Test) { test.send(Instruction { program_id: router_id(), accounts: vec![ AccountMeta::new(AUTHORITY, true), AccountMeta::new_readonly(USDC_MINT, false), AccountMeta::new(router_config_pda(), false), - AccountMeta::new_readonly(rent_id, false), + AccountMeta::new_readonly(rent_id(), false), AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), AccountMeta::new_readonly(system_program::ID, false), ], data: vec![0u8], }) .succeeds(); +} + +/// Set (or reset) the router's rate for `mint`, in USDC minor units per whole +/// token. The first call also creates the router's USDC treasury. +fn set_router_rate(test: &mut Test, mint: Pubkey, rate: u64) { let mut set_rate_data = vec![1u8]; - set_rate_data.extend_from_slice(&RATE.to_le_bytes()); + set_rate_data.extend_from_slice(&rate.to_le_bytes()); test.send(Instruction { program_id: router_id(), accounts: vec![ AccountMeta::new(AUTHORITY, true), AccountMeta::new_readonly(router_config_pda(), false), - AccountMeta::new_readonly(ASSET_MINT, false), + AccountMeta::new_readonly(mint, false), AccountMeta::new_readonly(USDC_MINT, false), - AccountMeta::new(router_rate_pda(&ASSET_MINT), false), + AccountMeta::new(router_rate_pda(&mint), false), AccountMeta::new(router_treasury_pda(), false), - AccountMeta::new_readonly(rent_id, false), + AccountMeta::new_readonly(rent_id(), false), AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), AccountMeta::new_readonly(system_program::ID, false), ], data: set_rate_data, }) .succeeds(); - - w } /// Deposit `DEPOSIT` USDC: declared accounts, then remaining accounts per @@ -285,7 +334,7 @@ fn deposit_mints_shares_and_deploys_into_the_basket(test: &mut Test) { let w = setup_deposit(test); add_pyth_feed(test, PYTH_PRICE, NOW); - const ASSET_OUT: u64 = DEPOSIT / RATE; // 4 + const ASSET_OUT: u64 = DEPOSIT * ONE_TOKEN / RATE; // 4 send_deposit(test, &w) .succeeds() @@ -397,7 +446,7 @@ fn withdraw( /// A holder's position valued in USDC minor units at the test's price: USDC /// plus the asset at 250 USDC per token. fn value_in_usdc(test: &Test, usdc_account: Pubkey, asset_account: Pubkey) -> u64 { - test.tokens(usdc_account) + test.tokens(asset_account) * RATE + test.tokens(usdc_account) + test.tokens(asset_account) * RATE / ONE_TOKEN } /// The first-depositor inflation attack: a small first deposit, then a donation @@ -415,7 +464,7 @@ fn donation_does_not_inflate_share_price(test: &mut Test) { // The smallest deposit that buys any of the asset at 250 USDC minor units // per asset minor unit. - const ATTACKER_DEPOSIT: u64 = RATE; + const ATTACKER_DEPOSIT: u64 = RATE / ONE_TOKEN; const DONATION: u64 = 1_000_000_000; // 1,000 USDC const VICTIM_DEPOSIT: u64 = 1_000_000_000; // 1,000 USDC @@ -536,3 +585,608 @@ fn deposit_rejects_leg_that_buys_nothing(test: &mut Test) { let fund = test.read::(w.fund); assert_eq!(u64::from(fund.total_shares), 0, "total_shares"); } + +// The two-asset standard fund the rebalance tests share, the same basket as the +// Anchor suite: TSLAx at index 0 (40%) and NVDAx at index 1 (60%), both bought +// and sold through the router. +const TSLA_MINT: Pubkey = Pubkey::new_from_array([20; 32]); +const NVDA_MINT: Pubkey = Pubkey::new_from_array([21; 32]); +const TSLA_FEED: Pubkey = Pubkey::new_from_array([22; 32]); +const NVDA_FEED: Pubkey = Pubkey::new_from_array([23; 32]); +const THIRD_MINT: Pubkey = Pubkey::new_from_array([24; 32]); +const THIRD_FEED: Pubkey = Pubkey::new_from_array([25; 32]); + +const TSLA_PRICE: i64 = 25_000_000_000; // $250 +const NVDA_PRICE: i64 = 18_000_000_000; // $180 +const TSLA_RATE: u64 = 250_000_000; // router USDC minor units per whole token +const NVDA_RATE: u64 = 180_000_000; + +fn registry_pda(test: &Test) -> Pubkey { + test.derive_pda(Registry::seeds(&AUTHORITY)) +} + +fn fund_pda(test: &Test) -> Pubkey { + test.derive_pda(Fund::seeds(FUND_INDEX)) +} + +fn asset_config_pda(test: &Test, index: u8) -> Pubkey { + test.derive_pda(AssetConfig::seeds(&fund_pda(test), index)) +} + +fn asset_vault_pda(test: &Test, index: u8) -> Pubkey { + test.derive_pda(AssetVaultPda::seeds(&fund_pda(test), index)) +} + +fn usdc_vault_pda(test: &Test) -> Pubkey { + test.derive_pda(UsdcVaultPda::seeds(&fund_pda(test))) +} + +fn approve_asset(test: &mut Test, mint: Pubkey, feed: Pubkey) { + test.send(ApproveAssetInstruction { + authority: AUTHORITY, + asset_mint: mint, + price_feed: feed, + }) + .succeeds(); +} + +/// Mints, router (config + rates + treasury), Pyth feeds, and a registry with +/// TSLAx and NVDAx approved. Does not create the fund. +fn setup_full(test: &mut Test) { + setup_with_tsla_decimals(test, DECIMALS); +} + +/// `setup_full`, with TSLAx minted at `tsla_decimals` instead of six. +fn setup_with_tsla_decimals(test: &mut Test, tsla_decimals: u8) { + load_router(test); + test.add(Wallet::new().at(AUTHORITY)); + test.add(Wallet::new().at(MANAGER)); + test.add(Mint::new(AUTHORITY).at(USDC_MINT).decimals(DECIMALS)); + // The router config account is every asset's mint authority, so the router + // can mint it on swap. + test.add( + Mint::new(router_config_pda()) + .at(TSLA_MINT) + .decimals(tsla_decimals), + ); + test.add( + Mint::new(router_config_pda()) + .at(NVDA_MINT) + .decimals(DECIMALS), + ); + write_price_feed(test, TSLA_FEED, TSLA_PRICE, -8, NOW, 1); + write_price_feed(test, NVDA_FEED, NVDA_PRICE, -8, NOW, 1); + + initialize_router(test); + set_router_rate(test, TSLA_MINT, TSLA_RATE); + set_router_rate(test, NVDA_MINT, NVDA_RATE); + + test.send(InitializeRegistryInstruction { + authority: AUTHORITY, + }) + .succeeds(); + approve_asset(test, TSLA_MINT, TSLA_FEED); + approve_asset(test, NVDA_MINT, NVDA_FEED); +} + +fn initialize_fund_instruction( + test: &Test, + rebalance_threshold_bps: u16, +) -> InitializeFundInstruction { + InitializeFundInstruction { + manager: MANAGER, + usdc_mint: USDC_MINT, + registry: registry_pda(test), + index: FUND_INDEX, + fee_bps: FEE_BPS, + max_slippage_bps: MAX_SLIPPAGE_BPS, + rebalance_threshold_bps, + swap_router: router_id(), + } +} + +fn init_fund(test: &mut Test) { + let ix = initialize_fund_instruction(test, REBALANCE_THRESHOLD_BPS); + test.send(ix).succeeds(); +} + +fn add_asset(test: &mut Test, index: u8, mint: Pubkey, weight_bps: u16) { + let registry = registry_pda(test); + test.send(AddAssetInstruction { + manager: MANAGER, + fund_index_seed: FUND_INDEX, + registry, + asset_mint: mint, + fund_asset_count_seed: index, + weight_bps, + }) + .succeeds(); +} + +/// init fund + add TSLAx (index 0, 40%) + NVDAx (index 1, 60%). +fn standard_fund(test: &mut Test) { + init_fund(test); + add_asset(test, 0, TSLA_MINT, 4000); + add_asset(test, 1, NVDA_MINT, 6000); +} + +/// One asset's deposit remaining_accounts, in the order the handler reads: +/// [asset_config, vault, mint, rate, price_feed]. Deposit deploys into the asset, +/// so vault and mint must be writable. +fn asset_deposit_metas(test: &Test, index: u8, mint: Pubkey, feed: Pubkey) -> Vec { + vec![ + AccountMeta::new_readonly(asset_config_pda(test, index), false), + AccountMeta::new(asset_vault_pda(test, index), false), + AccountMeta::new(mint, false), + AccountMeta::new_readonly(router_rate_pda(&mint), false), + AccountMeta::new_readonly(feed, false), + ] +} + +/// remaining_accounts for a deposit into the two-asset standard fund. +fn deposit_remaining(test: &Test) -> Vec { + let mut metas = asset_deposit_metas(test, 0, TSLA_MINT, TSLA_FEED); + metas.extend(asset_deposit_metas(test, 1, NVDA_MINT, NVDA_FEED)); + metas +} + +/// A depositor's wallet and token accounts in the standard fund's mints. +struct User { + owner: Pubkey, + usdc: Pubkey, + share: Pubkey, + tsla: Pubkey, + nvda: Pubkey, +} + +fn fund_user(test: &mut Test, usdc_amount: u64) -> User { + let owner = test.add(Wallet::new()); + let share_mint = test.derive_pda(ShareMintPda::seeds(&fund_pda(test))); + User { + owner, + usdc: test.add(TokenAccount::new(USDC_MINT, owner).amount(usdc_amount)), + share: test.add(TokenAccount::new(share_mint, owner)), + tsla: test.add(TokenAccount::new(TSLA_MINT, owner)), + nvda: test.add(TokenAccount::new(NVDA_MINT, owner)), + } +} + +fn deposit_instruction( + user: &User, + usdc_amount: u64, + remaining_accounts: Vec, +) -> DepositInstruction { + DepositInstruction { + depositor: user.owner, + fund_index_seed: FUND_INDEX, + usdc_mint: USDC_MINT, + depositor_usdc_account: user.usdc, + depositor_share_account: user.share, + router_config: router_config_pda(), + router_usdc_treasury: router_treasury_pda(), + swap_router_program: router_id(), + usdc_amount, + minimum_shares: 1, + remaining_accounts, + } +} + +fn do_deposit(test: &mut Test, user: &User, usdc_amount: u64) { + let ix = deposit_instruction(user, usdc_amount, deposit_remaining(test)); + test.send(ix).succeeds(); +} + +/// Burn `shares` of the standard fund and take the payout in kind. +fn do_withdraw(test: &mut Test, user: &User, shares: u64) { + let mut remaining_accounts = Vec::new(); + for (index, mint, user_asset) in [(0, TSLA_MINT, user.tsla), (1, NVDA_MINT, user.nvda)] { + remaining_accounts.extend([ + AccountMeta::new_readonly(asset_config_pda(test, index), false), + AccountMeta::new(asset_vault_pda(test, index), false), + AccountMeta::new_readonly(mint, false), + AccountMeta::new(user_asset, false), + ]); + } + test.send(WithdrawInstruction { + user: user.owner, + fund_index_seed: FUND_INDEX, + usdc_mint: USDC_MINT, + user_share_account: user.share, + user_usdc_account: user.usdc, + shares_to_burn: shares, + min_usdc_out: 0, + remaining_accounts, + }) + .succeeds(); +} + +/// Move NVDAx's price: rewrite its Pyth feed and update the router rate to match. +fn set_nvda_price(test: &mut Test, price: i64, rate: u64) { + write_price_feed(test, NVDA_FEED, price, -8, NOW, 1); + set_router_rate(test, NVDA_MINT, rate); +} + +fn set_weight(test: &mut Test, index: u8, weight_bps: u16) { + test.send(SetWeightInstruction { + manager: MANAGER, + fund_index_seed: FUND_INDEX, + asset_config_index_seed: index, + weight_bps, + }) + .succeeds(); +} + +/// The fund's recorded USDC and per-asset holdings. +fn read_holdings(test: &Test) -> (u64, [u64; MAX_ASSETS as usize]) { + let fund = test.read::(fund_pda(test)); + ( + u64::from(fund.usdc_holdings), + read_asset_holdings(&fund.asset_holdings), + ) +} + +/// Recorded holdings must equal the vaults' token balances whenever nothing has +/// been donated: a mismatch means a handler moved tokens without recording it. +fn assert_holdings_match_vaults(test: &Test) { + let (usdc_holdings, asset_holdings) = read_holdings(test); + assert_eq!( + usdc_holdings, + test.tokens(usdc_vault_pda(test)), + "recorded USDC matches the USDC vault" + ); + assert_eq!( + asset_holdings[0], + test.tokens(asset_vault_pda(test, 0)), + "recorded TSLAx matches the TSLAx vault" + ); + assert_eq!( + asset_holdings[1], + test.tokens(asset_vault_pda(test, 1)), + "recorded NVDAx matches the NVDAx vault" + ); +} + +/// A rebalance of the fund, signed by `caller`: the program computes the trade, +/// so the caller names only the pair. The remaining accounts are the same five +/// per asset that a deposit takes. +fn rebalance_instruction_with( + caller: Pubkey, + sell_index: u8, + buy_index: u8, + remaining_accounts: Vec, +) -> RebalanceInstruction { + RebalanceInstruction { + caller, + fund_index_seed: FUND_INDEX, + usdc_mint: USDC_MINT, + router_config: router_config_pda(), + router_usdc_treasury: router_treasury_pda(), + swap_router_program: router_id(), + sell_index, + buy_index, + remaining_accounts, + } +} + +/// Rebalance the standard fund, signed by a fresh wallet that is neither the +/// manager nor a depositor. +fn try_rebalance(test: &mut Test, sell_index: u8, buy_index: u8) -> Outcome { + let stranger = test.add(Wallet::new()); + let ix = rebalance_instruction_with(stranger, sell_index, buy_index, deposit_remaining(test)); + test.send(ix) +} + +fn do_rebalance(test: &mut Test, sell_index: u8, buy_index: u8) { + try_rebalance(test, sell_index, buy_index).succeeds(); +} + +/// Transfer `amount` from a token account straight into a fund vault with an +/// ordinary token transfer (SPL Token `Transfer`, instruction 3), never calling +/// a handler. +fn donate_token(test: &mut Test, owner: Pubkey, from: Pubkey, vault: Pubkey, amount: u64) { + let mut transfer_data = vec![3u8]; + transfer_data.extend_from_slice(&amount.to_le_bytes()); + test.send(Instruction { + program_id: SPL_TOKEN_PROGRAM_ID, + accounts: vec![ + AccountMeta::new(from, false), + AccountMeta::new(vault, false), + AccountMeta::new_readonly(owner, true), + ], + data: transfer_data, + }) + .succeeds(); +} + +#[quasar_test] +fn test_rebalance(test: &mut Test) { + setup_full(test); + standard_fund(test); + + // Alice deposits 900 USDC, auto-deployed to 1.44 TSLAx + 3.0 NVDAx (exactly 40/60). + let alice = fund_user(test, 900_000_000); + do_deposit(test, &alice, 900_000_000); + + // NVDAx rises 180 -> 200, pushing the basket to 37.5 / 62.5 by value. + set_nvda_price(test, 20_000_000_000, 200_000_000); + + // A stranger, neither the manager nor a depositor, rebalances. The program + // computes the trade back to 40/60: NVDAx is $24 over its $576 target and + // TSLAx $24 under its $384 target, so it sells 0.12 NVDAx for 24 USDC and + // buys 0.096 TSLAx with it. + do_rebalance(test, 1, 0); + + // 1.44 + 0.096 = 1.536 TSLAx; 3.0 - 0.12 = 2.88 NVDAx. Now 384 / 576 = 40 / 60. + // The USDC vault nets to zero across the two legs. + assert_eq!(test.tokens(asset_vault_pda(test, 0)), 1_536_000); + assert_eq!(test.tokens(asset_vault_pda(test, 1)), 2_880_000); + assert_eq!(test.tokens(usdc_vault_pda(test)), 0); + assert_holdings_match_vaults(test); +} + +/// A fund sitting at its target weights has nothing to rebalance, in either +/// direction, so nobody can trade it. +#[quasar_test] +fn test_rebalance_refuses_fund_at_target(test: &mut Test) { + setup_full(test); + standard_fund(test); + let alice = fund_user(test, 900_000_000); + do_deposit(test, &alice, 900_000_000); + + // A fund at target must not sell NVDAx, nor TSLAx. + try_rebalance(test, 1, 0).fails_with(FundError::DriftBelowThreshold); + try_rebalance(test, 0, 1).fails_with(FundError::DriftBelowThreshold); +} + +/// Drift smaller than the fund's threshold is not worth the slippage of a +/// trade, so the rebalance is refused. +#[quasar_test] +fn test_rebalance_refuses_drift_below_threshold(test: &mut Test) { + setup_full(test); + standard_fund(test); + let alice = fund_user(test, 900_000_000); + do_deposit(test, &alice, 900_000_000); + + // NVDAx 180 -> 185: 555 of a 915 fund is 60.66%, 0.66 points over its 60% + // target and under the two-point threshold. + set_nvda_price(test, 18_500_000_000, 185_000_000); + try_rebalance(test, 1, 0).fails_with(FundError::DriftBelowThreshold); + assert_eq!(read_holdings(test).1[1], 3_000_000); +} + +/// Churn: once a rebalance has restored the weights, calling it again, in +/// either direction, finds nothing to do. Nobody can trade the fund back and +/// forth to bleed it through slippage. +#[quasar_test] +fn test_rebalance_cannot_churn(test: &mut Test) { + setup_full(test); + standard_fund(test); + let alice = fund_user(test, 900_000_000); + do_deposit(test, &alice, 900_000_000); + + set_nvda_price(test, 20_000_000_000, 200_000_000); + do_rebalance(test, 1, 0); + let after_first = read_holdings(test); + + // A second rebalance must find nothing to sell, and rebalancing back the + // other way must be refused. + try_rebalance(test, 1, 0).fails_with(FundError::DriftBelowThreshold); + try_rebalance(test, 0, 1).fails_with(FundError::DriftBelowThreshold); + assert_eq!(read_holdings(test), after_first); +} + +/// A third basket asset the router can trade: its own mint (the router holds +/// the mint authority), router rate, Pyth feed, and registry approval. +fn create_routable_asset(test: &mut Test, price: i64, rate: u64) { + test.add( + Mint::new(router_config_pda()) + .at(THIRD_MINT) + .decimals(DECIMALS), + ); + set_router_rate(test, THIRD_MINT, rate); + write_price_feed(test, THIRD_FEED, price, -8, NOW, 1); + approve_asset(test, THIRD_MINT, THIRD_FEED); +} + +/// The buy side must be under its target: a rebalance cannot pour the sale into +/// an asset already at or over its weight. Two assets cannot show this, since +/// when one is over its target the other is under by the same amount, so the +/// fund here holds a third, at 40/40/20. +#[quasar_test] +fn test_rebalance_refuses_buying_overweight_asset(test: &mut Test) { + setup_full(test); + init_fund(test); + create_routable_asset(test, 10_000_000_000, 100_000_000); // $100 + add_asset(test, 0, TSLA_MINT, 4000); + add_asset(test, 1, NVDA_MINT, 4000); + add_asset(test, 2, THIRD_MINT, 2000); + + let mut remaining = deposit_remaining(test); + remaining.extend(asset_deposit_metas(test, 2, THIRD_MINT, THIRD_FEED)); + let alice = fund_user(test, 1_000_000_000); + let ix = deposit_instruction(&alice, 1_000_000_000, remaining.clone()); + test.send(ix).succeeds(); + + // NVDAx and the third asset both rise 30%. NVDAx is about $520 of $1,180 + // against a $472 target; the third asset $260 against $236; TSLAx $400 + // against $472. NVDAx is over by more than the threshold, the third asset + // is over too, and only TSLAx is under. + set_nvda_price(test, 23_400_000_000, 234_000_000); + write_price_feed(test, THIRD_FEED, 13_000_000_000, -8, NOW, 1); + set_router_rate(test, THIRD_MINT, 130_000_000); + + // An over-weight asset must not be bought. + let stranger = test.add(Wallet::new()); + test.send(rebalance_instruction_with( + stranger, + 1, + 2, + remaining.clone(), + )) + .fails_with(FundError::NotUnderweight); + + // Naming the same asset on both sides is refused outright. + test.send(rebalance_instruction_with( + stranger, + 1, + 1, + remaining.clone(), + )) + .fails_with(FundError::SameMint); + + // Selling NVDAx into TSLAx, the asset that is under, goes through. + test.send(rebalance_instruction_with(stranger, 1, 0, remaining)) + .succeeds(); + assert!(read_holdings(test).1[0] > 1_600_000); +} + +/// A rebalance values every asset, so it needs every asset's accounts, as a +/// deposit does. +#[quasar_test] +fn test_rebalance_rejects_incomplete_assets(test: &mut Test) { + setup_full(test); + standard_fund(test); + let alice = fund_user(test, 900_000_000); + do_deposit(test, &alice, 900_000_000); + set_nvda_price(test, 20_000_000_000, 200_000_000); + + // A rebalance missing an asset must revert. + let stranger = test.add(Wallet::new()); + let mut remaining = deposit_remaining(test); + remaining.truncate(remaining.len() - 5); + test.send(rebalance_instruction_with(stranger, 1, 0, remaining)) + .fails_with(FundError::IncompleteAssetAccounts); +} + +/// Setting a weight to zero retires an asset, and a rebalance then sells all of +/// it, however little is left: a retired asset needs no threshold, because +/// selling it to zero is a trade that can happen only once. +#[quasar_test] +fn test_rebalance_sells_retired_asset(test: &mut Test) { + setup_full(test); + standard_fund(test); + let alice = fund_user(test, 900_000_000); + do_deposit(test, &alice, 900_000_000); + + // Maria retires NVDAx and moves its weight to TSLAx. + set_weight(test, 1, 0); + set_weight(test, 0, 10_000); + + // NVDAx falls to $1, so the 3 NVDAx left are $3 of a $363 fund: under one + // percentage point, below the two-point threshold. + set_nvda_price(test, 100_000_000, 1_000_000); + do_rebalance(test, 1, 0); + + // All 3 NVDAx sold for 3 USDC, which bought 0.012 TSLAx. + let (usdc_holdings, asset_holdings) = read_holdings(test); + assert_eq!(asset_holdings[1], 0); + assert_eq!(asset_holdings[0], 1_452_000); + assert_eq!(usdc_holdings, 0); + assert_holdings_match_vaults(test); + + // A retired asset already sold has nothing left to sell. + try_rebalance(test, 1, 0).fails_with(FundError::DriftBelowThreshold); +} + +/// Donations can neither force a rebalance nor pay for one. Donated NVDAx is not +/// recorded, so it cannot push NVDAx over its target; donated USDC is never +/// spent, because the buy leg invests only what the sale brought in. +#[quasar_test] +fn test_rebalance_ignores_donations(test: &mut Test) { + setup_full(test); + standard_fund(test); + let alice = fund_user(test, 900_000_000); + do_deposit(test, &alice, 900_000_000); + + // The donor gets NVDAx the way anyone can: deposit, then withdraw in kind. + let donor = fund_user(test, 200_000_000); + do_deposit(test, &donor, 100_000_000); + let donor_shares = test.tokens(donor.share); + do_withdraw(test, &donor, donor_shares); + let donated_nvda = test.tokens(donor.nvda); + assert!(donated_nvda > 0); + let (vault_nvda, vault_usdc) = (asset_vault_pda(test, 1), usdc_vault_pda(test)); + donate_token(test, donor.owner, donor.nvda, vault_nvda, donated_nvda); + donate_token(test, donor.owner, donor.usdc, vault_usdc, 100_000_000); + + // Counted, the donated NVDAx would put NVDAx far over its target. Recorded + // holdings are still 40/60, so there is nothing to rebalance. + try_rebalance(test, 1, 0).fails_with(FundError::DriftBelowThreshold); + + // A real price move does drift the fund, and the rebalance spends only + // what its sale brought in: the donated USDC is still in the vault, outside + // the recorded holdings. + set_nvda_price(test, 20_000_000_000, 200_000_000); + do_rebalance(test, 1, 0); + let (usdc_holdings, asset_holdings) = read_holdings(test); + assert_eq!(usdc_holdings, 0); + assert_eq!(test.tokens(vault_usdc), 100_000_000); + assert_eq!(asset_holdings[1] + donated_nvda, test.tokens(vault_nvda)); +} + +/// The threshold is fixed at creation, within bounds: a manager cannot set it +/// near zero, where the fund would trade on every small move, or so high that +/// the target weights stop describing the fund. +#[quasar_test] +fn test_initialize_rejects_threshold_out_of_range(test: &mut Test) { + setup_full(test); + for threshold in [ + 0, + MIN_REBALANCE_THRESHOLD_BPS - 1, + MAX_REBALANCE_THRESHOLD_BPS + 1, + ] { + // A threshold outside the bounds must be rejected. + let ix = initialize_fund_instruction(test, threshold); + test.send(ix) + .fails_with(FundError::RebalanceThresholdOutOfRange); + } + init_fund(test); + let fund = test.read::(fund_pda(test)); + assert_eq!( + u16::from(fund.rebalance_threshold_bps), + REBALANCE_THRESHOLD_BPS + ); +} + +/// Valuation scales by each asset's decimals and each feed's exponent. TSLAx +/// here has eight decimals and a Pyth equity feed with exponent -5, while USDC +/// and NVDAx keep six decimals and NVDAx's feed keeps -8. Assuming six decimals +/// and -8 would value Alice's 1.44 TSLAx at $360 * 100 * 1,000, and Bob's +/// deposit would buy almost no shares. Scaled correctly, every figure matches +/// the six-decimal story. +#[quasar_test] +fn test_valuation_scales_by_decimals_and_exponent(test: &mut Test) { + setup_with_tsla_decimals(test, 8); + write_price_feed(test, TSLA_FEED, 25_000_000, -5, NOW, 1); // $250 + standard_fund(test); + assert_eq!( + test.read::(asset_config_pda(test, 0)).decimals, + 8 + ); + assert_eq!( + test.read::(asset_config_pda(test, 1)).decimals, + 6 + ); + + // Alice's 900 USDC deploys to 1.44 TSLAx (eight decimals) and 3 NVDAx. + let alice = fund_user(test, 900_000_000); + do_deposit(test, &alice, 900_000_000); + assert_eq!(test.tokens(alice.share), 900_000_000); + assert_eq!(read_holdings(test).1[0], 144_000_000); + assert_eq!(read_holdings(test).1[1], 3_000_000); + + // NVDAx to $200. The rebalance computes its trade in the same units: 0.12 + // NVDAx sold for 24 USDC, which buys 0.096 TSLAx, back to 40/60. + set_nvda_price(test, 20_000_000_000, 200_000_000); + do_rebalance(test, 1, 0); + assert_eq!(read_holdings(test).1[0], 153_600_000); + assert_eq!(read_holdings(test).1[1], 2_880_000); + + // The fund is worth $960, so Bob's 480 USDC buys 450 shares. + let bob = fund_user(test, 480_000_000); + do_deposit(test, &bob, 480_000_000); + assert_eq!(test.tokens(bob.share), 450_000_000); + assert_eq!(read_holdings(test).1[0], 230_400_000); + assert_eq!(read_holdings(test).1[1], 4_320_000); + assert_holdings_match_vaults(test); +} diff --git a/finance/managed-fund/quasar/mock-swap-router/src/instructions/swap_asset_for_usdc.rs b/finance/managed-fund/quasar/mock-swap-router/src/instructions/swap_asset_for_usdc.rs index c0dde402d..e5806e64c 100644 --- a/finance/managed-fund/quasar/mock-swap-router/src/instructions/swap_asset_for_usdc.rs +++ b/finance/managed-fund/quasar/mock-swap-router/src/instructions/swap_asset_for_usdc.rs @@ -54,10 +54,16 @@ pub fn handle_swap_asset_for_usdc( require!(rate > 0, RouterError::ZeroRate); require!(asset_amount_in > 0, RouterError::ZeroAmount); - // usdc_out = asset_amount_in * rate. + // usdc_out = asset_amount_in * rate / 10^asset_decimals (u128 intermediate, + // caller gets the floor) + let one_token = 10u128 + .checked_pow(accounts.asset_mint.decimals as u32) + .ok_or(RouterError::MathOverflow)?; let usdc_out: u64 = (asset_amount_in as u128) .checked_mul(rate as u128) .ok_or(RouterError::MathOverflow)? + .checked_div(one_token) + .ok_or(RouterError::MathOverflow)? .try_into() .map_err(|_| RouterError::MathOverflow)?; require!(usdc_out >= minimum_usdc_out, RouterError::SlippageExceeded); diff --git a/finance/managed-fund/quasar/mock-swap-router/src/instructions/swap_usdc_for_asset.rs b/finance/managed-fund/quasar/mock-swap-router/src/instructions/swap_usdc_for_asset.rs index 37c0b8cbd..25b23b933 100644 --- a/finance/managed-fund/quasar/mock-swap-router/src/instructions/swap_usdc_for_asset.rs +++ b/finance/managed-fund/quasar/mock-swap-router/src/instructions/swap_usdc_for_asset.rs @@ -49,8 +49,14 @@ pub fn handle_swap_usdc_for_asset( let rate = u64::from(accounts.asset_rate.usdc_per_token); require!(rate > 0, RouterError::ZeroRate); - // asset_out = usdc_amount_in / rate (floor). + // asset_out = usdc_amount_in * 10^asset_decimals / rate (u128 intermediate, + // caller gets the floor) + let one_token = 10u128 + .checked_pow(accounts.asset_mint.decimals as u32) + .ok_or(RouterError::MathOverflow)?; let asset_out: u64 = (usdc_amount_in as u128) + .checked_mul(one_token) + .ok_or(RouterError::MathOverflow)? .checked_div(rate as u128) .ok_or(RouterError::MathOverflow)? .try_into() diff --git a/finance/managed-fund/quasar/mock-swap-router/src/state/mod.rs b/finance/managed-fund/quasar/mock-swap-router/src/state/mod.rs index d2d0eb2cd..39e74eb16 100644 --- a/finance/managed-fund/quasar/mock-swap-router/src/state/mod.rs +++ b/finance/managed-fund/quasar/mock-swap-router/src/state/mod.rs @@ -15,13 +15,14 @@ pub struct RouterConfig { pub bump: u8, } -/// A fixed price for one asset. PDA: `["rate", mint]`. `usdc_per_token` is USDC -/// base units per token base unit (e.g. 250 means 1 token = 250 USDC when both -/// have 6 decimals). +/// A fixed price for one asset. PDA: `["rate", mint]`. #[account(discriminator = 2, set_inner)] #[seeds(b"rate", mint: Address)] pub struct AssetRate { pub mint: Address, + /// USDC base units per whole token, e.g. 250_000_000 means 1.0 TSLAx = $250 + /// with six-decimal USDC. The swaps scale by the asset mint's decimals, so the + /// rate means the same thing whatever precision the asset has. pub usdc_per_token: u64, pub bump: u8, } diff --git a/finance/managed-fund/quasar/mock-swap-router/src/tests.rs b/finance/managed-fund/quasar/mock-swap-router/src/tests.rs index e1a5a20f5..a9c809fb2 100644 --- a/finance/managed-fund/quasar/mock-swap-router/src/tests.rs +++ b/finance/managed-fund/quasar/mock-swap-router/src/tests.rs @@ -11,7 +11,7 @@ use { }; const DECIMALS: u8 = 6; -const RATE: u64 = 250; // 250 USDC base units per asset base unit +const RATE: u64 = 250_000_000; // USDC minor units per whole asset token ($250) // Deterministic addresses. const AUTHORITY: Pubkey = Pubkey::new_from_array([1; 32]); @@ -27,7 +27,8 @@ fn initialize_and_swap_usdc_for_asset(test: &mut Test) { let rate = test.derive_pda(AssetRate::seeds(&ASSET_MINT)); const USDC_IN: u64 = 1_000; - const ASSET_OUT: u64 = USDC_IN / RATE; // 4 + // 1,000 USDC minor units buy 1,000 * 10^6 / 250,000,000 = 4 asset minor units. + const ASSET_OUT: u64 = USDC_IN * 10u64.pow(DECIMALS as u32) / RATE; // 4 test.add(Wallet::new().at(AUTHORITY)); test.add(Mint::new(AUTHORITY).at(USDC_MINT).decimals(DECIMALS));