From 70f9e7ff198dd40dc840a900b9a8eb3efa9e25bc Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Tue, 10 Nov 2026 17:03:19 +0000 Subject: [PATCH 1/2] managed-fund: test the price feed and recipient errors No test triggered InvalidPriceFeed, NegativePrice, StalePriceFeed or InvalidRecipient. Each check now has a test that asserts the exact error and that no tokens or shares moved, then shows the honest path succeeds: - another asset's price feed, in a deposit and a rebalance (InvalidPriceFeed) - a price account too short for a Pyth price update (InvalidPriceFeed) - a price of zero and a negative price (NegativePrice) - a price 61 seconds old, refused, and one 60 seconds old, accepted (StalePriceFeed) - a withdrawal paying an asset to someone else's account, or to an account for another mint (InvalidRecipient) - quasar only, where these checks raise InvalidRecipient: a deposit or a fee collection minting shares to someone else's share account Applied to anchor-v1, anchor and quasar. With each check removed from the program, its test fails. Claude-Session: https://claude.ai/code/session_01GZHin6BQpAqf7j2ucSZWkx --- finance/managed-fund/anchor-v1/CHANGELOG.md | 6 + finance/managed-fund/anchor-v1/README.md | 2 +- .../managed-fund/tests/managed_fund.rs | 395 +++++++++++++++++- finance/managed-fund/anchor/CHANGELOG.md | 6 + finance/managed-fund/anchor/README.md | 2 +- .../managed-fund/tests/managed_fund.rs | 395 +++++++++++++++++- finance/managed-fund/quasar/CHANGELOG.md | 23 + finance/managed-fund/quasar/README.md | 15 +- .../quasar/managed-fund/src/tests.rs | 306 +++++++++++++- 9 files changed, 1094 insertions(+), 56 deletions(-) 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..35af8ef5 100644 --- a/finance/managed-fund/quasar/CHANGELOG.md +++ b/finance/managed-fund/quasar/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## Unreleased (2026-11-10) + +### 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/tests.rs b/finance/managed-fund/quasar/managed-fund/src/tests.rs index 39e5b0b8..77ef91fd 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,267 @@ 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); +} + +/// 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); +} From 48fcd2fbbb82043271c943c4d743a665398e6390 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Tue, 10 Nov 2026 17:06:03 +0000 Subject: [PATCH 2/2] managed-fund quasar: check withdraw's USDC recipient withdraw checked that each asset leg is paid to an account the holder owns for that asset's mint, but paid the USDC leg to whatever user_usdc_account the caller named. The Anchor versions require it to be the holder's USDC associated token account. The quasar version now checks its owner and mint, failing with InvalidRecipient, and test_withdraw_rejects_usdc_recipient_owned_by_someone_else checks it; without the check the test's withdrawal succeeds. Claude-Session: https://claude.ai/code/session_01GZHin6BQpAqf7j2ucSZWkx --- finance/managed-fund/quasar/CHANGELOG.md | 10 +++++++ .../managed-fund/src/instructions/withdraw.rs | 13 +++++++++ .../quasar/managed-fund/src/tests.rs | 29 +++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/finance/managed-fund/quasar/CHANGELOG.md b/finance/managed-fund/quasar/CHANGELOG.md index 35af8ef5..9bf51f7f 100644 --- a/finance/managed-fund/quasar/CHANGELOG.md +++ b/finance/managed-fund/quasar/CHANGELOG.md @@ -2,6 +2,16 @@ ## 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 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 77ef91fd..a097a150 100644 --- a/finance/managed-fund/quasar/managed-fund/src/tests.rs +++ b/finance/managed-fund/quasar/managed-fund/src/tests.rs @@ -1659,6 +1659,35 @@ fn test_withdraw_rejects_recipient_owned_by_someone_else(test: &mut Test) { 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.