Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions building-financial-products-in-solana/managed-fund.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Every handler that computes the fund's value walks indexes 0 through `asset_coun

Alice invests from the standard wallet of one SOL and 1,000 USDC. She runs `deposit()` with 900 USDC and a `minimum_shares` floor. The handler does three jobs in one transaction, and the order is what makes it safe.

**First it values the fund.** It walks every asset: reads the holding the program has recorded for that vault, loads the Pyth price, and accumulates value in u128, multiply before divide. A Pyth price is an integer and an exponent, so $250 arrives as 25,000,000,000 at exponent −8, or as 25,000,000 at exponent −5 on Pyth's US equity price feeds. A holding is in the asset's minor units, so 1.44 TSLAx is 1,440,000 at six decimals and 144,000,000 at eight. The handler reads the exponent from the price feed and the decimals recorded when the asset was added, and scales amount × price into USDC's minor units. With USDC's six decimals, that divides by 10^8 for a six-decimal asset on a −8 price feed, by 10^10 for an eight-decimal asset on the same price feed, and by 10^7 for an eight-decimal asset on a −5 price feed. A program that assumed one of those would value the others 100 or 1,000 times too high or too low. `test_valuation_scales_by_decimals_and_exponent` runs this chapter's story with an eight-decimal TSLAx on a −5 price feed and gets the same share counts. The price load rejects a price feed that is not the one recorded in the asset config, a price that is not positive, a price older than 60 seconds, or a price posted before the cluster's last restart. The recorded USDC is added to the total. That total is the **net asset value**, the NAV. The program keeps these records itself and updates them with what each transfer actually moves; the failure-modes section says why it never values the fund from its vaults' token balances. On the fund's first deposit the NAV is zero and shares are minted one-to-one: Alice's 900 USDC becomes 900 shares.
**First it values the fund.** It walks every asset: reads the holding the program has recorded for that vault, loads the Pyth price, and accumulates value in u128, multiply before divide. A Pyth price is an integer and an exponent, so $250 arrives as 25,000,000,000 at exponent −8, or as 25,000,000 at exponent −5 on Pyth's US equity price feeds. A holding is in the asset's minor units, so 1.44 TSLAx is 1,440,000 at six decimals and 144,000,000 at eight. The handler reads the exponent from the price feed and the decimals recorded when the asset was added, and scales amount × price into USDC's minor units. With USDC's six decimals, that divides by 10^8 for a six-decimal asset on a −8 price feed, by 10^10 for an eight-decimal asset on the same price feed, and by 10^7 for an eight-decimal asset on a −5 price feed. A program that assumed one of those would value the others 100 or 1,000 times too high or too low. `test_valuation_scales_by_decimals_and_exponent` runs this chapter's story with an eight-decimal TSLAx on a −5 price feed and gets the same share counts. The price load rejects a price feed that is not the one recorded in the asset config, a price that is not positive, a price older than 60 seconds, or a price posted before the cluster's last restart. It also rejects a price whose confidence interval is wider than 1% of the price. The recorded USDC is added to the total. That total is the **net asset value**, the NAV. The program keeps these records itself and updates them with what each transfer actually moves; the failure-modes section says why it never values the fund from its vaults' token balances. On the fund's first deposit the NAV is zero and shares are minted one-to-one: Alice's 900 USDC becomes 900 shares.

**Then it deploys the USDC at the weights.** 40% of 900, $360, buys TSLAx at $250: 1.44 TSLAx into the TSLAx vault. 60%, $540, buys NVDAx at $180: exactly 3 NVDAx. The swaps run through the router fixed at fund creation (the example ships a mock router standing in for Jupiter; a router that is not the configured one is rejected, checked by `test_deposit_rejects_unregistered_router`), and the fund PDA signs. The handler measures what each swap moved on the vaults and adds it to the recorded holdings. A swap that returns none of its asset refuses the whole deposit (`DepositTooSmall`).

Expand Down Expand Up @@ -102,14 +102,14 @@ All of them use u128 intermediates and multiply before dividing, and the remaind

