diff --git a/finance/managed-fund/anchor-v1/CHANGELOG.md b/finance/managed-fund/anchor-v1/CHANGELOG.md index 7ef472cc..b2e660fc 100644 --- a/finance/managed-fund/anchor-v1/CHANGELOG.md +++ b/finance/managed-fund/anchor-v1/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased (2026-11-10) + +### Added + +- **Tests for the price-feed and recipient checks.** No test triggered `InvalidPriceFeed`, `NegativePrice`, `StalePriceFeed` or `InvalidRecipient`. Each new test asserts the error and that no tokens or shares moved: `test_wrong_price_feed_rejected` passes TSLAx's fresh, fully verified feed in NVDAx's place and checks that a deposit and a rebalance both fail with `InvalidPriceFeed`, then that NVDAx's own feed gives `test_rebalance`'s trade; `test_truncated_price_feed_rejected` writes NVDAx's price account cut off at 125 bytes, before its `posted_slot`, and checks `InvalidPriceFeed`; `test_non_positive_price_rejected` checks that a price of zero and one of −$180 both fail with `NegativePrice`, and that at $180 again Bob's 480 USDC mints 480,000,000 shares; `test_stale_price_rejected` checks that a price 61 seconds old fails with `StalePriceFeed` and one exactly 60 seconds old is accepted; `test_withdraw_rejects_recipient_owned_by_someone_else` checks that Alice naming Bob's TSLAx account fails with `InvalidRecipient`, and `test_withdraw_rejects_recipient_for_another_mint` that naming her NVDAx account for her TSLAx does too. The tests gain `write_price_feed_account`, `withdraw_remaining_to`, `withdraw_instruction` and `balances` helpers. The program is unchanged. + ## [Unreleased] - 2026-10-05 ### Changed diff --git a/finance/managed-fund/anchor-v1/README.md b/finance/managed-fund/anchor-v1/README.md index 826eb68b..88ca9050 100644 --- a/finance/managed-fund/anchor-v1/README.md +++ b/finance/managed-fund/anchor-v1/README.md @@ -163,7 +163,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. TSLAx and NVDAx are minted with eight decimals, as the real tokens are, and USDC with six, so the basket amounts the tests assert are in eight-decimal minor units while shares and USDC stay in six. 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, each asserting the error code it fails with: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage (the router's `SlippageExceeded`, raised inside the swap CPI), an under-allocated fund, non-manager `set_weight` (Anchor's own constraint error), 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, and `test_valuation_scales_by_nine_decimals_and_exponent` does the same with TSLAx at nine decimals. `test_collect_fees` checks a year's fee comes out exact and `test_collect_fees_rounds_up` that a day's fee rounds up to the manager. `test_wide_confidence_price_rejected` widens NVDAx's confidence interval to 2% of its price and checks that deposit and rebalance fail with `OracleConfidenceTooWide`, that withdraw still pays out in kind, and that a band of exactly 1% is accepted. `test_partially_verified_price_rejected` writes NVDAx's feed as a `Partial` update signed by two signers and checks that a deposit fails with `PriceNotFullyVerified`, then rewrites it as `Full` at the same price and checks the deposit prices exactly as `test_deposit_first` does. `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. +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. TSLAx and NVDAx are minted with eight decimals, as the real tokens are, and USDC with six, so the basket amounts the tests assert are in eight-decimal minor units while shares and USDC stay in six. 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, each asserting the error code it fails with: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage (the router's `SlippageExceeded`, raised inside the swap CPI), an under-allocated fund, non-manager `set_weight` (Anchor's own constraint error), 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, and `test_valuation_scales_by_nine_decimals_and_exponent` does the same with TSLAx at nine decimals. `test_collect_fees` checks a year's fee comes out exact and `test_collect_fees_rounds_up` that a day's fee rounds up to the manager. `test_wide_confidence_price_rejected` widens NVDAx's confidence interval to 2% of its price and checks that deposit and rebalance fail with `OracleConfidenceTooWide`, that withdraw still pays out in kind, and that a band of exactly 1% is accepted. `test_partially_verified_price_rejected` writes NVDAx's feed as a `Partial` update signed by two signers and checks that a deposit fails with `PriceNotFullyVerified`, then rewrites it as `Full` at the same price and checks the deposit prices exactly as `test_deposit_first` does. `test_wrong_price_feed_rejected` passes TSLAx's feed in NVDAx's place and checks that deposit and rebalance fail with `InvalidPriceFeed`, as `test_truncated_price_feed_rejected` does for a price account cut off before its `posted_slot`; `test_non_positive_price_rejected` checks that a price of zero or below fails with `NegativePrice`, and `test_stale_price_rejected` that a price 61 seconds old fails with `StalePriceFeed` while one exactly 60 seconds old is accepted. `test_withdraw_rejects_recipient_owned_by_someone_else` and `test_withdraw_rejects_recipient_for_another_mint` check that a withdrawal naming another wallet's token account, or an account for another asset, fails with `InvalidRecipient`. Each of these checks that no tokens or shares moved. `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 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 9f08968d..9424ac2c 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 @@ -98,18 +98,7 @@ fn write_price_feed_with_confidence( ) { let data = build_mock_price_update_account(price, confidence, exponent, PUBLISH_TIME, posted_slot); - let rent = svm.minimum_balance_for_rent_exemption(data.len()); - svm.set_account( - key, - SolanaAccount { - lamports: rent, - data, - owner: pyth_receiver_program_id(), - executable: false, - rent_epoch: 0, - }, - ) - .unwrap(); + write_price_feed_account(svm, key, data); } /// Write a Pyth feed as a partially verified update, laid out as Pyth's @@ -126,6 +115,11 @@ fn write_partially_verified_price_feed( build_mock_price_update_account(price, DEFAULT_CONFIDENCE, PYTH_EXPONENT, PUBLISH_TIME, 1); data[40] = 0; data.insert(41, num_signatures); + write_price_feed_account(svm, key, data); +} + +/// Store `data` as a Pyth price account at `key`, owned by the Pyth receiver. +fn write_price_feed_account(svm: &mut LiteSVM, key: Pubkey, data: Vec) { let rent = svm.minimum_balance_for_rent_exemption(data.len()); svm.set_account( key, @@ -1483,15 +1477,30 @@ fn test_collect_fees_rounds_up() { } fn withdraw_remaining(ctx: &TestContext, user: &Pubkey) -> Vec { + withdraw_remaining_to( + ctx, + derive_ata(user, &ctx.tsla_mint), + derive_ata(user, &ctx.nvda_mint), + ) +} + +/// A withdrawal's remaining accounts, in the order the handler reads: +/// [asset_config, vault, mint, recipient] per asset, paying TSLAx to +/// `tsla_recipient` and NVDAx to `nvda_recipient`. +fn withdraw_remaining_to( + ctx: &TestContext, + tsla_recipient: Pubkey, + nvda_recipient: Pubkey, +) -> Vec { vec![ AccountMeta::new_readonly(ctx.asset_config(0), false), AccountMeta::new(ctx.vault_tsla, false), AccountMeta::new_readonly(ctx.tsla_mint, false), - AccountMeta::new(derive_ata(user, &ctx.tsla_mint), false), + AccountMeta::new(tsla_recipient, false), AccountMeta::new_readonly(ctx.asset_config(1), false), AccountMeta::new(ctx.vault_nvda, false), AccountMeta::new_readonly(ctx.nvda_mint, false), - AccountMeta::new(derive_ata(user, &ctx.nvda_mint), false), + AccountMeta::new(nvda_recipient, false), ] } @@ -1663,23 +1672,36 @@ fn do_withdraw(ctx: &mut TestContext, user: &Keypair, shares: u64, min_usdc_out: None, ) .unwrap(); - let user_usdc = derive_ata(&user.pubkey(), &ctx.usdc_mint); - let user_share = derive_ata(&user.pubkey(), &ctx.share_mint_pda); + let remaining = withdraw_remaining(ctx, &user.pubkey()); + let ix = withdraw_instruction(ctx, user, shares, min_usdc_out, remaining); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[user], &user.pubkey(), None) + .unwrap(); +} + +/// A withdrawal of `shares` by `user`, paid in kind to the recipients named in +/// `remaining`. +fn withdraw_instruction( + ctx: &TestContext, + user: &Keypair, + shares: u64, + min_usdc_out: u64, + remaining: Vec, +) -> Instruction { let mut metas = managed_fund::accounts::WithdrawAccountConstraints { user: user.pubkey(), fund: ctx.fund_pda, share_mint: ctx.share_mint_pda, usdc_mint: ctx.usdc_mint, - user_share_account: user_share, - user_usdc_account: user_usdc, + user_share_account: derive_ata(&user.pubkey(), &ctx.share_mint_pda), + user_usdc_account: derive_ata(&user.pubkey(), &ctx.usdc_mint), vault_usdc: ctx.vault_usdc, associated_token_program: ata_program_id(), token_program: token_program_id(), system_program: system_program::id(), } .to_account_metas(None); - metas.extend(withdraw_remaining(ctx, &user.pubkey())); - let ix = Instruction::new_with_bytes( + metas.extend(remaining); + Instruction::new_with_bytes( ctx.fund_program_id, &managed_fund::instruction::Withdraw { shares_to_burn: shares, @@ -1687,9 +1709,7 @@ fn do_withdraw(ctx: &mut TestContext, user: &Keypair, shares: u64, min_usdc_out: } .data(), metas, - ); - send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[user], &user.pubkey(), None) - .unwrap(); + ) } #[test] @@ -2528,3 +2548,332 @@ fn test_partially_verified_price_rejected() { ); assert_holdings_match_vaults(&ctx); } + +/// The balances a refused transaction must leave as they were: the fund's +/// three vaults, the router's USDC treasury and `token_accounts`, each read as +/// zero if it does not exist, then the fund's recorded share supply. +fn balances(ctx: &TestContext, token_accounts: &[Pubkey]) -> Vec { + let mut accounts = vec![ + ctx.vault_usdc, + ctx.vault_tsla, + ctx.vault_nvda, + ctx.router_usdc_treasury, + ]; + accounts.extend_from_slice(token_accounts); + let mut balances: Vec = accounts + .iter() + .map(|account| get_token_account_balance(&ctx.svm, account).unwrap_or(0)) + .collect(); + balances.push(read_fund(ctx).total_shares); + balances +} + +/// Each asset is priced from the Pyth account the registry bound to it. A +/// fresh, fully verified feed for another asset is refused with +/// `InvalidPriceFeed`: with TSLAx's $250 feed in NVDAx's place, a deposit and +/// a rebalance both fail and no tokens or shares move. With NVDAx's own feed +/// the same rebalance makes `test_rebalance`'s trade. +#[test] +fn test_wrong_price_feed_rejected() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + // Alice deposits 900 USDC: 1.44 TSLAx + 3.0 NVDAx at 40/60. NVDAx then + // rises to $200, so the fund has drifted and needs a rebalance. + 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); + + // NVDAx's price feed is the fifth of its five accounts, the tenth overall. + let mut remaining = deposit_remaining(&ctx); + remaining[9] = AccountMeta::new_readonly(ctx.price_feed_tsla, false); + + let bob = fund_user(&mut ctx, 480_000_000); + let bob_accounts = [ + derive_ata(&bob.pubkey(), &ctx.usdc_mint), + derive_ata(&bob.pubkey(), &ctx.share_mint_pda), + ]; + let before = balances(&ctx, &bob_accounts); + + let ix = deposit_instruction(&ctx, &bob, 480_000_000, 1, remaining.clone()); + assert_program_error( + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&bob], &bob.pubkey(), None), + FundError::InvalidPriceFeed, + "a deposit priced from another asset's feed must fail", + ); + let stranger = create_wallet(&mut ctx.svm, 1_000_000_000).unwrap(); + let ix = rebalance_instruction_with(&ctx, &stranger, 1, 0, remaining); + assert_program_error( + send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&stranger], + &stranger.pubkey(), + None, + ), + FundError::InvalidPriceFeed, + "a rebalance priced from another asset's feed must fail", + ); + assert_eq!( + balances(&ctx, &bob_accounts), + before, + "no tokens or shares moved" + ); + + // With NVDAx's own feed the rebalance sells 0.12 NVDAx and buys 0.096 TSLAx. + 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], 288_000_000); + assert_holdings_match_vaults(&ctx); +} + +/// A price account too short to hold a whole Pyth price update, here cut off +/// before its eight-byte `posted_slot` at offset 125, is refused with +/// `InvalidPriceFeed` and no tokens or shares move. Written whole again at the +/// same price, the same deposit mints its shares. +#[test] +fn test_truncated_price_feed_rejected() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + let amount = 1_000_000u64; // 1 USDC + let user = fund_user(&mut ctx, amount); + let mut data = build_mock_price_update_account( + NVDA_PRICE, + DEFAULT_CONFIDENCE, + PYTH_EXPONENT, + PUBLISH_TIME, + 1, + ); + data.truncate(125); + write_price_feed_account(&mut ctx.svm, ctx.price_feed_nvda, data); + + let user_accounts = [ + derive_ata(&user.pubkey(), &ctx.usdc_mint), + derive_ata(&user.pubkey(), &ctx.share_mint_pda), + ]; + let before = balances(&ctx, &user_accounts); + let ix = deposit_instruction(&ctx, &user, amount, amount, deposit_remaining(&ctx)); + assert_program_error( + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey(), None), + FundError::InvalidPriceFeed, + "a deposit priced from a truncated price account must fail", + ); + assert_eq!( + balances(&ctx, &user_accounts), + before, + "no tokens or shares moved" + ); + + set_price_feed(&mut ctx.svm, ctx.price_feed_nvda, NVDA_PRICE); + ctx.svm.expire_blockhash(); + let user_share = do_deposit(&mut ctx, &user, amount, amount); + assert_eq!( + get_token_account_balance(&ctx.svm, &user_share).unwrap(), + amount + ); +} + +/// Pyth reports a price as a signed integer. A price of zero values an asset at +/// nothing, and a negative one below nothing, so Bob's deposit would be priced +/// against a NAV missing Alice's NVDAx. Both are refused with `NegativePrice` +/// and no tokens or shares move. At NVDAx's $180 again, Bob's 480 USDC buys +/// 480,000,000 shares against Alice's 900 USDC and 900,000,000 shares. +#[test] +fn test_non_positive_price_rejected() { + 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); + + let bob = fund_user(&mut ctx, 480_000_000); + let bob_accounts = [ + derive_ata(&bob.pubkey(), &ctx.usdc_mint), + derive_ata(&bob.pubkey(), &ctx.share_mint_pda), + ]; + let before = balances(&ctx, &bob_accounts); + for price in [0, -NVDA_PRICE] { + set_price_feed(&mut ctx.svm, ctx.price_feed_nvda, price); + ctx.svm.expire_blockhash(); + let ix = deposit_instruction(&ctx, &bob, 480_000_000, 1, deposit_remaining(&ctx)); + assert_program_error( + send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&bob], + &bob.pubkey(), + None, + ), + FundError::NegativePrice, + "a deposit priced from a price of zero or below must fail", + ); + } + assert_eq!( + balances(&ctx, &bob_accounts), + before, + "no tokens or shares moved" + ); + + set_price_feed(&mut ctx.svm, ctx.price_feed_nvda, NVDA_PRICE); + ctx.svm.expire_blockhash(); + let bob_share = do_deposit(&mut ctx, &bob, 480_000_000, 1); + assert_eq!( + get_token_account_balance(&ctx.svm, &bob_share).unwrap(), + 480_000_000 + ); +} + +/// A price published more than 60 seconds ago is refused with +/// `StalePriceFeed` and no tokens or shares move. Both feeds were published at +/// `PUBLISH_TIME`: at 61 seconds past it the deposit fails, and at exactly 60 +/// the same deposit mints its shares. +#[test] +fn test_stale_price_rejected() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + let amount = 1_000_000u64; // 1 USDC + let user = fund_user(&mut ctx, amount); + let user_accounts = [ + derive_ata(&user.pubkey(), &ctx.usdc_mint), + derive_ata(&user.pubkey(), &ctx.share_mint_pda), + ]; + let before = balances(&ctx, &user_accounts); + + advance_seconds(&mut ctx, 61); + let ix = deposit_instruction(&ctx, &user, amount, amount, deposit_remaining(&ctx)); + assert_program_error( + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey(), None), + FundError::StalePriceFeed, + "a deposit priced from a 61-second-old price must fail", + ); + assert_eq!( + balances(&ctx, &user_accounts), + before, + "no tokens or shares moved" + ); + + advance_seconds(&mut ctx, 60); + ctx.svm.expire_blockhash(); + let user_share = do_deposit(&mut ctx, &user, amount, amount); + assert_eq!( + get_token_account_balance(&ctx.svm, &user_share).unwrap(), + amount + ); +} + +/// Withdraw pays each asset to a token account the caller names, and each must +/// belong to the holder whose shares are burned. Alice naming Bob's TSLAx +/// account for her TSLAx is refused with `InvalidRecipient`: no shares are +/// burned and no tokens move. Named her own, the same withdrawal pays her +/// 1.44 TSLAx and 3.0 NVDAx. +#[test] +fn test_withdraw_rejects_recipient_owned_by_someone_else() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + let alice = fund_user(&mut ctx, 900_000_000); + let alice_share = do_deposit(&mut ctx, &alice, 900_000_000, 1); + let alice_tsla = create_associated_token_account( + &mut ctx.svm, + &alice.pubkey(), + &ctx.tsla_mint, + &ctx.payer, + None, + ) + .unwrap(); + let alice_nvda = create_associated_token_account( + &mut ctx.svm, + &alice.pubkey(), + &ctx.nvda_mint, + &ctx.payer, + None, + ) + .unwrap(); + let bob = create_wallet(&mut ctx.svm, 1_000_000_000).unwrap(); + let bob_tsla = create_associated_token_account( + &mut ctx.svm, + &bob.pubkey(), + &ctx.tsla_mint, + &ctx.payer, + None, + ) + .unwrap(); + + let watched = [alice_share, alice_tsla, alice_nvda, bob_tsla]; + let before = balances(&ctx, &watched); + let remaining = withdraw_remaining_to(&ctx, bob_tsla, alice_nvda); + let ix = withdraw_instruction(&ctx, &alice, 900_000_000, 0, remaining); + assert_program_error( + send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&alice], + &alice.pubkey(), + None, + ), + FundError::InvalidRecipient, + "a withdrawal paying another wallet's token account must fail", + ); + assert_eq!( + balances(&ctx, &watched), + before, + "no tokens or shares moved" + ); + + let remaining = withdraw_remaining(&ctx, &alice.pubkey()); + let ix = withdraw_instruction(&ctx, &alice, 900_000_000, 0, remaining); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&alice], &alice.pubkey(), None) + .unwrap(); + assert_eq!( + get_token_account_balance(&ctx.svm, &alice_tsla).unwrap(), + 144_000_000 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &alice_nvda).unwrap(), + 300_000_000 + ); + assert_eq!(get_token_account_balance(&ctx.svm, &bob_tsla).unwrap(), 0); +} + +/// Each asset's recipient must hold that asset. Alice naming her own NVDAx +/// account for her TSLAx is refused with `InvalidRecipient`: no shares are +/// burned and no tokens move. +#[test] +fn test_withdraw_rejects_recipient_for_another_mint() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + let alice = fund_user(&mut ctx, 900_000_000); + let alice_share = do_deposit(&mut ctx, &alice, 900_000_000, 1); + let alice_nvda = create_associated_token_account( + &mut ctx.svm, + &alice.pubkey(), + &ctx.nvda_mint, + &ctx.payer, + None, + ) + .unwrap(); + + let watched = [alice_share, alice_nvda]; + let before = balances(&ctx, &watched); + let remaining = withdraw_remaining_to(&ctx, alice_nvda, alice_nvda); + let ix = withdraw_instruction(&ctx, &alice, 900_000_000, 0, remaining); + assert_program_error( + send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&alice], + &alice.pubkey(), + None, + ), + FundError::InvalidRecipient, + "a withdrawal paying TSLAx to an NVDAx account must fail", + ); + assert_eq!( + balances(&ctx, &watched), + before, + "no tokens or shares moved" + ); +} diff --git a/finance/managed-fund/anchor/CHANGELOG.md b/finance/managed-fund/anchor/CHANGELOG.md index 83703281..f2c29248 100644 --- a/finance/managed-fund/anchor/CHANGELOG.md +++ b/finance/managed-fund/anchor/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased (2026-11-10) + +### Added + +- **Tests for the price-feed and recipient checks.** No test triggered `InvalidPriceFeed`, `NegativePrice`, `StalePriceFeed` or `InvalidRecipient`. Each new test asserts the error and that no tokens or shares moved: `test_wrong_price_feed_rejected` passes TSLAx's fresh, fully verified feed in NVDAx's place and checks that a deposit and a rebalance both fail with `InvalidPriceFeed`, then that NVDAx's own feed gives `test_rebalance`'s trade; `test_truncated_price_feed_rejected` writes NVDAx's price account cut off at 125 bytes, before its `posted_slot`, and checks `InvalidPriceFeed`; `test_non_positive_price_rejected` checks that a price of zero and one of −$180 both fail with `NegativePrice`, and that at $180 again Bob's 480 USDC mints 480,000,000 shares; `test_stale_price_rejected` checks that a price 61 seconds old fails with `StalePriceFeed` and one exactly 60 seconds old is accepted; `test_withdraw_rejects_recipient_owned_by_someone_else` checks that Alice naming Bob's TSLAx account fails with `InvalidRecipient`, and `test_withdraw_rejects_recipient_for_another_mint` that naming her NVDAx account for her TSLAx does too. The tests gain `write_price_feed_account`, `withdraw_remaining_to`, `withdraw_instruction` and `balances` helpers. The program is unchanged. + ## [Unreleased] - 2026-10-05 ### Changed diff --git a/finance/managed-fund/anchor/README.md b/finance/managed-fund/anchor/README.md index baba50a2..e444b43e 100644 --- a/finance/managed-fund/anchor/README.md +++ b/finance/managed-fund/anchor/README.md @@ -163,7 +163,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. TSLAx and NVDAx are minted with eight decimals, as the real tokens are, and USDC with six, so the basket amounts the tests assert are in eight-decimal minor units while shares and USDC stay in six. 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, each asserting the error code it fails with: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage (the router's `SlippageExceeded`, raised inside the swap CPI), an under-allocated fund, non-manager `set_weight` (Anchor's own constraint error), 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, and `test_valuation_scales_by_nine_decimals_and_exponent` does the same with TSLAx at nine decimals. `test_collect_fees` checks a year's fee comes out exact and `test_collect_fees_rounds_up` that a day's fee rounds up to the manager. `test_wide_confidence_price_rejected` widens NVDAx's confidence interval to 2% of its price and checks that deposit and rebalance fail with `OracleConfidenceTooWide`, that withdraw still pays out in kind, and that a band of exactly 1% is accepted. `test_partially_verified_price_rejected` writes NVDAx's feed as a `Partial` update signed by two signers and checks that a deposit fails with `PriceNotFullyVerified`, then rewrites it as `Full` at the same price and checks the deposit prices exactly as `test_deposit_first` does. `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. +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. TSLAx and NVDAx are minted with eight decimals, as the real tokens are, and USDC with six, so the basket amounts the tests assert are in eight-decimal minor units while shares and USDC stay in six. 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, each asserting the error code it fails with: unapproved asset, weight overflow, over-cap fee and slippage, oracle-bounded deposit slippage (the router's `SlippageExceeded`, raised inside the swap CPI), an under-allocated fund, non-manager `set_weight` (Anchor's own constraint error), 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, and `test_valuation_scales_by_nine_decimals_and_exponent` does the same with TSLAx at nine decimals. `test_collect_fees` checks a year's fee comes out exact and `test_collect_fees_rounds_up` that a day's fee rounds up to the manager. `test_wide_confidence_price_rejected` widens NVDAx's confidence interval to 2% of its price and checks that deposit and rebalance fail with `OracleConfidenceTooWide`, that withdraw still pays out in kind, and that a band of exactly 1% is accepted. `test_partially_verified_price_rejected` writes NVDAx's feed as a `Partial` update signed by two signers and checks that a deposit fails with `PriceNotFullyVerified`, then rewrites it as `Full` at the same price and checks the deposit prices exactly as `test_deposit_first` does. `test_wrong_price_feed_rejected` passes TSLAx's feed in NVDAx's place and checks that deposit and rebalance fail with `InvalidPriceFeed`, as `test_truncated_price_feed_rejected` does for a price account cut off before its `posted_slot`; `test_non_positive_price_rejected` checks that a price of zero or below fails with `NegativePrice`, and `test_stale_price_rejected` that a price 61 seconds old fails with `StalePriceFeed` while one exactly 60 seconds old is accepted. `test_withdraw_rejects_recipient_owned_by_someone_else` and `test_withdraw_rejects_recipient_for_another_mint` check that a withdrawal naming another wallet's token account, or an account for another asset, fails with `InvalidRecipient`. Each of these checks that no tokens or shares moved. `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 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 9ca218cd..72ef85dd 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 @@ -95,18 +95,7 @@ fn write_price_feed_with_confidence( ) { let data = build_mock_price_update_account(price, confidence, exponent, PUBLISH_TIME, posted_slot); - let rent = svm.minimum_balance_for_rent_exemption(data.len()); - svm.set_account( - key, - SolanaAccount { - lamports: rent, - data, - owner: pyth_receiver_program_id(), - executable: false, - rent_epoch: 0, - }, - ) - .unwrap(); + write_price_feed_account(svm, key, data); } /// Write a Pyth feed as a partially verified update, laid out as Pyth's @@ -123,6 +112,11 @@ fn write_partially_verified_price_feed( build_mock_price_update_account(price, DEFAULT_CONFIDENCE, PYTH_EXPONENT, PUBLISH_TIME, 1); data[40] = 0; data.insert(41, num_signatures); + write_price_feed_account(svm, key, data); +} + +/// Store `data` as a Pyth price account at `key`, owned by the Pyth receiver. +fn write_price_feed_account(svm: &mut LiteSVM, key: Address, data: Vec) { let rent = svm.minimum_balance_for_rent_exemption(data.len()); svm.set_account( key, @@ -1485,15 +1479,30 @@ fn test_collect_fees_rounds_up() { } fn withdraw_remaining(ctx: &TestContext, user: &Address) -> Vec { + withdraw_remaining_to( + ctx, + derive_ata(user, &ctx.tsla_mint), + derive_ata(user, &ctx.nvda_mint), + ) +} + +/// A withdrawal's remaining accounts, in the order the handler reads: +/// [asset_config, vault, mint, recipient] per asset, paying TSLAx to +/// `tsla_recipient` and NVDAx to `nvda_recipient`. +fn withdraw_remaining_to( + ctx: &TestContext, + tsla_recipient: Address, + nvda_recipient: Address, +) -> Vec { vec![ AccountMeta::new_readonly(ctx.asset_config(0), false), AccountMeta::new(ctx.vault_tsla, false), AccountMeta::new_readonly(ctx.tsla_mint, false), - AccountMeta::new(derive_ata(user, &ctx.tsla_mint), false), + AccountMeta::new(tsla_recipient, false), AccountMeta::new_readonly(ctx.asset_config(1), false), AccountMeta::new(ctx.vault_nvda, false), AccountMeta::new_readonly(ctx.nvda_mint, false), - AccountMeta::new(derive_ata(user, &ctx.nvda_mint), false), + AccountMeta::new(nvda_recipient, false), ] } @@ -1665,23 +1674,36 @@ fn do_withdraw(ctx: &mut TestContext, user: &Keypair, shares: u64, min_usdc_out: None, ) .unwrap(); - let user_usdc = derive_ata(&user.pubkey(), &ctx.usdc_mint); - let user_share = derive_ata(&user.pubkey(), &ctx.share_mint_pda); + let remaining = withdraw_remaining(ctx, &user.pubkey()); + let ix = withdraw_instruction(ctx, user, shares, min_usdc_out, remaining); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[user], &user.pubkey(), None) + .unwrap(); +} + +/// A withdrawal of `shares` by `user`, paid in kind to the recipients named in +/// `remaining`. +fn withdraw_instruction( + ctx: &TestContext, + user: &Keypair, + shares: u64, + min_usdc_out: u64, + remaining: Vec, +) -> Instruction { let mut metas = managed_fund::accounts::WithdrawAccountConstraints { user: user.pubkey(), fund: ctx.fund_pda, share_mint: ctx.share_mint_pda, usdc_mint: ctx.usdc_mint, - user_share_account: user_share, - user_usdc_account: user_usdc, + user_share_account: derive_ata(&user.pubkey(), &ctx.share_mint_pda), + user_usdc_account: derive_ata(&user.pubkey(), &ctx.usdc_mint), vault_usdc: ctx.vault_usdc, associated_token_program: ata_program_id(), token_program: token_program_id(), system_program: system_program::ID, } .to_account_metas(None); - metas.extend(withdraw_remaining(ctx, &user.pubkey())); - let ix = Instruction::new_with_bytes( + metas.extend(remaining); + Instruction::new_with_bytes( ctx.fund_program_id, &managed_fund::instruction::Withdraw { shares_to_burn: shares, @@ -1689,9 +1711,7 @@ fn do_withdraw(ctx: &mut TestContext, user: &Keypair, shares: u64, min_usdc_out: } .data(), metas, - ); - send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[user], &user.pubkey(), None) - .unwrap(); + ) } #[test] @@ -2530,3 +2550,332 @@ fn test_partially_verified_price_rejected() { ); assert_holdings_match_vaults(&ctx); } + +/// The balances a refused transaction must leave as they were: the fund's +/// three vaults, the router's USDC treasury and `token_accounts`, each read as +/// zero if it does not exist, then the fund's recorded share supply. +fn balances(ctx: &TestContext, token_accounts: &[Address]) -> Vec { + let mut accounts = vec![ + ctx.vault_usdc, + ctx.vault_tsla, + ctx.vault_nvda, + ctx.router_usdc_treasury, + ]; + accounts.extend_from_slice(token_accounts); + let mut balances: Vec = accounts + .iter() + .map(|account| get_token_account_balance(&ctx.svm, account).unwrap_or(0)) + .collect(); + balances.push(read_fund(ctx).total_shares); + balances +} + +/// Each asset is priced from the Pyth account the registry bound to it. A +/// fresh, fully verified feed for another asset is refused with +/// `InvalidPriceFeed`: with TSLAx's $250 feed in NVDAx's place, a deposit and +/// a rebalance both fail and no tokens or shares move. With NVDAx's own feed +/// the same rebalance makes `test_rebalance`'s trade. +#[test] +fn test_wrong_price_feed_rejected() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + // Alice deposits 900 USDC: 1.44 TSLAx + 3.0 NVDAx at 40/60. NVDAx then + // rises to $200, so the fund has drifted and needs a rebalance. + 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); + + // NVDAx's price feed is the fifth of its five accounts, the tenth overall. + let mut remaining = deposit_remaining(&ctx); + remaining[9] = AccountMeta::new_readonly(ctx.price_feed_tsla, false); + + let bob = fund_user(&mut ctx, 480_000_000); + let bob_accounts = [ + derive_ata(&bob.pubkey(), &ctx.usdc_mint), + derive_ata(&bob.pubkey(), &ctx.share_mint_pda), + ]; + let before = balances(&ctx, &bob_accounts); + + let ix = deposit_instruction(&ctx, &bob, 480_000_000, 1, remaining.clone()); + assert_program_error( + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&bob], &bob.pubkey(), None), + FundError::InvalidPriceFeed, + "a deposit priced from another asset's feed must fail", + ); + let stranger = create_wallet(&mut ctx.svm, 1_000_000_000).unwrap(); + let ix = rebalance_instruction_with(&ctx, &stranger, 1, 0, remaining); + assert_program_error( + send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&stranger], + &stranger.pubkey(), + None, + ), + FundError::InvalidPriceFeed, + "a rebalance priced from another asset's feed must fail", + ); + assert_eq!( + balances(&ctx, &bob_accounts), + before, + "no tokens or shares moved" + ); + + // With NVDAx's own feed the rebalance sells 0.12 NVDAx and buys 0.096 TSLAx. + 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], 288_000_000); + assert_holdings_match_vaults(&ctx); +} + +/// A price account too short to hold a whole Pyth price update, here cut off +/// before its eight-byte `posted_slot` at offset 125, is refused with +/// `InvalidPriceFeed` and no tokens or shares move. Written whole again at the +/// same price, the same deposit mints its shares. +#[test] +fn test_truncated_price_feed_rejected() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + let amount = 1_000_000u64; // 1 USDC + let user = fund_user(&mut ctx, amount); + let mut data = build_mock_price_update_account( + NVDA_PRICE, + DEFAULT_CONFIDENCE, + PYTH_EXPONENT, + PUBLISH_TIME, + 1, + ); + data.truncate(125); + write_price_feed_account(&mut ctx.svm, ctx.price_feed_nvda, data); + + let user_accounts = [ + derive_ata(&user.pubkey(), &ctx.usdc_mint), + derive_ata(&user.pubkey(), &ctx.share_mint_pda), + ]; + let before = balances(&ctx, &user_accounts); + let ix = deposit_instruction(&ctx, &user, amount, amount, deposit_remaining(&ctx)); + assert_program_error( + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey(), None), + FundError::InvalidPriceFeed, + "a deposit priced from a truncated price account must fail", + ); + assert_eq!( + balances(&ctx, &user_accounts), + before, + "no tokens or shares moved" + ); + + set_price_feed(&mut ctx.svm, ctx.price_feed_nvda, NVDA_PRICE); + ctx.svm.expire_blockhash(); + let user_share = do_deposit(&mut ctx, &user, amount, amount); + assert_eq!( + get_token_account_balance(&ctx.svm, &user_share).unwrap(), + amount + ); +} + +/// Pyth reports a price as a signed integer. A price of zero values an asset at +/// nothing, and a negative one below nothing, so Bob's deposit would be priced +/// against a NAV missing Alice's NVDAx. Both are refused with `NegativePrice` +/// and no tokens or shares move. At NVDAx's $180 again, Bob's 480 USDC buys +/// 480,000,000 shares against Alice's 900 USDC and 900,000,000 shares. +#[test] +fn test_non_positive_price_rejected() { + 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); + + let bob = fund_user(&mut ctx, 480_000_000); + let bob_accounts = [ + derive_ata(&bob.pubkey(), &ctx.usdc_mint), + derive_ata(&bob.pubkey(), &ctx.share_mint_pda), + ]; + let before = balances(&ctx, &bob_accounts); + for price in [0, -NVDA_PRICE] { + set_price_feed(&mut ctx.svm, ctx.price_feed_nvda, price); + ctx.svm.expire_blockhash(); + let ix = deposit_instruction(&ctx, &bob, 480_000_000, 1, deposit_remaining(&ctx)); + assert_program_error( + send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&bob], + &bob.pubkey(), + None, + ), + FundError::NegativePrice, + "a deposit priced from a price of zero or below must fail", + ); + } + assert_eq!( + balances(&ctx, &bob_accounts), + before, + "no tokens or shares moved" + ); + + set_price_feed(&mut ctx.svm, ctx.price_feed_nvda, NVDA_PRICE); + ctx.svm.expire_blockhash(); + let bob_share = do_deposit(&mut ctx, &bob, 480_000_000, 1); + assert_eq!( + get_token_account_balance(&ctx.svm, &bob_share).unwrap(), + 480_000_000 + ); +} + +/// A price published more than 60 seconds ago is refused with +/// `StalePriceFeed` and no tokens or shares move. Both feeds were published at +/// `PUBLISH_TIME`: at 61 seconds past it the deposit fails, and at exactly 60 +/// the same deposit mints its shares. +#[test] +fn test_stale_price_rejected() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + let amount = 1_000_000u64; // 1 USDC + let user = fund_user(&mut ctx, amount); + let user_accounts = [ + derive_ata(&user.pubkey(), &ctx.usdc_mint), + derive_ata(&user.pubkey(), &ctx.share_mint_pda), + ]; + let before = balances(&ctx, &user_accounts); + + advance_seconds(&mut ctx, 61); + let ix = deposit_instruction(&ctx, &user, amount, amount, deposit_remaining(&ctx)); + assert_program_error( + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&user], &user.pubkey(), None), + FundError::StalePriceFeed, + "a deposit priced from a 61-second-old price must fail", + ); + assert_eq!( + balances(&ctx, &user_accounts), + before, + "no tokens or shares moved" + ); + + advance_seconds(&mut ctx, 60); + ctx.svm.expire_blockhash(); + let user_share = do_deposit(&mut ctx, &user, amount, amount); + assert_eq!( + get_token_account_balance(&ctx.svm, &user_share).unwrap(), + amount + ); +} + +/// Withdraw pays each asset to a token account the caller names, and each must +/// belong to the holder whose shares are burned. Alice naming Bob's TSLAx +/// account for her TSLAx is refused with `InvalidRecipient`: no shares are +/// burned and no tokens move. Named her own, the same withdrawal pays her +/// 1.44 TSLAx and 3.0 NVDAx. +#[test] +fn test_withdraw_rejects_recipient_owned_by_someone_else() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + let alice = fund_user(&mut ctx, 900_000_000); + let alice_share = do_deposit(&mut ctx, &alice, 900_000_000, 1); + let alice_tsla = create_associated_token_account( + &mut ctx.svm, + &alice.pubkey(), + &ctx.tsla_mint, + &ctx.payer, + None, + ) + .unwrap(); + let alice_nvda = create_associated_token_account( + &mut ctx.svm, + &alice.pubkey(), + &ctx.nvda_mint, + &ctx.payer, + None, + ) + .unwrap(); + let bob = create_wallet(&mut ctx.svm, 1_000_000_000).unwrap(); + let bob_tsla = create_associated_token_account( + &mut ctx.svm, + &bob.pubkey(), + &ctx.tsla_mint, + &ctx.payer, + None, + ) + .unwrap(); + + let watched = [alice_share, alice_tsla, alice_nvda, bob_tsla]; + let before = balances(&ctx, &watched); + let remaining = withdraw_remaining_to(&ctx, bob_tsla, alice_nvda); + let ix = withdraw_instruction(&ctx, &alice, 900_000_000, 0, remaining); + assert_program_error( + send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&alice], + &alice.pubkey(), + None, + ), + FundError::InvalidRecipient, + "a withdrawal paying another wallet's token account must fail", + ); + assert_eq!( + balances(&ctx, &watched), + before, + "no tokens or shares moved" + ); + + let remaining = withdraw_remaining(&ctx, &alice.pubkey()); + let ix = withdraw_instruction(&ctx, &alice, 900_000_000, 0, remaining); + send_transaction_from_instructions(&mut ctx.svm, vec![ix], &[&alice], &alice.pubkey(), None) + .unwrap(); + assert_eq!( + get_token_account_balance(&ctx.svm, &alice_tsla).unwrap(), + 144_000_000 + ); + assert_eq!( + get_token_account_balance(&ctx.svm, &alice_nvda).unwrap(), + 300_000_000 + ); + assert_eq!(get_token_account_balance(&ctx.svm, &bob_tsla).unwrap(), 0); +} + +/// Each asset's recipient must hold that asset. Alice naming her own NVDAx +/// account for her TSLAx is refused with `InvalidRecipient`: no shares are +/// burned and no tokens move. +#[test] +fn test_withdraw_rejects_recipient_for_another_mint() { + let mut ctx = setup_full(); + standard_fund(&mut ctx); + + let alice = fund_user(&mut ctx, 900_000_000); + let alice_share = do_deposit(&mut ctx, &alice, 900_000_000, 1); + let alice_nvda = create_associated_token_account( + &mut ctx.svm, + &alice.pubkey(), + &ctx.nvda_mint, + &ctx.payer, + None, + ) + .unwrap(); + + let watched = [alice_share, alice_nvda]; + let before = balances(&ctx, &watched); + let remaining = withdraw_remaining_to(&ctx, alice_nvda, alice_nvda); + let ix = withdraw_instruction(&ctx, &alice, 900_000_000, 0, remaining); + assert_program_error( + send_transaction_from_instructions( + &mut ctx.svm, + vec![ix], + &[&alice], + &alice.pubkey(), + None, + ), + FundError::InvalidRecipient, + "a withdrawal paying TSLAx to an NVDAx account must fail", + ); + assert_eq!( + balances(&ctx, &watched), + before, + "no tokens or shares moved" + ); +} diff --git a/finance/managed-fund/quasar/CHANGELOG.md b/finance/managed-fund/quasar/CHANGELOG.md index 55f5471a..9bf51f7f 100644 --- a/finance/managed-fund/quasar/CHANGELOG.md +++ b/finance/managed-fund/quasar/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## Unreleased (2026-11-10) + +### Fixed + +- `withdraw` now checks that `user_usdc_account` belongs to the withdrawing + holder and holds USDC, failing with `InvalidRecipient` otherwise, as it + already did for each asset's recipient and as the Anchor versions do through + their associated token account constraints. Before, a holder could name + anyone's USDC account for the USDC leg. Tested by + `test_withdraw_rejects_usdc_recipient_owned_by_someone_else`, which fails + without the check. + +### Added + +- Tests for the price-feed and recipient checks, which no test triggered. Each + asserts the error and that no tokens or shares moved. + `test_wrong_price_feed_rejected` passes TSLAx's fresh, fully verified feed in + NVDAx's place and checks that a deposit and a rebalance both fail with + `InvalidPriceFeed`; `test_truncated_price_feed_rejected` writes NVDAx's + price account cut off at 125 bytes, before its `posted_slot`, and checks + `InvalidPriceFeed`; `test_non_positive_price_rejected` checks that a price of + zero and one of −$180 both fail with `NegativePrice`; + `test_stale_price_rejected` checks that a price 61 seconds old fails with + `StalePriceFeed` and one exactly 60 seconds old is accepted. Four tests + check `InvalidRecipient`: `test_withdraw_rejects_recipient_owned_by_someone_else` + (Alice names Bob's TSLAx account), `test_withdraw_rejects_recipient_for_another_mint` + (Alice names her NVDAx account for her TSLAx), + `test_deposit_rejects_share_account_owned_by_someone_else` (Bob names + Alice's share account) and + `test_collect_fees_rejects_share_account_owned_by_someone_else` (the fee is + sent to Bob's share account). The tests gain `write_price_feed_account`, + `withdraw_to` and `balances` helpers. The program is unchanged. + ## [Unreleased] - 2026-10-05 ### Changed diff --git a/finance/managed-fund/quasar/README.md b/finance/managed-fund/quasar/README.md index 444e9cce..a97f69e4 100644 --- a/finance/managed-fund/quasar/README.md +++ b/finance/managed-fund/quasar/README.md @@ -187,7 +187,20 @@ and rebalance are refused while withdraw still pays out, and that a band of exactly 1% is accepted. `test_collect_fees` checks a year's 1% fee on 1,000,000,000 shares is 10,000,000 shares, exactly, and `test_collect_fees_rounds_up` that a day's fee, 27,397.26 shares, is minted as -27,398. The two-asset tests mint TSLAx and NVDAx with eight decimals, as the +27,398. `test_wrong_price_feed_rejected` passes TSLAx's feed in NVDAx's place +and checks that deposit and rebalance fail with `InvalidPriceFeed`, as +`test_truncated_price_feed_rejected` does for a price account cut off before +its `posted_slot`; `test_non_positive_price_rejected` checks that a price of +zero or below fails with `NegativePrice`, and `test_stale_price_rejected` that +a price 61 seconds old fails with `StalePriceFeed` while one exactly 60 seconds +old is accepted. A withdrawal naming another wallet's token account, or an +account for another asset, fails with `InvalidRecipient` +(`test_withdraw_rejects_recipient_owned_by_someone_else`, +`test_withdraw_rejects_recipient_for_another_mint`), and so do a deposit and a +fee collection minting shares to an account that is not the depositor's or the +manager's (`test_deposit_rejects_share_account_owned_by_someone_else`, +`test_collect_fees_rejects_share_account_owned_by_someone_else`). Each of these +checks that no tokens or shares moved. The two-asset tests mint TSLAx and NVDAx with eight decimals, as the real tokens are, and USDC with six, so their basket amounts are in eight-decimal minor units. Every refusal test asserts the error code it fails with. diff --git a/finance/managed-fund/quasar/managed-fund/src/instructions/withdraw.rs b/finance/managed-fund/quasar/managed-fund/src/instructions/withdraw.rs index d6a98ec4..68307e2d 100644 --- a/finance/managed-fund/quasar/managed-fund/src/instructions/withdraw.rs +++ b/finance/managed-fund/quasar/managed-fund/src/instructions/withdraw.rs @@ -76,6 +76,19 @@ pub fn handle_withdraw( let fund_key = *accounts.fund.address(); let user_key = *accounts.user.address(); + // The USDC leg is paid to the withdrawing holder, as each asset leg is + // below. + require_keys_eq!( + accounts.user_usdc_account.owner, + user_key, + FundError::InvalidRecipient + ); + require_keys_eq!( + accounts.user_usdc_account.mint, + *accounts.usdc_mint.address(), + FundError::InvalidRecipient + ); + let shares_u128 = shares_to_burn as u128; let total_u128 = total_shares as u128; diff --git a/finance/managed-fund/quasar/managed-fund/src/tests.rs b/finance/managed-fund/quasar/managed-fund/src/tests.rs index 39e5b0b8..a097a150 100644 --- a/finance/managed-fund/quasar/managed-fund/src/tests.rs +++ b/finance/managed-fund/quasar/managed-fund/src/tests.rs @@ -22,7 +22,15 @@ //! `test_wide_confidence_price_rejected` shows a feed whose confidence interval //! is past 1% of its price stops deposit and rebalance but not withdraw, and //! `test_collect_fees` and `test_collect_fees_rounds_up` mint the manager a -//! year's fee exactly and a day's fee rounded up. +//! year's fee exactly and a day's fee rounded up. The price-feed checks each +//! have a test that asserts the error and that no tokens or shares move: +//! `test_wrong_price_feed_rejected` (another asset's feed, in a deposit and a +//! rebalance), `test_truncated_price_feed_rejected`, +//! `test_non_positive_price_rejected` and `test_stale_price_rejected`. So do +//! the recipient checks: `test_withdraw_rejects_recipient_owned_by_someone_else`, +//! `test_withdraw_rejects_recipient_for_another_mint`, +//! `test_deposit_rejects_share_account_owned_by_someone_else` and +//! `test_collect_fees_rejects_share_account_owned_by_someone_else`. use { crate::{ @@ -145,6 +153,11 @@ fn write_price_feed_with_confidence( 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()); + write_price_feed_account(test, feed, data); +} + +/// Store `data` as a Pyth price account at `feed`. +fn write_price_feed_account(test: &mut Test, feed: Pubkey, data: Vec) { test.set_account(Account::new(feed, FEED_OWNER, 1_000_000, data)); } @@ -166,7 +179,7 @@ fn add_partially_verified_pyth_feed( data[90..94].copy_from_slice(&(-8i32).to_le_bytes()); data[94..102].copy_from_slice(&publish_time.to_le_bytes()); data[126..134].copy_from_slice(&1u64.to_le_bytes()); - test.set_account(Account::new(PRICE_FEED, FEED_OWNER, 1_000_000, data)); + write_price_feed_account(test, PRICE_FEED, data); } /// Pin the LastRestartSlot sysvar account, simulating a cluster restart at @@ -863,16 +876,32 @@ fn do_deposit(test: &mut Test, user: &User, usdc_amount: u64) { /// Burn `shares` of the standard fund and take the payout in kind. fn do_withdraw(test: &mut Test, user: &User, shares: u64) { + let ix = withdraw_to(test, user, shares, user.tsla, user.nvda); + test.send(ix).succeeds(); +} + +/// A withdrawal of `shares` of the standard fund by `user`, paying TSLAx to +/// `tsla_recipient` and NVDAx to `nvda_recipient`. +fn withdraw_to( + test: &Test, + user: &User, + shares: u64, + tsla_recipient: Pubkey, + nvda_recipient: Pubkey, +) -> WithdrawInstruction { let mut remaining_accounts = Vec::new(); - for (index, mint, user_asset) in [(0, TSLA_MINT, user.tsla), (1, NVDA_MINT, user.nvda)] { + for (index, mint, recipient) in [ + (0, TSLA_MINT, tsla_recipient), + (1, NVDA_MINT, nvda_recipient), + ] { 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), + AccountMeta::new(recipient, false), ]); } - test.send(WithdrawInstruction { + WithdrawInstruction { user: user.owner, fund_index_seed: FUND_INDEX, usdc_mint: USDC_MINT, @@ -881,8 +910,7 @@ fn do_withdraw(test: &mut Test, user: &User, shares: u64) { 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. @@ -1450,3 +1478,296 @@ fn test_collect_fees_rounds_up(test: &mut Test) { let fund = test.read::(fund_pda(test)); assert_eq!(u64::from(fund.total_shares), 1_000_027_398); } + +/// The balances a refused transaction must leave as they were: the fund's +/// three vaults, the router's USDC treasury and `token_accounts`, then the +/// fund's recorded share supply. +fn balances(test: &Test, token_accounts: &[Pubkey]) -> Vec { + let mut accounts = vec![ + usdc_vault_pda(test), + asset_vault_pda(test, 0), + asset_vault_pda(test, 1), + router_treasury_pda(), + ]; + accounts.extend_from_slice(token_accounts); + let mut balances: Vec = accounts + .iter() + .map(|account| test.tokens(*account)) + .collect(); + balances.push(u64::from(test.read::(fund_pda(test)).total_shares)); + balances +} + +/// Each asset is priced from the Pyth account the registry bound to it. A +/// fresh, fully verified feed for another asset is refused with +/// `InvalidPriceFeed`: with TSLAx's $250 feed in NVDAx's place, a deposit and +/// a rebalance both fail and no tokens or shares move. With NVDAx's own feed +/// the same rebalance makes `test_rebalance`'s trade. +#[quasar_test] +fn test_wrong_price_feed_rejected(test: &mut Test) { + setup_full(test); + standard_fund(test); + + // Alice deposits 900 USDC: 1.44 TSLAx + 3.0 NVDAx at 40/60. NVDAx then + // rises to $200, so the fund has drifted and needs a rebalance. + 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); + + // NVDAx's price feed is the fifth of its five accounts, the tenth overall. + let mut remaining = deposit_remaining(test); + remaining[9] = AccountMeta::new_readonly(TSLA_FEED, false); + + let bob = fund_user(test, 480_000_000); + let before = balances(test, &[bob.usdc, bob.share]); + let ix = deposit_instruction(&bob, 480_000_000, remaining.clone()); + test.send(ix).fails_with(FundError::InvalidPriceFeed); + let stranger = test.add(Wallet::new()); + let ix = rebalance_instruction_with(stranger, 1, 0, remaining); + test.send(ix).fails_with(FundError::InvalidPriceFeed); + assert_eq!( + balances(test, &[bob.usdc, bob.share]), + before, + "no tokens or shares moved" + ); + + // With NVDAx's own feed the rebalance sells 0.12 NVDAx and buys 0.096 TSLAx. + do_rebalance(test, 1, 0); + let (_, holdings) = read_holdings(test); + assert_eq!(holdings[0], 153_600_000); + assert_eq!(holdings[1], 288_000_000); + assert_holdings_match_vaults(test); +} + +/// A price account too short to hold a whole Pyth price update, here cut off +/// before its eight-byte `posted_slot` at offset 125, is refused with +/// `InvalidPriceFeed` and no tokens or shares move. Written whole again at the +/// same price, the same deposit mints its shares. +#[quasar_test] +fn test_truncated_price_feed_rejected(test: &mut Test) { + setup_full(test); + standard_fund(test); + + let amount = 1_000_000u64; // 1 USDC + let user = fund_user(test, amount); + let mut data = vec![0u8; 125]; + data[40] = 1; // VerificationLevel::Full + data[73..81].copy_from_slice(&NVDA_PRICE.to_le_bytes()); + data[89..93].copy_from_slice(&(-8i32).to_le_bytes()); + data[93..101].copy_from_slice(&NOW.to_le_bytes()); + write_price_feed_account(test, NVDA_FEED, data); + + let before = balances(test, &[user.usdc, user.share]); + test.send(deposit_instruction(&user, amount, deposit_remaining(test))) + .fails_with(FundError::InvalidPriceFeed); + assert_eq!( + balances(test, &[user.usdc, user.share]), + before, + "no tokens or shares moved" + ); + + write_price_feed(test, NVDA_FEED, NVDA_PRICE, -8, NOW, 1); + do_deposit(test, &user, amount); + assert_eq!(test.tokens(user.share), amount); +} + +/// Pyth reports a price as a signed integer. A price of zero values an asset at +/// nothing, and a negative one below nothing, so Bob's deposit would be priced +/// against a NAV missing Alice's NVDAx. Both are refused with `NegativePrice` +/// and no tokens or shares move. At NVDAx's $180 again, Bob's 480 USDC buys +/// 480,000,000 shares against Alice's 900 USDC and 900,000,000 shares. +#[quasar_test] +fn test_non_positive_price_rejected(test: &mut Test) { + setup_full(test); + standard_fund(test); + + let alice = fund_user(test, 900_000_000); + do_deposit(test, &alice, 900_000_000); + + let bob = fund_user(test, 480_000_000); + let before = balances(test, &[bob.usdc, bob.share]); + for price in [0, -NVDA_PRICE] { + write_price_feed(test, NVDA_FEED, price, -8, NOW, 1); + let ix = deposit_instruction(&bob, 480_000_000, deposit_remaining(test)); + test.send(ix).fails_with(FundError::NegativePrice); + } + assert_eq!( + balances(test, &[bob.usdc, bob.share]), + before, + "no tokens or shares moved" + ); + + write_price_feed(test, NVDA_FEED, NVDA_PRICE, -8, NOW, 1); + do_deposit(test, &bob, 480_000_000); + assert_eq!(test.tokens(bob.share), 480_000_000); +} + +/// A price published more than 60 seconds ago is refused with +/// `StalePriceFeed` and no tokens or shares move. Both feeds were published at +/// `NOW`: at 61 seconds past it the deposit fails, and at exactly 60 the same +/// deposit mints its shares. +#[quasar_test] +fn test_stale_price_rejected(test: &mut Test) { + setup_full(test); + standard_fund(test); + + let amount = 1_000_000u64; // 1 USDC + let user = fund_user(test, amount); + let before = balances(test, &[user.usdc, user.share]); + + test.warp_to_timestamp(NOW + 61); + test.send(deposit_instruction(&user, amount, deposit_remaining(test))) + .fails_with(FundError::StalePriceFeed); + assert_eq!( + balances(test, &[user.usdc, user.share]), + before, + "no tokens or shares moved" + ); + + test.warp_to_timestamp(NOW + 60); + do_deposit(test, &user, amount); + assert_eq!(test.tokens(user.share), amount); +} + +/// Withdraw pays each asset to a token account the caller names, and each must +/// belong to the holder whose shares are burned. Alice naming Bob's TSLAx +/// account for her TSLAx is refused with `InvalidRecipient`: no shares are +/// burned and no tokens move. Named her own, the same withdrawal pays her +/// 1.44 TSLAx and 3.0 NVDAx. +#[quasar_test] +fn test_withdraw_rejects_recipient_owned_by_someone_else(test: &mut Test) { + setup_full(test); + standard_fund(test); + + let alice = fund_user(test, 900_000_000); + do_deposit(test, &alice, 900_000_000); + let bob = fund_user(test, 0); + + let watched = [alice.share, alice.tsla, alice.nvda, bob.tsla]; + let before = balances(test, &watched); + let ix = withdraw_to(test, &alice, 900_000_000, bob.tsla, alice.nvda); + test.send(ix).fails_with(FundError::InvalidRecipient); + assert_eq!( + balances(test, &watched), + before, + "no tokens or shares moved" + ); + + do_withdraw(test, &alice, 900_000_000); + assert_eq!(test.tokens(alice.tsla), 144_000_000); + assert_eq!(test.tokens(alice.nvda), 300_000_000); + assert_eq!(test.tokens(bob.tsla), 0); +} + +/// The USDC leg of a withdrawal is paid to a USDC account that must belong to +/// the holder whose shares are burned, as each asset leg is. Alice naming Bob's +/// USDC account is refused with `InvalidRecipient`: no shares are burned and no +/// tokens move. Named her own, the same withdrawal succeeds. +#[quasar_test] +fn test_withdraw_rejects_usdc_recipient_owned_by_someone_else(test: &mut Test) { + setup_full(test); + standard_fund(test); + + let alice = fund_user(test, 900_000_000); + do_deposit(test, &alice, 900_000_000); + let bob = fund_user(test, 0); + + let watched = [alice.share, alice.usdc, alice.tsla, alice.nvda, bob.usdc]; + let before = balances(test, &watched); + let mut ix = withdraw_to(test, &alice, 900_000_000, alice.tsla, alice.nvda); + ix.user_usdc_account = bob.usdc; + test.send(ix).fails_with(FundError::InvalidRecipient); + assert_eq!( + balances(test, &watched), + before, + "no tokens or shares moved" + ); + + do_withdraw(test, &alice, 900_000_000); + assert_eq!(test.tokens(alice.share), 0); + assert_eq!(test.tokens(bob.usdc), 0); +} + +/// Each asset's recipient must hold that asset. Alice naming her own NVDAx +/// account for her TSLAx is refused with `InvalidRecipient`: no shares are +/// burned and no tokens move. +#[quasar_test] +fn test_withdraw_rejects_recipient_for_another_mint(test: &mut Test) { + setup_full(test); + standard_fund(test); + + let alice = fund_user(test, 900_000_000); + do_deposit(test, &alice, 900_000_000); + + let watched = [alice.share, alice.tsla, alice.nvda]; + let before = balances(test, &watched); + let ix = withdraw_to(test, &alice, 900_000_000, alice.nvda, alice.nvda); + test.send(ix).fails_with(FundError::InvalidRecipient); + assert_eq!( + balances(test, &watched), + before, + "no tokens or shares moved" + ); +} + +/// Deposit mints shares to the share account the depositor names, which must +/// belong to the depositor. Bob naming Alice's share account is refused with +/// `InvalidRecipient`: Alice gains no shares and no tokens move. Named his own, +/// the same deposit mints Bob 480,000,000 shares. +#[quasar_test] +fn test_deposit_rejects_share_account_owned_by_someone_else(test: &mut Test) { + setup_full(test); + standard_fund(test); + + let alice = fund_user(test, 900_000_000); + do_deposit(test, &alice, 900_000_000); + let bob = fund_user(test, 480_000_000); + + let watched = [alice.share, bob.usdc, bob.share]; + let before = balances(test, &watched); + let mut ix = deposit_instruction(&bob, 480_000_000, deposit_remaining(test)); + ix.depositor_share_account = alice.share; + test.send(ix).fails_with(FundError::InvalidRecipient); + assert_eq!( + balances(test, &watched), + before, + "no tokens or shares moved" + ); + + do_deposit(test, &bob, 480_000_000); + assert_eq!(test.tokens(bob.share), 480_000_000); + assert_eq!(test.tokens(alice.share), 900_000_000); +} + +/// The management fee is minted to a share account that must belong to the +/// fund's manager, since anyone may send `collect_fees`. A share account +/// belonging to Bob is refused with `InvalidRecipient` and no shares are +/// minted. Named the manager's, the same collection mints a year's 1% fee, +/// 10,000,000 shares. +#[quasar_test] +fn test_collect_fees_rejects_share_account_owned_by_someone_else(test: &mut Test) { + setup_full(test); + standard_fund(test); + let alice = fund_user(test, 1_000_000_000); + do_deposit(test, &alice, 1_000_000_000); + let bob = fund_user(test, 0); + + test.warp_to_timestamp(NOW + SECONDS_PER_YEAR); + let (manager_share, instruction) = collect_fees(test); + let to_bob = CollectFeesInstruction { + manager_share_account: bob.share, + ..instruction + }; + let before = balances(test, &[bob.share, manager_share]); + test.send(to_bob).fails_with(FundError::InvalidRecipient); + assert_eq!( + balances(test, &[bob.share, manager_share]), + before, + "no shares minted" + ); + + test.send(instruction) + .succeeds() + .has_tokens(manager_share, 10_000_000); + assert_eq!(test.tokens(bob.share), 0); +}