From f6cdfb19e4187b56dbc095fc17723aa0b9fd5b5f Mon Sep 17 00:00:00 2001 From: Bayyan16 Date: Thu, 27 Aug 2026 19:48:51 +0700 Subject: [PATCH] fix(security): require canonical backing ledger on withdrawals --- src/v16_program.rs | 70 ++++++++----------- tests/v16_cu.rs | 111 ++++++++++++++++++++++++++++++ tests/v16_fork_lp_vault_redeem.rs | 3 +- tests/v16_wrapper.rs | 73 +++++++++++++++++++- 4 files changed, 214 insertions(+), 43 deletions(-) diff --git a/src/v16_program.rs b/src/v16_program.rs index e761cf3d2..db00bd92d 100644 --- a/src/v16_program.rs +++ b/src/v16_program.rs @@ -10403,16 +10403,19 @@ pub mod processor { let vault_token = account(accounts, 3)?; let vault_authority_ai = account(accounts, 4)?; let token_program = account(accounts, 5)?; - let ledger_ai = accounts.get(6); + // Principal withdrawals must always update the backing-domain ledger. + // Treating this trailing account as optional allowed callers to skip + // both the principal-consistency check and the ledger decrement. + let ledger_ai = account(accounts, 6)?; expect_signer(authority)?; expect_writable(market_ai)?; expect_writable(dest_token)?; expect_writable(vault_token)?; expect_owner(market_ai, program_id)?; - if let Some(ledger_ai) = ledger_ai { - expect_writable(ledger_ai)?; - expect_owner(ledger_ai, program_id)?; - } + expect_writable(ledger_ai)?; + expect_owner(ledger_ai, program_id)?; + let (ledger_pda, _) = state::derive_lp_backing_ledger(program_id, market_ai.key, domain); + expect_key(ledger_ai, &ledger_pda)?; verify_token_program(token_program)?; if amount == 0 { return Err(PercolatorError::InvalidInstruction.into()); @@ -10476,46 +10479,31 @@ pub mod processor { }; let (_, bucket) = backing_domain_parts_view(&group, domain_usize)?; - let mut ledger_data = if let Some(ledger_ai) = ledger_ai { - Some(ledger_ai.try_borrow_mut_data()?) - } else { - None - }; - let mut ledger_state = if let Some(data) = ledger_data.as_deref() { - let (mut ledger, initialized) = read_or_new_backing_domain_ledger( - data, - market_ai.key.to_bytes(), - ledger_authority, - domain, - &bucket, - )?; - sync_backing_domain_ledger(&mut ledger, &bucket)?; - if amount > ledger.total_principal_atoms { - return Err(PercolatorError::EngineCounterUnderflow.into()); - } - Some((ledger, initialized)) - } else { - None - }; + let mut ledger_data = ledger_ai.try_borrow_mut_data()?; + let (mut ledger, initialized) = read_or_new_backing_domain_ledger( + &ledger_data, + market_ai.key.to_bytes(), + ledger_authority, + domain, + &bucket, + )?; + sync_backing_domain_ledger(&mut ledger, &bucket)?; + if amount > ledger.total_principal_atoms { + return Err(PercolatorError::EngineCounterUnderflow.into()); + } group .withdraw_fresh_counterparty_backing_not_atomic(domain_usize, amount) .map_err(map_v16_error)?; - if let Some((ledger, _)) = ledger_state.as_mut() { - ledger.total_principal_atoms = ledger - .total_principal_atoms - .checked_sub(amount) - .ok_or(PercolatorError::EngineCounterUnderflow)?; - ledger.total_principal_withdrawn_atoms = ledger - .total_principal_withdrawn_atoms - .checked_add(amount) - .ok_or(PercolatorError::EngineArithmeticOverflow)?; - } + ledger.total_principal_atoms = ledger + .total_principal_atoms + .checked_sub(amount) + .ok_or(PercolatorError::EngineCounterUnderflow)?; + ledger.total_principal_withdrawn_atoms = ledger + .total_principal_withdrawn_atoms + .checked_add(amount) + .ok_or(PercolatorError::EngineArithmeticOverflow)?; group.validate_shape().map_err(map_v16_error)?; - if let (Some(data), Some((ledger, initialized))) = - (ledger_data.as_deref_mut(), ledger_state.as_ref()) - { - write_or_init_backing_domain_ledger(data, ledger, *initialized)?; - } + write_or_init_backing_domain_ledger(&mut ledger_data, &ledger, initialized)?; } let bump_arr = [bump]; diff --git a/tests/v16_cu.rs b/tests/v16_cu.rs index 4831d9d2e..ab0c10920 100644 --- a/tests/v16_cu.rs +++ b/tests/v16_cu.rs @@ -844,6 +844,25 @@ impl V16CuEnv { self.program_account(state::backing_domain_ledger_account_len()) } + fn canonical_backing_domain_ledger_account(&mut self, domain: u16) -> Pubkey { + let (ledger, _) = state::derive_lp_backing_ledger(&self.program_id, &self.market, domain); + if self.svm.get_account(&ledger).is_none() { + self.svm + .set_account( + ledger, + Account { + lamports: 1_000_000_000, + data: vec![0u8; state::backing_domain_ledger_account_len()], + owner: self.program_id, + executable: false, + rent_epoch: 0, + }, + ) + .unwrap(); + } + ledger + } + fn insurance_ledger_account(&mut self) -> Pubkey { self.program_account(state::insurance_ledger_account_len()) } @@ -2171,6 +2190,7 @@ impl V16CuEnv { amount: u128, expiry_slot: u64, ) -> u64 { + let ledger = self.canonical_backing_domain_ledger_account(domain); send_tx( &mut self.svm, self.program_id, @@ -2186,6 +2206,7 @@ impl V16CuEnv { AccountMeta::new(source, false), AccountMeta::new(self.vault, false), AccountMeta::new_readonly(spl_token::ID, false), + AccountMeta::new(ledger, false), ], &[&self.admin], ) @@ -2305,6 +2326,7 @@ impl V16CuEnv { amount: u128, expiry_slot: u64, ) -> (Pubkey, u64) { + let ledger = self.canonical_backing_domain_ledger_account(domain); let source = Pubkey::new_unique(); self.svm .set_account( @@ -2333,6 +2355,7 @@ impl V16CuEnv { AccountMeta::new(source, false), AccountMeta::new(self.vault, false), AccountMeta::new_readonly(spl_token::ID, false), + AccountMeta::new(ledger, false), ], &[&self.admin], ) @@ -2390,6 +2413,7 @@ impl V16CuEnv { amount: u128, expiry_slot: u64, ) -> Pubkey { + let ledger = self.canonical_backing_domain_ledger_account(domain); self.ensure_signer_account(authority.pubkey()); let source = self.token_account(authority.pubkey(), amount as u64); send_tx( @@ -2407,6 +2431,7 @@ impl V16CuEnv { AccountMeta::new(source, false), AccountMeta::new(self.vault, false), AccountMeta::new_readonly(spl_token::ID, false), + AccountMeta::new(ledger, false), ], &[authority], ) @@ -2549,6 +2574,7 @@ impl V16CuEnv { domain: u16, amount: u128, ) -> u64 { + let ledger = self.canonical_backing_domain_ledger_account(domain); send_tx( &mut self.svm, self.program_id, @@ -2561,6 +2587,7 @@ impl V16CuEnv { AccountMeta::new(self.vault, false), AccountMeta::new_readonly(self.vault_authority, false), AccountMeta::new_readonly(spl_token::ID, false), + AccountMeta::new(ledger, false), ], &[&self.admin], ) @@ -2573,6 +2600,7 @@ impl V16CuEnv { domain: u16, amount: u128, ) -> Result { + let ledger = self.canonical_backing_domain_ledger_account(domain); send_tx( &mut self.svm, self.program_id, @@ -2585,6 +2613,7 @@ impl V16CuEnv { AccountMeta::new(self.vault, false), AccountMeta::new_readonly(self.vault_authority, false), AccountMeta::new_readonly(spl_token::ID, false), + AccountMeta::new(ledger, false), ], &[&self.admin], ) @@ -2605,6 +2634,7 @@ impl V16CuEnv { domain: u16, amount: u128, ) -> u64 { + let ledger = self.canonical_backing_domain_ledger_account(domain); self.ensure_signer_account(authority.pubkey()); send_tx( &mut self.svm, @@ -2618,6 +2648,7 @@ impl V16CuEnv { AccountMeta::new(self.vault, false), AccountMeta::new_readonly(self.vault_authority, false), AccountMeta::new_readonly(spl_token::ID, false), + AccountMeta::new(ledger, false), ], &[authority], ) @@ -3717,6 +3748,84 @@ fn v16_bpf_privileged_reactivate_uses_authenticated_slot() { ); } +#[test] +fn v16_bpf_withdraw_backing_bucket_requires_canonical_ledger() { + let mut env = V16CuEnv::new(); + let domain = 1; + let ledger = env.canonical_backing_domain_ledger_account(domain); + env.top_up_backing_bucket(domain, 100, 10); + let dest = env.token_account(env.admin.pubkey(), 0); + + let market_before = env.svm.get_account(&env.market).unwrap().data; + let ledger_before = env.svm.get_account(&ledger).unwrap().data; + let vault_before = env.token_amount(env.vault); + let dest_before = env.token_amount(dest); + let impostor = env.backing_domain_ledger_account(); + + let withdraw_accounts = |ledger: Option| { + let mut accounts = vec![ + AccountMeta::new(env.admin.pubkey(), true), + AccountMeta::new(env.market, false), + AccountMeta::new(dest, false), + AccountMeta::new(env.vault, false), + AccountMeta::new_readonly(env.vault_authority, false), + AccountMeta::new_readonly(spl_token::ID, false), + ]; + if let Some(ledger) = ledger { + accounts.push(AccountMeta::new(ledger, false)); + } + accounts + }; + + let omitted = send_tx( + &mut env.svm, + env.program_id, + &env.payer, + ProgInstruction::WithdrawBackingBucket { domain, amount: 40 }, + withdraw_accounts(None), + &[&env.admin], + ); + assert!(omitted.is_err(), "omitting the ledger must fail closed"); + + env.svm.expire_blockhash(); + let substituted = send_tx( + &mut env.svm, + env.program_id, + &env.payer, + ProgInstruction::WithdrawBackingBucket { domain, amount: 40 }, + withdraw_accounts(Some(impostor)), + &[&env.admin], + ); + assert!( + substituted.is_err(), + "a noncanonical program-owned ledger must fail closed" + ); + assert_eq!(env.svm.get_account(&env.market).unwrap().data, market_before); + assert_eq!(env.svm.get_account(&ledger).unwrap().data, ledger_before); + assert_eq!(env.token_amount(env.vault), vault_before); + assert_eq!(env.token_amount(dest), dest_before); + + env.svm.expire_blockhash(); + send_tx( + &mut env.svm, + env.program_id, + &env.payer, + ProgInstruction::WithdrawBackingBucket { domain, amount: 40 }, + withdraw_accounts(Some(ledger)), + &[&env.admin], + ) + .expect("canonical ledger withdrawal"); + + let ledger_after = state::read_backing_domain_ledger( + &env.svm.get_account(&ledger).unwrap().data, + ) + .unwrap(); + assert_eq!(ledger_after.total_principal_atoms, 60); + assert_eq!(ledger_after.total_principal_withdrawn_atoms, 40); + assert_eq!(env.token_amount(env.vault), 60); + assert_eq!(env.token_amount(dest), 40); +} + #[test] fn v16_bpf_permissionless_oracle_liquidation_uses_only_its_own_domain_insurance() { let mut env = V16CuEnv::new(); @@ -8930,6 +9039,7 @@ fn v16_attack_resolved_backing_withdraw_requires_full_user_wind_down() { ); let vault_before = g.vault; let dest_before = env.token_amount(dest); + let ledger = env.canonical_backing_domain_ledger_account(1); env.svm.expire_blockhash(); let r = env.send( ProgInstruction::WithdrawBackingBucket { @@ -8943,6 +9053,7 @@ fn v16_attack_resolved_backing_withdraw_requires_full_user_wind_down() { AccountMeta::new(env.vault, false), AccountMeta::new_readonly(env.vault_authority, false), AccountMeta::new_readonly(spl_token::ID, false), + AccountMeta::new(ledger, false), ], &[&env.admin.insecure_clone()], ); diff --git a/tests/v16_fork_lp_vault_redeem.rs b/tests/v16_fork_lp_vault_redeem.rs index a93273cce..056f16d91 100644 --- a/tests/v16_fork_lp_vault_redeem.rs +++ b/tests/v16_fork_lp_vault_redeem.rs @@ -599,7 +599,8 @@ fn execute_redemption_backing_state_matches_withdraw() { let admin_a = env_a.admin.insecure_clone(); let src_a = Pubkey::new_unique(); set_token(&mut env_a.svm, src_a, env_a.collateral_mint, admin_a.pubkey(), 10_000_000); - let ledger_a = Pubkey::new_unique(); + let (ledger_a, _) = + state::derive_lp_backing_ledger(&env_a.program_id, &env_a.market, DOMAIN); env_a.svm.set_account(ledger_a, Account { lamports: 1_000_000_000, data: vec![0u8; state::backing_domain_ledger_account_len()], owner: env_a.program_id, executable: false, rent_epoch: 0 }).unwrap(); let dest_a = Pubkey::new_unique(); set_token(&mut env_a.svm, dest_a, env_a.collateral_mint, admin_a.pubkey(), 0); diff --git a/tests/v16_wrapper.rs b/tests/v16_wrapper.rs index 63e9f5ef5..4494d2556 100644 --- a/tests/v16_wrapper.rs +++ b/tests/v16_wrapper.rs @@ -7323,6 +7323,13 @@ fn v16_wrapper_withdraw_backing_bucket_returns_only_unencumbered_backing() { let mut attacker = signer(); let mint = init_market(&mut admin, &mut market); + let (ledger_key, _) = state::derive_lp_backing_ledger(&program_id(), &market.key, 1); + let mut ledger = TestAccount::new( + ledger_key, + program_id(), + state::backing_domain_ledger_account_len(), + ) + .writable(); // v17: backing_bucket_authority is per-asset; use UpdateAssetAuthority for asset-0. run_ix( Instruction::UpdateAssetAuthority { @@ -7349,6 +7356,7 @@ fn v16_wrapper_withdraw_backing_bucket_returns_only_unencumbered_backing() { &mut source, &mut vault, &mut token_program, + &mut ledger, ], ) .unwrap(); @@ -7358,6 +7366,7 @@ fn v16_wrapper_withdraw_backing_bucket_returns_only_unencumbered_backing() { let source_epoch_after_topup = after_topup_group.source_credit[1].credit_epoch; let topped_up = market.data.clone(); + let ledger_before = ledger.data.clone(); let mut attacker_dest = user_token_account(attacker.key, mint, 0); let mut vault_auth = vault_authority_account(&market); let unauthorized = run_ix( @@ -7375,6 +7384,44 @@ fn v16_wrapper_withdraw_backing_bucket_returns_only_unencumbered_backing() { ], ); assert_err_and_market_unchanged(unauthorized, &market, &topped_up); + assert_eq!(ledger.data, ledger_before); + + let mut missing_ledger_dest = user_token_account(bucket_authority.key, mint, 0); + let missing_ledger = run_ix( + Instruction::WithdrawBackingBucket { + domain: 1, + amount: 1, + }, + &mut [ + &mut bucket_authority, + &mut market, + &mut missing_ledger_dest, + &mut vault, + &mut vault_auth, + &mut token_program, + ], + ); + assert_err_and_market_unchanged(missing_ledger, &market, &topped_up); + assert_eq!(ledger.data, ledger_before); + + let mut impostor_ledger = backing_domain_ledger_account(); + let substituted_ledger = run_ix( + Instruction::WithdrawBackingBucket { + domain: 1, + amount: 1, + }, + &mut [ + &mut bucket_authority, + &mut market, + &mut missing_ledger_dest, + &mut vault, + &mut vault_auth, + &mut token_program, + &mut impostor_ledger, + ], + ); + assert_err_and_market_unchanged(substituted_ledger, &market, &topped_up); + assert_eq!(ledger.data, ledger_before); let mut dest = user_token_account(bucket_authority.key, mint, 0); run_ix( @@ -7389,6 +7436,7 @@ fn v16_wrapper_withdraw_backing_bucket_returns_only_unencumbered_backing() { &mut vault, &mut vault_auth, &mut token_program, + &mut ledger, ], ) .unwrap(); @@ -7414,6 +7462,9 @@ fn v16_wrapper_withdraw_backing_bucket_returns_only_unencumbered_backing() { group.source_credit[1].fresh_reserved_backing_num, 60 * BOUND_SCALE ); + let ledger_after_withdraw = state::read_backing_domain_ledger(&ledger.data).unwrap(); + assert_eq!(ledger_after_withdraw.total_principal_atoms, 60); + assert_eq!(ledger_after_withdraw.total_principal_withdrawn_atoms, 40); let before_overdraw = market.data.clone(); let overdraw = run_ix( @@ -7428,6 +7479,7 @@ fn v16_wrapper_withdraw_backing_bucket_returns_only_unencumbered_backing() { &mut vault, &mut vault_auth, &mut token_program, + &mut ledger, ], ); assert_err_and_market_unchanged(overdraw, &market, &before_overdraw); @@ -7458,6 +7510,7 @@ fn v16_wrapper_withdraw_backing_bucket_returns_only_unencumbered_backing() { &mut vault, &mut vault_auth, &mut token_program, + &mut ledger, ], ) .unwrap(); @@ -7490,6 +7543,7 @@ fn v16_wrapper_withdraw_backing_bucket_returns_only_unencumbered_backing() { &mut vault, &mut vault_auth, &mut token_program, + &mut ledger, ], ); assert_err_and_market_unchanged(claim_dilution, &market, &claim_backed); @@ -7501,6 +7555,13 @@ fn v16_wrapper_withdraw_backing_bucket_rejects_stress_and_allows_full_clean_drai let mut market = market_account(); let mint = init_market(&mut admin, &mut market); + let (ledger_key, _) = state::derive_lp_backing_ledger(&program_id(), &market.key, 1); + let mut ledger = TestAccount::new( + ledger_key, + program_id(), + state::backing_domain_ledger_account_len(), + ) + .writable(); let mut source = user_token_account(admin.key, mint, 25); let mut vault = vault_token_account(&market, mint, 25); let mut token_program = token_program_account(); @@ -7516,6 +7577,7 @@ fn v16_wrapper_withdraw_backing_bucket_rejects_stress_and_allows_full_clean_drai &mut source, &mut vault, &mut token_program, + &mut ledger, ], ) .unwrap(); @@ -7534,6 +7596,7 @@ fn v16_wrapper_withdraw_backing_bucket_rejects_stress_and_allows_full_clean_drai &mut vault, &mut vault_auth, &mut token_program, + &mut ledger, ], ); assert!(zero.is_err()); @@ -7562,6 +7625,7 @@ fn v16_wrapper_withdraw_backing_bucket_rejects_stress_and_allows_full_clean_drai &mut vault, &mut vault_auth, &mut token_program, + &mut ledger, ], ); assert_err_and_market_unchanged(stressed_withdraw, &market, &stressed); @@ -7580,6 +7644,7 @@ fn v16_wrapper_withdraw_backing_bucket_rejects_stress_and_allows_full_clean_drai &mut vault, &mut vault_auth, &mut token_program, + &mut ledger, ], ) .unwrap(); @@ -7681,8 +7746,14 @@ fn v16_wrapper_withdraw_backing_bucket_rejects_bad_custody_accounts() { fn v16_wrapper_backing_domain_ledger_tracks_authority_topup_earnings_and_withdraw() { let mut admin = signer(); let mut market = market_account(); - let mut ledger = backing_domain_ledger_account(); let mint = init_market(&mut admin, &mut market); + let (ledger_key, _) = state::derive_lp_backing_ledger(&program_id(), &market.key, 1); + let mut ledger = TestAccount::new( + ledger_key, + program_id(), + state::backing_domain_ledger_account_len(), + ) + .writable(); let mut source = user_token_account(admin.key, mint, 100); let mut vault = vault_token_account(&market, mint, 0);