- **The manager is included in the threat model**, and the program treats them as such: assets only from Victor's list, price feeds only from Victor's entries, swaps only through the fixed router, minimums only from the oracle, fee capped and immutable. No handler sends vault tokens to them, and no handler lets them choose a trade.
- **Nobody can churn the fund.** Churn is trading a fund back and forth for no investment reason, so that it pays slippage on every leg. If the manager chose the rebalancing trades, they could sell a balanced fund's NVDAx for TSLAx and back again, each leg filling up to 1% below the oracle price. The fund would end each round trip at 40/60 and poorer, through trades every rule allowed. That is not only carelessness: the value lost goes to whoever fills the trades, and a manager who controls when and how much the fund trades can arrange to be that party. Here no caller chooses a trade. `rebalance()` computes it, refuses below the threshold, and finds nothing to sell once the weights are restored, which is what makes it safe to open to anyone. `test_rebalance_cannot_churn` rebalances once and checks that a second call, in either direction, is refused.
- **Every number in the fund depends on the price feed.** A stale price is refused (`StalePriceFeed`, 60-second window), as is a non-positive price (`NegativePrice`) and a substituted price feed account (`InvalidPriceFeed`), so no deposit, withdrawal, or rebalance runs on a bad price. A price posted at or before the cluster's last restart is refused too (`PricePredatesRestart`). After a halt the timestamp trails real time, so a price from before the halt can still look under a minute old, and the *Time* chapter explains why. What this example does not check is the price feed's confidence interval; a production fund should, and the *Offchain Truth* chapter explains what confidence means and why "fresh but uncertain" is its own failure mode.
- **Every number in the fund depends on the price feed.** A stale price is refused (`StalePriceFeed`, 60-second window), as is a non-positive price (`NegativePrice`) and a substituted price feed account (`InvalidPriceFeed`), so no deposit or rebalance runs on a bad price. A price posted at or before the cluster's last restart is refused too (`PricePredatesRestart`). After a halt the timestamp trails real time, so a price from before the halt can still look under a minute old, and the *Time* chapter explains why. So is a price whose confidence interval is wider than 1% of the price (`OracleConfidenceTooWide`). The *Offchain Truth* chapter explains what confidence means and why "fresh but uncertain" is its own failure mode. Here the cost is concrete: a deposit is priced from the price feed and a rebalance sets its slippage floor from it, so a band wider than the fund's 1% slippage tolerance would let a trade fill outside that tolerance. Withdrawal reads no price, so while deposits and rebalances wait for the band to narrow, investors can still take their slice in kind. `test_wide_confidence_price_rejected` widens NVDAx's band to 2% of its price and checks that deposit and rebalance are refused, that withdrawal still pays out, and that a band of exactly 1% is accepted.
- **Donations are ignored, which prevents the inflation attack.** Anyone can transfer tokens straight into a vault. A program that valued the fund from its vaults' balances would let a one-minor-unit first deposit and a donation price one share above the next deposit, rounding it to zero shares. This program values the fund from `usdc_holdings` and `asset_holdings`, so a donation changes a balance and nothing the handlers read. Run the *Adversary* chapter's numbers against it: after the attacker's one minor unit and 1,000 USDC donation, the next depositor's 1,000 USDC still buys 1,000 shares. The donation stays in the vault, and no withdrawal ever pays it out. Nor can a donation force a rebalance or pay for one: the handler values the recorded holdings, so donated tokens cannot push an asset over its target, and the buy leg spends only what the sale brought in (`test_rebalance_ignores_donations`).
- **A deposit too small to buy anything is refused.** Ignoring donations leaves one gap: a deposit so small that a swap takes its USDC and returns none of the asset. It would mint shares against a fund recorded as worth nothing, and every later deposit would then divide by zero, so it fails with `DepositTooSmall`. A depositor's `minimum_shares` floor would also stop the inflation attack on its own, by reverting a deposit that rounds to too few shares, but only for a depositor whose wallet sets it, so the program does not rely on it. It remains for what neither check covers, a share price that moved between signing and landing. The empty-pool section of the *Adversary* chapter compares this defense with the others.
- **Partial baskets are refused**, on deposit, withdrawal, and valuation alike (`IncompleteAssetAccounts`): an attacker who could get the fund valued without its best-performing vault could buy shares at a discount.

## Testing Considerations

The suite (with hand-built Pyth price-update accounts injected so prices are deterministic) covers the full lifecycle end to end (`test_full_lifecycle`). It then checks each rule separately. Under-allocated funds refuse deposits, unapproved assets refuse listing, weight overflows refuse, the 17th asset refuses, and non-managers cannot set weights. Unregistered routers refuse, slippage floors reject on deposit and withdrawal, incomplete account lists refuse, and later depositors pay fair NAV. `test_full_lifecycle` checks after every step that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the *Adversary* chapter's attack and checks that 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` test the other two checks. Every rebalance test signs as a stranger, neither the manager nor a depositor. `test_rebalance_refuses_fund_at_target`, `test_rebalance_refuses_drift_below_threshold`, and `test_rebalance_cannot_churn` check that a fund at its targets, 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_deposit_rejects_price_from_before_restart` simulates a halt, checks that a deposit is refused while the prices predate the restart, and checks that it goes through once Pyth posts again. The pattern to copy is the price injection: valuation code cannot be tested with prices you cannot control, so the harness makes the price feed a fixture and then moves it deliberately.
The suite (with hand-built Pyth price-update accounts injected so prices are deterministic) covers the full lifecycle end to end (`test_full_lifecycle`). It then checks each rule separately. Under-allocated funds refuse deposits, unapproved assets refuse listing, weight overflows refuse, the 17th asset refuses, and non-managers cannot set weights. Unregistered routers refuse, slippage floors reject on deposit and withdrawal, incomplete account lists refuse, and later depositors pay fair NAV. `test_full_lifecycle` checks after every step that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the *Adversary* chapter's attack and checks that 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` test the other two checks. Every rebalance test signs as a stranger, neither the manager nor a depositor. `test_rebalance_refuses_fund_at_target`, `test_rebalance_refuses_drift_below_threshold`, and `test_rebalance_cannot_churn` check that a fund at its targets, 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_deposit_rejects_price_from_before_restart` simulates a halt, checks that a deposit is refused while the prices predate the restart, and checks that it goes through once Pyth posts again. `test_wide_confidence_price_rejected` moves a price feed's confidence interval instead of its price. The pattern to copy is the price injection: valuation code cannot be tested with prices you cannot control, so the harness makes the price feed a fixture and then moves it deliberately.

## What You Learnt from Building the Managed Fund

Expand Down
Loading