From 23ca2ec156a3c67fd0e388f9ab5d637c324819ec Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:39:41 +0900 Subject: [PATCH 1/7] experiment: close sell_token_account if its empty and authority exists currently vibed --- client/src/instructions.rs | 3 + interface/src/instruction/settle/begin.rs | 95 +++++++++---- interface/src/lib.rs | 3 + programs/settlement/src/settle/begin.rs | 27 +++- .../settlement/tests/begin_settle_orders.rs | 128 +++++++++++++++++- programs/settlement/tests/common/token.rs | 19 ++- 6 files changed, 243 insertions(+), 32 deletions(-) diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 137c840e..b233a6f8 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -37,12 +37,14 @@ impl From> for Instruction { fn from(builder: BeginSettle<'_>) -> Self { let mut order_pdas = Vec::with_capacity(builder.orders.len()); let mut sell_token_accounts = Vec::with_capacity(builder.orders.len()); + let mut buy_token_accounts = Vec::with_capacity(builder.orders.len()); let mut bumps = Vec::with_capacity(builder.orders.len()); let mut pull_lists: Vec<&[Pull]> = Vec::with_capacity(builder.orders.len()); for order in builder.orders { let (order_pda, bump) = find_order_pda(&builder.program_id, &order.intent.uid()); order_pdas.push(order_pda); sell_token_accounts.push(order.intent.sell_token_account); + buy_token_accounts.push(order.intent.buy_token_account); bumps.push(bump); pull_lists.push(order.pulls); } @@ -55,6 +57,7 @@ impl From> for Instruction { order_pdas: &order_pdas, order_pda_bumps: &bumps, sell_token_accounts: &sell_token_accounts, + buy_token_accounts: &buy_token_accounts, pulls: &pull_lists, } .into() diff --git a/interface/src/instruction/settle/begin.rs b/interface/src/instruction/settle/begin.rs index 129cefb6..237e5e84 100644 --- a/interface/src/instruction/settle/begin.rs +++ b/interface/src/instruction/settle/begin.rs @@ -24,6 +24,8 @@ pub struct Pull { /// - `order_pdas[i]` is the canonical order PDA (see [`crate::pda::order`]) /// - `order_pda_bumps[i]` is the bump of the canonical order PDA /// - `sell_token_accounts[i]` is the order's sell token account, +/// - `buy_token_accounts[i]` is the order's buy token account, used as the +/// destination for the sell token account's rent if it's closed once empty, /// - `pulls[i]` the list of [`Pull`]s to perform from that order's sell token /// account, each sending an amount from the `i`-th order sell token account /// to a destination. @@ -36,12 +38,12 @@ pub struct Pull { /// [bump×n][transfer_count×n][amount: u64 LE ×T]`. /// Required accounts: `[instructions_sysvar (R), state_pda (R), token_program /// (R)]` followed, per order, by `[order_pda (R), sell_token_account (W), -/// destination (W)...]`. +/// buy_token_account (W), destination (W)...]`. /// /// The program requires the order PDAs to be strictly increasing by address. /// This builder establishes that ordering for the caller: it sorts the orders by -/// PDA address, carrying each order's sell token account, bump, transfer count, -/// amounts, and destination metas before emitting them. +/// PDA address, carrying each order's sell token account, buy token account, +/// bump, transfer count, amounts, and destination metas before emitting them. pub struct BeginSettle<'a> { pub program_id: Pubkey, pub state_pda: Pubkey, @@ -53,6 +55,7 @@ pub struct BeginSettle<'a> { pub order_pdas: &'a [Pubkey], pub order_pda_bumps: &'a [u8], pub sell_token_accounts: &'a [Pubkey], + pub buy_token_accounts: &'a [Pubkey], pub pulls: &'a [&'a [Pull]], } @@ -66,6 +69,7 @@ impl From> for Instruction { order_pdas, order_pda_bumps, sell_token_accounts, + buy_token_accounts, pulls, } = builder; @@ -105,9 +109,11 @@ impl From> for Instruction { for &i in &order { // Read-only account for the order. accounts.push(AccountMeta::new_readonly(order_pdas[i], false)); - // Writable accounts settling the order: its sell token account and the - // recipient of each transfer. + // Writable accounts settling the order: its sell token account, its + // buy token account (the destination if the sell token account is + // closed once empty), and the recipient of each transfer. accounts.push(AccountMeta::new(sell_token_accounts[i], false)); + accounts.push(AccountMeta::new(buy_token_accounts[i], false)); for pull in pulls[i] { accounts.push(AccountMeta::new(pull.destination, false)); } @@ -126,6 +132,7 @@ impl From> for Instruction { pub struct SettledOrder<'a, A> { pub order_pda: &'a A, pub sell_token_account: &'a A, + pub buy_token_account: &'a A, pub bump: u8, /// Destination accounts for this order's transfers. pub destinations: &'a [A], @@ -140,7 +147,7 @@ pub struct SettledOrders<'a, A> { /// Order accounts, laid out per order as /// [order_accounts_1, order_accounts_2, ...] where /// - each order_accounts is a series of accounts: - /// `order_pda_N, sell_token_account_N, destination_N_1, destination_N_2, ..., destination_N_M` + /// `order_pda_N, sell_token_account_N, buy_token_account_N, destination_N_1, destination_N_2, ..., destination_N_M` /// - and M is `counts[N]` order_accounts: &'a [A], bumps: &'a [u8], @@ -172,7 +179,8 @@ impl<'a, A> SettledOrders<'a, A> { let order_pda = &self.order_accounts[account_offset]; let sell_token_account = &self.order_accounts[account_offset + 1]; - let dest_start = account_offset + 2; + let buy_token_account = &self.order_accounts[account_offset + 2]; + let dest_start = account_offset + 3; let dest_end = dest_start + count; let destinations = &self.order_accounts[dest_start..dest_end]; account_offset = dest_end; @@ -184,6 +192,7 @@ impl<'a, A> SettledOrders<'a, A> { Some(SettledOrder { order_pda, sell_token_account, + buy_token_account, bump, destinations, amounts, @@ -253,11 +262,12 @@ impl<'a, A> InstructionInputParsing<'a, A> for BeginSettleInput<'a, A> { }; let transfer_count = amounts.len(); - // Each order contributes its order PDA, sell token account, and one - // destination per transfer, so the order accounts count is `2n + T`. + // Each order contributes its order PDA, sell token account, buy token + // account, and one destination per transfer, so the order accounts + // count is `3n + T`. let expected_accounts = order_count - .checked_mul(2) - .and_then(|two_n| two_n.checked_add(transfer_count)) + .checked_mul(3) + .and_then(|three_n| three_n.checked_add(transfer_count)) .ok_or(ProgramError::InvalidInstructionData)?; if order_accounts.len() != expected_accounts { return Err(SettlementError::AccountCountNotMatchingOrderCount.into()); @@ -322,6 +332,7 @@ mod tests { order_pdas: &[], order_pda_bumps: &[], sell_token_accounts: &[], + buy_token_accounts: &[], pulls: &[], } .into(); @@ -355,9 +366,11 @@ mod tests { // are chosen to sort in the opposite order. let high_order_pda = Pubkey::new_from_array([0xbb; 32]); let high_sell_token_account = Pubkey::new_from_array([0xa0; 32]); + let high_buy_token_account = Pubkey::new_from_array([0xa2; 32]); let high_bump = 0xaa; let low_order_pda = Pubkey::new_from_array([0xaa; 32]); let low_sell_token_account = Pubkey::new_from_array([0xb0; 32]); + let low_buy_token_account = Pubkey::new_from_array([0xb2; 32]); let low_bump = 0xbb; let Instruction { data, accounts, .. } = BeginSettle { program_id, @@ -367,6 +380,7 @@ mod tests { order_pdas: &[high_order_pda, low_order_pda], order_pda_bumps: &[high_bump, low_bump], sell_token_accounts: &[high_sell_token_account, low_sell_token_account], + buy_token_accounts: &[high_buy_token_account, low_buy_token_account], pulls: &[&[], &[]], } .into(); @@ -390,12 +404,14 @@ mod tests { SPL_TOKEN_PROGRAM_ID, low_order_pda, low_sell_token_account, + low_buy_token_account, high_order_pda, high_sell_token_account, + high_buy_token_account, ]; let actual: Vec = accounts.iter().map(|account| account.pubkey).collect(); assert_eq!(actual, expected); - // The fixed accounts and the order PDAs are read-only; only the sell + // The fixed accounts and the order PDAs are read-only; the sell and buy // token accounts are writable, following the sorted order. let writable: Vec = accounts .iter() @@ -404,7 +420,12 @@ mod tests { .collect(); assert_eq!( writable, - vec![low_sell_token_account, high_sell_token_account], + vec![ + low_sell_token_account, + low_buy_token_account, + high_sell_token_account, + high_buy_token_account, + ], ); assert!(accounts.iter().all(|account| !account.is_signer)); } @@ -415,8 +436,10 @@ mod tests { let state_pda = Pubkey::new_unique(); let order_a = Pubkey::new_from_array([0x01; 32]); let sell_a = Pubkey::new_from_array([0x02; 32]); + let buy_a = Pubkey::new_from_array([0x08; 32]); let order_b = Pubkey::new_from_array([0x03; 32]); let sell_b = Pubkey::new_from_array([0x04; 32]); + let buy_b = Pubkey::new_from_array([0x09; 32]); let dest_a0 = Pubkey::new_from_array([0x05; 32]); let dest_a1 = Pubkey::new_from_array([0x06; 32]); let dest_b0 = Pubkey::new_from_array([0x07; 32]); @@ -430,6 +453,7 @@ mod tests { order_pdas: &[order_a, order_b], order_pda_bumps: &[0xa1, 0xb1], sell_token_accounts: &[sell_a, sell_b], + buy_token_accounts: &[buy_a, buy_b], pulls: &[ &[ Pull { @@ -471,22 +495,27 @@ mod tests { SPL_TOKEN_PROGRAM_ID, order_a, sell_a, + buy_a, dest_a0, dest_a1, order_b, sell_b, + buy_b, dest_b0, ]; let actual: Vec = accounts.iter().map(|account| account.pubkey).collect(); assert_eq!(actual, expected); - // The fixed accounts and the order PDAs are read-only; sell and + // The fixed accounts and the order PDAs are read-only; sell, buy, and // destination accounts are writable for the transfer. let writable: Vec = accounts .iter() .filter(|account| account.is_writable) .map(|account| account.pubkey) .collect(); - assert_eq!(writable, vec![sell_a, dest_a0, dest_a1, sell_b, dest_b0]); + assert_eq!( + writable, + vec![sell_a, buy_a, dest_a0, dest_a1, sell_b, buy_b, dest_b0], + ); assert!(accounts.iter().all(|account| !account.is_signer)); } @@ -572,12 +601,14 @@ mod tests { let token_program = Address::new_from_array([0xa2u8; 32]); let order_pda = Address::new_from_array([2u8; 32]); let sell_token = Address::new_from_array([3u8; 32]); + let buy_token = Address::new_from_array([4u8; 32]); let mut accounts = [ fake_account(sysvar), fake_account(state), fake_account(token_program), fake_account(order_pda), fake_account(sell_token), + fake_account(buy_token), ]; let data = ix_data![ [SettlementInstruction::BeginSettle.discriminator()], @@ -605,6 +636,7 @@ mod tests { let order = orders.next().expect("one settled order"); assert_eq!(order.order_pda.address(), &order_pda); assert_eq!(order.sell_token_account.address(), &sell_token); + assert_eq!(order.buy_token_account.address(), &buy_token); assert_eq!(order.bump, 0xab); assert_eq!(order.destinations.len(), 0); assert!(orders.next().is_none()); @@ -617,6 +649,7 @@ mod tests { let token_program = Address::new_from_array([0xa2u8; 32]); let order_pda = Address::new_from_array([2u8; 32]); let sell_token = Address::new_from_array([3u8; 32]); + let buy_token = Address::new_from_array([6u8; 32]); let dest0 = Address::new_from_array([4u8; 32]); let dest1 = Address::new_from_array([5u8; 32]); let mut accounts = [ @@ -625,6 +658,7 @@ mod tests { fake_account(token_program), fake_account(order_pda), fake_account(sell_token), + fake_account(buy_token), fake_account(dest0), fake_account(dest1), ]; @@ -646,6 +680,7 @@ mod tests { let order = orders.next().expect("one settled order"); assert_eq!(order.order_pda.address(), &order_pda); assert_eq!(order.sell_token_account.address(), &sell_token); + assert_eq!(order.buy_token_account.address(), &buy_token); assert_eq!(order.bump, 0xab); let transfers: Vec<(&Address, u64)> = order .destinations @@ -661,12 +696,13 @@ mod tests { fn begin_settle_input_pairs_every_order_with_its_bump() { const ORDER_COUNT: usize = 16; - let mut expected: Vec<(Address, Address, u8)> = Vec::new(); + let mut expected: Vec<(Address, Address, Address, u8)> = Vec::new(); for i in 0..ORDER_COUNT { let order_pda = Address::new_from_array([i as u8; 32]); let sell_token = Address::new_from_array([(i + ORDER_COUNT) as u8; 32]); - let bump: u8 = (i + 2 * ORDER_COUNT) as u8; - expected.push((order_pda, sell_token, bump)); + let buy_token = Address::new_from_array([(i + 2 * ORDER_COUNT) as u8; 32]); + let bump: u8 = (i + 3 * ORDER_COUNT) as u8; + expected.push((order_pda, sell_token, buy_token, bump)); } // The three fixed accounts (`[0xff..]`, `[0xfe..]`, `[0xfd..]`) differ @@ -677,9 +713,10 @@ mod tests { fake_account_from_array([0xfd; 32]), ]; let mut bumps = Vec::new(); - for &(order_pda, sell_token, bump) in &expected { + for &(order_pda, sell_token, buy_token, bump) in &expected { accounts.push(fake_account(order_pda)); accounts.push(fake_account(sell_token)); + accounts.push(fake_account(buy_token)); bumps.push(bump); } // Grouped data: discriminator, finalize index, auction id, order count, @@ -697,9 +734,10 @@ mod tests { let orders: Vec<_> = parsed.orders.iter().collect(); assert_eq!(orders.len(), ORDER_COUNT); - for (order, (order_pda, sell_token, bump)) in orders.iter().zip(&expected) { + for (order, (order_pda, sell_token, buy_token, bump)) in orders.iter().zip(&expected) { assert_eq!(order.order_pda.address(), order_pda); assert_eq!(order.sell_token_account.address(), sell_token); + assert_eq!(order.buy_token_account.address(), buy_token); assert_eq!(order.bump, *bump); assert_eq!(order.destinations.len(), 0); } @@ -707,10 +745,11 @@ mod tests { #[test] fn begin_settle_input_rejects_account_count_mismatch() { - // The body declares one order with no transfers, which needs exactly two - // order accounts (its order PDA and sell token account). Only one order - // account is supplied after the fixed accounts, so the number of accounts - // doesn't match the `2n + T` the body implies. + // The body declares one order with no transfers, which needs exactly + // three order accounts (its order PDA, sell token account, and buy + // token account). Only one order account is supplied after the fixed + // accounts, so the number of accounts doesn't match the `3n + T` the + // body implies. let mut accounts = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 1 }>(); let data = ix_data![ [SettlementInstruction::BeginSettle.discriminator()], @@ -728,10 +767,10 @@ mod tests { #[test] fn begin_settle_input_rejects_counts_not_summing_to_destinations() { - // One order whose two destination accounts (plus its order PDA and sell - // token account) make the lengths recover T = 2 transfers, but the - // transfer-count byte claims only one. - let mut accounts = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 4 }>(); + // One order whose two destination accounts (plus its order PDA, sell + // token account, and buy token account) make the lengths recover T = 2 + // transfers, but the transfer-count byte claims only one. + let mut accounts = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 5 }>(); let data = ix_data![ [SettlementInstruction::BeginSettle.discriminator()], [0, 0], // finalize index diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 15d32634..869da1d0 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -160,6 +160,9 @@ pub enum SettlementError { /// `ReclaimOrder`'s `reclaim_recipient` account doesn't match the /// `created_by` address recorded in the order. ReclaimRecipientMismatch = 25, + /// A `BeginSettle` buy token account doesn't match the `buy_token_account` + /// recorded in the order's intent. + BuyTokenAccountMismatch = 26, } impl From for u32 { diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index b5d7865f..3e569c82 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -11,7 +11,9 @@ use pinocchio::{ }, AccountView, Address, ProgramResult, }; -use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; +use pinocchio_token::{ + instructions::CloseAccount, instructions::Transfer, state::Account as TokenAccount, +}; use settlement_interface::{ data::order::OrderAccount, instruction::{ @@ -217,6 +219,7 @@ fn process_order( let SettledOrder { order_pda, sell_token_account, + buy_token_account, bump, destinations, amounts, @@ -246,6 +249,13 @@ fn process_order( if !address_matches_pubkey(sell_token_account.address(), &intent.sell_token_account) { return Err(SettlementError::SellTokenAccountMismatch.into()); } + // The buy token account must be the one named in the intent: it's the + // destination for the sell token account's reclaimed rent if it's closed + // below, and an arbitrary caller-supplied account must not be able to + // redirect those funds. + if !address_matches_pubkey(buy_token_account.address(), &intent.buy_token_account) { + return Err(SettlementError::BuyTokenAccountMismatch.into()); + } // Assert the order intent owner matches that of the sell token account. { // `from_account_view` confirms this is a real SPL token account @@ -269,6 +279,21 @@ fn process_order( u64::from_le_bytes(*amount), ) .invoke_signed(core::slice::from_ref(state_pda_signer))?; + + // If the sell token account is now empty and the state PDA is able to + // close it, then close it. The borrow is released at the end of this + // block, before `CloseAccount` needs to mutably touch the account. + let should_close = { + let token_account = TokenAccount::from_account_view(sell_token_account) + .map_err(|_| SettlementError::SellTokenAccountInvalid)?; + token_account.amount() == 0 + && token_account.close_authority() == Some(state_account.address()) + }; + + if should_close { + CloseAccount::new(sell_token_account, buy_token_account, state_account) + .invoke_signed(core::slice::from_ref(state_pda_signer))?; + } } Ok(()) diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index 6a36453f..ee6293c3 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -163,6 +163,7 @@ fn rejects_wrong_bump() { order_pdas: &[order_pda], order_pda_bumps: &[bump ^ 0x01], sell_token_accounts: &[intent.sell_token_account], + buy_token_accounts: &[intent.buy_token_account], pulls: &no_pulls(1), }; let finalize = FinalizeSettleRaw { @@ -187,6 +188,7 @@ fn rejects_fabricated_program_owned_account() { let mint = token::create_mint(&mut svm, &payer); let sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); + let buy_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); let intent = sample_intent(payer.pubkey(), sell_token, 0); let body: [u8; EncodedOrderAccount::SIZE] = EncodedOrderAccount::from(OrderAccount { cancelled: false, @@ -209,6 +211,7 @@ fn rejects_fabricated_program_owned_account() { order_pdas: &[fake_order], order_pda_bumps: &[bump], sell_token_accounts: &[sell_token], + buy_token_accounts: &[buy_token], pulls: &no_pulls(1), }; // Mostly placeholder values: the transaction will reject before reaching @@ -236,6 +239,7 @@ fn rejects_non_order_account_in_order_slot() { let mint = token::create_mint(&mut svm, &payer); let sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); + let buy_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); // Put a token account in the order slot. Its 165-byte data can't decode as a // order body, so it's rejected before the canonical-address check. @@ -249,6 +253,7 @@ fn rejects_non_order_account_in_order_slot() { order_pdas: &[sell_token], order_pda_bumps: &[0], sell_token_accounts: &[sell_token], + buy_token_accounts: &[buy_token], pulls: &no_pulls(1), }; // The finalize just carries a placeholder push matching the order in count. @@ -435,9 +440,10 @@ fn rejects_orders_in_wrong_address_order() { AccountMeta::new_readonly(find_state_pda(&program_id).0, false), AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), ]; - for (order_pda, sell_token_account, _, _) in orders { + for (order_pda, sell_token_account, buy_token_account, _) in orders { accounts.push(AccountMeta::new_readonly(order_pda, false)); accounts.push(AccountMeta::new(sell_token_account, false)); + accounts.push(AccountMeta::new(buy_token_account, false)); } let begin = Instruction { program_id, @@ -602,6 +608,124 @@ fn pulls_funds_to_destination() { ); } +#[test] +fn rejects_buy_token_account_mismatch() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + + // Supply a different token account than the one the order's intent names. + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + let wrong_buy_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); + let mut instructions = settle_and_pay( + &mut svm, + &program_id, + &payer, + &[InitializedIntent { + intent: &intent, + pulls: &[], + }], + ); + replace_first_matching_account( + &mut instructions[usize::from(BEGIN_INDEX)], + &intent.buy_token_account, + wrong_buy_token, + ); + + assert_begin_error( + send(&mut svm, &payer, instructions), + SettlementError::BuyTokenAccountMismatch, + ); +} + +#[test] +fn closes_sell_token_account_once_emptied_with_matching_close_authority() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let state_pda = find_state_pda(&program_id).0; + + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_mint(&mint) + .build(); + let sell_token = intent.sell_token_account; + let buy_token = intent.buy_token_account; + let initial_amount = 42_000_000; + token::fund_and_delegate(&mut svm, &program_id, &payer, &sell_token, initial_amount); + token::set_close_authority(&mut svm, &payer, &sell_token, &state_pda); + let destination = token::create_token_account(&mut svm, &payer, &mint, &Pubkey::new_unique()); + + let sell_token_rent = svm + .get_account(&sell_token) + .expect("sell token account should exist before settlement") + .lamports; + let buy_token_rent = svm + .get_account(&buy_token) + .expect("buy token account should exist before settlement") + .lamports; + + let instructions = settle_and_pay( + &mut svm, + &program_id, + &payer, + &[InitializedIntent { + intent: &intent, + pulls: &[Pull { + destination, + amount: initial_amount, + }], + }], + ); + send(&mut svm, &payer, instructions).expect("closing sell token account as part of settlement should succeed"); + + assert_eq!(token::balance(&svm, &destination), initial_amount); + // The now-empty sell token account is closed, and its rent goes to the + // order's buy token account. + assert!( + svm.get_account(&sell_token).is_none(), + "the emptied sell token account should have been closed" + ); + assert_eq!( + svm.get_account(&buy_token) + .expect("buy token account should exist after settlement") + .lamports, + sell_token_rent + buy_token_rent, + ); +} + +#[test] +fn leaves_sell_token_account_open_without_matching_close_authority() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + + // No close authority is set on the sell token account, unlike + // `closes_sell_token_account_once_emptied_with_matching_close_authority`. + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_mint(&mint) + .build(); + let sell_token = intent.sell_token_account; + let initial_amount = 42_000_000; + token::fund_and_delegate(&mut svm, &program_id, &payer, &sell_token, initial_amount); + let destination = token::create_token_account(&mut svm, &payer, &mint, &Pubkey::new_unique()); + + let instructions = settle_and_pay( + &mut svm, + &program_id, + &payer, + &[InitializedIntent { + intent: &intent, + pulls: &[Pull { + destination, + amount: initial_amount, + }], + }], + ); + send(&mut svm, &payer, instructions).expect("leaving sell token account open without authority should succeed"); + + assert_eq!(token::balance(&svm, &destination), initial_amount); + // The sell token account is empty but wasn't closed, since the state PDA + // was never authorized as its close authority. + assert_eq!(token::balance(&svm, &sell_token), 0); +} + #[test] fn pulls_to_multiple_destinations() { let (mut svm, program_id, payer) = setup(); @@ -925,7 +1049,7 @@ fn rejects_extra_account() { ); // Append one extra account to `BeginSettle`, so the account count no longer - // matches the `2n + T` the instruction data implies. + // matches the `3n + T` the instruction data implies. instructions[usize::from(BEGIN_INDEX)] .accounts .push(AccountMeta::new_readonly(Pubkey::new_unique(), false)); diff --git a/programs/settlement/tests/common/token.rs b/programs/settlement/tests/common/token.rs index 28a81178..370ce23a 100644 --- a/programs/settlement/tests/common/token.rs +++ b/programs/settlement/tests/common/token.rs @@ -2,7 +2,8 @@ use litesvm::{types::TransactionMetadata, LiteSVM}; use litesvm_token::{ - Approve, CreateAccount, CreateAssociatedTokenAccount, CreateMint, MintTo, Transfer, + spl_token::instruction::AuthorityType, Approve, CreateAccount, CreateAssociatedTokenAccount, + CreateMint, MintTo, SetAuthority, Transfer, }; use settlement_client::settlement_interface::pda::state::find_state_pda; use solana_sdk::{pubkey::Pubkey, signature::Keypair}; @@ -151,6 +152,22 @@ pub fn assert_no_token_instruction_touching( } } +/// Set `account`'s SPL close authority to `new_authority`, signed by `owner` +/// (the account's current SPL owner, which may set the close authority as long +/// as none is set yet). +pub fn set_close_authority( + svm: &mut LiteSVM, + owner: &Keypair, + account: &Pubkey, + new_authority: &Pubkey, +) { + SetAuthority::new(svm, owner, account, AuthorityType::CloseAccount) + .owner(owner) + .new_authority(new_authority) + .send() + .expect("setting close authority should succeed"); +} + /// Read the mint that `account` holds tokens of. pub fn mint_of(svm: &LiteSVM, account: &Pubkey) -> Pubkey { litesvm_token::get_spl_account::(svm, account) From b4b655a3c47107c447e373bd6e3e8c73ccdcaeca Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:48:56 +0900 Subject: [PATCH 2/7] lint fix --- programs/settlement/tests/begin_settle_orders.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index ee6293c3..35ad6331 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -674,7 +674,8 @@ fn closes_sell_token_account_once_emptied_with_matching_close_authority() { }], }], ); - send(&mut svm, &payer, instructions).expect("closing sell token account as part of settlement should succeed"); + send(&mut svm, &payer, instructions) + .expect("closing sell token account as part of settlement should succeed"); assert_eq!(token::balance(&svm, &destination), initial_amount); // The now-empty sell token account is closed, and its rent goes to the @@ -718,7 +719,8 @@ fn leaves_sell_token_account_open_without_matching_close_authority() { }], }], ); - send(&mut svm, &payer, instructions).expect("leaving sell token account open without authority should succeed"); + send(&mut svm, &payer, instructions) + .expect("leaving sell token account open without authority should succeed"); assert_eq!(token::balance(&svm, &destination), initial_amount); // The sell token account is empty but wasn't closed, since the state PDA From 16ccdb1b55e576f6136e06f14e9afff6e72c0cd9 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:34:44 +0900 Subject: [PATCH 3/7] make it so the account to receive the rent is in the intent this is maybe crossing a threshold of complexity for the begin_settle function, but it is what it is. --- DESIGN.md | 8 + client/src/instructions.rs | 6 +- interface/src/data/intent.rs | 61 ++++- interface/src/data/order.rs | 6 +- interface/src/instruction/create_order.rs | 6 +- interface/src/instruction/settle/begin.rs | 99 +++---- interface/src/lib.rs | 8 +- programs/settlement/src/settle/begin.rs | 53 ++-- .../settlement/tests/begin_settle_orders.rs | 255 ++++++++++++++++-- programs/settlement/tests/common/order.rs | 9 + test-cli/src/cmd/create_order.rs | 10 +- 11 files changed, 403 insertions(+), 118 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 7363c3de..1f954ac2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -100,6 +100,8 @@ struct OrderIntent { // Either Buy or Sell kind: OrderKind partially_fillable: bool + // Receives the sell token account's rent when a settlement closes it. + sell_account_rent_recipient: Pubkey // Usual app data field, it isn't directly used in the program. app_data: [u8; 32] } @@ -164,6 +166,12 @@ Creating the order in advance is _not_ needed: if the order wasn’t created bef Note that deleting the order PDA is _not_ enough to invalidate an order. In fact, if an order signature is available, the same order could always be created again until it expires. +### Sell Token Account Clearing + +Upon settlement, if an order whose `sell_token_account` is left with 0 funds *and* the settlement account's state account has been granted close authority, the account will be automatically closed and the rent proceeds sent to `sell_account_rent_recipient`. + +If the `sell_token_account` has not granted close authority or has any remaining funds, the account will not be closed and `sell_account_rent_recipient` is ignored. + ### Order clearing Allocating an order PDA requires paying rent. diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 9fab7dc5..cacd952f 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -37,14 +37,14 @@ impl From> for Instruction { fn from(builder: BeginSettle<'_>) -> Self { let mut order_pdas = Vec::with_capacity(builder.orders.len()); let mut sell_token_accounts = Vec::with_capacity(builder.orders.len()); - let mut buy_token_accounts = Vec::with_capacity(builder.orders.len()); + let mut sell_account_rent_recipients = Vec::with_capacity(builder.orders.len()); let mut bumps = Vec::with_capacity(builder.orders.len()); let mut pull_lists: Vec<&[Pull]> = Vec::with_capacity(builder.orders.len()); for order in builder.orders { let (order_pda, bump) = find_order_pda(&builder.program_id, &order.intent.uid()); order_pdas.push(order_pda); sell_token_accounts.push(order.intent.sell_token_account); - buy_token_accounts.push(order.intent.buy_token_account); + sell_account_rent_recipients.push(order.intent.sell_account_rent_recipient); bumps.push(bump); pull_lists.push(order.pulls); } @@ -57,7 +57,7 @@ impl From> for Instruction { order_pdas: &order_pdas, order_pda_bumps: &bumps, sell_token_accounts: &sell_token_accounts, - buy_token_accounts: &buy_token_accounts, + sell_account_rent_recipients: &sell_account_rent_recipients, pulls: &pull_lists, } .into() diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index ba1ac0fa..8fd2f159 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -77,13 +77,24 @@ pub struct OrderIntent { /// must consume the full sell amount (fill-or-kill). pub partially_fillable: bool, + /// Account that receives the lamports reclaimed from + /// `sell_token_account` when a settlement closes it. Closing only + /// happens if the account is left empty by the settlement and its SPL + /// close authority is the settlement state PDA, which is the owner's + /// opt-in; naming the recipient here means it's fixed by the signed + /// intent instead of chosen by whoever settles the order. + /// + /// Settlements that don't close the sell token account never read it, so + /// it's unconstrained for orders that never opt into closing. + pub sell_account_rent_recipient: Pubkey, + /// Opaque 32 bytes set by the order creator. Not interpreted by the /// settlement program; used off-chain for metadata such as the /// frontend version, slippage hints, or attribution. pub app_data: [u8; 32], } -/// Canonical 150-byte representation of an [`OrderIntent`]. The wire format and +/// Canonical 182-byte representation of an [`OrderIntent`]. The wire format and /// the order UID preimage. /// /// Layout: one character per byte, cell widths proportional to field size, @@ -93,12 +104,12 @@ pub struct OrderIntent { /// ```text /// partially_fillable ─────┐ /// kind ────┐│ -/// ┌───────────────────────────────┬───────────────────────────────┬───────────────────────────────┬───────┬───────┬───┬┬┬───────────────────────────────┐ -/// │ │ │ │sell_ │buy_ │val│││ │ -/// │ owner │ buy_token_account │ sell_token_account │ │ │id_│││ app_data │ -/// │ │ │ │amount │amount │to │││ │ -/// └───────────────────────────────┴───────────────────────────────┴───────────────────────────────┴───────┴───────┴───┴┴┴───────────────────────────────┘ -/// 0 32 64 96 104 112 116 118 150 +/// ┌───────────────────────────────┬───────────────────────────────┬───────────────────────────────┬───────┬───────┬───┬┬┬───────────────────────────────┬───────────────────────────────┐ +/// │ │ │ │sell_ │buy_ │val│││ sell_account_ │ │ +/// │ owner │ buy_token_account │ sell_token_account │ │ │id_│││ rent_recipient │ app_data │ +/// │ │ │ │amount │amount │to │││ │ │ +/// └───────────────────────────────┴───────────────────────────────┴───────────────────────────────┴───────┴───────┴───┴┴┴───────────────────────────────┴───────────────────────────────┘ +/// 0 32 64 96 104 112 116 118 150 182 /// 117 /// ``` #[derive(Clone, Debug, Deref, Eq, PartialEq)] @@ -114,9 +125,10 @@ impl EncodedOrderIntent { const WIDTH_VALID_TO: usize = size_of::(); const WIDTH_KIND: usize = size_of::(); const WIDTH_PARTIALLY_FILLABLE: usize = size_of::(); + const WIDTH_SELL_ACCOUNT_RENT_RECIPIENT: usize = size_of::(); const WIDTH_APP_DATA: usize = size_of::<[u8; 32]>(); - pub const SIZE: usize = 150; + pub const SIZE: usize = 182; /// Canonical hash of the bytes. pub fn hash(&self) -> Hash { @@ -163,6 +175,7 @@ impl From<&OrderIntent> for EncodedOrderIntent { valid_to, kind, partially_fillable, + sell_account_rent_recipient, app_data, ) = mut_array_refs![ &mut out, @@ -174,6 +187,7 @@ impl From<&OrderIntent> for EncodedOrderIntent { EncodedOrderIntent::WIDTH_VALID_TO, EncodedOrderIntent::WIDTH_KIND, EncodedOrderIntent::WIDTH_PARTIALLY_FILLABLE, + EncodedOrderIntent::WIDTH_SELL_ACCOUNT_RENT_RECIPIENT, EncodedOrderIntent::WIDTH_APP_DATA ]; *owner = intent.owner.to_bytes(); @@ -184,6 +198,7 @@ impl From<&OrderIntent> for EncodedOrderIntent { *valid_to = intent.valid_to.to_le_bytes(); *kind = [intent.kind as u8]; *partially_fillable = [intent.partially_fillable as u8]; + *sell_account_rent_recipient = intent.sell_account_rent_recipient.to_bytes(); *app_data = intent.app_data; Self(out) } @@ -209,6 +224,7 @@ impl TryFrom<&[u8; EncodedOrderIntent::SIZE]> for OrderIntent { valid_to, kind, partially_fillable, + sell_account_rent_recipient, app_data, ) = array_refs![ bytes, @@ -220,6 +236,7 @@ impl TryFrom<&[u8; EncodedOrderIntent::SIZE]> for OrderIntent { EncodedOrderIntent::WIDTH_VALID_TO, EncodedOrderIntent::WIDTH_KIND, EncodedOrderIntent::WIDTH_PARTIALLY_FILLABLE, + EncodedOrderIntent::WIDTH_SELL_ACCOUNT_RENT_RECIPIENT, EncodedOrderIntent::WIDTH_APP_DATA ]; @@ -240,6 +257,7 @@ impl TryFrom<&[u8; EncodedOrderIntent::SIZE]> for OrderIntent { [1] => true, _ => return Err(ProgramError::InvalidInstructionData), }, + sell_account_rent_recipient: Pubkey::new_from_array(*sell_account_rent_recipient), app_data: *app_data, }) } @@ -285,6 +303,7 @@ pub mod fixtures { valid_to: 0xdead_beef, kind, partially_fillable, + sell_account_rent_recipient: Pubkey::new_from_array([0x55; 32]), app_data: [0x44; 32], } } @@ -306,9 +325,21 @@ pub mod fixtures { arb_order_kind(), any::(), any::<[u8; 32]>(), + any::<[u8; 32]>(), ) .prop_map( - |(owner, buy_tok, sell_tok, sell_amount, buy_amount, valid_to, kind, pf, app)| { + |( + owner, + buy_tok, + sell_tok, + sell_amount, + buy_amount, + valid_to, + kind, + pf, + rent_recipient, + app, + )| { OrderIntent { owner: Pubkey::new_from_array(owner), buy_token_account: Pubkey::new_from_array(buy_tok), @@ -318,6 +349,7 @@ pub mod fixtures { valid_to, kind, partially_fillable: pf, + sell_account_rent_recipient: Pubkey::new_from_array(rent_recipient), app_data: app, } }, @@ -377,6 +409,10 @@ mod tests { EncodedOrderIntent::WIDTH_PARTIALLY_FILLABLE, size_of_val(&intent.partially_fillable) ); + assert_eq!( + EncodedOrderIntent::WIDTH_SELL_ACCOUNT_RENT_RECIPIENT, + size_of_val(&intent.sell_account_rent_recipient) + ); assert_eq!( EncodedOrderIntent::WIDTH_APP_DATA, size_of_val(&intent.app_data) @@ -458,7 +494,7 @@ mod tests { #[test] fn uid_digest_regression() { let intent = sample_intent(OrderKind::Buy, true); - let expected = hex!("7ce7c6a74671090771fa33851387444064aca759ce55b80708723076722f5e00"); + let expected = hex!("7634777e7f671c95c082d21eb1e3d685d764d54f8716115c9baabdcb68ea5f61"); assert_eq!(intent.uid(), Hash::from(expected)); } @@ -493,6 +529,11 @@ mod tests { 0x01, // partially_fillable (true = 1) 0x01, + // sell_account_rent_recipient ([0x55; 32]) + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, // app_data ([0x44; 32]) 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, diff --git a/interface/src/data/order.rs b/interface/src/data/order.rs index 19be4643..b6d5982d 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -82,7 +82,7 @@ impl OrderAccount { } } -/// Canonical 200-byte representation of an [`OrderAccount`]. The bytes +/// Canonical 232-byte representation of an [`OrderAccount`]. The bytes /// written to/read from the order PDA's data area. /// /// Layout: one character per byte, cell widths proportional to field size, @@ -98,7 +98,7 @@ impl OrderAccount { /// │││with- │re- │ created_by │ intent (EncodedOrderIntent) │ /// │││drawn │ceived │ │ │ /// └┴┴───────┴───────┴───────────────────────────────┴─────────────────...─────────────────┘ -/// 0 1 2 10 18 50 ... 200 +/// 0 1 2 10 18 50 ... 232 /// ``` #[derive(Clone, Debug, Deref, Eq, PartialEq)] pub struct EncodedOrderAccount([u8; Self::SIZE]); @@ -112,7 +112,7 @@ impl EncodedOrderAccount { const W_CREATED_BY: usize = size_of::(); const W_INTENT: usize = EncodedOrderIntent::SIZE; - pub const SIZE: usize = 200; + pub const SIZE: usize = 232; /// Single-byte account discriminator. See [`crate::SettlementAccount`]. pub const DISCRIMINATOR: u8 = crate::SettlementAccount::OrderAccount.discriminator(); diff --git a/interface/src/instruction/create_order.rs b/interface/src/instruction/create_order.rs index ab35db2a..a3715449 100644 --- a/interface/src/instruction/create_order.rs +++ b/interface/src/instruction/create_order.rs @@ -36,7 +36,7 @@ use crate::{data::intent::EncodedOrderIntent, SettlementInstruction}; /// be a TOKEN account in the first place. This is checked at execution /// time. /// -/// Wire format: `[discriminator=2, ..150 intent bytes]`, 151 bytes. +/// Wire format: `[discriminator=2, ..182 intent bytes]`, 183 bytes. /// Required accounts: /// `[owner (S), created_by (W,S), order_pda (W), system_program (R)]`. /// The system program needs to be available but doesn't need to be at that @@ -80,7 +80,7 @@ impl<'a, A> InstructionInputParsing<'a, A> for CreateOrderInput<'a, A> { const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::CreateOrder; fn parse_body(instruction_data: &'a [u8], accounts: &'a mut [A]) -> Result { - // Body (discriminator already stripped): exactly the 150 intent bytes. + // Body (discriminator already stripped): exactly the 182 intent bytes. if instruction_data.len() != EncodedOrderIntent::SIZE { return Err(ProgramError::InvalidInstructionData); } @@ -122,7 +122,7 @@ pub mod fixtures { /// and the system program. pub const NUM_ACCOUNTS: usize = 4; - /// Canonical 150-byte intent payload for a valid sell order owned by + /// Canonical 182-byte intent payload for a valid sell order owned by /// [`DEFAULT_OWNER`]. pub fn valid_intent_bytes() -> [u8; EncodedOrderIntent::SIZE] { (&EncodedOrderIntent::from(&OrderIntent { diff --git a/interface/src/instruction/settle/begin.rs b/interface/src/instruction/settle/begin.rs index 237e5e84..8c384a0c 100644 --- a/interface/src/instruction/settle/begin.rs +++ b/interface/src/instruction/settle/begin.rs @@ -24,8 +24,11 @@ pub struct Pull { /// - `order_pdas[i]` is the canonical order PDA (see [`crate::pda::order`]) /// - `order_pda_bumps[i]` is the bump of the canonical order PDA /// - `sell_token_accounts[i]` is the order's sell token account, -/// - `buy_token_accounts[i]` is the order's buy token account, used as the -/// destination for the sell token account's rent if it's closed once empty, +/// - `sell_account_rent_recipients[i]` is the order's +/// `sell_account_rent_recipient`, which receives the sell token account's +/// rent if it's closed once empty. The program only requires it to match the +/// intent on a settlement that closes the account, so any address works for +/// an order whose sell token account stays open, /// - `pulls[i]` the list of [`Pull`]s to perform from that order's sell token /// account, each sending an amount from the `i`-th order sell token account /// to a destination. @@ -38,11 +41,11 @@ pub struct Pull { /// [bump×n][transfer_count×n][amount: u64 LE ×T]`. /// Required accounts: `[instructions_sysvar (R), state_pda (R), token_program /// (R)]` followed, per order, by `[order_pda (R), sell_token_account (W), -/// buy_token_account (W), destination (W)...]`. +/// sell_account_rent_recipient (W), destination (W)...]`. /// /// The program requires the order PDAs to be strictly increasing by address. /// This builder establishes that ordering for the caller: it sorts the orders by -/// PDA address, carrying each order's sell token account, buy token account, +/// PDA address, carrying each order's sell token account, rent recipient, /// bump, transfer count, amounts, and destination metas before emitting them. pub struct BeginSettle<'a> { pub program_id: Pubkey, @@ -55,7 +58,7 @@ pub struct BeginSettle<'a> { pub order_pdas: &'a [Pubkey], pub order_pda_bumps: &'a [u8], pub sell_token_accounts: &'a [Pubkey], - pub buy_token_accounts: &'a [Pubkey], + pub sell_account_rent_recipients: &'a [Pubkey], pub pulls: &'a [&'a [Pull]], } @@ -69,7 +72,7 @@ impl From> for Instruction { order_pdas, order_pda_bumps, sell_token_accounts, - buy_token_accounts, + sell_account_rent_recipients, pulls, } = builder; @@ -110,10 +113,10 @@ impl From> for Instruction { // Read-only account for the order. accounts.push(AccountMeta::new_readonly(order_pdas[i], false)); // Writable accounts settling the order: its sell token account, its - // buy token account (the destination if the sell token account is - // closed once empty), and the recipient of each transfer. + // rent recipient (which receives the sell token account's lamports + // if it's closed once empty), and the recipient of each transfer. accounts.push(AccountMeta::new(sell_token_accounts[i], false)); - accounts.push(AccountMeta::new(buy_token_accounts[i], false)); + accounts.push(AccountMeta::new(sell_account_rent_recipients[i], false)); for pull in pulls[i] { accounts.push(AccountMeta::new(pull.destination, false)); } @@ -132,7 +135,7 @@ impl From> for Instruction { pub struct SettledOrder<'a, A> { pub order_pda: &'a A, pub sell_token_account: &'a A, - pub buy_token_account: &'a A, + pub sell_account_rent_recipient: &'a A, pub bump: u8, /// Destination accounts for this order's transfers. pub destinations: &'a [A], @@ -147,7 +150,7 @@ pub struct SettledOrders<'a, A> { /// Order accounts, laid out per order as /// [order_accounts_1, order_accounts_2, ...] where /// - each order_accounts is a series of accounts: - /// `order_pda_N, sell_token_account_N, buy_token_account_N, destination_N_1, destination_N_2, ..., destination_N_M` + /// `order_pda_N, sell_token_account_N, sell_account_rent_recipient_N, destination_N_1, destination_N_2, ..., destination_N_M` /// - and M is `counts[N]` order_accounts: &'a [A], bumps: &'a [u8], @@ -179,7 +182,7 @@ impl<'a, A> SettledOrders<'a, A> { let order_pda = &self.order_accounts[account_offset]; let sell_token_account = &self.order_accounts[account_offset + 1]; - let buy_token_account = &self.order_accounts[account_offset + 2]; + let sell_account_rent_recipient = &self.order_accounts[account_offset + 2]; let dest_start = account_offset + 3; let dest_end = dest_start + count; let destinations = &self.order_accounts[dest_start..dest_end]; @@ -192,7 +195,7 @@ impl<'a, A> SettledOrders<'a, A> { Some(SettledOrder { order_pda, sell_token_account, - buy_token_account, + sell_account_rent_recipient, bump, destinations, amounts, @@ -262,8 +265,8 @@ impl<'a, A> InstructionInputParsing<'a, A> for BeginSettleInput<'a, A> { }; let transfer_count = amounts.len(); - // Each order contributes its order PDA, sell token account, buy token - // account, and one destination per transfer, so the order accounts + // Each order contributes its order PDA, sell token account, rent + // recipient, and one destination per transfer, so the order accounts // count is `3n + T`. let expected_accounts = order_count .checked_mul(3) @@ -332,7 +335,7 @@ mod tests { order_pdas: &[], order_pda_bumps: &[], sell_token_accounts: &[], - buy_token_accounts: &[], + sell_account_rent_recipients: &[], pulls: &[], } .into(); @@ -366,11 +369,11 @@ mod tests { // are chosen to sort in the opposite order. let high_order_pda = Pubkey::new_from_array([0xbb; 32]); let high_sell_token_account = Pubkey::new_from_array([0xa0; 32]); - let high_buy_token_account = Pubkey::new_from_array([0xa2; 32]); + let high_rent_recipient = Pubkey::new_from_array([0xa2; 32]); let high_bump = 0xaa; let low_order_pda = Pubkey::new_from_array([0xaa; 32]); let low_sell_token_account = Pubkey::new_from_array([0xb0; 32]); - let low_buy_token_account = Pubkey::new_from_array([0xb2; 32]); + let low_rent_recipient = Pubkey::new_from_array([0xb2; 32]); let low_bump = 0xbb; let Instruction { data, accounts, .. } = BeginSettle { program_id, @@ -380,7 +383,7 @@ mod tests { order_pdas: &[high_order_pda, low_order_pda], order_pda_bumps: &[high_bump, low_bump], sell_token_accounts: &[high_sell_token_account, low_sell_token_account], - buy_token_accounts: &[high_buy_token_account, low_buy_token_account], + sell_account_rent_recipients: &[high_rent_recipient, low_rent_recipient], pulls: &[&[], &[]], } .into(); @@ -404,15 +407,15 @@ mod tests { SPL_TOKEN_PROGRAM_ID, low_order_pda, low_sell_token_account, - low_buy_token_account, + low_rent_recipient, high_order_pda, high_sell_token_account, - high_buy_token_account, + high_rent_recipient, ]; let actual: Vec = accounts.iter().map(|account| account.pubkey).collect(); assert_eq!(actual, expected); - // The fixed accounts and the order PDAs are read-only; the sell and buy - // token accounts are writable, following the sorted order. + // The fixed accounts and the order PDAs are read-only; the sell token + // accounts and rent recipients are writable, following the sorted order. let writable: Vec = accounts .iter() .filter(|account| account.is_writable) @@ -422,9 +425,9 @@ mod tests { writable, vec![ low_sell_token_account, - low_buy_token_account, + low_rent_recipient, high_sell_token_account, - high_buy_token_account, + high_rent_recipient, ], ); assert!(accounts.iter().all(|account| !account.is_signer)); @@ -436,10 +439,10 @@ mod tests { let state_pda = Pubkey::new_unique(); let order_a = Pubkey::new_from_array([0x01; 32]); let sell_a = Pubkey::new_from_array([0x02; 32]); - let buy_a = Pubkey::new_from_array([0x08; 32]); + let rent_a = Pubkey::new_from_array([0x08; 32]); let order_b = Pubkey::new_from_array([0x03; 32]); let sell_b = Pubkey::new_from_array([0x04; 32]); - let buy_b = Pubkey::new_from_array([0x09; 32]); + let rent_b = Pubkey::new_from_array([0x09; 32]); let dest_a0 = Pubkey::new_from_array([0x05; 32]); let dest_a1 = Pubkey::new_from_array([0x06; 32]); let dest_b0 = Pubkey::new_from_array([0x07; 32]); @@ -453,7 +456,7 @@ mod tests { order_pdas: &[order_a, order_b], order_pda_bumps: &[0xa1, 0xb1], sell_token_accounts: &[sell_a, sell_b], - buy_token_accounts: &[buy_a, buy_b], + sell_account_rent_recipients: &[rent_a, rent_b], pulls: &[ &[ Pull { @@ -495,18 +498,18 @@ mod tests { SPL_TOKEN_PROGRAM_ID, order_a, sell_a, - buy_a, + rent_a, dest_a0, dest_a1, order_b, sell_b, - buy_b, + rent_b, dest_b0, ]; let actual: Vec = accounts.iter().map(|account| account.pubkey).collect(); assert_eq!(actual, expected); - // The fixed accounts and the order PDAs are read-only; sell, buy, and - // destination accounts are writable for the transfer. + // The fixed accounts and the order PDAs are read-only; the sell token + // accounts, rent recipients, and destinations are writable. let writable: Vec = accounts .iter() .filter(|account| account.is_writable) @@ -514,7 +517,7 @@ mod tests { .collect(); assert_eq!( writable, - vec![sell_a, buy_a, dest_a0, dest_a1, sell_b, buy_b, dest_b0], + vec![sell_a, rent_a, dest_a0, dest_a1, sell_b, rent_b, dest_b0], ); assert!(accounts.iter().all(|account| !account.is_signer)); } @@ -601,14 +604,14 @@ mod tests { let token_program = Address::new_from_array([0xa2u8; 32]); let order_pda = Address::new_from_array([2u8; 32]); let sell_token = Address::new_from_array([3u8; 32]); - let buy_token = Address::new_from_array([4u8; 32]); + let rent_recipient = Address::new_from_array([4u8; 32]); let mut accounts = [ fake_account(sysvar), fake_account(state), fake_account(token_program), fake_account(order_pda), fake_account(sell_token), - fake_account(buy_token), + fake_account(rent_recipient), ]; let data = ix_data![ [SettlementInstruction::BeginSettle.discriminator()], @@ -636,7 +639,7 @@ mod tests { let order = orders.next().expect("one settled order"); assert_eq!(order.order_pda.address(), &order_pda); assert_eq!(order.sell_token_account.address(), &sell_token); - assert_eq!(order.buy_token_account.address(), &buy_token); + assert_eq!(order.sell_account_rent_recipient.address(), &rent_recipient); assert_eq!(order.bump, 0xab); assert_eq!(order.destinations.len(), 0); assert!(orders.next().is_none()); @@ -649,7 +652,7 @@ mod tests { let token_program = Address::new_from_array([0xa2u8; 32]); let order_pda = Address::new_from_array([2u8; 32]); let sell_token = Address::new_from_array([3u8; 32]); - let buy_token = Address::new_from_array([6u8; 32]); + let rent_recipient = Address::new_from_array([6u8; 32]); let dest0 = Address::new_from_array([4u8; 32]); let dest1 = Address::new_from_array([5u8; 32]); let mut accounts = [ @@ -658,7 +661,7 @@ mod tests { fake_account(token_program), fake_account(order_pda), fake_account(sell_token), - fake_account(buy_token), + fake_account(rent_recipient), fake_account(dest0), fake_account(dest1), ]; @@ -680,7 +683,7 @@ mod tests { let order = orders.next().expect("one settled order"); assert_eq!(order.order_pda.address(), &order_pda); assert_eq!(order.sell_token_account.address(), &sell_token); - assert_eq!(order.buy_token_account.address(), &buy_token); + assert_eq!(order.sell_account_rent_recipient.address(), &rent_recipient); assert_eq!(order.bump, 0xab); let transfers: Vec<(&Address, u64)> = order .destinations @@ -700,9 +703,9 @@ mod tests { for i in 0..ORDER_COUNT { let order_pda = Address::new_from_array([i as u8; 32]); let sell_token = Address::new_from_array([(i + ORDER_COUNT) as u8; 32]); - let buy_token = Address::new_from_array([(i + 2 * ORDER_COUNT) as u8; 32]); + let rent_recipient = Address::new_from_array([(i + 2 * ORDER_COUNT) as u8; 32]); let bump: u8 = (i + 3 * ORDER_COUNT) as u8; - expected.push((order_pda, sell_token, buy_token, bump)); + expected.push((order_pda, sell_token, rent_recipient, bump)); } // The three fixed accounts (`[0xff..]`, `[0xfe..]`, `[0xfd..]`) differ @@ -713,10 +716,10 @@ mod tests { fake_account_from_array([0xfd; 32]), ]; let mut bumps = Vec::new(); - for &(order_pda, sell_token, buy_token, bump) in &expected { + for &(order_pda, sell_token, rent_recipient, bump) in &expected { accounts.push(fake_account(order_pda)); accounts.push(fake_account(sell_token)); - accounts.push(fake_account(buy_token)); + accounts.push(fake_account(rent_recipient)); bumps.push(bump); } // Grouped data: discriminator, finalize index, auction id, order count, @@ -734,10 +737,10 @@ mod tests { let orders: Vec<_> = parsed.orders.iter().collect(); assert_eq!(orders.len(), ORDER_COUNT); - for (order, (order_pda, sell_token, buy_token, bump)) in orders.iter().zip(&expected) { + for (order, (order_pda, sell_token, rent_recipient, bump)) in orders.iter().zip(&expected) { assert_eq!(order.order_pda.address(), order_pda); assert_eq!(order.sell_token_account.address(), sell_token); - assert_eq!(order.buy_token_account.address(), buy_token); + assert_eq!(order.sell_account_rent_recipient.address(), rent_recipient); assert_eq!(order.bump, *bump); assert_eq!(order.destinations.len(), 0); } @@ -746,8 +749,8 @@ mod tests { #[test] fn begin_settle_input_rejects_account_count_mismatch() { // The body declares one order with no transfers, which needs exactly - // three order accounts (its order PDA, sell token account, and buy - // token account). Only one order account is supplied after the fixed + // three order accounts (its order PDA, sell token account, and rent + // recipient). Only one order account is supplied after the fixed // accounts, so the number of accounts doesn't match the `3n + T` the // body implies. let mut accounts = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 1 }>(); @@ -768,7 +771,7 @@ mod tests { #[test] fn begin_settle_input_rejects_counts_not_summing_to_destinations() { // One order whose two destination accounts (plus its order PDA, sell - // token account, and buy token account) make the lengths recover T = 2 + // token account, and rent recipient) make the lengths recover T = 2 // transfers, but the transfer-count byte claims only one. let mut accounts = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 5 }>(); let data = ix_data![ diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 386ed126..76d5cded 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -165,9 +165,11 @@ pub enum SettlementError { /// `ReclaimOrder`'s `reclaim_recipient` account doesn't match the /// `created_by` address recorded in the order. ReclaimRecipientMismatch = 27, - /// A `BeginSettle` buy token account doesn't match the `buy_token_account` - /// recorded in the order's intent. - BuyTokenAccountMismatch = 28, + /// `BeginSettle`: a settlement closing an order's sell token account + /// supplied a rent recipient account that doesn't match the + /// `sell_account_rent_recipient` recorded in the order's intent. Only + /// checked when the account is closed; otherwise the slot is unused. + SellAccountRentRecipientMismatch = 28, } impl From for u32 { diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 03486bf6..c23039b5 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -219,6 +219,10 @@ fn settle_orders<'a>( /// This checks that the order is valid, settleable, and that `push_destination` /// matches the buy token account. Once the order passes those checks, its pulls /// are executed and its settlement limit price is validated against the intent. +/// Finally, a sell token account left empty by the pulls is closed if the state +/// PDA holds its SPL close authority, sending the reclaimed lamports to the +/// intent's `sell_account_rent_recipient`, which is the only case where the +/// order's rent recipient account is checked at all. #[must_use = "ignoring the output may lead to an unintended on-chain state"] fn process_order( program_id: &Address, @@ -231,7 +235,7 @@ fn process_order( let SettledOrder { order_pda, sell_token_account, - buy_token_account, + sell_account_rent_recipient, bump, destinations, amounts, @@ -261,13 +265,6 @@ fn process_order( if !address_matches_pubkey(sell_token_account.address(), &intent.sell_token_account) { return Err(SettlementError::SellTokenAccountMismatch.into()); } - // The buy token account must be the one named in the intent: it's the - // destination for the sell token account's reclaimed rent if it's closed - // below, and an arbitrary caller-supplied account must not be able to - // redirect those funds. - if !address_matches_pubkey(buy_token_account.address(), &intent.buy_token_account) { - return Err(SettlementError::BuyTokenAccountMismatch.into()); - } // Assert the order intent owner matches that of the sell token account. { // `from_account_view` confirms this is a real SPL token account @@ -292,24 +289,36 @@ fn process_order( .ok_or(SettlementError::PullAmountOverflow)?; Transfer::new(sell_token_account, destination, state_account, amount) .invoke_signed(core::slice::from_ref(state_pda_signer))?; + } - // If the sell token account is now empty and the state PDA is able to - // close it, then close it. The borrow is released at the end of this - // block, before `CloseAccount` needs to mutably touch the account. - let should_close = { - let token_account = TokenAccount::from_account_view(sell_token_account) - .map_err(|_| SettlementError::SellTokenAccountInvalid)?; - token_account.amount() == 0 - && token_account.close_authority() == Some(state_account.address()) - }; + validate_limit_price(&intent, amount_in, push_amount)?; - if should_close { - CloseAccount::new(sell_token_account, buy_token_account, state_account) - .invoke_signed(core::slice::from_ref(state_pda_signer))?; + // Once all pulls are done, reclaim the sell token account's rent if it's + // left empty and the owner authorized us to close it. + let should_close = { + let token_account = TokenAccount::from_account_view(sell_token_account) + .map_err(|_| SettlementError::SellTokenAccountInvalid)?; + token_account.amount() == 0 + && token_account.close_authority() == Some(state_account.address()) + }; + if should_close { + // Confirm the rent recipient account given by the solver is the one intended for the user. + // We explicitly only check this here so that the solver can specify different/duplicated account if the account + // will not be closed. + if !address_matches_pubkey( + sell_account_rent_recipient.address(), + &intent.sell_account_rent_recipient, + ) { + return Err(SettlementError::SellAccountRentRecipientMismatch.into()); } - } - validate_limit_price(&intent, amount_in, push_amount)?; + CloseAccount::new( + sell_token_account, + sell_account_rent_recipient, + state_account, + ) + .invoke_signed(core::slice::from_ref(state_pda_signer))?; + } Ok(()) } diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index d81f508b..64717a1c 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -194,7 +194,7 @@ fn rejects_wrong_bump() { order_pdas: &[order_pda], order_pda_bumps: &[bump ^ 0x01], sell_token_accounts: &[intent.sell_token_account], - buy_token_accounts: &[intent.buy_token_account], + sell_account_rent_recipients: &[intent.sell_account_rent_recipient], pulls: &no_pulls(1), }; let finalize = FinalizeSettleRaw { @@ -219,7 +219,6 @@ fn rejects_fabricated_program_owned_account() { let mint = token::create_mint(&mut svm, &payer); let sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); - let buy_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); let intent = sample_intent(payer.pubkey(), sell_token, 0); let body: [u8; EncodedOrderAccount::SIZE] = EncodedOrderAccount::from(OrderAccount { cancelled: false, @@ -242,7 +241,7 @@ fn rejects_fabricated_program_owned_account() { order_pdas: &[fake_order], order_pda_bumps: &[bump], sell_token_accounts: &[sell_token], - buy_token_accounts: &[buy_token], + sell_account_rent_recipients: &[intent.sell_account_rent_recipient], pulls: &no_pulls(1), }; // Mostly placeholder values: the transaction will reject before reaching @@ -270,7 +269,6 @@ fn rejects_non_order_account_in_order_slot() { let mint = token::create_mint(&mut svm, &payer); let sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); - let buy_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); // Put a token account in the order slot. Its 165-byte data can't decode as a // order body, so it's rejected before the canonical-address check. @@ -284,7 +282,7 @@ fn rejects_non_order_account_in_order_slot() { order_pdas: &[sell_token], order_pda_bumps: &[0], sell_token_accounts: &[sell_token], - buy_token_accounts: &[buy_token], + sell_account_rent_recipients: &[Pubkey::new_unique()], pulls: &no_pulls(1), }; // The finalize just carries a placeholder push matching the order in count. @@ -439,19 +437,22 @@ fn rejects_orders_in_wrong_address_order() { // instructions by hand in the current wire format. Begin data is // `[discriminator, finalize_ix_index (LE), order_count, bump×n, transfer_count×n]` // (no transfers here) and begin accounts are `[instructions_sysvar, state_pda, - // token_program, (order_pda, sell_token_account)...]`. The finalize's push - // destinations are laid out in the same decreasing order, so the first order's - // destination check passes and the second order trips the ordering check. + // token_program, (order_pda, sell_token_account, sell_account_rent_recipient)...]`. + // The finalize's push destinations are laid out in the same decreasing order, so + // the first order's destination check passes and the second order trips the + // ordering check. let mut orders = [ ( first_pda, first.sell_token_account, + first.sell_account_rent_recipient, first.buy_token_account, first_bump, ), ( second_pda, second.sell_token_account, + second.sell_account_rent_recipient, second.buy_token_account, second_bump, ), @@ -462,7 +463,7 @@ fn rejects_orders_in_wrong_address_order() { data.extend_from_slice(&u16::from(FINALIZE_INDEX).to_le_bytes()); data.extend_from_slice(&0i64.to_le_bytes()); // auction id data.push(orders.len() as u8); - data.extend(orders.iter().map(|&(_, _, _, bump)| bump)); + data.extend(orders.iter().map(|&(.., bump)| bump)); // No transfers: one zero transfer-count byte per order. data.extend(orders.iter().map(|_| 0u8)); @@ -471,10 +472,10 @@ fn rejects_orders_in_wrong_address_order() { AccountMeta::new_readonly(find_state_pda(&program_id).0, false), AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), ]; - for (order_pda, sell_token_account, buy_token_account, _) in orders { + for (order_pda, sell_token_account, rent_recipient, _, _) in orders { accounts.push(AccountMeta::new_readonly(order_pda, false)); accounts.push(AccountMeta::new(sell_token_account, false)); - accounts.push(AccountMeta::new(buy_token_account, false)); + accounts.push(AccountMeta::new(rent_recipient, false)); } let begin = Instruction { program_id, @@ -487,7 +488,7 @@ fn rejects_orders_in_wrong_address_order() { // the ordering before the pushes execute, so only the destinations and their // count matter, not the source buffers they'd draw from. let source_buffers: Vec = orders.iter().map(|_| Pubkey::new_unique()).collect(); - let destinations: Vec = orders.iter().map(|&(_, _, buy, _)| buy).collect(); + let destinations: Vec = orders.iter().map(|&(_, _, _, buy, _)| buy).collect(); let bumps = vec![0u8; orders.len()]; let amounts = vec![0u64; orders.len()]; let finalize = FinalizeSettleRaw { @@ -644,14 +645,21 @@ fn pulls_funds_to_destination() { ); } +/// A settlement that closes the sell token account must send the reclaimed rent +/// to the recipient the intent names, so a substituted account is rejected. The +/// order's sell token account is already empty and has the state PDA as its +/// close authority, which is what makes this settlement close it. #[test] -fn rejects_buy_token_account_mismatch() { +fn rejects_sell_account_rent_recipient_mismatch_when_closing() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); + let state_pda = find_state_pda(&program_id).0; + + let rent_recipient = Pubkey::new_unique(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_account_rent_recipient(&rent_recipient) + .build(); + token::set_close_authority(&mut svm, &payer, &intent.sell_token_account, &state_pda); - // Supply a different token account than the one the order's intent names. - let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - let wrong_buy_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); let mut instructions = settle_and_pay( &mut svm, &program_id, @@ -663,13 +671,124 @@ fn rejects_buy_token_account_mismatch() { ); replace_first_matching_account( &mut instructions[usize::from(BEGIN_INDEX)], - &intent.buy_token_account, - wrong_buy_token, + &rent_recipient, + Pubkey::new_unique(), ); assert_begin_error( send(&mut svm, &payer, instructions), - SettlementError::BuyTokenAccountMismatch, + SettlementError::SellAccountRentRecipientMismatch, + ); +} + +/// The other reason a settlement doesn't close the sell token account: the +/// order authorizes closing (the state PDA is the close authority) but the pull +/// leaves funds behind. The rent recipient goes unread there too, so a +/// throwaway address is accepted. +#[test] +fn accepts_mismatched_rent_recipient_when_sell_account_retains_funds() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let state_pda = find_state_pda(&program_id).0; + + // Pull half the balance: the account is authorized to be closed but isn't + // empty afterwards. + let initial_amount = 42_000_000; + let pulled = initial_amount / 2; + let paid = 84_000_000; + let rent_recipient = Pubkey::new_unique(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_mint(&mint) + .sell_amount(initial_amount) + .buy_amount(paid) + .sell_account_rent_recipient(&rent_recipient) + .build(); + let sell_token = intent.sell_token_account; + token::fund_and_delegate(&mut svm, &program_id, &payer, &sell_token, initial_amount); + token::set_close_authority(&mut svm, &payer, &sell_token, &state_pda); + let destination = token::create_token_account(&mut svm, &payer, &mint, &Pubkey::new_unique()); + + let mut instructions = settle_and_pay_amounts( + &mut svm, + &program_id, + &payer, + &[InitializedIntent { + intent: &intent, + pulls: &[Pull { + destination, + amount: pulled, + }], + }], + &[paid], + ); + let throwaway = Pubkey::new_unique(); + replace_first_matching_account( + &mut instructions[usize::from(BEGIN_INDEX)], + &rent_recipient, + throwaway, + ); + + send(&mut svm, &payer, instructions) + .expect("a partial pull leaves the sell account open and its rent recipient unread"); + + assert_eq!(token::balance(&svm, &destination), pulled); + assert_eq!( + token::balance(&svm, &sell_token), + initial_amount - pulled, + "the sell token account should still hold the funds that weren't pulled" + ); + assert!( + svm.get_account(&throwaway) + .is_none_or(|account| account.lamports == 0), + "the throwaway account should not have received anything" + ); +} + +/// The rent recipient slot is only read when the settlement closes the sell +/// token account. An order that doesn't authorize closing never touches it, so +/// a settlement may put a throwaway address there. +#[test] +fn accepts_mismatched_rent_recipient_when_not_closing() { + let (mut svm, program_id, payer) = setup(); + + // No close authority is set on the sell token account, so this settlement + // can't close it however empty it is. + let rent_recipient = Pubkey::new_unique(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_account_rent_recipient(&rent_recipient) + .build(); + let mut instructions = settle_and_pay( + &mut svm, + &program_id, + &payer, + &[InitializedIntent { + intent: &intent, + pulls: &[], + }], + ); + let throwaway = Pubkey::new_unique(); + replace_first_matching_account( + &mut instructions[usize::from(BEGIN_INDEX)], + &rent_recipient, + throwaway, + ); + + send(&mut svm, &payer, instructions) + .expect("a settlement that doesn't close the sell account ignores the rent recipient"); + // Nothing was closed, so no lamports moved to either address. + assert!( + svm.get_account(&throwaway) + .is_none_or(|account| account.lamports == 0), + "the throwaway account should not have received anything" + ); + assert!( + svm.get_account(&rent_recipient) + .is_none_or(|account| account.lamports == 0), + "the intent's rent recipient should not have received anything" + ); + assert!( + svm.get_account(&intent.sell_token_account).is_some(), + "the sell token account should still be open" ); } @@ -683,10 +802,12 @@ fn closes_sell_token_account_once_emptied_with_matching_close_authority() { // sell 42_000_000 for at least 84_000_000 at limit price. let initial_amount = 42_000_000; let paid = 84_000_000; + let rent_recipient = Pubkey::new_unique(); let intent = OrderBuilder::new(&mut svm, &program_id, &payer) .sell_mint(&mint) .sell_amount(initial_amount) .buy_amount(paid) + .sell_account_rent_recipient(&rent_recipient) .build(); let sell_token = intent.sell_token_account; let buy_token = intent.buy_token_account; @@ -698,7 +819,7 @@ fn closes_sell_token_account_once_emptied_with_matching_close_authority() { .get_account(&sell_token) .expect("sell token account should exist before settlement") .lamports; - let buy_token_rent = svm + let buy_token_lamports = svm .get_account(&buy_token) .expect("buy token account should exist before settlement") .lamports; @@ -720,17 +841,101 @@ fn closes_sell_token_account_once_emptied_with_matching_close_authority() { .expect("closing sell token account as part of settlement should succeed"); assert_eq!(token::balance(&svm, &destination), initial_amount); - // The now-empty sell token account is closed, and its rent goes to the - // order's buy token account. + // The now-empty sell token account is closed, and its rent goes to the rent + // recipient the intent names. assert!( - svm.get_account(&sell_token).is_none(), + svm.get_account(&sell_token).is_none_or(|account| { + // Some SVM backends keep a zeroed record for a closed account. + account.lamports == 0 + }), "the emptied sell token account should have been closed" ); + assert_eq!( + svm.get_account(&rent_recipient) + .expect("the rent recipient should hold the reclaimed rent") + .lamports, + sell_token_rent, + ); + // The buy token account is not the rent recipient, so only its pushed + // proceeds change, never its lamports. assert_eq!( svm.get_account(&buy_token) .expect("buy token account should exist after settlement") .lamports, - sell_token_rent + buy_token_rent, + buy_token_lamports, + ); +} + +/// A sell token account emptied by an early pull is only closed once every pull +/// for that order has run: closing mid-loop would make the remaining pulls fail +/// on a closed account. The delegation here exceeds the balance so the delegate +/// survives the emptying pull and the trailing pull is reached at all. +#[test] +fn closes_sell_token_account_only_after_all_pulls() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let state_pda = find_state_pda(&program_id).0; + + let balance = 30_000_000; + let delegated = 42_000_000; + let paid = 60_000_000; + let rent_recipient = Pubkey::new_unique(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_mint(&mint) + .sell_amount(balance) + .buy_amount(paid) + .sell_account_rent_recipient(&rent_recipient) + .build(); + let sell_token = intent.sell_token_account; + token::mint_to(&mut svm, &payer, &mint, &sell_token, balance); + token::delegate(&mut svm, &payer, &sell_token, &state_pda, delegated); + token::set_close_authority(&mut svm, &payer, &sell_token, &state_pda); + + let sell_token_rent = svm + .get_account(&sell_token) + .expect("sell token account should exist before settlement") + .lamports; + + // The first pull empties the account; the second one is a zero-amount pull + // that would fail if the account had already been closed. + let emptying_destination = + token::create_token_account(&mut svm, &payer, &mint, &Pubkey::new_unique()); + let trailing_destination = + token::create_token_account(&mut svm, &payer, &mint, &Pubkey::new_unique()); + let instructions = settle_and_pay_amounts( + &mut svm, + &program_id, + &payer, + &[InitializedIntent { + intent: &intent, + pulls: &[ + Pull { + destination: emptying_destination, + amount: balance, + }, + Pull { + destination: trailing_destination, + amount: 0, + }, + ], + }], + &[paid], + ); + send(&mut svm, &payer, instructions) + .expect("a pull following the one that empties the sell account should succeed"); + + assert_eq!(token::balance(&svm, &emptying_destination), balance); + assert_eq!(token::balance(&svm, &trailing_destination), 0); + assert!( + svm.get_account(&sell_token) + .is_none_or(|account| account.lamports == 0), + "the emptied sell token account should have been closed" + ); + assert_eq!( + svm.get_account(&rent_recipient) + .expect("the rent recipient should hold the reclaimed rent") + .lamports, + sell_token_rent, ); } diff --git a/programs/settlement/tests/common/order.rs b/programs/settlement/tests/common/order.rs index 86fb6b70..1e8b7c4e 100644 --- a/programs/settlement/tests/common/order.rs +++ b/programs/settlement/tests/common/order.rs @@ -23,6 +23,7 @@ pub fn sample_intent(owner: Pubkey, sell_token_account: Pubkey, salt: u8) -> Ord valid_to: 0xdead_beef, kind: OrderKind::Sell, partially_fillable: true, + sell_account_rent_recipient: Pubkey::new_from_array([0x44; 32]), app_data: [salt; 32], } } @@ -124,6 +125,14 @@ impl<'a> OrderBuilder<'a> { self } + /// Set the account receiving the sell token account's rent when a + /// settlement closes it. Defaults to [`sample_intent`]'s placeholder + /// address, which no test asserts on. + pub fn sell_account_rent_recipient(mut self, recipient: &Pubkey) -> Self { + self.intent.sell_account_rent_recipient = *recipient; + self + } + pub fn build(self) -> OrderIntent { let Self { svm, diff --git a/test-cli/src/cmd/create_order.rs b/test-cli/src/cmd/create_order.rs index b9226bd1..28ff018d 100644 --- a/test-cli/src/cmd/create_order.rs +++ b/test-cli/src/cmd/create_order.rs @@ -7,7 +7,7 @@ use settlement_client::{ pda::order::find_order_pda, }, }; -use solana_sdk::{signature::Signer, transaction::Transaction}; +use solana_sdk::{pubkey::Pubkey, signature::Signer, transaction::Transaction}; use std::time::{SystemTime, UNIX_EPOCH}; use super::Context; @@ -22,6 +22,11 @@ struct CommonArgs { /// Allow partial fills across multiple settlements #[arg(long)] partially_fillable: bool, + + /// Address receiving the sell token account's rent if a settlement closes + /// the account once it's empty (defaults to the payer) + #[arg(long)] + sell_account_rent_recipient: Option, } #[derive(Parser)] @@ -164,6 +169,9 @@ fn execute(ctx: Context, parsed: ParsedOrder, common: CommonArgs) -> anyhow::Res valid_to: common.valid_to, kind, partially_fillable: common.partially_fillable, + sell_account_rent_recipient: common + .sell_account_rent_recipient + .unwrap_or_else(|| ctx.payer.pubkey()), app_data: [0u8; 32], }; From 61324b23377c807c28fea53255e9583ed0a484f6 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:15:48 +0900 Subject: [PATCH 4/7] simplify comment --- interface/src/data/intent.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index 8fd2f159..ac0fdd18 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -81,11 +81,8 @@ pub struct OrderIntent { /// `sell_token_account` when a settlement closes it. Closing only /// happens if the account is left empty by the settlement and its SPL /// close authority is the settlement state PDA, which is the owner's - /// opt-in; naming the recipient here means it's fixed by the signed - /// intent instead of chosen by whoever settles the order. - /// - /// Settlements that don't close the sell token account never read it, so - /// it's unconstrained for orders that never opt into closing. + /// opt-in. + /// This field is unused unless the sell account is to be closed. pub sell_account_rent_recipient: Pubkey, /// Opaque 32 bytes set by the order creator. Not interpreted by the From e99f57f277ecdef11dd53c67858a41e4225baf20 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:56:01 +0900 Subject: [PATCH 5/7] feedback from the pre-review agent --- DESIGN.md | 4 ++-- interface/src/data/intent.rs | 3 +-- interface/src/instruction/create_order.rs | 2 +- interface/src/lib.rs | 3 +-- programs/settlement/src/settle/begin.rs | 10 ++++---- .../settlement/tests/begin_settle_orders.rs | 4 ++++ test-cli/src/cmd/create_order.rs | 24 +++++++++++++++---- test-cli/src/instructions.rs | 19 +++++++++++++++ 8 files changed, 53 insertions(+), 16 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 5013a086..bc78e8f2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -188,9 +188,9 @@ Creating the order in advance is _not_ needed: if the order wasn’t created bef Note that deleting the order PDA is _not_ enough to invalidate an order. In fact, if an order signature is available, the same order could always be created again until it expires. -### Sell Token Account Clearing +### Sell Token Account clearing -Upon settlement, if an order whose `sell_token_account` is left with 0 funds *and* the settlement account's state account has been granted close authority, the account will be automatically closed and the rent proceeds sent to `sell_account_rent_recipient`. +Upon settlement, if an order whose `sell_token_account` is left with 0 funds *and* the settlement account's state account has been granted close authority, the sell token account will be automatically closed and the rent proceeds sent to `sell_account_rent_recipient`. If the `sell_token_account` has not granted close authority or has any remaining funds, the account will not be closed and `sell_account_rent_recipient` is ignored. diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index ac0fdd18..1610355f 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -82,7 +82,6 @@ pub struct OrderIntent { /// happens if the account is left empty by the settlement and its SPL /// close authority is the settlement state PDA, which is the owner's /// opt-in. - /// This field is unused unless the sell account is to be closed. pub sell_account_rent_recipient: Pubkey, /// Opaque 32 bytes set by the order creator. Not interpreted by the @@ -106,7 +105,7 @@ pub struct OrderIntent { /// │ owner │ buy_token_account │ sell_token_account │ │ │id_│││ rent_recipient │ app_data │ /// │ │ │ │amount │amount │to │││ │ │ /// └───────────────────────────────┴───────────────────────────────┴───────────────────────────────┴───────┴───────┴───┴┴┴───────────────────────────────┴───────────────────────────────┘ -/// 0 32 64 96 104 112 116 118 150 182 +/// 0 32 64 96 104 112 116 118 150 182 /// 117 /// ``` #[derive(Clone, Debug, Deref, Eq, PartialEq)] diff --git a/interface/src/instruction/create_order.rs b/interface/src/instruction/create_order.rs index 96533bf1..40cad70a 100644 --- a/interface/src/instruction/create_order.rs +++ b/interface/src/instruction/create_order.rs @@ -40,7 +40,7 @@ use crate::{data::intent::EncodedOrderIntent, SettlementInstruction}; /// instruction reverts with `AccountAlreadyInitialized`. Recreating the same /// order is only possible after its PDA has been closed. /// -/// Wire format: `[discriminator=2, ..intent bytes]`, bytes. +/// Wire format: `[discriminator=2, ..intent bytes]` /// Required accounts: /// `[owner (S), created_by (W,S), order_pda (W), system_program (R)]`. /// The system program needs to be available but doesn't need to be at that diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 0ee1ec3c..5d915625 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -181,8 +181,7 @@ pub enum SettlementError { ReclaimRecipientMismatch = 31, /// `BeginSettle`: a settlement closing an order's sell token account /// supplied a rent recipient account that doesn't match the - /// `sell_account_rent_recipient` recorded in the order's intent. Only - /// checked when the account is closed; otherwise the slot is unused. + /// `sell_account_rent_recipient` recorded in the order's intent. SellAccountRentRecipientMismatch = 32, } diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 366a5b15..dabddda3 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -227,8 +227,7 @@ fn settle_orders( /// are executed and its settlement limit price is validated against the intent. /// Finally, a sell token account left empty by the pulls is closed if the state /// PDA holds its SPL close authority, sending the reclaimed lamports to the -/// intent's `sell_account_rent_recipient`, which is the only case where the -/// order's rent recipient account is checked at all. +/// intent's `sell_account_rent_recipient`. #[must_use = "ignoring the output may lead to an unintended on-chain state"] fn process_order( program_id: &Address, @@ -324,9 +323,10 @@ fn process_order( && token_account.close_authority() == Some(state_account.address()) }; if should_close { - // Confirm the rent recipient account given by the solver is the one intended for the user. - // We explicitly only check this here so that the solver can specify different/duplicated account if the account - // will not be closed. + // Confirm the rent recipient account given by the solver is the + // one intended for the user. We explicitly only check this here + // so that the solver can specify a different/duplicated account + // if the account will not be closed. if sell_account_rent_recipient.address() != &expected_rent_recipient { return Err(SettlementError::SellAccountRentRecipientMismatch.into()); } diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index 4f390694..18ab9505 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -964,6 +964,10 @@ fn leaves_sell_token_account_open_without_matching_close_authority() { // The sell token account is empty but wasn't closed, since the state PDA // was never authorized as its close authority. assert_eq!(token::balance(&svm, &sell_token), 0); + assert!( + svm.get_account(&sell_token).is_some(), + "the sell token account should still be open" + ); } #[test] diff --git a/test-cli/src/cmd/create_order.rs b/test-cli/src/cmd/create_order.rs index 1a9e9db2..bd001ac5 100644 --- a/test-cli/src/cmd/create_order.rs +++ b/test-cli/src/cmd/create_order.rs @@ -4,7 +4,7 @@ use settlement_client::{ instructions::CreateOrder, settlement_interface::{ data::intent::{OrderIntent, OrderKind}, - pda::order::find_order_pda, + pda::{order::find_order_pda, state::find_state_pda}, }, }; use solana_sdk::{pubkey::Pubkey, signature::Signer, transaction::Transaction}; @@ -24,9 +24,14 @@ struct CommonArgs { partially_fillable: bool, /// Address receiving the sell token account's rent if a settlement closes - /// the account once it's empty (defaults to the payer) + /// the account once it's empty (defaults to the payer). If specified, will also + /// set close authority to the settlement program unless otherwise specified. #[arg(long)] sell_account_rent_recipient: Option, + + /// Explicitly control. If unset, will grant close authority if `--sell-account-rent-recipient` is set + /// and the close authority is not already granted. + set_close_authority: Option, } #[derive(Parser)] @@ -139,7 +144,6 @@ fn execute(ctx: Context, parsed: ParsedOrder, common: CommonArgs) -> anyhow::Res } = parsed; // If the sell token is SOL, wrap it into the payer's WSOL ATA first. - // NOTE: later this will be swapped for the solflow program. let mut ixs = Vec::new(); if sell_is_sol { @@ -152,9 +156,21 @@ fn execute(ctx: Context, parsed: ParsedOrder, common: CommonArgs) -> anyhow::Res // Create the account on the buy side if necessary ixs.extend(buy.create_ata_ix(&ctx.payer.pubkey())); + // Grant CloseAuthority permission to the settlement program state account if + // rent recipient was set. + if common.set_close_authority.unwrap_or(true) { + let (state_pda, _bump) = find_state_pda(&ctx.program_id); + ixs.push(crate::instructions::set_close_authority( + &spl_token_interface::ID, + &sell.ta, + &ctx.payer.pubkey(), + &state_pda, + )?); + } + // Approve the settlement state PDA to pull sell tokens on the user's behalf. ixs.push(crate::instructions::approve( - &ctx.program_id, + &spl_token_interface::ID, &sell.ta, &ctx.payer.pubkey(), sell_amount, diff --git a/test-cli/src/instructions.rs b/test-cli/src/instructions.rs index 5513a8bb..036b9817 100644 --- a/test-cli/src/instructions.rs +++ b/test-cli/src/instructions.rs @@ -54,3 +54,22 @@ pub fn approve( ) .context("failed to build Approve instruction") } + +/// Build `SetAuthority` instruction which modifies the current CloseAuthority +/// for an account. +pub fn set_close_authority( + program_id: &Pubkey, + token_account: &Pubkey, + owner: &Pubkey, + new_close_authority: &Pubkey, +) -> anyhow::Result { + token_ix::set_authority( + program_id, + token_account, + Some(new_close_authority), + token_ix::AuthorityType::CloseAccount, + owner, + &[], + ) + .context("failed to build SetAuthority instruction") +} From 5c4c5feb81b00c40c0fe9330cff69b3f756ee9d5 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:06:09 +0900 Subject: [PATCH 6/7] Only set close authority when a rent recipient is set `create_order` unconditionally granted CloseAuthority to the settlement state PDA. Default to granting it only when `--sell-account-rent-recipient` is set, and skip (with a warning) when the sell account already has a close authority pointing somewhere other than the state PDA. To make that check possible, `ResolvedToken` now carries the fetched token account data (`None` when the account is still to be created), and `instructions::approve` takes the delegate explicitly instead of re-deriving the state PDA internally. Co-Authored-By: Claude Opus 5 (1M context) --- test-cli/src/cmd/create_order.rs | 34 +++++++++++++++++++++++--------- test-cli/src/instructions.rs | 17 ++++++++-------- test-cli/src/token.rs | 22 +++++++++++++++++++++ 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/test-cli/src/cmd/create_order.rs b/test-cli/src/cmd/create_order.rs index bd001ac5..a2477010 100644 --- a/test-cli/src/cmd/create_order.rs +++ b/test-cli/src/cmd/create_order.rs @@ -7,7 +7,9 @@ use settlement_client::{ pda::{order::find_order_pda, state::find_state_pda}, }, }; -use solana_sdk::{pubkey::Pubkey, signature::Signer, transaction::Transaction}; +use solana_sdk::{ + program_option::COption, pubkey::Pubkey, signature::Signer, transaction::Transaction, +}; use std::time::{SystemTime, UNIX_EPOCH}; use super::Context; @@ -31,6 +33,7 @@ struct CommonArgs { /// Explicitly control. If unset, will grant close authority if `--sell-account-rent-recipient` is set /// and the close authority is not already granted. + #[arg(long)] set_close_authority: Option, } @@ -156,16 +159,28 @@ fn execute(ctx: Context, parsed: ParsedOrder, common: CommonArgs) -> anyhow::Res // Create the account on the buy side if necessary ixs.extend(buy.create_ata_ix(&ctx.payer.pubkey())); + let (state_pda, _bump) = find_state_pda(&ctx.program_id); + // Grant CloseAuthority permission to the settlement program state account if // rent recipient was set. - if common.set_close_authority.unwrap_or(true) { - let (state_pda, _bump) = find_state_pda(&ctx.program_id); - ixs.push(crate::instructions::set_close_authority( - &spl_token_interface::ID, - &sell.ta, - &ctx.payer.pubkey(), - &state_pda, - )?); + if common + .set_close_authority + .unwrap_or(common.sell_account_rent_recipient.is_some()) + { + // A sell account that doesn't exist yet (`None`) is created earlier in + // this same transaction, so it's still fine to set the authority on it. + if let Some(COption::Some(close_authority)) = sell.ta_data.map(|ta| ta.close_authority) { + if close_authority != state_pda { + println!("WARN: Skipping set of close authority: already set to non-settlement account {close_authority}"); + } + } else { + ixs.push(crate::instructions::set_close_authority( + &spl_token_interface::ID, + &sell.ta, + &ctx.payer.pubkey(), + &state_pda, + )?); + } } // Approve the settlement state PDA to pull sell tokens on the user's behalf. @@ -173,6 +188,7 @@ fn execute(ctx: Context, parsed: ParsedOrder, common: CommonArgs) -> anyhow::Res &spl_token_interface::ID, &sell.ta, &ctx.payer.pubkey(), + &state_pda, sell_amount, )?); diff --git a/test-cli/src/instructions.rs b/test-cli/src/instructions.rs index 036b9817..22770c9b 100644 --- a/test-cli/src/instructions.rs +++ b/test-cli/src/instructions.rs @@ -2,7 +2,7 @@ use crate::token; use anyhow::Context as _; -use settlement_client::settlement_interface::{pda::state::find_state_pda, Pubkey}; +use settlement_client::settlement_interface::Pubkey; use solana_instruction::Instruction; use solana_rpc_client::rpc_client::RpcClient; use spl_token_interface::instruction::{self as token_ix}; @@ -35,19 +35,18 @@ pub fn wrap_sol( } /// Build an `Approve` instruction delegating `amount` tokens on `token_account` -/// to the PDA derived from `program_id`. +/// to `delegate`. pub fn approve( - program_id: &Pubkey, + token_program_id: &Pubkey, token_account: &Pubkey, owner: &Pubkey, + delegate: &Pubkey, amount: u64, ) -> anyhow::Result { - let (settlement_pda, _) = find_state_pda(program_id); - token_ix::approve( - &spl_token_interface::id(), + token_program_id, token_account, - &settlement_pda, + delegate, owner, &[], amount, @@ -58,13 +57,13 @@ pub fn approve( /// Build `SetAuthority` instruction which modifies the current CloseAuthority /// for an account. pub fn set_close_authority( - program_id: &Pubkey, + token_program_id: &Pubkey, token_account: &Pubkey, owner: &Pubkey, new_close_authority: &Pubkey, ) -> anyhow::Result { token_ix::set_authority( - program_id, + token_program_id, token_account, Some(new_close_authority), token_ix::AuthorityType::CloseAccount, diff --git a/test-cli/src/token.rs b/test-cli/src/token.rs index a353af54..1490a0c8 100644 --- a/test-cli/src/token.rs +++ b/test-cli/src/token.rs @@ -46,6 +46,8 @@ fn known_token(genesis_hash: &str, symbol: &str) -> Option<&'static KnownToken> pub struct ResolvedToken { /// SPL token account to use in the order (ATA if supplied program argument was a mint). pub ta: Pubkey, + /// The actual token account data, or `None` when `ta` does not exist yet. + pub ta_data: Option, /// Mint address for the token. pub mint: Pubkey, /// The actual mint data @@ -83,6 +85,7 @@ pub fn resolve(rpc: &RpcClient, owner: &Pubkey, token_str: &str) -> anyhow::Resu ); return Ok(ResolvedToken { ta: wsol_ata, + ta_data: fetch_ta_data(rpc, &wsol_ata)?, mint: wsol_mint, create_ata: determine_create_ata(rpc, &wsol_mint, owner)?, mint_data: fetch_mint_data(rpc, &wsol_mint)?, @@ -107,6 +110,7 @@ pub fn resolve(rpc: &RpcClient, owner: &Pubkey, token_str: &str) -> anyhow::Resu ); return Ok(ResolvedToken { ta: ata, + ta_data: fetch_ta_data(rpc, &ata)?, create_ata: determine_create_ata(rpc, &known.mint, owner)?, mint: known.mint, mint_data: fetch_mint_data(rpc, &known.mint)?, @@ -137,6 +141,7 @@ pub fn resolve_from_token_account( Ok(ResolvedToken { ta: *token_account, + ta_data: Some(decoded_account), mint: decoded_account.mint, mint_data: fetch_mint_data(rpc, &decoded_account.mint)?, // The account was just fetched and unpacked above, so it already exists. @@ -166,6 +171,7 @@ pub fn interpret_token_from_user_input( if let Ok(token_account) = TokenAccount::unpack(&account.data) { Ok(ResolvedToken { ta: *token_account_or_mint, + ta_data: Some(token_account), mint: token_account.mint, mint_data: fetch_mint_data(rpc, &token_account.mint)?, // The account was just fetched and unpacked above, so it already exists. @@ -179,6 +185,7 @@ pub fn interpret_token_from_user_input( ); Ok(ResolvedToken { ta: ata, + ta_data: fetch_ta_data(rpc, &ata)?, mint_data: mint, mint: *token_account_or_mint, create_ata: determine_create_ata(rpc, token_account_or_mint, owner)?, @@ -220,3 +227,18 @@ fn fetch_mint_data(rpc: &RpcClient, mint: &Pubkey) -> anyhow::Result { Err(anyhow::anyhow!("account {mint} is not a mint")) } } + +/// Read `ta`'s current state, or `None` if it doesn't exist on-chain yet — the +/// callers resolve accounts they may still have to create, so a missing account +/// isn't an error here. +fn fetch_ta_data(rpc: &RpcClient, ta: &Pubkey) -> anyhow::Result> { + let Ok(data) = rpc.get_account_data(ta) else { + return Ok(None); + }; + + if let Ok(ta_data) = TokenAccount::unpack(&data) { + Ok(Some(ta_data)) + } else { + Err(anyhow::anyhow!("account {ta} is not a SPL token account")) + } +} From 666c76445574a5dcef94f3541cb0a10450b73aa7 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:53:36 +0900 Subject: [PATCH 7/7] fix bench report drift --- bench-report.json | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/bench-report.json b/bench-report.json index 3c87b636..b7d4a883 100644 --- a/bench-report.json +++ b/bench-report.json @@ -21,25 +21,25 @@ "settle/settles_multiple_orders": 18 }, "compute_units": { - "create_buffers/happy_path_creates_initialized_buffer_token_account": 10333, - "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21718, - "create_buffers/max_buffers_in_one_instruction": 176853, - "create_order/happy_path_creates_order_pda_with_expected_body": 4938, - "initialize/happy_path_initializes_state_pda_with_expected_data": 4520, - "reclaim_buffer/funded_buffer_is_skipped": 6368, - "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7518, - "reclaim_buffer/max_buffers_in_one_instruction": 136543, - "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18111, - "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2153, - "settle/finalizes_with_no_pushes": 7026, - "settle/pulls_from_multiple_orders": 19869, - "settle/pulls_funds_to_destination": 13467, - "settle/pulls_to_multiple_destinations": 14608, - "settle/pushes_a_single_order": 12326, - "settle/pushes_several_orders_from_different_buffers": 17585, - "settle/pushes_several_orders_from_one_buffer": 17586, - "settle/settles_a_single_order": 12344, - "settle/settles_multiple_orders": 22892 + "create_buffers/happy_path_creates_initialized_buffer_token_account": 10338, + "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21727, + "create_buffers/max_buffers_in_one_instruction": 176916, + "create_order/happy_path_creates_order_pda_with_expected_body": 4942, + "initialize/happy_path_initializes_state_pda_with_expected_data": 4525, + "reclaim_buffer/funded_buffer_is_skipped": 6376, + "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7523, + "reclaim_buffer/max_buffers_in_one_instruction": 136548, + "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18119, + "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2157, + "settle/finalizes_with_no_pushes": 7041, + "settle/pulls_from_multiple_orders": 19919, + "settle/pulls_funds_to_destination": 13501, + "settle/pulls_to_multiple_destinations": 14647, + "settle/pushes_a_single_order": 12354, + "settle/pushes_several_orders_from_different_buffers": 17623, + "settle/pushes_several_orders_from_one_buffer": 17624, + "settle/settles_a_single_order": 12372, + "settle/settles_multiple_orders": 22940 }, "transaction_bytes": { "create_buffers/happy_path_creates_initialized_buffer_token_account": 303,