From 0047ca936b23bf59d768360913c7e47e5243fbec Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:18:57 +0200 Subject: [PATCH 01/30] Refactor: cleanup before updating FinalizeSettle --- client/src/instructions.rs | 14 +- interface/src/instruction/settle/begin.rs | 47 +++-- interface/src/instruction/settle/mod.rs | 2 +- .../settlement/tests/begin_settle_orders.rs | 166 +++++------------- programs/settlement/tests/common/mod.rs | 1 + programs/settlement/tests/common/order.rs | 91 ++++++++++ 6 files changed, 166 insertions(+), 155 deletions(-) create mode 100644 programs/settlement/tests/common/order.rs diff --git a/client/src/instructions.rs b/client/src/instructions.rs index b0680e0..0a91a34 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -15,10 +15,10 @@ use settlement_interface::{ // We want the client to provide all instruction builders. pub use settlement_interface::instruction::settle::{FinalizeSettle, Pull}; -/// An order to settle together with the funds to pull from it: `intent` -/// identifies the order and `pulls` lists the [`Pull`]s to make from its sell -/// token account. -pub struct SettledOrder<'a> { +/// An order ready to be settled, together with the funds to pull from it: +/// `intent` identifies the order and `pulls` lists the [`Pull`]s to make from +/// its sell token account. +pub struct InitializedIntent<'a> { pub intent: &'a OrderIntent, pub pulls: &'a [Pull], } @@ -27,7 +27,7 @@ pub struct SettledOrder<'a> { pub struct BeginSettle<'a> { pub program_id: Pubkey, pub finalize_ix_index: u16, - pub orders: &'a [SettledOrder<'a>], + pub orders: &'a [InitializedIntent<'a>], } impl From> for Instruction { @@ -145,9 +145,9 @@ mod tests { let program_id = Pubkey::new_unique(); // No pulls here: this test only checks that orders are derived and // laid out correctly. - let orders: Vec = intents + let orders: Vec = intents .iter() - .map(|intent| SettledOrder { intent, pulls: &[] }) + .map(|intent| InitializedIntent { intent, pulls: &[] }) .collect(); let ix = Instruction::from(BeginSettle { program_id, diff --git a/interface/src/instruction/settle/begin.rs b/interface/src/instruction/settle/begin.rs index c690008..9026ff1 100644 --- a/interface/src/instruction/settle/begin.rs +++ b/interface/src/instruction/settle/begin.rs @@ -307,12 +307,11 @@ mod tests { assert_eq!(ix_program_id, program_id); assert_eq!( data, - [ - &[SettlementInstruction::BeginSettle.discriminator()][..], - &hex!("1337")[..], // counterpart index - &[0][..], // order count - ] - .concat(), + ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + hex!("1337"), // counterpart index + [0], // order count + ], ); // No orders: the three fixed accounts (sysvar, state PDA, token program). assert_eq!(accounts.len(), 3); @@ -352,14 +351,13 @@ mod tests { // Bumps follow the sorted order: the low PDA's bump comes first. assert_eq!( data, - [ - &[SettlementInstruction::BeginSettle.discriminator()][..], - &hex!("1337")[..], // counterpart index - &[2][..], // order count - &[low_bump, high_bump][..], // bumps - &[0, 0][..], // transfer counts (both zero) - ] - .concat(), + ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + hex!("1337"), // counterpart index + [2], // order count + [low_bump, high_bump], // bumps + [0, 0], // transfer counts (both zero) + ], ); let expected: Vec = vec![ @@ -428,18 +426,17 @@ mod tests { assert_eq!( data, - [ - &[SettlementInstruction::BeginSettle.discriminator()][..], - &hex!("1337")[..], // counterpart index - &[2][..], // order count - &[0xa1, 0xb1][..], // bumps - &[2, 1][..], // counts + ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + hex!("1337"), // counterpart index + [2], // order count + [0xa1, 0xb1], // bumps + [2, 1], // counts // amounts - &hex!("0000000000000102")[..], - &hex!("0000000000000304")[..], - &hex!("0000000000000506")[..], - ] - .concat(), + hex!("0000000000000102"), + hex!("0000000000000304"), + hex!("0000000000000506"), + ], ); let expected: Vec = vec![ diff --git a/interface/src/instruction/settle/mod.rs b/interface/src/instruction/settle/mod.rs index d9afa22..b2c553f 100644 --- a/interface/src/instruction/settle/mod.rs +++ b/interface/src/instruction/settle/mod.rs @@ -9,7 +9,7 @@ pub use spl_token_interface::ID as SPL_TOKEN_PROGRAM_ID; mod begin; mod finalize; -pub use begin::{BeginSettle, BeginSettleInput, Pull, SettledOrder, SettledOrders}; +pub use begin::{BeginSettle, BeginSettleInput, Pull, SettledOrder}; pub use finalize::{FinalizeSettle, FinalizeSettleInput}; /// Reads the first two bytes of a byte slice (instruction data) and diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index abf1811..4191a78 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -6,18 +6,14 @@ //! order-list checks, which is what these tests exercise. use crate::common::{ - assert_instruction_error, assert_settlement_error, create_account, set_unix_timestamp, setup, - signed_tx, token, + assert_instruction_error, assert_settlement_error, create_account, + order::{create_order_pda, sample_intent, OrderBuilder}, + set_unix_timestamp, setup, token, }; use litesvm::{types::TransactionMetadata, LiteSVM}; -use settlement_client::instructions::{ - BeginSettle, CreateOrder, FinalizeSettle, Pull, SettledOrder, -}; +use settlement_client::instructions::{BeginSettle, FinalizeSettle, InitializedIntent, Pull}; use settlement_client::settlement_interface::{ - data::{ - intent::{OrderIntent, OrderKind}, - order::{EncodedOrderAccount, OrderAccount}, - }, + data::order::{EncodedOrderAccount, OrderAccount}, instruction::settle::{ BeginSettle as BeginSettleRaw, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, }, @@ -40,80 +36,6 @@ fn no_pulls(n: usize) -> Vec<&'static [Pull]> { vec![&[]; n] } -fn sample_intent(owner: Pubkey, sell_token_account: Pubkey, salt: u8) -> OrderIntent { - OrderIntent { - owner, - buy_token_account: Pubkey::new_from_array([0x22; 32]), - sell_token_account, - sell_amount: 1_000_000, - buy_amount: 2_000_000, - valid_to: 0xdead_beef, - kind: OrderKind::Sell, - partially_fillable: true, - // `salt` is folded into `app_data` so callers can mint several orders that - // hash to different UIDs (and therefore different order PDAs). - app_data: [salt; 32], - } -} - -/// Create `intent`'s order PDA on-chain, signed and paid for by `owner`. -fn create_order_pda(svm: &mut LiteSVM, program_id: &Pubkey, owner: &Keypair, intent: &OrderIntent) { - let ix = CreateOrder { - program_id: *program_id, - owner: owner.pubkey(), - created_by: owner.pubkey(), - intent, - }; - let tx = signed_tx(svm, owner, owner, ix); - svm.send_transaction(tx) - .expect("create_order should succeed"); -} - -/// Builder that mints a valid settleable order on-chain and returns its intent. -/// If nothing else is specified, It uses default parameters to build the order. -/// Individula parameters can be changed before building the order. -struct SettleableOrder<'a> { - svm: &'a mut LiteSVM, - program_id: &'a Pubkey, - payer: &'a Keypair, - intent: OrderIntent, -} - -impl<'a> SettleableOrder<'a> { - fn new( - svm: &'a mut LiteSVM, - program_id: &'a Pubkey, - payer: &'a Keypair, - mint: &'a Pubkey, - ) -> Self { - let sell_token = token::create_token_account(svm, payer, mint, &payer.pubkey()); - let intent = sample_intent(payer.pubkey(), sell_token, 0); - Self { - svm, - program_id, - payer, - intent, - } - } - - /// Make this order distinct from its siblings: `salt` is folded into - /// `app_data` so each value hashes to a different UID (and order PDA). - fn salt(mut self, salt: u8) -> Self { - self.intent.app_data = [salt; 32]; - self - } - - fn valid_to(mut self, valid_to: u32) -> Self { - self.intent.valid_to = valid_to; - self - } - - fn build(self) -> OrderIntent { - create_order_pda(self.svm, self.program_id, self.payer, &self.intent); - self.intent - } -} - /// Send `[begin, finalize_settle(..)]` signed by `payer`, where `begin` is a /// pre-built `BeginSettle` instruction. fn send_settlement( @@ -141,7 +63,7 @@ fn settle( svm: &mut LiteSVM, program_id: &Pubkey, payer: &Keypair, - orders: &[SettledOrder], + orders: &[InitializedIntent], ) -> Result { send_settlement( svm, @@ -184,12 +106,12 @@ fn settles_a_single_order() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); settle( &mut svm, &program_id, &payer, - &[SettledOrder { + &[InitializedIntent { intent: &intent, pulls: &[], }], @@ -205,15 +127,15 @@ fn settles_multiple_orders() { let mut intents = Vec::new(); for salt in 0..3u8 { intents.push( - SettleableOrder::new(&mut svm, &program_id, &payer, &mint) + OrderBuilder::new(&mut svm, &program_id, &payer, &mint) .salt(salt) .build(), ); } - let orders: Vec = intents + let orders: Vec = intents .iter() - .map(|intent| SettledOrder { intent, pulls: &[] }) + .map(|intent| InitializedIntent { intent, pulls: &[] }) .collect(); settle(&mut svm, &program_id, &payer, &orders).expect("multi-order settlement should succeed"); } @@ -223,7 +145,7 @@ fn rejects_wrong_bump() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); assert_settlement_error( settle_raw( @@ -297,7 +219,7 @@ fn rejects_sell_token_account_mismatch() { let mint = token::create_mint(&mut svm, &payer); // Supply a different token account than the one the order's intent names. - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); let wrong_sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); assert_settlement_error( @@ -328,7 +250,7 @@ fn rejects_sell_token_owner_mismatch() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[InitializedIntent { intent: &intent, pulls: &[], }], @@ -350,7 +272,7 @@ fn rejects_non_token_sell_account() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[InitializedIntent { intent: &intent, pulls: &[], }], @@ -364,18 +286,18 @@ fn rejects_duplicate_orders() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); assert_settlement_error( settle( &mut svm, &program_id, &payer, &[ - SettledOrder { + InitializedIntent { intent: &intent, pulls: &[], }, - SettledOrder { + InitializedIntent { intent: &intent, pulls: &[], }, @@ -390,10 +312,10 @@ fn rejects_orders_in_wrong_address_order() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let first = SettleableOrder::new(&mut svm, &program_id, &payer, &mint) + let first = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) .salt(0) .build(); - let second = SettleableOrder::new(&mut svm, &program_id, &payer, &mint) + let second = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) .salt(1) .build(); @@ -483,7 +405,7 @@ fn rejects_cancelled_order() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[InitializedIntent { intent: &intent, pulls: &[], }], @@ -498,7 +420,7 @@ fn rejects_expired_order() { let mint = token::create_mint(&mut svm, &payer); let valid_to = 1_000_000; - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint) + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) .valid_to(valid_to) .build(); let after_expiration = i64::from(valid_to) + 1; @@ -509,7 +431,7 @@ fn rejects_expired_order() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[InitializedIntent { intent: &intent, pulls: &[], }], @@ -524,7 +446,7 @@ fn settles_order_at_exact_valid_to() { let mint = token::create_mint(&mut svm, &payer); let valid_to = 1_000_000; - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint) + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) .valid_to(valid_to) .build(); set_unix_timestamp(&mut svm, i64::from(valid_to)); @@ -533,7 +455,7 @@ fn settles_order_at_exact_valid_to() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[InitializedIntent { intent: &intent, pulls: &[], }], @@ -546,7 +468,7 @@ fn pulls_funds_to_destination() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &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); @@ -557,7 +479,7 @@ fn pulls_funds_to_destination() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[InitializedIntent { intent: &intent, pulls: &[Pull { destination, @@ -580,7 +502,7 @@ fn pulls_to_multiple_destinations() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let sell_token = intent.sell_token_account; let initial_amount: u64 = 1_000_000; token::fund_and_delegate(&mut svm, &program_id, &payer, &sell_token, initial_amount); @@ -593,7 +515,7 @@ fn pulls_to_multiple_destinations() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[InitializedIntent { intent: &intent, pulls: &[ Pull { @@ -627,10 +549,10 @@ fn pulls_from_multiple_orders() { let mint = token::create_mint(&mut svm, &payer); // Two distinct orders, each selling from its own token account. - let first = SettleableOrder::new(&mut svm, &program_id, &payer, &mint) + let first = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) .salt(0) .build(); - let second = SettleableOrder::new(&mut svm, &program_id, &payer, &mint) + let second = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) .salt(1) .build(); let initial_amount_first = 1_337_000; @@ -659,14 +581,14 @@ fn pulls_from_multiple_orders() { &program_id, &payer, &[ - SettledOrder { + InitializedIntent { intent: &first, pulls: &[Pull { destination: dest_first, amount: pulled_first, }], }, - SettledOrder { + InitializedIntent { intent: &second, pulls: &[Pull { destination: dest_second, @@ -694,7 +616,7 @@ fn zero_pulls_moves_nothing() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let sell_token = intent.sell_token_account; let initial_amount = 42_000_000; token::mint_to(&mut svm, &payer, &mint, &sell_token, initial_amount); @@ -703,7 +625,7 @@ fn zero_pulls_moves_nothing() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[InitializedIntent { intent: &intent, pulls: &[], }], @@ -721,7 +643,7 @@ fn rejects_wrong_state_pda() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); let not_the_state_pda = Pubkey::new_unique(); @@ -749,14 +671,14 @@ fn rejects_wrong_token_program() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); // The builder always fills in the SPL Token program, so we swap the // token-program account out afterwards. let mut begin: Instruction = BeginSettle { program_id, finalize_ix_index: 1, - orders: &[SettledOrder { + orders: &[InitializedIntent { intent: &intent, pulls: &[], }], @@ -776,7 +698,7 @@ fn rejects_pull_delegated_to_incorrect_address() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let amount = 100_000; let sell_token = intent.sell_token_account; // Funds are present but some account other than the state PDA was @@ -789,7 +711,7 @@ fn rejects_pull_delegated_to_incorrect_address() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[InitializedIntent { intent: &intent, pulls: &[Pull { destination, @@ -808,7 +730,7 @@ fn rejects_pull_exceeding_delegation() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let sell_token = intent.sell_token_account; // Funded generously, but the state PDA is delegated only 100_000. let initial_amount = 42_000_000; @@ -827,7 +749,7 @@ fn rejects_pull_exceeding_delegation() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[InitializedIntent { intent: &intent, pulls: &[Pull { destination, @@ -850,12 +772,12 @@ fn rejects_extra_account() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); // A well-formed single-order, no-transfer settlement... let mut begin: Instruction = BeginSettle { program_id, finalize_ix_index: 1, - orders: &[SettledOrder { + orders: &[InitializedIntent { intent: &intent, pulls: &[], }], diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index b93dec3..ceebf75 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -6,6 +6,7 @@ )] pub mod lookup_table; +pub mod order; pub mod pda; pub mod token; diff --git a/programs/settlement/tests/common/order.rs b/programs/settlement/tests/common/order.rs new file mode 100644 index 0000000..7f23f79 --- /dev/null +++ b/programs/settlement/tests/common/order.rs @@ -0,0 +1,91 @@ +//! On-chain order construction shared by the settlement integration tests. + +use litesvm::LiteSVM; +use settlement_client::instructions::CreateOrder; +use settlement_client::settlement_interface::data::intent::{OrderIntent, OrderKind}; +use solana_sdk::{ + pubkey::Pubkey, + signature::{Keypair, Signer}, +}; + +use super::{signed_tx, token}; + +/// A default valid sell order owned by `owner`, selling from `sell_token_account`. +/// `salt` is folded into `app_data` so callers can mint several orders that hash +/// to different UIDs (and therefore different order PDAs). +pub fn sample_intent(owner: Pubkey, sell_token_account: Pubkey, salt: u8) -> OrderIntent { + OrderIntent { + owner, + buy_token_account: Pubkey::new_from_array([0x22; 32]), + sell_token_account, + sell_amount: 1_000_000, + buy_amount: 2_000_000, + valid_to: 0xdead_beef, + kind: OrderKind::Sell, + partially_fillable: true, + app_data: [salt; 32], + } +} + +/// Create `intent`'s order PDA on-chain, signed and paid for by `owner`. +pub fn create_order_pda( + svm: &mut LiteSVM, + program_id: &Pubkey, + owner: &Keypair, + intent: &OrderIntent, +) { + let ix = CreateOrder { + program_id: *program_id, + owner: owner.pubkey(), + created_by: owner.pubkey(), + intent, + }; + let tx = signed_tx(svm, owner, owner, ix); + svm.send_transaction(tx) + .expect("create_order should succeed"); +} + +/// Builder that mints a valid settleable order on-chain and returns its intent. +/// If nothing else is specified, it uses default parameters to build the order. +/// Individual parameters can be changed before building the order. +pub struct OrderBuilder<'a> { + svm: &'a mut LiteSVM, + program_id: &'a Pubkey, + payer: &'a Keypair, + intent: OrderIntent, +} + +impl<'a> OrderBuilder<'a> { + pub fn new( + svm: &'a mut LiteSVM, + program_id: &'a Pubkey, + payer: &'a Keypair, + mint: &'a Pubkey, + ) -> Self { + let sell_token = token::create_token_account(svm, payer, mint, &payer.pubkey()); + let intent = sample_intent(payer.pubkey(), sell_token, 0); + Self { + svm, + program_id, + payer, + intent, + } + } + + /// Make this order distinct from its siblings: `salt` is folded into + /// `app_data` so each value hashes to a different UID (and order PDA). + pub fn salt(mut self, salt: u8) -> Self { + self.intent.app_data = [salt; 32]; + self + } + + pub fn valid_to(mut self, valid_to: u32) -> Self { + self.intent.valid_to = valid_to; + self + } + + pub fn build(self) -> OrderIntent { + create_order_pda(self.svm, self.program_id, self.payer, &self.intent); + self.intent + } +} From b8b3804d39c987e91c92956a80cef00d034a830f Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:24:20 +0200 Subject: [PATCH 02/30] Parse pushes in `FinalizeSettle` --- DESIGN.md | 3 +- client/src/instructions.rs | 151 +++++- interface/src/instruction/settle/finalize.rs | 471 ++++++++++++++++-- interface/src/instruction/settle/mod.rs | 2 +- interface/src/lib.rs | 4 + .../settlement/tests/begin_settle_orders.rs | 1 + .../tests/finalize_settle_pushes.rs | 251 ++++++++++ .../tests/matching_begin_finalize.rs | 4 + .../settlement/tests/program_deployment.rs | 1 + 9 files changed, 831 insertions(+), 57 deletions(-) create mode 100644 programs/settlement/tests/finalize_settle_pushes.rs diff --git a/DESIGN.md b/DESIGN.md index ec39931..7363c3d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -256,9 +256,8 @@ Differences with Ethereum: A settlement transaction is split into multiple instructions. All settlement operations occur between a `BeginSettle` and a `FinalizeSettle` instruction with the exception of arbitrary interactions, which can take place at any point of a transaction. Except for that, the order of instructions in the transaction is arbitrary. - `BeginSettle`: Snapshots each order's receiver token account, spender token account, and withdrawal balances. Pulls funds from each order’s sell token account to the solver-specified destination accounts, using the settlement state PDA’s token delegation. Carries an explicit `finalize_ix_index` pointing to its paired `FinalizeSettle`. -- `Push`: It references a unique SPL transfer token instruction between `BeginSettle` and `FinalizeSettle` that sends the proceeds of an order to its buy token account. - (arbitrary interactions): Any instruction from the solver. This could be a token transfer, an AMM swap, or anything else. -- `FinalizeSettle`: Reads balances again, computes deltas against the snapshots, validates clearing/limit prices, updates `amount_received` and order status, revokes solver approvals. Carries an explicit `begin_ix_index` pointing to its paired `BeginSettle`. +- `FinalizeSettle`: Pushes the proceeds of each order from the settlement’s buffer accounts to the order’s buy token account, using the settlement state PDA’s authority over the buffers. Reads balances again, computes deltas against the snapshots, validates clearing/limit prices, updates `amount_received` and order status, revokes solver approvals. Carries an explicit `begin_ix_index` pointing to its paired `BeginSettle`. Additionally, a settlement transaction will include the batch number as part of the instruction bytes of `BeginSettle`. diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 0a91a34..84403d5 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -13,7 +13,7 @@ use settlement_interface::{ // Reexport the instruction builders that don't change from the interface. // We want the client to provide all instruction builders. -pub use settlement_interface::instruction::settle::{FinalizeSettle, Pull}; +pub use settlement_interface::instruction::settle::Pull; /// An order ready to be settled, together with the funds to pull from it: /// `intent` identifies the order and `pulls` lists the [`Pull`]s to make from @@ -57,6 +57,66 @@ impl From> for Instruction { } } +/// A settled order whose proceeds are pushed to it: `intent` identifies the +/// order (its `buy_token_account` is the push destination), `mint` selects the +/// canonical source buffer, and `amount` is the quantity to push. +pub struct FinalizedIntent<'a> { + pub intent: &'a OrderIntent, + pub mint: Pubkey, + pub amount: u64, +} + +/// Builder for a `FinalizeSettle` instruction pushing each order's proceeds to +/// its buy token account. +/// +/// The destination is the order intent's `buy_token_account` and the source is +/// the canonical buffer PDA for `mint` (see [`find_buffer_pda`]). The orders are +/// sorted by their canonical order PDA (the same key [`BeginSettle`] orders its +/// settled-order list by) so the two instructions present the orders in the +/// same order and their lists line up. +pub struct FinalizeSettle<'a> { + pub program_id: Pubkey, + pub begin_ix_index: u16, + pub orders: &'a [FinalizedIntent<'a>], +} + +impl From> for Instruction { + fn from(builder: FinalizeSettle<'_>) -> Self { + // Sort the orders by their canonical order PDA, the key `BeginSettle` + // lays its settled orders out by, so the two instruction lists align. + // For BeginSettle, sorting can take place in the interface. But the + // order PDAs don't appear in the actual FinalizeSettle instruction, so + // the sorting can only happen here. + let mut order: Vec = (0..builder.orders.len()).collect(); + order.sort_by_key(|&i| { + find_order_pda(&builder.program_id, &builder.orders[i].intent.uid()).0 + }); + + let mut source_buffers: Vec = Vec::with_capacity(builder.orders.len()); + let mut destinations = Vec::with_capacity(builder.orders.len()); + let mut bumps = Vec::with_capacity(builder.orders.len()); + let mut amounts = Vec::with_capacity(builder.orders.len()); + for &i in &order { + let (buffer_pda, bump) = find_buffer_pda(&builder.program_id, &builder.orders[i].mint); + source_buffers.push(buffer_pda); + destinations.push(builder.orders[i].intent.buy_token_account); + bumps.push(bump); + amounts.push(builder.orders[i].amount); + } + let (state_pda, _bump) = find_state_pda(&builder.program_id); + settlement_interface::instruction::settle::FinalizeSettle { + program_id: builder.program_id, + state_pda, + begin_ix_index: builder.begin_ix_index, + source_buffers: &source_buffers, + destinations: &destinations, + bumps: &bumps, + amounts: &amounts, + } + .into() + } +} + pub struct CreateOrder<'a> { pub program_id: Pubkey, pub owner: Pubkey, @@ -127,14 +187,16 @@ mod tests { data::intent::fixtures::arb_order_intent, instruction::{ fixtures::fake_account_from_array, - settle::{BeginSettleInput, INSTRUCTIONS_SYSVAR_ID}, + settle::{ + BeginSettleInput, FinalizeSettleInput, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, + }, InstructionInputParsing, }, pda::order::find_order_pda, }; proptest! { - // `begin_settle` derives each order's PDA from its intent and forwards to + // `BeginSettle` derives each order's PDA from its intent and forwards to // the interface builder so that the on-chain parser recovers exactly // those orders. #[test] @@ -188,5 +250,88 @@ mod tests { prop_assert_eq!(order.bump, *bump); } } + + // `FinalizeSettle` derives each order's source buffer from its mint and + // destination from the intent, sorting by canonical order PDA like + // `BeginSettle` so the on-chain parser recovers exactly those pushes in + // that order. + #[test] + fn finalize_settle_derives_buffers_from_mints( + begin_ix_index in any::(), + cases in prop::collection::vec( + (arb_order_intent(), any::<[u8; 32]>(), any::()), + 1..=5, + ), + ) { + let program_id = Pubkey::new_unique(); + let orders: Vec = cases + .iter() + .map(|(intent, mint, amount)| FinalizedIntent { + intent, + mint: Pubkey::new_from_array(*mint), + amount: *amount, + }) + .collect(); + let ix = Instruction::from(FinalizeSettle { + program_id, + begin_ix_index, + orders: &orders, + }); + + // Expected pushes: each order's buffer PDA (and its canonical bump), + // buy token account, and amount, sorted by the order's canonical PDA + // (the builder's order). + struct ExpectedPush { + order_pda: Pubkey, + buffer: Pubkey, + bump: u8, + destination: Pubkey, + amount: u64, + } + let mut expected: Vec = orders + .iter() + .map(|order| { + let (order_pda, _bump) = find_order_pda(&program_id, &order.intent.uid()); + let (buffer, bump) = find_buffer_pda(&program_id, &order.mint); + ExpectedPush { + order_pda, + buffer, + bump, + destination: order.intent.buy_token_account, + amount: order.amount, + } + }) + .collect(); + expected.sort_by_key(|push| push.order_pda); + + let mut accounts: Vec<_> = ix + .accounts + .iter() + .map(|meta| fake_account_from_array(meta.pubkey.to_bytes())) + .collect(); + let parsed = FinalizeSettleInput::parse(&ix.data, &mut accounts) + .map_err(|e| TestCaseError::fail(format!("parse failed: {e:?}")))?; + + prop_assert_eq!(parsed.begin_ix_index, begin_ix_index); + prop_assert_eq!( + parsed.instructions_sysvar_account.address(), + &INSTRUCTIONS_SYSVAR_ID, + ); + let (state_pda, _bump) = find_state_pda(&program_id); + prop_assert_eq!(parsed.state_pda_account.address(), &state_pda); + prop_assert_eq!( + parsed.token_program_account.address(), + &SPL_TOKEN_PROGRAM_ID, + ); + + let parsed_pushes: Vec<_> = parsed.pushes.iter().collect(); + prop_assert_eq!(parsed_pushes.len(), expected.len()); + for (push, expected) in parsed_pushes.iter().zip(&expected) { + prop_assert_eq!(push.source_buffer.address(), &expected.buffer); + prop_assert_eq!(push.destination.address(), &expected.destination); + prop_assert_eq!(push.bump, expected.bump); + prop_assert_eq!(u64::from_be_bytes(*push.amount), expected.amount); + } + } } } diff --git a/interface/src/instruction/settle/finalize.rs b/interface/src/instruction/settle/finalize.rs index e6dc737..8684333 100644 --- a/interface/src/instruction/settle/finalize.rs +++ b/interface/src/instruction/settle/finalize.rs @@ -8,60 +8,207 @@ use solana_program_error::ProgramError; use solana_pubkey::Pubkey; use crate::instruction::InstructionInputParsing; -use crate::SettlementInstruction; +use crate::{SettlementError, SettlementInstruction}; -use super::{recover_counterpart, INSTRUCTIONS_SYSVAR_ID}; +use super::{recover_counterpart, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID}; -/// Builder for a `FinalizeSettle` instruction. +/// Builder for a `FinalizeSettle` instruction pushing the funds described by the +/// parallel lists: +/// - `source_buffers[i]` is the buffer token account the funds come from, +/// - `destinations[i]` is the account the funds go to (an order's buy token +/// account), +/// - `bumps[i]` is the canonical bump of `source_buffers[i]`, so the program +/// re-derives the buffer PDA with one hash instead of searching, +/// - `amounts[i]` is the amount to push. /// -/// `begin_ix_index` is the index of the paired `BeginSettle` instruction in the -/// same transaction. +/// This instruction comes in pair with a `BeginSettle` instruction. The slices +/// are assumed to be built in parallel with the order information specified in +/// `BeginSettle`. Notably, (1) the source buffer is the one corresponding to +/// the order's buy token, and (2) the destinations must be the buy token +/// accounts specified in the corresponding order. +/// The slices are assumed to have the same length but this is not enforced by +/// the builder. /// -/// Wire format: `[discriminator=1, begin_ix_index: u16 BE]`, 3 bytes. -/// Required accounts: `[instructions_sysvar (R)]`. -pub struct FinalizeSettle { +/// Wire format (with `n` total pushes): +/// `[discriminator=1][begin_ix_index: u16 BE][bump: u8 ×n][amount: u64 BE ×n]`. +/// Accounts: +/// `[instructions_sysvar (R), state_pda (R), token_program (R)]` followed, per +/// push, by `[source_buffer (W), destination (W)]`. +/// +/// `FinalizeSettle` validates that each source is the canonical buffer for its +/// destination's mint and executes the transfers; the order correspondence and +/// that each destination is an order's buy token account are `BeginSettle`'s +/// checks. So a push isn't aware of what orders are being paid, just the accounts +/// to move funds between, the source's bump, and the amount. The same buffer may +/// legitimately fund several pushes. +pub struct FinalizeSettle<'a> { pub program_id: Pubkey, + pub state_pda: Pubkey, pub begin_ix_index: u16, + pub source_buffers: &'a [Pubkey], + pub destinations: &'a [Pubkey], + pub bumps: &'a [u8], + pub amounts: &'a [u64], } -impl From for Instruction { - fn from(builder: FinalizeSettle) -> Self { +impl From> for Instruction { + fn from(builder: FinalizeSettle<'_>) -> Self { + let FinalizeSettle { + program_id, + state_pda, + begin_ix_index, + source_buffers, + destinations, + bumps, + amounts, + } = builder; + + let data: Vec = core::iter::once(SettlementInstruction::FinalizeSettle.discriminator()) + .chain(begin_ix_index.to_be_bytes()) + .chain(bumps.iter().copied()) + .chain(amounts.iter().flat_map(|amount| amount.to_be_bytes())) + .collect(); + + let mut accounts = vec![ + AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false), + AccountMeta::new_readonly(state_pda, false), + AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), + ]; + for (source, destination) in source_buffers.iter().zip(destinations) { + accounts.push(AccountMeta::new(*source, false)); + accounts.push(AccountMeta::new(*destination, false)); + } + Instruction { - program_id: builder.program_id, - accounts: vec![AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false)], - data: [ - &[SettlementInstruction::FinalizeSettle.discriminator()], - &builder.begin_ix_index.to_be_bytes()[..], - ] - .concat(), + program_id, + accounts, + data, } } } +/// A single fund push parsed from `FinalizeSettle`: move `amount` (big-endian +/// `u64`) from `source_buffer` to `destination`. `bump` is `source_buffer`'s +/// claimed canonical buffer bump, which the program re-derives against. +pub struct Push<'a> { + pub source_buffer: &'a AccountView, + pub destination: &'a AccountView, + pub bump: u8, + pub amount: &'a [u8; 8], +} + +/// Struct storing accounts, bumps, and amounts from parsing the input of +/// `FinalizeSettle`, laid out as a flat list of `[source_buffer, destination]` +/// account pairs parallel to `bumps` and `amounts`. The parsing step that created +/// this struct guarantees `push_accounts.len() == 2 * amounts.len()` and +/// `bumps.len() == amounts.len()`, so the offsets below never run short. +pub struct Pushes<'a> { + /// `[source_buffer, destination]` per push, flattened. + push_accounts: &'a [AccountView], + bumps: &'a [u8], + /// One push amount (big-endian `u64`) per push, parallel to `bumps`. + amounts: &'a [[u8; 8]], +} + +impl<'a> Pushes<'a> { + /// Returns an iterator yielding one [`Push`] per step. + #[allow( + clippy::arithmetic_side_effects, + reason = "offsets are bounded by tx limits" + )] + pub fn iter(&self) -> impl Iterator> + '_ { + let push_count = self.bumps.len(); + let mut i = 0usize; + let mut account_offset = 0usize; + std::iter::from_fn(move || { + if i >= push_count { + return None; + } + let bump = self.bumps[i]; + let amount = &self.amounts[i]; + i += 1; + + let source_buffer = &self.push_accounts[account_offset]; + let destination = &self.push_accounts[account_offset + 1]; + account_offset += 2; + + Some(Push { + source_buffer, + destination, + bump, + amount, + }) + }) + } +} + /// Parsed inputs (instruction-data fields + relevant accounts) of a /// `FinalizeSettle` instruction. /// /// Strictly the raw extracted form. Fields are read from `instruction_data` and /// `accounts` but **not validated** against runtime context except confirming -/// that the discriminator matches the desired input. +/// that the discriminator matches the desired input and that the number of +/// accounts and amounts is consistent. pub struct FinalizeSettleInput<'a> { pub begin_ix_index: u16, pub instructions_sysvar_account: &'a AccountView, + pub state_pda_account: &'a AccountView, + pub token_program_account: &'a AccountView, + pub pushes: Pushes<'a>, } +/// This implementation defines how instruction bytes and accounts are laid out +/// in the transaction. It's the source of truth for deciding where the data +/// is stored. impl<'a> InstructionInputParsing<'a> for FinalizeSettleInput<'a> { const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::FinalizeSettle; fn parse_body( - instruction_data: &[u8], + instruction_data: &'a [u8], accounts: &'a mut [AccountView], ) -> Result { - let (begin_ix_index, _) = recover_counterpart(instruction_data)?; - let instructions_sysvar_account = - accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + let (begin_ix_index, body) = recover_counterpart(instruction_data)?; + + let [instructions_sysvar_account, state_pda_account, token_program_account, push_accounts @ ..] = + accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + // The body after the begin index is, per push, a bump byte (all `n` + // first) then a big-endian `u64` amount: `9 * n` bytes. Unlike + // `BeginSettle`, there's no explicit count byte, so `n` is recovered as + // `body.len() / 9`; a body that isn't a whole number of these 9-byte + // pushes can't be parsed into the push layout at all (and would otherwise + // leave `bumps` and `amounts` with mismatched lengths). + if body.len() % 9 != 0 { + return Err(ProgramError::InvalidInstructionData); + } + let push_count = body.len() / 9; + let (bumps, amount_bytes) = body.split_at(push_count); + let (amounts, []) = amount_bytes.as_chunks::<8>() else { + return Err(ProgramError::InvalidInstructionData); + }; + + // Each push contributes a source buffer and a destination account, so + // the push-account count is `2 * n`. + let expected_accounts = push_count + .checked_mul(2) + .ok_or(ProgramError::InvalidInstructionData)?; + if push_accounts.len() != expected_accounts { + return Err(SettlementError::AccountCountNotMatchingPushCount.into()); + } + Ok(Self { begin_ix_index, instructions_sysvar_account, + state_pda_account, + token_program_account, + pushes: Pushes { + push_accounts, + bumps, + amounts, + }, }) } } @@ -69,39 +216,121 @@ impl<'a> InstructionInputParsing<'a> for FinalizeSettleInput<'a> { #[cfg(test)] mod tests { use super::*; - use crate::instruction::fixtures::fake_account; + use crate::instruction::fixtures::{ + fake_account, fake_account_from_array, fake_sequential_accounts, + }; use crate::instruction::settle::tests::ix_data; use hex_literal::hex; use solana_address::Address; + /// The fixed accounts every `FinalizeSettle` carries before its push + /// accounts: the instructions sysvar, the settlement state PDA, and the + /// token program. + const FIXED_ACCOUNTS: usize = 3; + #[test] - fn expected_encoding_finalize_settle() { + fn expected_encoding_finalize_settle_no_pushes() { let program_id = Pubkey::new_unique(); - let Instruction { data, accounts, .. } = FinalizeSettle { + let state_pda = Pubkey::new_unique(); + let Instruction { + program_id: ix_program_id, + accounts, + data, + } = FinalizeSettle { program_id, + state_pda, begin_ix_index: 0x1337, + source_buffers: &[], + destinations: &[], + bumps: &[], + amounts: &[], } .into(); + assert_eq!(ix_program_id, program_id); assert_eq!( data, - [ - &[SettlementInstruction::FinalizeSettle.discriminator()][..], - &hex!("1337")[..], // counterpart index - ] - .concat(), + ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + hex!("1337"), // counterpart index + ], ); - - // Only the instructions sysvar is referenced. - assert_eq!(accounts.len(), 1); + // No pushes: the three fixed accounts (sysvar, state PDA, token program). + assert_eq!(accounts.len(), 3); assert_eq!(accounts[0].pubkey, INSTRUCTIONS_SYSVAR_ID); - assert!(!accounts[0].is_writable); - assert!(!accounts[0].is_signer); + assert_eq!(accounts[1].pubkey, state_pda); + assert_eq!(accounts[2].pubkey, SPL_TOKEN_PROGRAM_ID); + assert!(accounts + .iter() + .all(|meta| !meta.is_writable && !meta.is_signer)); } #[test] - fn finalize_settle_input_parses_valid_input() { - let address = Address::new_from_array([0x42u8; 32]); - let mut accounts = [fake_account(address)]; + fn finalize_settle_encodes_pushes() { + let program_id = Pubkey::new_unique(); + let state_pda = Pubkey::new_unique(); + let source_a = Pubkey::new_from_array([0x01; 32]); + let dest_a = Pubkey::new_from_array([0x02; 32]); + let source_b = Pubkey::new_from_array([0x03; 32]); + let dest_b = Pubkey::new_from_array([0x04; 32]); + + let ix = Instruction::from(FinalizeSettle { + program_id, + state_pda, + begin_ix_index: 0x1337, + source_buffers: &[source_a, source_b], + destinations: &[dest_a, dest_b], + bumps: &[0xa1, 0xb1], + amounts: &[0x01020304, 0x05060708], + }); + + assert_eq!( + ix.data, + ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + hex!("1337"), // counterpart index + [0xa1, 0xb1], // bumps + // amounts + hex!("0000000001020304"), + hex!("0000000005060708"), + ], + ); + + let actual: Vec = ix.accounts.iter().map(|meta| meta.pubkey).collect(); + assert_eq!( + actual, + vec![ + INSTRUCTIONS_SYSVAR_ID, + state_pda, + SPL_TOKEN_PROGRAM_ID, + source_a, + dest_a, + source_b, + dest_b, + ], + ); + // The fixed accounts are read-only; the source buffers and destinations + // are writable for the transfers. + let writable: Vec = ix + .accounts + .iter() + .filter(|meta| meta.is_writable) + .map(|meta| meta.pubkey) + .collect(); + assert_eq!(writable, vec![source_a, dest_a, source_b, dest_b]); + assert!(ix.accounts.iter().all(|meta| !meta.is_signer)); + } + + #[test] + fn finalize_settle_input_parses_no_pushes() { + let sysvar = Address::new_from_array([0x42u8; 32]); + // The state-PDA and token-program slots are reserved but not surfaced. + let state = Address::new_from_array([0x43u8; 32]); + let token_program = Address::new_from_array([0x44u8; 32]); + let mut accounts = [ + fake_account(sysvar), + fake_account(state), + fake_account(token_program), + ]; let data = ix_data![ [SettlementInstruction::FinalizeSettle.discriminator()], [0x13, 0x37], // begin index @@ -109,9 +338,124 @@ mod tests { let FinalizeSettleInput { begin_ix_index, instructions_sysvar_account, + state_pda_account, + token_program_account, + pushes, } = FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); assert_eq!(begin_ix_index, 0x1337); - assert_eq!(instructions_sysvar_account.address(), &address); + assert_eq!(instructions_sysvar_account.address(), &sysvar); + assert_eq!(state_pda_account.address(), &state); + assert_eq!(token_program_account.address(), &token_program); + assert_eq!(pushes.iter().count(), 0); + } + + #[test] + fn finalize_settle_input_parses_pushes() { + let sysvar = Address::new_from_array([1u8; 32]); + let state = Address::new_from_array([0xa1u8; 32]); + let token_program = Address::new_from_array([0xa2u8; 32]); + // The same source buffer funds both pushes: parsing makes no uniqueness + // assumption about source buffers. + let source = Address::new_from_array([3u8; 32]); + let dest0 = Address::new_from_array([4u8; 32]); + let dest1 = Address::new_from_array([5u8; 32]); + let mut accounts = [ + fake_account(sysvar), + fake_account(state), + fake_account(token_program), + fake_account(source), + fake_account(dest0), + fake_account(source), + fake_account(dest1), + ]; + let data = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [0x13, 0x37], // begin index + [0xfe, 0xfd], // bumps + 0x1122u64.to_be_bytes(), + 0x3344u64.to_be_bytes(), + ]; + + let FinalizeSettleInput { pushes, .. } = + FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); + + let parsed: Vec<(&Address, &Address, u8, u64)> = pushes + .iter() + .map(|push| { + ( + push.source_buffer.address(), + push.destination.address(), + push.bump, + u64::from_be_bytes(*push.amount), + ) + }) + .collect(); + assert_eq!( + parsed, + vec![ + (&source, &dest0, 0xfe, 0x1122), + (&source, &dest1, 0xfd, 0x3344), + ], + ); + } + + #[test] + fn finalize_settle_input_parses_many_pushes() { + const PUSH_COUNT: usize = 16; + + struct ExpectedPush { + source: Address, + dest: Address, + bump: u8, + amount: u64, + } + let mut expected: Vec = Vec::new(); + for i in 0..PUSH_COUNT { + let source = Address::new_from_array([i as u8; 32]); + let dest = Address::new_from_array([(i + PUSH_COUNT) as u8; 32]); + let bump = (i + 2 * PUSH_COUNT) as u8; + let amount = u64::from_be_bytes([(i + 3 * PUSH_COUNT) as u8; 8]); + expected.push(ExpectedPush { + source, + dest, + bump, + amount, + }); + } + + // The three fixed accounts (`[0xff..]`, `[0xfe..]`, `[0xfd..]`) differ + // from every source/destination address above. + let mut accounts = vec![ + fake_account_from_array([0xff; 32]), + fake_account_from_array([0xfe; 32]), + fake_account_from_array([0xfd; 32]), + ]; + let mut bump_bytes = Vec::new(); + let mut amount_bytes = Vec::new(); + for push in &expected { + accounts.push(fake_account(push.source)); + accounts.push(fake_account(push.dest)); + bump_bytes.push(push.bump); + amount_bytes.extend_from_slice(&push.amount.to_be_bytes()); + } + let data = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [0x13, 0x37], // begin index + bump_bytes, + amount_bytes, + ]; + + let parsed = + FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); + let pushes: Vec<_> = parsed.pushes.iter().collect(); + + assert_eq!(pushes.len(), PUSH_COUNT); + for (push, expected) in pushes.iter().zip(&expected) { + assert_eq!(push.source_buffer.address(), &expected.source); + assert_eq!(push.destination.address(), &expected.dest); + assert_eq!(push.bump, expected.bump); + assert_eq!(u64::from_be_bytes(*push.amount), expected.amount); + } } #[test] @@ -141,20 +485,45 @@ mod tests { } #[test] - fn finalize_settle_input_ignores_extra_parameters() { - let first_address = Address::new_from_array([1u8; 32]); - let second_address = Address::new_from_array([2u8; 32]); - let mut accounts = [fake_account(first_address), fake_account(second_address)]; + fn finalize_settle_input_rejects_account_count_mismatch() { + // One push (a bump byte then a `u64` amount) needs exactly two push + // accounts: its source buffer and destination. + let data: Vec = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [13, 37], // begin index + [0xff], // the push's bump + 31337u64.to_be_bytes(), + ]; + + // Too few: only one push account follows the fixed accounts. + let mut too_few = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 1 }>(); + assert_eq!( + FinalizeSettleInput::parse(&data, &mut too_few).err(), + Some(SettlementError::AccountCountNotMatchingPushCount.into()), + ); + + // Too many: three push accounts follow the fixed accounts. + let mut too_many = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 3 }>(); + assert_eq!( + FinalizeSettleInput::parse(&data, &mut too_many).err(), + Some(SettlementError::AccountCountNotMatchingPushCount.into()), + ); + } + + #[test] + fn finalize_settle_input_rejects_partial_push() { + // Four trailing bytes: not a whole number of 9-byte pushes (a bump plus a + // `u64` amount), so the body can't be parsed into the push layout. + let mut accounts = fake_sequential_accounts::(); let data = ix_data![ [SettlementInstruction::FinalizeSettle.discriminator()], - [0x13, 0x37], // begin index - [42], // extra + [13, 37], // begin index + [0xff], // the push's bump + [0x11, 0x22, 0x33, 0x44], // a partial push (4 bytes) ]; - let FinalizeSettleInput { - begin_ix_index, - instructions_sysvar_account, - } = FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); - assert_eq!(begin_ix_index, 0x1337); - assert_eq!(instructions_sysvar_account.address(), &first_address); + assert_eq!( + FinalizeSettleInput::parse(&data, &mut accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); } } diff --git a/interface/src/instruction/settle/mod.rs b/interface/src/instruction/settle/mod.rs index b2c553f..358035a 100644 --- a/interface/src/instruction/settle/mod.rs +++ b/interface/src/instruction/settle/mod.rs @@ -10,7 +10,7 @@ mod begin; mod finalize; pub use begin::{BeginSettle, BeginSettleInput, Pull, SettledOrder}; -pub use finalize::{FinalizeSettle, FinalizeSettleInput}; +pub use finalize::{FinalizeSettle, FinalizeSettleInput, Push, Pushes}; /// Reads the first two bytes of a byte slice (instruction data) and /// interprets them as a big-endian u16, returning it together with the diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 732831d..0c7eb86 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -112,6 +112,10 @@ pub enum SettlementError { /// `BeginSettle`'s state account isn't the canonical settlement state PDA, /// which must sign the pulls as the user's token delegate. StateAccountMismatch = 18, + /// `FinalizeSettle`'s push-account count doesn't match its instruction + /// data: each push contributes a source buffer and a destination account, + /// so the count must be twice the number of push amounts. + AccountCountNotMatchingPushCount = 19, } impl From for u32 { diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index 4191a78..650af0b 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -47,6 +47,7 @@ fn send_settlement( let finalize = FinalizeSettle { program_id: *program_id, begin_ix_index: 0, + orders: &[], }; let tx = Transaction::new_signed_with_payer( &[begin.into(), finalize.into()], diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs new file mode 100644 index 0000000..dc981a0 --- /dev/null +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -0,0 +1,251 @@ +//! Integration tests for the fund-push list carried by `FinalizeSettle`. + +use crate::common::{order::OrderBuilder, setup, to_instruction_error, token}; +use litesvm::LiteSVM; +use settlement_client::instructions::{ + BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, +}; +use settlement_client::settlement_interface::{Instruction, SettlementError}; +use solana_sdk::{ + instruction::{AccountMeta, InstructionError}, + program_error::ProgramError, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, +}; + +mod common; + +/// The following [`send_settlement`] function simulates a settlement and for +/// that hardcodes some instruction indices that will be referenced in the +/// tests. We make those indices more explicit with a constant. +const BEGIN_INDEX: u8 = 0; +const FINALIZE_INDEX: u8 = 1; + +/// Send `[begin, finalize]` signed by `payer`, where `finalize` is a pre-built +/// `FinalizeSettle` at [`FINALIZE_INDEX`] and `begin` settles `orders` (with no +/// pulls) at [`BEGIN_INDEX`], the same orders the finalize is expected to push +/// to. +fn send_settlement( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + orders: &[FinalizedIntent], + finalize: impl Into, +) -> Result<(), TransactionError> { + let begin_orders: Vec = orders + .iter() + .map(|order| InitializedIntent { + intent: order.intent, + pulls: &[], + }) + .collect(); + let begin = Instruction::from(BeginSettle { + program_id: *program_id, + finalize_ix_index: FINALIZE_INDEX.into(), + orders: &begin_orders, + }); + // Assemble the transaction, confirming each instruction lands at its named + // index to make sure the constants are meaningfully defined. + let mut instructions = Vec::new(); + assert_eq!(instructions.len(), usize::from(BEGIN_INDEX)); + instructions.push(begin); + assert_eq!(instructions.len(), usize::from(FINALIZE_INDEX)); + instructions.push(finalize.into()); + let tx = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer], + svm.latest_blockhash(), + ); + // Drop the success metadata, not needed in these tests. + svm.send_transaction(tx).map(|_| ()).map_err(|e| e.err) +} + +/// Settle `orders` (begin) and push their proceeds (finalize) in a minimal +/// `[BeginSettle, FinalizeSettle]` transaction signed by `payer`. +fn finalize( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + orders: &[FinalizedIntent], +) -> Result<(), TransactionError> { + let finalize = FinalizeSettle { + program_id: *program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders, + }; + send_settlement(svm, program_id, payer, orders, finalize) +} + +#[test] +fn finalizes_with_no_pushes() { + let (mut svm, program_id, payer) = setup(); + + finalize(&mut svm, &program_id, &payer, &[]).expect("a finalize with no pushes should succeed"); +} + +#[test] +fn finalizes_with_single_push() { + let (mut svm, program_id, payer) = setup(); + let sell_mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &sell_mint).build(); + + finalize( + &mut svm, + &program_id, + &payer, + &[FinalizedIntent { + intent: &intent, + mint: Pubkey::new_unique(), + amount: 1_000, + }], + ) + .expect("a single push should parse and be accepted"); +} + +#[test] +fn finalizes_with_several_pushes_same_mint() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) + .salt(1) + .build(); + let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) + .salt(2) + .build(); + + finalize( + &mut svm, + &program_id, + &payer, + &[ + FinalizedIntent { + intent: &intent0, + mint, + amount: 1_000, + }, + FinalizedIntent { + intent: &intent1, + mint, + amount: 2_000, + }, + ], + ) + .expect("several pushes should parse and be accepted"); +} + +#[test] +fn finalizes_with_several_pushes_different_mint() { + let (mut svm, program_id, payer) = setup(); + let mint_1 = token::create_mint(&mut svm, &payer); + let mint_2 = token::create_mint(&mut svm, &payer); + let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint_1).build(); + let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint_2).build(); + + finalize( + &mut svm, + &program_id, + &payer, + &[ + FinalizedIntent { + intent: &intent0, + mint: mint_1, + amount: 1_000, + }, + FinalizedIntent { + intent: &intent1, + mint: mint_2, + amount: 2_000, + }, + ], + ) + .expect("several pushes should parse and be accepted"); +} + +#[test] +fn rejects_push_account_count_mismatch() { + let (mut svm, program_id, payer) = setup(); + let sell_mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &sell_mint).build(); + let orders = [FinalizedIntent { + intent: &intent, + mint: Pubkey::new_unique(), + amount: 1_000, + }]; + + // A well-formed single-push finalize... + let mut finalize = Instruction::from(FinalizeSettle { + program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders: &orders, + }); + // ...with one extra account appended. + finalize + .accounts + .push(AccountMeta::new_readonly(Pubkey::new_unique(), false)); + + assert_eq!( + send_settlement(&mut svm, &program_id, &payer, &orders, finalize), + Err(TransactionError::InstructionError( + FINALIZE_INDEX, + to_instruction_error(SettlementError::AccountCountNotMatchingPushCount), + )), + ); +} + +#[test] +fn rejects_too_few_accounts() { + let (mut svm, program_id, payer) = setup(); + + // A well-formed single-push finalize... + let mut finalize = Instruction::from(FinalizeSettle { + program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders: &[], + }); + // ...with one account popped. + finalize.accounts.pop(); + + let result = send_settlement(&mut svm, &program_id, &payer, &[], finalize); + let Err(TransactionError::InstructionError(index, ix_error)) = result else { + panic!("expected an instruction error, got {result:?}"); + }; + assert_eq!(index, FINALIZE_INDEX); + assert_eq!( + // This unusual way to test is because `InstructionError::NotEnoughAccountKeys` + // is deprecated, while `ProgramError::NotEnoughAccountKeys` is not. + // Rather than silencing a linting, let's use the program error. + ProgramError::try_from(ix_error), + Ok(ProgramError::NotEnoughAccountKeys), + ); +} + +#[test] +fn rejects_partial_push_amount() { + let (mut svm, program_id, payer) = setup(); + let sell_mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &sell_mint).build(); + let orders = [FinalizedIntent { + intent: &intent, + mint: Pubkey::new_unique(), + amount: 1_000, + }]; + + // A well-formed single-push finalize... + let mut finalize = Instruction::from(FinalizeSettle { + program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders: &orders, + }); + // ...with one byte popped, so the trailing amount is no longer a whole `u64`. + finalize.data.pop(); + + assert_eq!( + send_settlement(&mut svm, &program_id, &payer, &orders, finalize), + Err(TransactionError::InstructionError( + FINALIZE_INDEX, + InstructionError::InvalidInstructionData, + )), + ); +} diff --git a/programs/settlement/tests/matching_begin_finalize.rs b/programs/settlement/tests/matching_begin_finalize.rs index 0484e8d..6048b33 100644 --- a/programs/settlement/tests/matching_begin_finalize.rs +++ b/programs/settlement/tests/matching_begin_finalize.rs @@ -44,6 +44,7 @@ fn run_sequence( AbstractInstruction::Fin(idx) => FinalizeSettle { program_id: *program_id, begin_ix_index: *idx, + orders: &[], } .into(), // 0-lamport self-transfer: a side-effect-free instruction that @@ -193,6 +194,7 @@ fn rejects_non_instructions_sysvar_account_at_position_zero() { let finalize = FinalizeSettle { program_id, begin_ix_index: 0, + orders: &[], } .into(); @@ -231,6 +233,7 @@ fn rejects_counterpart_instruction_in_different_program() { let stranger = FinalizeSettle { program_id: solana_system_interface::program::ID, begin_ix_index: 0, + orders: &[], } .into(); @@ -314,6 +317,7 @@ fn rejects_cpi_call_to_finalize_settle() { FinalizeSettle { program_id: settlement_id, begin_ix_index: 0, + orders: &[], }, ); diff --git a/programs/settlement/tests/program_deployment.rs b/programs/settlement/tests/program_deployment.rs index 526778f..373f7a3 100644 --- a/programs/settlement/tests/program_deployment.rs +++ b/programs/settlement/tests/program_deployment.rs @@ -38,6 +38,7 @@ fn program_can_be_invoked() { FinalizeSettle { program_id, begin_ix_index: 0, + orders: &[], } .into(), ], From 3e07aaa8a0de6b3fa34834dc2b6206d1fb492b94 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:39:04 +0200 Subject: [PATCH 03/30] Validate pushes to users in BeginSettle --- Cargo.lock | 1 + Cargo.toml | 1 + interface/src/instruction/settle/finalize.rs | 30 +- interface/src/instruction/settle/mod.rs | 2 +- interface/src/lib.rs | 8 + programs/settlement/Cargo.toml | 1 + programs/settlement/src/settle/begin.rs | 201 ++++++++- .../settlement/tests/begin_settle_orders.rs | 407 +++++++++++++----- programs/settlement/tests/common/buffer.rs | 63 +++ programs/settlement/tests/common/mod.rs | 1 + programs/settlement/tests/common/order.rs | 52 ++- programs/settlement/tests/common/token.rs | 38 +- .../tests/finalize_settle_pushes.rs | 17 +- 13 files changed, 660 insertions(+), 162 deletions(-) create mode 100644 programs/settlement/tests/common/buffer.rs diff --git a/Cargo.lock b/Cargo.lock index 35d4ed7..32d9963 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2217,6 +2217,7 @@ dependencies = [ "settlement-interface", "solana-address-lookup-table-interface", "solana-instruction", + "solana-instructions-sysvar", "solana-sdk", "solana-system-interface 3.2.0", ] diff --git a/Cargo.toml b/Cargo.toml index ba3f078..49c8d68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ solana-address = "2" solana-address-lookup-table-interface = "3" solana-hash = "3" solana-instruction = "3" +solana-instructions-sysvar = "3" solana-program-error = "3" solana-pubkey = "3" solana-sdk = "3" diff --git a/interface/src/instruction/settle/finalize.rs b/interface/src/instruction/settle/finalize.rs index 8684333..ddef36e 100644 --- a/interface/src/instruction/settle/finalize.rs +++ b/interface/src/instruction/settle/finalize.rs @@ -12,6 +12,11 @@ use crate::{SettlementError, SettlementInstruction}; use super::{recover_counterpart, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID}; +/// The number of fixed accounts every `FinalizeSettle` carries before its push +/// accounts: the instructions sysvar, the settlement state PDA, and the token +/// program. +pub const FINALIZE_FIXED_ACCOUNTS: usize = 3; + /// Builder for a `FinalizeSettle` instruction pushing the funds described by the /// parallel lists: /// - `source_buffers[i]` is the buffer token account the funds come from, @@ -223,10 +228,21 @@ mod tests { use hex_literal::hex; use solana_address::Address; - /// The fixed accounts every `FinalizeSettle` carries before its push - /// accounts: the instructions sysvar, the settlement state PDA, and the - /// token program. - const FIXED_ACCOUNTS: usize = 3; + #[test] + fn finalize_fixed_accounts_matches_builder() { + // A no-push finalize carries exactly the fixed accounts, so the constant + // must equal the account count the builder emits with no pushes. + let ix = Instruction::from(FinalizeSettle { + program_id: Pubkey::new_unique(), + state_pda: Pubkey::new_unique(), + begin_ix_index: 0, + source_buffers: &[], + destinations: &[], + bumps: &[], + amounts: &[], + }); + assert_eq!(ix.accounts.len(), FINALIZE_FIXED_ACCOUNTS); + } #[test] fn expected_encoding_finalize_settle_no_pushes() { @@ -496,14 +512,14 @@ mod tests { ]; // Too few: only one push account follows the fixed accounts. - let mut too_few = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 1 }>(); + let mut too_few = fake_sequential_accounts::<{ FINALIZE_FIXED_ACCOUNTS + 1 }>(); assert_eq!( FinalizeSettleInput::parse(&data, &mut too_few).err(), Some(SettlementError::AccountCountNotMatchingPushCount.into()), ); // Too many: three push accounts follow the fixed accounts. - let mut too_many = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 3 }>(); + let mut too_many = fake_sequential_accounts::<{ FINALIZE_FIXED_ACCOUNTS + 3 }>(); assert_eq!( FinalizeSettleInput::parse(&data, &mut too_many).err(), Some(SettlementError::AccountCountNotMatchingPushCount.into()), @@ -514,7 +530,7 @@ mod tests { fn finalize_settle_input_rejects_partial_push() { // Four trailing bytes: not a whole number of 9-byte pushes (a bump plus a // `u64` amount), so the body can't be parsed into the push layout. - let mut accounts = fake_sequential_accounts::(); + let mut accounts = fake_sequential_accounts::(); let data = ix_data![ [SettlementInstruction::FinalizeSettle.discriminator()], [13, 37], // begin index diff --git a/interface/src/instruction/settle/mod.rs b/interface/src/instruction/settle/mod.rs index 358035a..107cdba 100644 --- a/interface/src/instruction/settle/mod.rs +++ b/interface/src/instruction/settle/mod.rs @@ -10,7 +10,7 @@ mod begin; mod finalize; pub use begin::{BeginSettle, BeginSettleInput, Pull, SettledOrder}; -pub use finalize::{FinalizeSettle, FinalizeSettleInput, Push, Pushes}; +pub use finalize::{FinalizeSettle, FinalizeSettleInput, Push, Pushes, FINALIZE_FIXED_ACCOUNTS}; /// Reads the first two bytes of a byte slice (instruction data) and /// interprets them as a big-endian u16, returning it together with the diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 0c7eb86..595677f 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -116,6 +116,14 @@ pub enum SettlementError { /// data: each push contributes a source buffer and a destination account, /// so the count must be twice the number of push amounts. AccountCountNotMatchingPushCount = 19, + /// `BeginSettle`: the number of pushes carried by the paired `FinalizeSettle` + /// doesn't equal the number of settled orders. Each order must be paid by + /// exactly one push. + SettledOrderPushCountMismatch = 20, + /// `BeginSettle`: a paired `FinalizeSettle` push doesn't send its proceeds + /// to the order's buy token account; its destination differs from the + /// `buy_token_account` in the order's intent. + PushDestinationMismatch = 21, } impl From for u32 { diff --git a/programs/settlement/Cargo.toml b/programs/settlement/Cargo.toml index 0f5f03a..c22a7fd 100644 --- a/programs/settlement/Cargo.toml +++ b/programs/settlement/Cargo.toml @@ -26,6 +26,7 @@ proptest.workspace = true settlement-client.workspace = true settlement-interface = { workspace = true, features = ["test-fixtures"] } solana-address-lookup-table-interface = { workspace = true, features = ["bincode"] } +solana-instructions-sysvar.workspace = true solana-sdk.workspace = true solana-system-interface.workspace = true diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 3fc2e8e..7099e28 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -5,7 +5,11 @@ use std::ops::Deref; use pinocchio::{ cpi::{Seed, Signer}, error::ProgramError, - sysvars::{clock::Clock, instructions::Instructions, Sysvar}, + sysvars::{ + clock::Clock, + instructions::{Instructions, IntrospectedInstruction}, + Sysvar, + }, AccountView, Address, ProgramResult, }; use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; @@ -13,7 +17,7 @@ use settlement_interface::{ data::order::EncodedOrderAccount, instruction::{ create_buffer::SPL_TOKEN_PROGRAM_ID, - settle::{BeginSettleInput, SettledOrder}, + settle::{BeginSettleInput, SettledOrder, FINALIZE_FIXED_ACCOUNTS}, InstructionInputParsing, }, pda::{order::order_pda_signer_seeds, state::state_pda_seeds}, @@ -59,16 +63,44 @@ pub fn process_begin_settle( input.finalize_ix_index, )?; - pull_funds( + let finalize_ix = instructions.load_instruction_at(usize::from(input.finalize_ix_index))?; + + settle_orders( program_id, input.token_program_account, input.state_pda_account, input.orders.iter(), + &finalize_ix, )?; Ok(()) } +/// The destination address of each push carried by the paired `FinalizeSettle`, +/// seen through instruction introspection, in order and stopping at the first +/// missing account. +/// +/// The push structure isn't validated here: the paired `FinalizeSettle` re-parses +/// the same instruction from its own data and rejects a dangling source buffer or +/// a push count that disagrees with its accounts. The caller pairs these +/// destinations with the settled orders one-to-one, which is what catches a count +/// mismatch. +fn push_destinations<'a>( + instruction: &'a IntrospectedInstruction<'a>, +) -> impl Iterator { + // Each push occupies a `[source_buffer, destination]` meta pair after the + // fixed accounts, so the destinations are every second meta beginning at the + // first push's destination. The first index with no meta ends the list. + (FINALIZE_FIXED_ACCOUNTS + 1..) + .step_by(2) + .map_while(|destination_index| { + instruction + .get_instruction_account_at(destination_index) + .ok() + .map(|account| &account.key) + }) +} + /// Reject a `BeginSettle` whose pair encloses another settlement: no /// `BeginSettle`/`FinalizeSettle` of this program may appear strictly between /// `current_index` and `finalize_ix_index`. The bounds themselves are excluded. @@ -111,19 +143,26 @@ fn validate_no_nested_settlement>( Ok(()) } -/// Validate and pull funds for each order, requiring: +/// Validate each order against its push, and pull user funds. This requires: /// - the legacy SPL Token program; /// - the canonical state PDA, which signs each transfer as the user's delegate; /// - orders strictly increasing by address, rejecting duplicates. /// -/// Further validation and the actual transfers are processed through +/// Each order is paid by exactly one push. The orders and the finalize's pushes +/// are both laid out sorted by order PDA, so order `i` is paid by push `i`, and +/// that push's destination must be order `i`'s buy token account. Pairing them in +/// a single pass (one push consumed per order, with none left over) rejects any +/// count mismatch without counting the orders up front. +/// +/// Further validation and the actual pulls are processed through /// [`process_order`]. #[must_use = "ignoring the output may lead to an unintended on-chain state"] -fn pull_funds<'a>( +fn settle_orders<'a>( program_id: &Address, token_program_account: &AccountView, state_pda_account: &AccountView, orders: impl IntoIterator>, + finalize_ix: &IntrospectedInstruction, ) -> ProgramResult { if token_program_account.address() != &SPL_TOKEN_PROGRAM_ID { return Err(ProgramError::IncorrectProgramId); @@ -147,6 +186,10 @@ fn pull_funds<'a>( let now = Clock::get()?.unix_timestamp; + // Pull one push destination per order; running out mid-loop means fewer pushes + // than orders. A leftover push (more pushes than orders) is caught after. + let mut destinations = push_destinations(finalize_ix); + for order in orders { let order_pda = order.order_pda; if previous.is_some_and(|previous| order_pda.address() <= previous) { @@ -154,19 +197,36 @@ fn pull_funds<'a>( } previous = Some(order_pda.address()); - process_order(program_id, order, now, state_pda_account, &state_pda_signer)?; + let push_destination = destinations + .next() + .ok_or(SettlementError::SettledOrderPushCountMismatch)?; + + process_order( + program_id, + order, + push_destination, + now, + state_pda_account, + &state_pda_signer, + )?; + } + + if destinations.next().is_some() { + return Err(SettlementError::SettledOrderPushCountMismatch.into()); } Ok(()) } -/// Validate a single order and process its pulls. -/// This checks that the order is valid and settleable. Once the order passes -/// those checks, its pulls are executed. +/// Validate a single order, process its pulls, and confirm its push pays it. +/// 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. #[must_use = "ignoring the output may lead to an unintended on-chain state"] fn process_order( program_id: &Address, order: SettledOrder<'_>, + push_destination: &Address, now: i64, state_account: &AccountView, state_pda_signer: &Signer, @@ -209,6 +269,11 @@ fn process_order( return Err(SettlementError::OrderExpired.into()); } + // The push paying this order must send to the order's buy token account. + if !address_matches_pubkey(push_destination, &intent.buy_token_account) { + return Err(SettlementError::PushDestinationMismatch.into()); + } + // The sell token account must be the one named in the intent, owned by // the intent owner: an order can only sell funds its own owner controls. if !address_matches_pubkey(sell_token_account.address(), &intent.sell_token_account) { @@ -245,3 +310,119 @@ fn process_order( fn address_matches_pubkey(address: &Address, pubkey: &Pubkey) -> bool { address.as_array() == &pubkey.to_bytes() } + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + use settlement_interface::instruction::fixtures::fake_account; + use settlement_interface::instruction::settle::{FinalizeSettle, FinalizeSettleInput}; + use settlement_interface::instruction::InstructionInputParsing; + use solana_instruction::{BorrowedAccountMeta, BorrowedInstruction, Instruction}; + + /// Strategy producing `count` random pushes as the parallel + /// `(source_buffers, destinations, bumps, amounts)` lists the `FinalizeSettle` + /// builder takes. + fn arb_pushes( + count: impl Into, + ) -> impl Strategy, Vec, Vec, Vec)> { + prop::collection::vec( + ( + any::<[u8; 32]>().prop_map(Pubkey::new_from_array), + any::<[u8; 32]>().prop_map(Pubkey::new_from_array), + any::(), + any::(), + ), + count, + ) + .prop_map(|pushes| { + let source_buffers = pushes.iter().map(|&(source, ..)| source).collect(); + let destinations = pushes.iter().map(|&(_, dest, ..)| dest).collect(); + let bumps = pushes.iter().map(|&(.., bump, _)| bump).collect(); + let amounts = pushes.iter().map(|&(.., amount)| amount).collect(); + (source_buffers, destinations, bumps, amounts) + }) + } + + /// Encode `ix` as the introspected instruction from the instructions + /// sysvar. + fn introspected_instruction(ix: &Instruction) -> IntrospectedInstruction<'static> { + // From the Solana docs for `BorrowedInstruction`: "This struct is + // used by the runtime when constructing the instructions sysvar." + let borrowed = BorrowedInstruction { + program_id: &ix.program_id, + accounts: ix + .accounts + .iter() + .map(|meta| BorrowedAccountMeta { + pubkey: &meta.pubkey, + is_signer: meta.is_signer, + is_writable: meta.is_writable, + }) + .collect(), + data: &ix.data, + }; + // From the Solana docs for this function: "construct the account data + // for the instructions sysvar." + let instructions_sysvar_data = + solana_instructions_sysvar::construct_instructions_data(&[borrowed]); + // SAFETY: from Pinocchio's docs for `new_unchecked`: "this function is + // unsafe because it does not check if the provided data is from the + // Sysvar Account." + // We built the data using `construct_instructions_data`, so we know the + // data is correctly built. + // https://docs.rs/pinocchio/0.11.1/pinocchio/sysvars/instructions/struct.Instructions.html#method.new_unchecked + // https://docs.rs/solana-instructions-sysvar/3.0.0/src/solana_instructions_sysvar/lib.rs.html#85-141 + let instructions = unsafe { Instructions::new_unchecked(instructions_sysvar_data) }; + // Leak the buffer so the returned view can borrow it for the rest of the + // test process. + let instructions: &'static Instructions> = Box::leak(Box::new(instructions)); + instructions + .load_instruction_at(0) + .expect("the finalize is the only instruction, at index 0") + } + + proptest! { + /// `BeginSettle` reads a paired `FinalizeSettle`'s push destinations by + /// introspection through `push_destinations`, while `FinalizeSettle` + /// reads its own pushes via `FinalizeSettleInput` (off the instruction + /// data + accounts). + /// For any well-formed finalize the two must recover the same push + /// count and the same destination for every push. + #[test] + fn finalize_pushes_agrees_with_finalize_settle_input( + program_id in any::<[u8; 32]>(), + state_pda in any::<[u8; 32]>(), + begin_ix_index in any::(), + (source_buffers, destinations, bumps, amounts) in arb_pushes(0..=16usize), + ) { + let count = source_buffers.len(); + + let ix = Instruction::from(FinalizeSettle { + program_id: Pubkey::new_from_array(program_id), + state_pda: Pubkey::new_from_array(state_pda), + begin_ix_index, + source_buffers: &source_buffers, + destinations: &destinations, + bumps: &bumps, + amounts: &amounts, + }); + + let introspected = introspected_instruction(&ix); + let introspected_destinations: Vec
= + push_destinations(&introspected).copied().collect(); + + let mut accounts: Vec = + ix.accounts.iter().map(|account| fake_account(account.pubkey)).collect(); + let parsed = FinalizeSettleInput::parse(&ix.data, &mut accounts) + .expect("a well-formed finalize parses"); + let parsed_destinations: Vec
= + parsed.pushes.iter().map(|push| *push.destination.address()).collect(); + + prop_assert_eq!(introspected_destinations.len(), count); + prop_assert_eq!(parsed.pushes.iter().count(), count); + prop_assert_eq!(&introspected_destinations, &destinations); + prop_assert_eq!(&parsed_destinations, &destinations); + } + } +} diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index 650af0b..ef47162 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -1,21 +1,34 @@ //! Integration tests for the settled-orders list carried by `BeginSettle`. //! //! Each settlement transaction here is the minimal `[BeginSettle, FinalizeSettle]` -//! pair (begin at index 0 pointing to finalize at index 1, and vice versa) so -//! that the begin/finalize pairing always validates and execution reaches the -//! order-list checks, which is what these tests exercise. +//! pair (begin at [`BEGIN_INDEX`] pointing to finalize at [`FINALIZE_INDEX`], and +//! vice versa) so that the begin/finalize pairing always validates and execution +//! reaches the order-list checks, which is what these tests exercise. +//! +//! `BeginSettle` pairs one push with each order and checks that push pays the +//! order's buy token account, so even a settlement expected to be rejected during +//! order validation must pair with a finalize whose pushes match the orders in +//! both count and destination. [`settle`] and [`settle_raw`] attach such pushes +//! (with placeholder source, bump, and amount, so they never execute), while +//! [`settle_and_pay`] attaches fully real ones for settlements expected to +//! succeed. Tests rejected before the push checks (wrong token program or state +//! PDA) pair with an empty finalize ([`send_settlement`]). use crate::common::{ - assert_instruction_error, assert_settlement_error, create_account, + assert_instruction_error, assert_settlement_error, buffer, create_account, order::{create_order_pda, sample_intent, OrderBuilder}, set_unix_timestamp, setup, token, }; use litesvm::{types::TransactionMetadata, LiteSVM}; -use settlement_client::instructions::{BeginSettle, FinalizeSettle, InitializedIntent, Pull}; +use litesvm_token::spl_token::error::TokenError; +use settlement_client::instructions::{ + BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, +}; use settlement_client::settlement_interface::{ data::order::{EncodedOrderAccount, OrderAccount}, instruction::settle::{ - BeginSettle as BeginSettleRaw, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, + BeginSettle as BeginSettleRaw, FinalizeSettle as FinalizeSettleRaw, INSTRUCTIONS_SYSVAR_ID, + SPL_TOKEN_PROGRAM_ID, }, pda::{order::find_order_pda, state::find_state_pda}, Instruction, SettlementError, SettlementInstruction, @@ -30,14 +43,21 @@ use solana_sdk::{ mod common; +/// The positions of the two instructions in every settlement transaction below, +/// named so each instruction's reference to its counterpart reads clearly. +const BEGIN_INDEX: u16 = 0; +const FINALIZE_INDEX: u16 = 1; + /// A list of empty transfer lists, one per order. Used for settling `n` orders /// without pulling any funds. fn no_pulls(n: usize) -> Vec<&'static [Pull]> { vec![&[]; n] } -/// Send `[begin, finalize_settle(..)]` signed by `payer`, where `begin` is a -/// pre-built `BeginSettle` instruction. +/// Send `[begin, finalize]` signed by `payer`, where `begin` is a pre-built +/// `BeginSettle` instruction and `finalize` settles no pushes. Use it only for +/// cases rejected before `BeginSettle`'s one-push-per-order count check (wrong +/// token program or state PDA); otherwise the empty finalize trips that check. fn send_settlement( svm: &mut LiteSVM, program_id: &Pubkey, @@ -46,7 +66,7 @@ fn send_settlement( ) -> Result { let finalize = FinalizeSettle { program_id: *program_id, - begin_ix_index: 0, + begin_ix_index: BEGIN_INDEX, orders: &[], }; let tx = Transaction::new_signed_with_payer( @@ -58,57 +78,148 @@ fn send_settlement( svm.send_transaction(tx).map_err(|e| e.err) } +/// Send `[begin, finalize]` where `finalize` carries one push per `destination` +/// (enough to satisfy `BeginSettle`'s one-push-per-order pairing) and, with +/// each push targeting its order's buy token account, its push-destination +/// check too. The pushes' source, bump, and amount are placeholders: these +/// settlements are expected to be rejected during order validation, so the +/// finalize never runs. +fn send_settlement_with_placeholder_pushes( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + begin: impl Into, + destinations: &[Pubkey], +) -> Result { + let push_count = destinations.len(); + let placeholder_sources: Vec = (0..push_count).map(|_| Pubkey::new_unique()).collect(); + let bumps = vec![0u8; push_count]; + let amounts = vec![0u64; push_count]; + let finalize = Instruction::from(FinalizeSettleRaw { + program_id: *program_id, + state_pda: find_state_pda(program_id).0, + begin_ix_index: BEGIN_INDEX, + source_buffers: &placeholder_sources, + destinations, + bumps: &bumps, + amounts: &amounts, + }); + let tx = Transaction::new_signed_with_payer( + &[begin.into(), finalize], + Some(&payer.pubkey()), + &[payer], + svm.latest_blockhash(), + ); + svm.send_transaction(tx).map_err(|e| e.err) +} + /// Settle `orders` in a minimal `[BeginSettle, FinalizeSettle]` transaction -/// (begin at index 0, finalize at index 1) signed by `payer`. +/// (begin at [`BEGIN_INDEX`], finalize at [`FINALIZE_INDEX`]) signed by `payer`. +/// The finalize carries placeholder pushes matching the orders in count and +/// destination, so this clears the push checks and reaches `BeginSettle`'s order +/// validation: use it for cases expected to be rejected there. fn settle( svm: &mut LiteSVM, program_id: &Pubkey, payer: &Keypair, orders: &[InitializedIntent], ) -> Result { - send_settlement( + let destinations: Vec = orders + .iter() + .map(|order| order.intent.buy_token_account) + .collect(); + send_settlement_with_placeholder_pushes( svm, program_id, payer, BeginSettle { program_id: *program_id, - finalize_ix_index: 1, + finalize_ix_index: FINALIZE_INDEX, orders, }, + &destinations, ) } -/// Settle orders described by raw, parallel `(order_pda, sell_token, bump)` -/// lists, pulling nothing. Uses the canonical state PDA and SPL Token program so -/// execution reaches the order-validation checks; tests that need a -/// non-canonical state PDA or token program build the instruction directly. +/// Settle `orders` and pay each one: the finalize pushes a zero amount from each +/// order's canonical buy-token buffer to its buy token account, lining up +/// one-to-one with the orders so `BeginSettle`'s push pass passes. The buffer for +/// each order's buy mint is created on demand. Use it for settlements expected to +/// succeed. (Real push amounts are exercised in `finalize_settle_pushes.rs`.) +fn settle_and_pay( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + orders: &[InitializedIntent], +) -> Result { + let settled: Vec = orders + .iter() + .map(|order| { + let buy_mint = token::mint_of(svm, &order.intent.buy_token_account); + buffer::ensure_buffer_exists(svm, program_id, payer, &buy_mint); + FinalizedIntent { + intent: order.intent, + mint: buy_mint, + amount: 0, + } + }) + .collect(); + + let begin = Instruction::from(BeginSettle { + program_id: *program_id, + finalize_ix_index: FINALIZE_INDEX, + orders, + }); + let finalize = Instruction::from(FinalizeSettle { + program_id: *program_id, + begin_ix_index: BEGIN_INDEX, + orders: &settled, + }); + let tx = Transaction::new_signed_with_payer( + &[begin, finalize], + Some(&payer.pubkey()), + &[payer], + svm.latest_blockhash(), + ); + svm.send_transaction(tx).map_err(|e| e.err) +} + +/// Settle orders described by raw, parallel `(order_pda, sell_token, buy_token, +/// bump)` lists, pulling nothing. Uses the canonical state PDA and SPL Token +/// program so execution reaches the order-validation checks; tests that need a +/// non-canonical state PDA or token program build the instruction directly. The +/// finalize carries placeholder pushes, one per order and aimed at that order's +/// `buy_token`, to clear the push count and destination checks; every caller +/// expects rejection during order validation. Callers rejected before the push +/// destination check (a non-canonical or undecodable order) may pass any +/// `buy_token`. fn settle_raw( svm: &mut LiteSVM, program_id: &Pubkey, payer: &Keypair, order_pdas: &[Pubkey], sell_token_accounts: &[Pubkey], + buy_token_accounts: &[Pubkey], bumps: &[u8], ) -> Result { let begin = BeginSettleRaw { program_id: *program_id, state_pda: find_state_pda(program_id).0, - finalize_ix_index: 1, + finalize_ix_index: FINALIZE_INDEX, order_pdas, order_pda_bumps: bumps, sell_token_accounts, pulls: &no_pulls(bumps.len()), }; - send_settlement(svm, program_id, payer, begin) + send_settlement_with_placeholder_pushes(svm, program_id, payer, begin, buy_token_accounts) } #[test] fn settles_a_single_order() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); - settle( + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + settle_and_pay( &mut svm, &program_id, &payer, @@ -123,12 +234,11 @@ fn settles_a_single_order() { #[test] fn settles_multiple_orders() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); let mut intents = Vec::new(); for salt in 0..3u8 { intents.push( - OrderBuilder::new(&mut svm, &program_id, &payer, &mint) + OrderBuilder::new(&mut svm, &program_id, &payer) .salt(salt) .build(), ); @@ -138,15 +248,15 @@ fn settles_multiple_orders() { .iter() .map(|intent| InitializedIntent { intent, pulls: &[] }) .collect(); - settle(&mut svm, &program_id, &payer, &orders).expect("multi-order settlement should succeed"); + settle_and_pay(&mut svm, &program_id, &payer, &orders) + .expect("multi-order settlement should succeed"); } #[test] fn rejects_wrong_bump() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); assert_settlement_error( settle_raw( @@ -155,6 +265,7 @@ fn rejects_wrong_bump() { &payer, &[order_pda], &[intent.sell_token_account], + &[intent.buy_token_account], &[bump ^ 0x01], ), SettlementError::OrderNotCanonical, @@ -186,6 +297,7 @@ fn rejects_fabricated_program_owned_account() { &payer, &[fake_order], &[sell_token], + &[Pubkey::new_unique()], &[255], ), SettlementError::OrderNotCanonical, @@ -208,6 +320,7 @@ fn rejects_non_order_account_in_order_slot() { &payer, &[sell_token], &[sell_token], + &[Pubkey::new_unique()], &[255], ), InstructionError::InvalidAccountData, @@ -220,7 +333,7 @@ fn rejects_sell_token_account_mismatch() { 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, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); let wrong_sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); assert_settlement_error( @@ -230,6 +343,7 @@ fn rejects_sell_token_account_mismatch() { &payer, &[order_pda], &[wrong_sell_token], + &[intent.buy_token_account], &[bump], ), SettlementError::SellTokenAccountMismatch, @@ -285,11 +399,10 @@ fn rejects_non_token_sell_account() { #[test] fn rejects_duplicate_orders() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); assert_settlement_error( - settle( + settle_and_pay( &mut svm, &program_id, &payer, @@ -311,12 +424,11 @@ fn rejects_duplicate_orders() { #[test] fn rejects_orders_in_wrong_address_order() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); - let first = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) + let first = OrderBuilder::new(&mut svm, &program_id, &payer) .salt(0) .build(); - let second = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) + let second = OrderBuilder::new(&mut svm, &program_id, &payer) .salt(1) .build(); @@ -324,21 +436,33 @@ fn rejects_orders_in_wrong_address_order() { let (second_pda, second_bump) = find_order_pda(&program_id, &second.uid()); // Lay out the two distinct orders strictly decreasing by PDA address, which - // the program rejects. The interface builder would sort them, so build the - // instruction by hand in the current wire format: data is + // the program rejects. The interface builders would sort them, so build both + // instructions by hand in the current wire format. Begin data is // `[discriminator, finalize_ix_index (BE), order_count, bump×n, transfer_count×n]` - // (no transfers here) and accounts are `[instructions_sysvar, state_pda, - // token_program, (order_pda, sell_token_account)...]`. + // (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. let mut orders = [ - (first_pda, first.sell_token_account, first_bump), - (second_pda, second.sell_token_account, second_bump), + ( + first_pda, + first.sell_token_account, + first.buy_token_account, + first_bump, + ), + ( + second_pda, + second.sell_token_account, + second.buy_token_account, + second_bump, + ), ]; - orders.sort_by_key(|&(pda, _, _)| std::cmp::Reverse(pda)); + orders.sort_by_key(|&(pda, ..)| std::cmp::Reverse(pda)); let mut data = vec![SettlementInstruction::BeginSettle.discriminator()]; - data.extend_from_slice(&1u16.to_be_bytes()); + data.extend_from_slice(&FINALIZE_INDEX.to_be_bytes()); 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)); @@ -347,24 +471,39 @@ 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, _, _) in orders { accounts.push(AccountMeta::new_readonly(order_pda, false)); accounts.push(AccountMeta::new(sell_token_account, false)); } + let begin = Instruction { + program_id, + accounts, + data, + }; - assert_settlement_error( - send_settlement( - &mut svm, - &program_id, - &payer, - Instruction { - program_id, - accounts, - data, - }, - ), - SettlementError::OrdersNotStrictlyIncreasing, + // One zero-amount push per order, paying each order's buy token account, + // aligned with begin's decreasing order. `BeginSettle` checks only the + // destinations, so the sources are placeholders (and the finalize never runs, + // as begin rejects the ordering first). + let placeholder_source = Pubkey::new_unique(); + let finalize = Instruction::from(FinalizeSettleRaw { + program_id, + state_pda: find_state_pda(&program_id).0, + begin_ix_index: BEGIN_INDEX, + source_buffers: &[placeholder_source, placeholder_source], + destinations: &[orders[0].2, orders[1].2], + bumps: &[0, 0], + amounts: &[0, 0], + }); + + let tx = Transaction::new_signed_with_payer( + &[begin, finalize], + Some(&payer.pubkey()), + &[&payer], + svm.latest_blockhash(), ); + let result = svm.send_transaction(tx).map(|_| ()).map_err(|e| e.err); + assert_settlement_error(result, SettlementError::OrdersNotStrictlyIncreasing); } #[test] @@ -418,10 +557,9 @@ fn rejects_cancelled_order() { #[test] fn rejects_expired_order() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); let valid_to = 1_000_000; - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) .valid_to(valid_to) .build(); let after_expiration = i64::from(valid_to) + 1; @@ -444,15 +582,14 @@ fn rejects_expired_order() { #[test] fn settles_order_at_exact_valid_to() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); let valid_to = 1_000_000; - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) .valid_to(valid_to) .build(); set_unix_timestamp(&mut svm, i64::from(valid_to)); - settle( + settle_and_pay( &mut svm, &program_id, &payer, @@ -467,16 +604,19 @@ fn settles_order_at_exact_valid_to() { #[test] fn pulls_funds_to_destination() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); + let sell_mint = token::create_mint(&mut svm, &payer); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_mint(&sell_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 destination = + token::create_token_account(&mut svm, &payer, &sell_mint, &Pubkey::new_unique()); let amount = 2_000_000; - settle( + settle_and_pay( &mut svm, &program_id, &payer, @@ -501,18 +641,20 @@ fn pulls_funds_to_destination() { #[test] fn pulls_to_multiple_destinations() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); + let sell_mint = token::create_mint(&mut svm, &payer); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_mint(&sell_mint) + .build(); let sell_token = intent.sell_token_account; let initial_amount: u64 = 1_000_000; token::fund_and_delegate(&mut svm, &program_id, &payer, &sell_token, initial_amount); - let dest0 = token::create_token_account(&mut svm, &payer, &mint, &Pubkey::new_unique()); - let dest1 = token::create_token_account(&mut svm, &payer, &mint, &Pubkey::new_unique()); + let dest0 = token::create_token_account(&mut svm, &payer, &sell_mint, &Pubkey::new_unique()); + let dest1 = token::create_token_account(&mut svm, &payer, &sell_mint, &Pubkey::new_unique()); let pulled0 = 300_000; let pulled1 = 100_000; - settle( + settle_and_pay( &mut svm, &program_id, &payer, @@ -547,13 +689,15 @@ fn pulls_to_multiple_destinations() { #[test] fn pulls_from_multiple_orders() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); + let sell_mint = token::create_mint(&mut svm, &payer); // Two distinct orders, each selling from its own token account. - let first = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) + let first = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_mint(&sell_mint) .salt(0) .build(); - let second = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) + let second = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_mint(&sell_mint) .salt(1) .build(); let initial_amount_first = 1_337_000; @@ -572,12 +716,14 @@ fn pulls_from_multiple_orders() { &second.sell_token_account, initial_amount_second, ); - let dest_first = token::create_token_account(&mut svm, &payer, &mint, &Pubkey::new_unique()); - let dest_second = token::create_token_account(&mut svm, &payer, &mint, &Pubkey::new_unique()); + let dest_first = + token::create_token_account(&mut svm, &payer, &sell_mint, &Pubkey::new_unique()); + let dest_second = + token::create_token_account(&mut svm, &payer, &sell_mint, &Pubkey::new_unique()); let pulled_first = 42_000; let pulled_second = 67_000; - settle( + settle_and_pay( &mut svm, &program_id, &payer, @@ -615,36 +761,65 @@ fn pulls_from_multiple_orders() { #[test] fn zero_pulls_moves_nothing() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); - - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + // The order sells `sell_mint` and is paid in a distinct `buy_mint`, so the + // buy-side push touches only `buy_mint` accounts. That isolates the sell + // mint: with no pulls, no token instruction should reference its account. + let sell_mint = token::create_mint(&mut svm, &payer); + let buy_mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_mint(&sell_mint) + .buy_mint(&buy_mint) + .build(); let sell_token = intent.sell_token_account; - let initial_amount = 42_000_000; - token::mint_to(&mut svm, &payer, &mint, &sell_token, initial_amount); - let transaction = settle( - &mut svm, - &program_id, - &payer, - &[InitializedIntent { + let initial_amount = 42_000_000; + token::mint_to(&mut svm, &payer, &sell_mint, &sell_token, initial_amount); + // The buy-side buffer must exist for the (zero-amount) push to draw from. + buffer::ensure_buffer_exists(&mut svm, &program_id, &payer, &buy_mint); + + // Build the `[begin, finalize]` settlement by hand so the issued token + // instructions can be inspected. Begin settles the order with no pulls; + // finalize pushes a zero amount from the buy buffer to the buy token account. + let begin = Instruction::from(BeginSettle { + program_id, + finalize_ix_index: FINALIZE_INDEX, + orders: &[InitializedIntent { intent: &intent, pulls: &[], }], - ) - .expect("settling without pulling should succeed"); - + }); + let finalize = Instruction::from(FinalizeSettle { + program_id, + begin_ix_index: BEGIN_INDEX, + orders: &[FinalizedIntent { + intent: &intent, + mint: buy_mint, + amount: 0, + }], + }); + let tx = Transaction::new_signed_with_payer( + &[begin, finalize], + Some(&payer.pubkey()), + &[&payer], + svm.latest_blockhash(), + ); + let account_keys = tx.message.account_keys.clone(); + let transaction = svm + .send_transaction(tx) + .expect("settling without pulling should succeed"); + + // No token instruction references the sell token account (the sell mint's + // only account here): the lone token transfer is the buy-side push, which + // draws from `buy_mint`'s buffer. Its balance is also left untouched. + token::assert_no_token_instruction_touching(&transaction, &account_keys, &sell_token); assert_eq!(token::balance(&svm, &sell_token), initial_amount); - // Confirm that there are no transfers because there are no token - // invocations in general. - token::assert_no_spl_token_invocation(&transaction); } #[test] fn rejects_wrong_state_pda() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); let not_the_state_pda = Pubkey::new_unique(); @@ -656,7 +831,7 @@ fn rejects_wrong_state_pda() { BeginSettleRaw { program_id, state_pda: not_the_state_pda, - finalize_ix_index: 1, + finalize_ix_index: FINALIZE_INDEX, order_pdas: &[order_pda], order_pda_bumps: &[bump], sell_token_accounts: &[intent.sell_token_account], @@ -670,15 +845,14 @@ fn rejects_wrong_state_pda() { #[test] fn rejects_wrong_token_program() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); // The builder always fills in the SPL Token program, so we swap the // token-program account out afterwards. let mut begin: Instruction = BeginSettle { program_id, - finalize_ix_index: 1, + finalize_ix_index: FINALIZE_INDEX, orders: &[InitializedIntent { intent: &intent, pulls: &[], @@ -697,16 +871,19 @@ fn rejects_wrong_token_program() { #[test] fn rejects_pull_delegated_to_incorrect_address() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); + let sell_mint = token::create_mint(&mut svm, &payer); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_mint(&sell_mint) + .build(); let amount = 100_000; let sell_token = intent.sell_token_account; // Funds are present but some account other than the state PDA was // approved as a delegate. - token::mint_to(&mut svm, &payer, &mint, &sell_token, 1_000_000); + token::mint_to(&mut svm, &payer, &sell_mint, &sell_token, 1_000_000); token::delegate(&mut svm, &payer, &sell_token, &Pubkey::new_unique(), amount); - let destination = token::create_token_account(&mut svm, &payer, &mint, &Pubkey::new_unique()); + let destination = + token::create_token_account(&mut svm, &payer, &sell_mint, &Pubkey::new_unique()); let result = settle( &mut svm, @@ -720,23 +897,25 @@ fn rejects_pull_delegated_to_incorrect_address() { }], }], ); - assert!( - result.is_err(), - "pulling without an approved delegation must fail" + assert_instruction_error( + result, + InstructionError::Custom(TokenError::OwnerMismatch as u32), ); } #[test] fn rejects_pull_exceeding_delegation() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); + let sell_mint = token::create_mint(&mut svm, &payer); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_mint(&sell_mint) + .build(); let sell_token = intent.sell_token_account; // Funded generously, but the state PDA is delegated only 100_000. let initial_amount = 42_000_000; let delegated = 100_000; - token::mint_to(&mut svm, &payer, &mint, &sell_token, initial_amount); + token::mint_to(&mut svm, &payer, &sell_mint, &sell_token, initial_amount); token::delegate( &mut svm, &payer, @@ -744,7 +923,8 @@ fn rejects_pull_exceeding_delegation() { &find_state_pda(&program_id).0, delegated, ); - let destination = token::create_token_account(&mut svm, &payer, &mint, &Pubkey::new_unique()); + let destination = + token::create_token_account(&mut svm, &payer, &sell_mint, &Pubkey::new_unique()); let result = settle( &mut svm, @@ -758,9 +938,9 @@ fn rejects_pull_exceeding_delegation() { }], }], ); - assert!( - result.is_err(), - "a pull exceeding the approved delegation must fail" + assert_instruction_error( + result, + InstructionError::Custom(TokenError::InsufficientFunds as u32), ); assert_eq!(token::balance(&svm, &sell_token), initial_amount); assert_eq!(token::balance(&svm, &destination), 0); @@ -771,13 +951,12 @@ fn rejects_pull_exceeding_delegation() { #[test] fn rejects_extra_account() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); // A well-formed single-order, no-transfer settlement... let mut begin: Instruction = BeginSettle { program_id, - finalize_ix_index: 1, + finalize_ix_index: FINALIZE_INDEX, orders: &[InitializedIntent { intent: &intent, pulls: &[], diff --git a/programs/settlement/tests/common/buffer.rs b/programs/settlement/tests/common/buffer.rs new file mode 100644 index 0000000..7fd24be --- /dev/null +++ b/programs/settlement/tests/common/buffer.rs @@ -0,0 +1,63 @@ +//! Buffer-account helpers for the settlement integration tests. + +use litesvm::LiteSVM; +use settlement_client::instructions::CreateBuffers; +use settlement_client::settlement_interface::pda::buffer::find_buffer_pda; +use settlement_client::settlement_interface::Instruction; +use solana_sdk::{ + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::Transaction, +}; + +use super::token; + +/// The canonical buffer PDA for `mint`. +pub fn buffer_pda(program_id: &Pubkey, mint: &Pubkey) -> Pubkey { + find_buffer_pda(program_id, mint).0 +} + +/// Create the canonical buffer for `mint`, paid for by `payer`, unless it +/// already exists, and return its address. Idempotent so several orders can +/// share one buy mint. +pub fn ensure_buffer_exists( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + mint: &Pubkey, +) -> Pubkey { + let pda = buffer_pda(program_id, mint); + if svm.get_account(&pda).is_some() { + return pda; + } + let ix = Instruction::from(CreateBuffers { + program_id: *program_id, + payer: payer.pubkey(), + mints: &[*mint], + }); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[payer], + svm.latest_blockhash(), + ); + svm.send_transaction(tx) + .expect("create_buffer should succeed"); + pda +} + +/// Ensure the buffer for `mint` exists and mint `amount` of `mint` into it, so a +/// push can draw from it. Returns the buffer address. +pub fn ensure_funded( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + mint: &Pubkey, + amount: u64, +) -> Pubkey { + let pda = ensure_buffer_exists(svm, program_id, payer, mint); + if amount > 0 { + token::mint_to(svm, payer, mint, &pda, amount); + } + pda +} diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index ceebf75..79fa5dd 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -5,6 +5,7 @@ reason = "integration tests compile as separate crates, so items only used by a subset of the test binaries look dead to the others" )] +pub mod buffer; pub mod lookup_table; pub mod order; pub mod pda; diff --git a/programs/settlement/tests/common/order.rs b/programs/settlement/tests/common/order.rs index 7f23f79..7dc20de 100644 --- a/programs/settlement/tests/common/order.rs +++ b/programs/settlement/tests/common/order.rs @@ -48,27 +48,33 @@ pub fn create_order_pda( /// Builder that mints a valid settleable order on-chain and returns its intent. /// If nothing else is specified, it uses default parameters to build the order. /// Individual parameters can be changed before building the order. +/// +/// `build` always creates real sell and buy token accounts. Each side gets its +/// own freshly generated mint, so the two differ unless a test pins one with +/// [`OrderBuilder::sell_mint`] / [`OrderBuilder::buy_mint`] — which it needs only +/// to line the mint up with something external, like a buffer or a pull +/// destination. pub struct OrderBuilder<'a> { svm: &'a mut LiteSVM, program_id: &'a Pubkey, payer: &'a Keypair, intent: OrderIntent, + sell_mint: Option, + buy_mint: Option, } impl<'a> OrderBuilder<'a> { - pub fn new( - svm: &'a mut LiteSVM, - program_id: &'a Pubkey, - payer: &'a Keypair, - mint: &'a Pubkey, - ) -> Self { - let sell_token = token::create_token_account(svm, payer, mint, &payer.pubkey()); - let intent = sample_intent(payer.pubkey(), sell_token, 0); + pub fn new(svm: &'a mut LiteSVM, program_id: &'a Pubkey, payer: &'a Keypair) -> Self { + // The sell and buy token accounts are created at `build` time; + // `sample_intent`'s placeholder addresses stand in until then. + let intent = sample_intent(payer.pubkey(), Pubkey::default(), 0); Self { svm, program_id, payer, intent, + sell_mint: None, + buy_mint: None, } } @@ -84,8 +90,34 @@ impl<'a> OrderBuilder<'a> { self } + /// Pin the mint of the order's sell token account. Defaults to a fresh mint. + pub fn sell_mint(mut self, mint: &Pubkey) -> Self { + self.sell_mint = Some(*mint); + self + } + + /// Pin the mint of the order's buy token account. Defaults to a fresh mint. + pub fn buy_mint(mut self, mint: &Pubkey) -> Self { + self.buy_mint = Some(*mint); + self + } + pub fn build(self) -> OrderIntent { - create_order_pda(self.svm, self.program_id, self.payer, &self.intent); - self.intent + let Self { + svm, + program_id, + payer, + mut intent, + sell_mint, + buy_mint, + } = self; + let sell_mint = sell_mint.unwrap_or_else(|| token::create_mint(svm, payer)); + intent.sell_token_account = + token::create_token_account(svm, payer, &sell_mint, &payer.pubkey()); + let buy_mint = buy_mint.unwrap_or_else(|| token::create_mint(svm, payer)); + intent.buy_token_account = + token::create_token_account(svm, payer, &buy_mint, &payer.pubkey()); + create_order_pda(svm, program_id, payer, &intent); + intent } } diff --git a/programs/settlement/tests/common/token.rs b/programs/settlement/tests/common/token.rs index 4e6ac87..28a8117 100644 --- a/programs/settlement/tests/common/token.rs +++ b/programs/settlement/tests/common/token.rs @@ -120,17 +120,35 @@ pub fn delegated_amount(svm: &LiteSVM, account: &Pubkey) -> u64 { .delegated_amount } -/// Assert that there was no token invocation in the transaction. -pub fn assert_no_spl_token_invocation(transaction: &TransactionMetadata) { - let token_program = litesvm_token::spl_token::ID.to_string(); - assert!( - !transaction - .logs +/// Assert that no SPL Token instruction issued by the transaction references +/// `account`. Each token transfer the program performs is a CPI recorded in +/// `transaction.inner_instructions`. We can use that to check the token-program +/// instructions, so a settlement that must leave one side untouched can prove +/// no token instruction so much as named it. +pub fn assert_no_token_instruction_touching( + transaction: &TransactionMetadata, + account_keys: &[Pubkey], + account: &Pubkey, +) { + let token_program = Pubkey::new_from_array(litesvm_token::spl_token::ID.to_bytes()); + for instruction in transaction + .inner_instructions + .iter() + .flatten() + .map(|inner| &inner.instruction) + { + if account_keys[usize::from(instruction.program_id_index)] != token_program { + continue; + } + let touches_account = instruction + .accounts .iter() - .any(|line| line.contains(&token_program) && line.contains("invoke")), - "expected no SPL Token invocation, but one ran; full tx logs:\n{:#?}", - transaction.logs, - ); + .any(|&index| account_keys[usize::from(index)] == *account); + assert!( + !touches_account, + "expected no SPL Token instruction touching {account}, but one did", + ); + } } /// Read the mint that `account` holds tokens of. diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs index dc981a0..cbeae07 100644 --- a/programs/settlement/tests/finalize_settle_pushes.rs +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -88,8 +88,7 @@ fn finalizes_with_no_pushes() { #[test] fn finalizes_with_single_push() { let (mut svm, program_id, payer) = setup(); - let sell_mint = token::create_mint(&mut svm, &payer); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &sell_mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); finalize( &mut svm, @@ -108,10 +107,10 @@ fn finalizes_with_single_push() { fn finalizes_with_several_pushes_same_mint() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) + let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer) .salt(1) .build(); - let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) + let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer) .salt(2) .build(); @@ -140,8 +139,8 @@ fn finalizes_with_several_pushes_different_mint() { let (mut svm, program_id, payer) = setup(); let mint_1 = token::create_mint(&mut svm, &payer); let mint_2 = token::create_mint(&mut svm, &payer); - let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint_1).build(); - let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint_2).build(); + let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer).build(); finalize( &mut svm, @@ -166,8 +165,7 @@ fn finalizes_with_several_pushes_different_mint() { #[test] fn rejects_push_account_count_mismatch() { let (mut svm, program_id, payer) = setup(); - let sell_mint = token::create_mint(&mut svm, &payer); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &sell_mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); let orders = [FinalizedIntent { intent: &intent, mint: Pubkey::new_unique(), @@ -224,8 +222,7 @@ fn rejects_too_few_accounts() { #[test] fn rejects_partial_push_amount() { let (mut svm, program_id, payer) = setup(); - let sell_mint = token::create_mint(&mut svm, &payer); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &sell_mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); let orders = [FinalizedIntent { intent: &intent, mint: Pubkey::new_unique(), From f6ad585f0f6dce2118f92a0220bc5bf27662943b Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:28:20 +0200 Subject: [PATCH 04/30] Push funds to users in `FinalizeSettle` --- interface/src/lib.rs | 7 + interface/src/pda/buffer.rs | 6 + programs/settlement/src/settle/finalize.rs | 88 +++- .../tests/finalize_settle_pushes.rs | 375 +++++++++++++++--- 4 files changed, 416 insertions(+), 60 deletions(-) diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 595677f..27fdeb6 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -124,6 +124,13 @@ pub enum SettlementError { /// to the order's buy token account; its destination differs from the /// `buy_token_account` in the order's intent. PushDestinationMismatch = 21, + /// `FinalizeSettle`: a push doesn't draw funds from the canonical buffer + /// for its destination's mint. + PushSourceNotBuffer = 22, + /// `FinalizeSettle`: a push's destination isn't a valid SPL token account + /// (wrong data length or not owned by the token program), so its mint can't + /// be read to derive the buffer. + InvalidBuyTokenAccount = 23, } impl From for u32 { diff --git a/interface/src/pda/buffer.rs b/interface/src/pda/buffer.rs index cfc505c..80b1a6d 100644 --- a/interface/src/pda/buffer.rs +++ b/interface/src/pda/buffer.rs @@ -26,6 +26,12 @@ pub fn buffer_pda_seeds(mint: &[u8; 32]) -> [&[u8]; 3] { [SETTLEMENT_SEED, mint, BUFFER_SEED] } +/// Canonical seeds for re-deriving the buffer PDA for `mint` with `bump`. +pub fn buffer_pda_signer_seeds<'a>(mint: &'a [u8; 32], bump: &'a [u8; 1]) -> [&'a [u8]; 4] { + let [s0, s1, s2] = buffer_pda_seeds(mint); + [s0, s1, s2, bump] +} + /// Derive the canonical buffer PDA address (and bump) for the token `mint`. pub fn find_buffer_pda(program_id: &Pubkey, mint: &Pubkey) -> (Pubkey, u8) { Pubkey::find_program_address(&buffer_pda_seeds(mint.as_array()), program_id) diff --git a/programs/settlement/src/settle/finalize.rs b/programs/settlement/src/settle/finalize.rs index 403e2db..42804b5 100644 --- a/programs/settlement/src/settle/finalize.rs +++ b/programs/settlement/src/settle/finalize.rs @@ -1,8 +1,19 @@ //! `FinalizeSettle` instruction handler. -use pinocchio::{sysvars::instructions::Instructions, AccountView, Address, ProgramResult}; +use pinocchio::{ + cpi::{Seed, Signer}, + error::ProgramError, + sysvars::instructions::Instructions, + AccountView, Address, ProgramResult, +}; +use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; use settlement_interface::{ - instruction::{settle::FinalizeSettleInput, InstructionInputParsing}, + instruction::{ + create_buffer::SPL_TOKEN_PROGRAM_ID, + settle::{FinalizeSettleInput, Pushes}, + InstructionInputParsing, + }, + pda::{buffer::buffer_pda_signer_seeds, state::state_pda_seeds}, SettlementError, SettlementInstruction, }; @@ -31,9 +42,76 @@ pub fn process_finalize_settle( current_index, input.begin_ix_index, SettlementInstruction::BeginSettle, + )?; + + // `BeginSettle` (which the counterpart check above guarantees ran) already + // validated the push count and destinations. `push_funds` adds the only + // remaining check: each push draws from the canonical buffer for its mint. + + push_funds( + program_id, + input.token_program_account, + input.state_pda_account, + input.pushes, ) +} + +/// Push each order's proceeds out of the settlement's buffers. Requires the +/// legacy SPL Token program and the canonical state PDA, which signs each +/// transfer as the buffers' SPL authority. Each push's source must be the +/// canonical buffer for its destination's mint; pairing the destination to an +/// order is `BeginSettle`'s job. +#[must_use = "ignoring the output may lead to an unintended on-chain state"] +fn push_funds<'a>( + program_id: &Address, + token_program_account: &AccountView, + state_pda_account: &AccountView, + pushes: Pushes<'a>, +) -> ProgramResult { + if token_program_account.address() != &SPL_TOKEN_PROGRAM_ID { + return Err(ProgramError::IncorrectProgramId); + } + + // The buffers' SPL authority is the state PDA, so it must sign each transfer. + let seeds = state_pda_seeds(); + let (state_pda, state_bump) = Address::find_program_address(&seeds, program_id); + if state_pda_account.address() != &state_pda { + return Err(SettlementError::StateAccountMismatch.into()); + } + + let [seed] = seeds; + let state_bump = [state_bump]; + let signer_seeds = [seed, &state_bump].map(Seed::from); + let state_pda_signer = Signer::from(&signer_seeds); + + for push in pushes.iter() { + // Read the destination's mint; the borrow ends with this block, before + // the transfer reuses the account. + let mint = { + let destination = TokenAccount::from_account_view(push.destination) + .map_err(|_| SettlementError::InvalidBuyTokenAccount)?; + *destination.mint() + }; + // Re-derive the buffer from the carried bump (one hash, not a full + // search). A buffer exists only at its canonical address, so a wrong + // bump yields an address the transfer can't draw from. + let derived = Address::create_program_address( + &buffer_pda_signer_seeds(mint.as_array(), &[push.bump]), + program_id, + ) + .map_err(|_| SettlementError::PushSourceNotBuffer)?; + if push.source_buffer.address() != &derived { + return Err(SettlementError::PushSourceNotBuffer.into()); + } + + Transfer::new( + push.source_buffer, + push.destination, + state_pda_account, + u64::from_be_bytes(*push.amount), + ) + .invoke_signed(core::slice::from_ref(&state_pda_signer))?; + } - // Some checks are carried out by `BeginSettle` and we don't repeat them - // under the assumption that the counterpart exists and, since it's a - // `BeginSettle`, it performs the checks. + Ok(()) } diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs index cbeae07..5c8a2d5 100644 --- a/programs/settlement/tests/finalize_settle_pushes.rs +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -1,13 +1,26 @@ -//! Integration tests for the fund-push list carried by `FinalizeSettle`. +//! Integration tests for the fund pushes carried by `FinalizeSettle` and +//! validated by `BeginSettle`. +//! +//! Each settlement transaction is a `[BeginSettle, FinalizeSettle]` pair (begin +//! at [`BEGIN_INDEX`] pointing to finalize at [`FINALIZE_INDEX`], and vice +//! versa). `BeginSettle` settles the orders the finalize pays (created on-chain +//! via `OrderBuilder` with no pulls, so only the push side moves funds) and +//! validates that each order is paid by exactly one push to its buy token +//! account. `FinalizeSettle` then executes the transfers out of the buffers, +//! signed by the settlement state PDA that owns them. -use crate::common::{order::OrderBuilder, setup, to_instruction_error, token}; +use crate::common::{ + buffer, create_account, + order::{create_order_pda, sample_intent, OrderBuilder}, + setup, to_instruction_error, token, +}; use litesvm::LiteSVM; use settlement_client::instructions::{ BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, }; use settlement_client::settlement_interface::{Instruction, SettlementError}; use solana_sdk::{ - instruction::{AccountMeta, InstructionError}, + instruction::InstructionError, program_error::ProgramError, pubkey::Pubkey, signature::{Keypair, Signer}, @@ -22,6 +35,27 @@ mod common; const BEGIN_INDEX: u8 = 0; const FINALIZE_INDEX: u8 = 1; +/// Assert the transaction failed in `BeginSettle` (at [`BEGIN_INDEX`]) with +/// `expected`. +fn assert_begin_error(result: Result<(), TransactionError>, expected: SettlementError) { + assert_eq!( + result, + Err(TransactionError::InstructionError( + BEGIN_INDEX, + to_instruction_error(expected), + )), + ); +} + +/// Assert the transaction failed in `FinalizeSettle` (at [`FINALIZE_INDEX`]) +/// with `expected`. +fn assert_finalize_error(result: Result<(), TransactionError>, expected: InstructionError) { + assert_eq!( + result, + Err(TransactionError::InstructionError(FINALIZE_INDEX, expected)), + ); +} + /// Send `[begin, finalize]` signed by `payer`, where `finalize` is a pre-built /// `FinalizeSettle` at [`FINALIZE_INDEX`] and `begin` settles `orders` (with no /// pulls) at [`BEGIN_INDEX`], the same orders the finalize is expected to push @@ -86,34 +120,51 @@ fn finalizes_with_no_pushes() { } #[test] -fn finalizes_with_single_push() { +fn pushes_a_single_order() { let (mut svm, program_id, payer) = setup(); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + let mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .buy_mint(&mint) + .build(); + let funding = 1_000; + let buffer_pda = buffer::ensure_funded(&mut svm, &program_id, &payer, &mint, funding); + let amount = 400; finalize( &mut svm, &program_id, &payer, &[FinalizedIntent { intent: &intent, - mint: Pubkey::new_unique(), - amount: 1_000, + mint, + amount, }], ) - .expect("a single push should parse and be accepted"); + .expect("a single push should be paid"); + + assert_eq!(token::balance(&svm, &intent.buy_token_account), amount); + assert_eq!(token::balance(&svm, &buffer_pda), funding - amount); } #[test] -fn finalizes_with_several_pushes_same_mint() { +fn pushes_several_orders_from_one_buffer() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); + // Distinct orders (each `OrderBuilder` makes fresh sell and buy token + // accounts) sharing one buy mint, so both pushes draw from one buffer. let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer) - .salt(1) + .buy_mint(&mint) + .salt(0) .build(); let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer) - .salt(2) + .buy_mint(&mint) + .salt(1) .build(); + let funding = 10_000; + let buffer_pda = buffer::ensure_funded(&mut svm, &program_id, &payer, &mint, funding); + let amount0 = 1_000; + let amount1 = 2_000; finalize( &mut svm, &program_id, @@ -122,26 +173,42 @@ fn finalizes_with_several_pushes_same_mint() { FinalizedIntent { intent: &intent0, mint, - amount: 1_000, + amount: amount0, }, FinalizedIntent { intent: &intent1, mint, - amount: 2_000, + amount: amount1, }, ], ) - .expect("several pushes should parse and be accepted"); + .expect("several pushes from one buffer should be paid"); + + assert_eq!(token::balance(&svm, &intent0.buy_token_account), amount0); + assert_eq!(token::balance(&svm, &intent1.buy_token_account), amount1); + assert_eq!( + token::balance(&svm, &buffer_pda), + funding - amount0 - amount1, + ); } #[test] -fn finalizes_with_several_pushes_different_mint() { +fn pushes_several_orders_from_different_buffers() { let (mut svm, program_id, payer) = setup(); - let mint_1 = token::create_mint(&mut svm, &payer); - let mint_2 = token::create_mint(&mut svm, &payer); - let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + let mint0 = token::create_mint(&mut svm, &payer); + let mint1 = token::create_mint(&mut svm, &payer); + let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer) + .buy_mint(&mint0) + .build(); + let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer) + .buy_mint(&mint1) + .build(); + let funding = 5_000; + let buffer0 = buffer::ensure_funded(&mut svm, &program_id, &payer, &mint0, funding); + let buffer1 = buffer::ensure_funded(&mut svm, &program_id, &payer, &mint1, funding); + let amount0 = 1_000; + let amount1 = 2_000; finalize( &mut svm, &program_id, @@ -149,46 +216,182 @@ fn finalizes_with_several_pushes_different_mint() { &[ FinalizedIntent { intent: &intent0, - mint: mint_1, - amount: 1_000, + mint: mint0, + amount: amount0, }, FinalizedIntent { intent: &intent1, - mint: mint_2, - amount: 2_000, + mint: mint1, + amount: amount1, }, ], ) - .expect("several pushes should parse and be accepted"); + .expect("pushes from different buffers should be paid"); + + assert_eq!(token::balance(&svm, &intent0.buy_token_account), amount0); + assert_eq!(token::balance(&svm, &intent1.buy_token_account), amount1); + assert_eq!(token::balance(&svm, &buffer0), funding - amount0); + assert_eq!(token::balance(&svm, &buffer1), funding - amount1); +} + +#[test] +fn rejects_push_to_wrong_destination() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + let orders = [FinalizedIntent { + intent: &intent, + mint, + amount: 100, + }]; + + let mut finalize = Instruction::from(FinalizeSettle { + program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders: &orders, + }); + // Redirect the push to an account that isn't the order's buy token account. + // Accounts: `[sysvar, state, token_program, source, destination]`. + let destination_index = 4; + finalize.accounts[destination_index].pubkey = Pubkey::new_unique(); + + assert_begin_error( + send_settlement(&mut svm, &program_id, &payer, &orders, finalize), + SettlementError::PushDestinationMismatch, + ); +} + +#[test] +fn rejects_push_from_non_buffer_source() { + let (mut svm, program_id, payer) = setup(); + let buy_mint = token::create_mint(&mut svm, &payer); + let other_mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .buy_mint(&buy_mint) + .build(); + // The push draws from the buffer for `other_mint`, not the buy token's mint, + // which `FinalizeSettle` rejects when it reads the destination's mint. + let orders = [FinalizedIntent { + intent: &intent, + mint: other_mint, + amount: 100, + }]; + + assert_finalize_error( + finalize(&mut svm, &program_id, &payer, &orders), + to_instruction_error(SettlementError::PushSourceNotBuffer), + ); +} + +#[test] +fn rejects_push_from_substituted_source() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .buy_mint(&mint) + .build(); + buffer::ensure_funded(&mut svm, &program_id, &payer, &mint, 1_000); + let orders = [FinalizedIntent { + intent: &intent, + mint, + amount: 100, + }]; + + let mut finalize = Instruction::from(FinalizeSettle { + program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders: &orders, + }); + // Point the push at an account that isn't the canonical buffer, leaving the + // rest well-formed. Accounts: `[sysvar, state, token_program, source, + // destination]`. `BeginSettle` doesn't validate the source, so it passes; + // `FinalizeSettle` re-derives the buffer from the destination's mint and + // rejects the mismatch before touching the substituted account. + let source_index = 3; + finalize.accounts[source_index].pubkey = Pubkey::new_unique(); + + assert_finalize_error( + send_settlement(&mut svm, &program_id, &payer, &orders, finalize), + to_instruction_error(SettlementError::PushSourceNotBuffer), + ); +} + +#[test] +fn rejects_fewer_pushes_than_orders() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + let orders = [FinalizedIntent { + intent: &intent, + mint, + amount: 100, + }]; + + // A finalize carrying no pushes, paired with a begin settling one order. + let finalize = FinalizeSettle { + program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders: &[], + }; + + assert_begin_error( + send_settlement(&mut svm, &program_id, &payer, &orders, finalize), + SettlementError::SettledOrderPushCountMismatch, + ); +} + +#[test] +fn rejects_more_pushes_than_orders() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + + // A finalize that pushes to one order, paired with a begin that settles none, + // so the extra push has no order to account for it. + let finalize = FinalizeSettle { + program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders: &[FinalizedIntent { + intent: &intent, + mint, + amount: 0, + }], + }; + + assert_begin_error( + send_settlement(&mut svm, &program_id, &payer, &[], finalize), + SettlementError::SettledOrderPushCountMismatch, + ); } #[test] fn rejects_push_account_count_mismatch() { let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); let orders = [FinalizedIntent { intent: &intent, - mint: Pubkey::new_unique(), - amount: 1_000, + mint, + amount: 100, }]; - // A well-formed single-push finalize... + // A well-formed single-push finalize (five accounts, a nine-byte push body)... let mut finalize = Instruction::from(FinalizeSettle { program_id, begin_ix_index: BEGIN_INDEX.into(), orders: &orders, }); - // ...with one extra account appended. - finalize - .accounts - .push(AccountMeta::new_readonly(Pubkey::new_unique(), false)); + // ...with another push's worth of data bytes appended but no matching + // accounts. `BeginSettle` derives the push count from the (unchanged) account + // metas (one push, matching its one order and paying the right destination) + // so it passes. Only the finalize reads the data, where it now parses two + // pushes against two push accounts and rejects the mismatch. This is the + // account/data disagreement `BeginSettle` structurally can't see. + finalize.data.extend_from_slice(&[0u8; 9]); - assert_eq!( + assert_finalize_error( send_settlement(&mut svm, &program_id, &payer, &orders, finalize), - Err(TransactionError::InstructionError( - FINALIZE_INDEX, - to_instruction_error(SettlementError::AccountCountNotMatchingPushCount), - )), + to_instruction_error(SettlementError::AccountCountNotMatchingPushCount), ); } @@ -196,53 +399,115 @@ fn rejects_push_account_count_mismatch() { fn rejects_too_few_accounts() { let (mut svm, program_id, payer) = setup(); - // A well-formed single-push finalize... + // A well-formed no-push finalize... let mut finalize = Instruction::from(FinalizeSettle { program_id, begin_ix_index: BEGIN_INDEX.into(), orders: &[], }); - // ...with one account popped. + // ...with one of its three fixed accounts popped. `BeginSettle` runs first + // but only reads push destinations off the accounts (finding none, matching + // its zero orders) so it passes. The finalize then can't even destructure + // its fixed accounts and raises `NotEnoughAccountKeys`. finalize.accounts.pop(); - let result = send_settlement(&mut svm, &program_id, &payer, &[], finalize); - let Err(TransactionError::InstructionError(index, ix_error)) = result else { - panic!("expected an instruction error, got {result:?}"); + let err = send_settlement(&mut svm, &program_id, &payer, &[], finalize) + .expect_err("a finalize missing a fixed account must be rejected"); + let TransactionError::InstructionError(FINALIZE_INDEX, ix_err) = err else { + panic!("expected the finalize (index {FINALIZE_INDEX}) to fail, got {err:?}"); }; - assert_eq!(index, FINALIZE_INDEX); + // Compare against the non-deprecated `ProgramError` variant the program + // returns; naming the `InstructionError` variant directly would touch a + // deprecated alias. assert_eq!( - // This unusual way to test is because `InstructionError::NotEnoughAccountKeys` - // is deprecated, while `ProgramError::NotEnoughAccountKeys` is not. - // Rather than silencing a linting, let's use the program error. - ProgramError::try_from(ix_error), + ProgramError::try_from(ix_err), Ok(ProgramError::NotEnoughAccountKeys), ); } +#[test] +fn rejects_invalid_buy_token_account() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); + + // The order's buy token account (the push destination) isn't a token account, + // so `FinalizeSettle` can't read its mint to derive the buffer. `BeginSettle` + // accepts it: the push destination still matches the intent's buy token. + let not_a_token_account = Pubkey::new_unique(); + let mut intent = sample_intent(payer.pubkey(), sell_token, 0); + intent.buy_token_account = not_a_token_account; + create_order_pda(&mut svm, &program_id, &payer, &intent); + let orders = [FinalizedIntent { + intent: &intent, + mint, + amount: 0, + }]; + + assert_finalize_error( + finalize(&mut svm, &program_id, &payer, &orders), + to_instruction_error(SettlementError::InvalidBuyTokenAccount), + ); +} + +#[test] +fn rejects_buy_token_account_owned_by_wrong_program() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); + + // Genuine token-account bytes: right length, a real mint at offset 0, so + // `from_account_view` would gladly read the mint. Assign those exact bytes + // to an account not owned by the token program, so it's not a valid + // token account. + let genuine = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); + let token_shaped = svm + .get_account(&genuine) + .expect("the genuine token account exists") + .data; + let impostor = create_account(&mut svm, &Pubkey::new_unique(), &token_shaped); + + // `BeginSettle` only checks the push destination matches the intent's buy + // token, so it accepts the impostor; `FinalizeSettle` rejects it when the + // owner check in `from_account_view` fails, before the mint is ever read. + let mut intent = sample_intent(payer.pubkey(), sell_token, 0); + intent.buy_token_account = impostor; + create_order_pda(&mut svm, &program_id, &payer, &intent); + let orders = [FinalizedIntent { + intent: &intent, + mint, + amount: 0, + }]; + + assert_finalize_error( + finalize(&mut svm, &program_id, &payer, &orders), + to_instruction_error(SettlementError::InvalidBuyTokenAccount), + ); +} + #[test] fn rejects_partial_push_amount() { let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); let orders = [FinalizedIntent { intent: &intent, - mint: Pubkey::new_unique(), - amount: 1_000, + mint, + amount: 100, }]; - // A well-formed single-push finalize... let mut finalize = Instruction::from(FinalizeSettle { program_id, begin_ix_index: BEGIN_INDEX.into(), orders: &orders, }); - // ...with one byte popped, so the trailing amount is no longer a whole `u64`. + // Drop one byte so the trailing amount is no longer a whole `u64`. Begin + // validates the push from the (unchanged) account metas and passes; finalize + // then rejects the malformed data. finalize.data.pop(); - assert_eq!( + assert_finalize_error( send_settlement(&mut svm, &program_id, &payer, &orders, finalize), - Err(TransactionError::InstructionError( - FINALIZE_INDEX, - InstructionError::InvalidInstructionData, - )), + InstructionError::InvalidInstructionData, ); } From aad2a642d23afa10d95dc9a76460001a9062ca8b Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:04:08 +0200 Subject: [PATCH 05/30] Consistency: index alignment and underscore separator --- programs/settlement/tests/finalize_settle_pushes.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs index dc981a0..6a0aa2a 100644 --- a/programs/settlement/tests/finalize_settle_pushes.rs +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -138,10 +138,10 @@ fn finalizes_with_several_pushes_same_mint() { #[test] fn finalizes_with_several_pushes_different_mint() { let (mut svm, program_id, payer) = setup(); - let mint_1 = token::create_mint(&mut svm, &payer); - let mint_2 = token::create_mint(&mut svm, &payer); - let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint_1).build(); - let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint_2).build(); + let mint0 = token::create_mint(&mut svm, &payer); + let mint1 = token::create_mint(&mut svm, &payer); + let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint0).build(); + let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint1).build(); finalize( &mut svm, @@ -150,12 +150,12 @@ fn finalizes_with_several_pushes_different_mint() { &[ FinalizedIntent { intent: &intent0, - mint: mint_1, + mint: mint0, amount: 1_000, }, FinalizedIntent { intent: &intent1, - mint: mint_2, + mint: mint1, amount: 2_000, }, ], From 903fa88bdd2813776f2d2aff0777e2f39a9c949a Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:49:46 +0200 Subject: [PATCH 06/30] Test that two accounts popped trigger an error --- .../tests/finalize_settle_pushes.rs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs index 6a0aa2a..44b3841 100644 --- a/programs/settlement/tests/finalize_settle_pushes.rs +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -221,6 +221,48 @@ fn rejects_too_few_accounts() { ); } +// Similar to `rejects_too_few_accounts`, but pops two accounts instead of one. +// This is because variable-length accounts in the instruction are naturally +// grouped in pairs, so a single missing account could just be an unsuccessful +// pairing rather than accounting for missing accounts. +#[test] +fn rejects_two_too_few_accounts() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + let orders = [ + FinalizedIntent { + intent: &intent0, + mint, + amount: 0, + }, + FinalizedIntent { + intent: &intent1, + mint, + amount: 0, + }, + ]; + + // A well-formed two-push finalize... + let mut finalize = Instruction::from(FinalizeSettle { + program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders: &orders, + }); + // ...with the last push's whole (source, destination) pair popped. + finalize.accounts.pop(); + finalize.accounts.pop(); + + assert_eq!( + send_settlement(&mut svm, &program_id, &payer, &orders, finalize), + Err(TransactionError::InstructionError( + FINALIZE_INDEX, + to_instruction_error(SettlementError::AccountCountNotMatchingPushCount), + )), + ); +} + #[test] fn rejects_partial_push_amount() { let (mut svm, program_id, payer) = setup(); From 514e20bd8b8b9564d5d646e67f97ac33861d6d89 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:39:01 +0200 Subject: [PATCH 07/30] Explicitly link mints to intents even if technically not needed --- programs/settlement/tests/finalize_settle_pushes.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs index dc28398..845e813 100644 --- a/programs/settlement/tests/finalize_settle_pushes.rs +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -109,9 +109,11 @@ fn finalizes_with_several_pushes_same_mint() { let mint = token::create_mint(&mut svm, &payer); let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer) .salt(1) + .buy_mint(&mint) .build(); let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer) .salt(2) + .buy_mint(&mint) .build(); finalize( @@ -139,8 +141,12 @@ fn finalizes_with_several_pushes_different_mint() { let (mut svm, program_id, payer) = setup(); let mint0 = token::create_mint(&mut svm, &payer); let mint1 = token::create_mint(&mut svm, &payer); - let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer) + .buy_mint(&mint0) + .build(); + let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer) + .buy_mint(&mint1) + .build(); finalize( &mut svm, From e71d23e53f656d263aa264fcfb72c71db3db5a2d Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:39:19 +0200 Subject: [PATCH 08/30] Clearer proptest name --- programs/settlement/src/settle/begin.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 7099e28..187b633 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -390,7 +390,7 @@ mod tests { /// For any well-formed finalize the two must recover the same push /// count and the same destination for every push. #[test] - fn finalize_pushes_agrees_with_finalize_settle_input( + fn push_destinations_output_matches_finalize_parser( program_id in any::<[u8; 32]>(), state_pda in any::<[u8; 32]>(), begin_ix_index in any::(), From 75130af26c5b6513c6052dccc910b7fee96fc933 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:40:49 +0200 Subject: [PATCH 09/30] Remove unnecessary count checks --- programs/settlement/src/settle/begin.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 187b633..e005083 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -396,8 +396,6 @@ mod tests { begin_ix_index in any::(), (source_buffers, destinations, bumps, amounts) in arb_pushes(0..=16usize), ) { - let count = source_buffers.len(); - let ix = Instruction::from(FinalizeSettle { program_id: Pubkey::new_from_array(program_id), state_pda: Pubkey::new_from_array(state_pda), @@ -419,8 +417,6 @@ mod tests { let parsed_destinations: Vec
= parsed.pushes.iter().map(|push| *push.destination.address()).collect(); - prop_assert_eq!(introspected_destinations.len(), count); - prop_assert_eq!(parsed.pushes.iter().count(), count); prop_assert_eq!(&introspected_destinations, &destinations); prop_assert_eq!(&parsed_destinations, &destinations); } From eb98478fc8041bbfc29fe7b1c87d10dc6947857a Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:49:30 +0200 Subject: [PATCH 10/30] Clearer comment --- programs/settlement/tests/begin_settle_orders.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index ef47162..33745c4 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -761,9 +761,8 @@ fn pulls_from_multiple_orders() { #[test] fn zero_pulls_moves_nothing() { let (mut svm, program_id, payer) = setup(); - // The order sells `sell_mint` and is paid in a distinct `buy_mint`, so the - // buy-side push touches only `buy_mint` accounts. That isolates the sell - // mint: with no pulls, no token instruction should reference its account. + // The intent specifies a sell mint. We want to see that, when no pull is + // specified, this account isn't touched in the transaction. let sell_mint = token::create_mint(&mut svm, &payer); let buy_mint = token::create_mint(&mut svm, &payer); let intent = OrderBuilder::new(&mut svm, &program_id, &payer) From 8fe14a488294e7ef3a7b3033bef6a55033587efc Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:06:59 +0200 Subject: [PATCH 11/30] Simplify `push_destinations` --- programs/settlement/src/settle/begin.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index e005083..632c6fe 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -77,8 +77,7 @@ pub fn process_begin_settle( } /// The destination address of each push carried by the paired `FinalizeSettle`, -/// seen through instruction introspection, in order and stopping at the first -/// missing account. +/// seen through instruction introspection, in order. /// /// The push structure isn't validated here: the paired `FinalizeSettle` re-parses /// the same instruction from its own data and rejects a dangling source buffer or @@ -90,14 +89,16 @@ fn push_destinations<'a>( ) -> impl Iterator { // Each push occupies a `[source_buffer, destination]` meta pair after the // fixed accounts, so the destinations are every second meta beginning at the - // first push's destination. The first index with no meta ends the list. - (FINALIZE_FIXED_ACCOUNTS + 1..) + // first push's destination. + (FINALIZE_FIXED_ACCOUNTS + 1..instruction.num_account_metas()) .step_by(2) - .map_while(|destination_index| { - instruction + .map(|destination_index| { + // The index stays below `num_account_metas`, so the lookup, whose only + // error is an out-of-bounds index, always succeeds. + &instruction .get_instruction_account_at(destination_index) - .ok() - .map(|account| &account.key) + .expect("index within num_account_metas") + .key }) } From a81feaa5d834cfcafcafc17523b29f567123c99b Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:40:19 +0200 Subject: [PATCH 12/30] Don't send transaction in helpers, just build it --- .../settlement/tests/begin_settle_orders.rs | 475 ++++++++---------- programs/settlement/tests/common/mod.rs | 9 +- .../settlement/tests/common/settlement.rs | 37 ++ .../tests/finalize_settle_pushes.rs | 94 ++-- 4 files changed, 290 insertions(+), 325 deletions(-) create mode 100644 programs/settlement/tests/common/settlement.rs diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index 33745c4..f1f5c7b 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -12,14 +12,17 @@ //! (with placeholder source, bump, and amount, so they never execute), while //! [`settle_and_pay`] attaches fully real ones for settlements expected to //! succeed. Tests rejected before the push checks (wrong token program or state -//! PDA) pair with an empty finalize ([`send_settlement`]). +//! PDA) pair with an empty finalize ([`build_settlement_with_placeholder_pushes`] +//! with no destinations). use crate::common::{ assert_instruction_error, assert_settlement_error, buffer, create_account, order::{create_order_pda, sample_intent, OrderBuilder}, - set_unix_timestamp, setup, token, + send, set_unix_timestamp, + settlement::{settlement_tx, BEGIN_INDEX, FINALIZE_INDEX}, + setup, token, }; -use litesvm::{types::TransactionMetadata, LiteSVM}; +use litesvm::LiteSVM; use litesvm_token::spl_token::error::TokenError; use settlement_client::instructions::{ BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, @@ -38,59 +41,35 @@ use solana_sdk::{ instruction::{AccountMeta, InstructionError}, pubkey::Pubkey, signature::{Keypair, Signer}, - transaction::{Transaction, TransactionError}, + transaction::Transaction, }; mod common; -/// The positions of the two instructions in every settlement transaction below, -/// named so each instruction's reference to its counterpart reads clearly. -const BEGIN_INDEX: u16 = 0; -const FINALIZE_INDEX: u16 = 1; - /// A list of empty transfer lists, one per order. Used for settling `n` orders /// without pulling any funds. fn no_pulls(n: usize) -> Vec<&'static [Pull]> { vec![&[]; n] } -/// Send `[begin, finalize]` signed by `payer`, where `begin` is a pre-built -/// `BeginSettle` instruction and `finalize` settles no pushes. Use it only for +/// Build `[begin, finalize]` signed by `payer`, where `begin` is pre-built and +/// `finalize` carries one push per `destination` (enough to satisfy +/// `BeginSettle`'s one-push-per-order pairing) and, with each push targeting its +/// order's buy token account, its push-destination check too. The pushes' +/// source, bump, and amount are placeholders: these settlements are expected to +/// be rejected during order validation, so the finalize never runs. +/// +/// With no destinations the finalize settles no pushes; use that form only for /// cases rejected before `BeginSettle`'s one-push-per-order count check (wrong -/// token program or state PDA); otherwise the empty finalize trips that check. -fn send_settlement( - svm: &mut LiteSVM, - program_id: &Pubkey, - payer: &Keypair, - begin: impl Into, -) -> Result { - let finalize = FinalizeSettle { - program_id: *program_id, - begin_ix_index: BEGIN_INDEX, - orders: &[], - }; - let tx = Transaction::new_signed_with_payer( - &[begin.into(), finalize.into()], - Some(&payer.pubkey()), - &[payer], - svm.latest_blockhash(), - ); - svm.send_transaction(tx).map_err(|e| e.err) -} - -/// Send `[begin, finalize]` where `finalize` carries one push per `destination` -/// (enough to satisfy `BeginSettle`'s one-push-per-order pairing) and, with -/// each push targeting its order's buy token account, its push-destination -/// check too. The pushes' source, bump, and amount are placeholders: these -/// settlements are expected to be rejected during order validation, so the -/// finalize never runs. -fn send_settlement_with_placeholder_pushes( - svm: &mut LiteSVM, +/// token program or state PDA), where the empty finalize would otherwise trip +/// that check. +fn build_settlement_with_placeholder_pushes( + svm: &LiteSVM, program_id: &Pubkey, payer: &Keypair, begin: impl Into, destinations: &[Pubkey], -) -> Result { +) -> Transaction { let push_count = destinations.len(); let placeholder_sources: Vec = (0..push_count).map(|_| Pubkey::new_unique()).collect(); let bumps = vec![0u8; push_count]; @@ -98,60 +77,56 @@ fn send_settlement_with_placeholder_pushes( let finalize = Instruction::from(FinalizeSettleRaw { program_id: *program_id, state_pda: find_state_pda(program_id).0, - begin_ix_index: BEGIN_INDEX, + begin_ix_index: BEGIN_INDEX.into(), source_buffers: &placeholder_sources, destinations, bumps: &bumps, amounts: &amounts, }); - let tx = Transaction::new_signed_with_payer( - &[begin.into(), finalize], - Some(&payer.pubkey()), - &[payer], - svm.latest_blockhash(), - ); - svm.send_transaction(tx).map_err(|e| e.err) + settlement_tx(svm, payer, begin, finalize) } -/// Settle `orders` in a minimal `[BeginSettle, FinalizeSettle]` transaction +/// Build a minimal `[BeginSettle, FinalizeSettle]` transaction settling `orders` /// (begin at [`BEGIN_INDEX`], finalize at [`FINALIZE_INDEX`]) signed by `payer`. /// The finalize carries placeholder pushes matching the orders in count and /// destination, so this clears the push checks and reaches `BeginSettle`'s order /// validation: use it for cases expected to be rejected there. fn settle( - svm: &mut LiteSVM, + svm: &LiteSVM, program_id: &Pubkey, payer: &Keypair, orders: &[InitializedIntent], -) -> Result { +) -> Transaction { let destinations: Vec = orders .iter() .map(|order| order.intent.buy_token_account) .collect(); - send_settlement_with_placeholder_pushes( + build_settlement_with_placeholder_pushes( svm, program_id, payer, BeginSettle { program_id: *program_id, - finalize_ix_index: FINALIZE_INDEX, + finalize_ix_index: FINALIZE_INDEX.into(), orders, }, &destinations, ) } -/// Settle `orders` and pay each one: the finalize pushes a zero amount from each -/// order's canonical buy-token buffer to its buy token account, lining up -/// one-to-one with the orders so `BeginSettle`'s push pass passes. The buffer for -/// each order's buy mint is created on demand. Use it for settlements expected to -/// succeed. (Real push amounts are exercised in `finalize_settle_pushes.rs`.) +/// Build a `[BeginSettle, FinalizeSettle]` transaction settling `orders` and +/// paying each one: the finalize pushes a zero amount from each order's canonical +/// buy-token buffer to its buy token account, lining up one-to-one with the +/// orders so `BeginSettle`'s push pass passes. The buffer for each order's buy +/// mint is created on demand (hence `&mut svm`, unlike the other builders). Use +/// it for settlements expected to succeed. (Real push amounts are exercised in +/// `finalize_settle_pushes.rs`.) fn settle_and_pay( svm: &mut LiteSVM, program_id: &Pubkey, payer: &Keypair, orders: &[InitializedIntent], -) -> Result { +) -> Transaction { let settled: Vec = orders .iter() .map(|order| { @@ -165,23 +140,17 @@ fn settle_and_pay( }) .collect(); - let begin = Instruction::from(BeginSettle { + let begin = BeginSettle { program_id: *program_id, - finalize_ix_index: FINALIZE_INDEX, + finalize_ix_index: FINALIZE_INDEX.into(), orders, - }); - let finalize = Instruction::from(FinalizeSettle { + }; + let finalize = FinalizeSettle { program_id: *program_id, - begin_ix_index: BEGIN_INDEX, + begin_ix_index: BEGIN_INDEX.into(), orders: &settled, - }); - let tx = Transaction::new_signed_with_payer( - &[begin, finalize], - Some(&payer.pubkey()), - &[payer], - svm.latest_blockhash(), - ); - svm.send_transaction(tx).map_err(|e| e.err) + }; + settlement_tx(svm, payer, begin, finalize) } /// Settle orders described by raw, parallel `(order_pda, sell_token, buy_token, @@ -194,24 +163,24 @@ fn settle_and_pay( /// destination check (a non-canonical or undecodable order) may pass any /// `buy_token`. fn settle_raw( - svm: &mut LiteSVM, + svm: &LiteSVM, program_id: &Pubkey, payer: &Keypair, order_pdas: &[Pubkey], sell_token_accounts: &[Pubkey], buy_token_accounts: &[Pubkey], bumps: &[u8], -) -> Result { +) -> Transaction { let begin = BeginSettleRaw { program_id: *program_id, state_pda: find_state_pda(program_id).0, - finalize_ix_index: FINALIZE_INDEX, + finalize_ix_index: FINALIZE_INDEX.into(), order_pdas, order_pda_bumps: bumps, sell_token_accounts, pulls: &no_pulls(bumps.len()), }; - send_settlement_with_placeholder_pushes(svm, program_id, payer, begin, buy_token_accounts) + build_settlement_with_placeholder_pushes(svm, program_id, payer, begin, buy_token_accounts) } #[test] @@ -219,7 +188,7 @@ fn settles_a_single_order() { let (mut svm, program_id, payer) = setup(); let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - settle_and_pay( + let tx = settle_and_pay( &mut svm, &program_id, &payer, @@ -227,8 +196,8 @@ fn settles_a_single_order() { intent: &intent, pulls: &[], }], - ) - .expect("settlement should succeed"); + ); + send(&mut svm, tx).expect("settlement should succeed"); } #[test] @@ -248,8 +217,8 @@ fn settles_multiple_orders() { .iter() .map(|intent| InitializedIntent { intent, pulls: &[] }) .collect(); - settle_and_pay(&mut svm, &program_id, &payer, &orders) - .expect("multi-order settlement should succeed"); + let tx = settle_and_pay(&mut svm, &program_id, &payer, &orders); + send(&mut svm, tx).expect("multi-order settlement should succeed"); } #[test] @@ -258,18 +227,16 @@ fn rejects_wrong_bump() { let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); - assert_settlement_error( - settle_raw( - &mut svm, - &program_id, - &payer, - &[order_pda], - &[intent.sell_token_account], - &[intent.buy_token_account], - &[bump ^ 0x01], - ), - SettlementError::OrderNotCanonical, + let tx = settle_raw( + &svm, + &program_id, + &payer, + &[order_pda], + &[intent.sell_token_account], + &[intent.buy_token_account], + &[bump ^ 0x01], ); + assert_settlement_error(send(&mut svm, tx), SettlementError::OrderNotCanonical); } #[test] @@ -290,18 +257,16 @@ fn rejects_fabricated_program_owned_account() { // address that isn't the canonical order PDA. let fake_order = create_account(&mut svm, &program_id, &body); - assert_settlement_error( - settle_raw( - &mut svm, - &program_id, - &payer, - &[fake_order], - &[sell_token], - &[Pubkey::new_unique()], - &[255], - ), - SettlementError::OrderNotCanonical, + let tx = settle_raw( + &svm, + &program_id, + &payer, + &[fake_order], + &[sell_token], + &[Pubkey::new_unique()], + &[255], ); + assert_settlement_error(send(&mut svm, tx), SettlementError::OrderNotCanonical); } #[test] @@ -313,18 +278,16 @@ fn rejects_non_order_account_in_order_slot() { // Put a token account in the order slot. Its 165-byte data can't decode as a // 199-byte order body, so it's rejected before the canonical-address check. - assert_instruction_error( - settle_raw( - &mut svm, - &program_id, - &payer, - &[sell_token], - &[sell_token], - &[Pubkey::new_unique()], - &[255], - ), - InstructionError::InvalidAccountData, + let tx = settle_raw( + &svm, + &program_id, + &payer, + &[sell_token], + &[sell_token], + &[Pubkey::new_unique()], + &[255], ); + assert_instruction_error(send(&mut svm, tx), InstructionError::InvalidAccountData); } #[test] @@ -336,16 +299,17 @@ fn rejects_sell_token_account_mismatch() { let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); let wrong_sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); + let tx = settle_raw( + &svm, + &program_id, + &payer, + &[order_pda], + &[wrong_sell_token], + &[intent.buy_token_account], + &[bump], + ); assert_settlement_error( - settle_raw( - &mut svm, - &program_id, - &payer, - &[order_pda], - &[wrong_sell_token], - &[intent.buy_token_account], - &[bump], - ), + send(&mut svm, tx), SettlementError::SellTokenAccountMismatch, ); } @@ -360,18 +324,16 @@ fn rejects_sell_token_owner_mismatch() { let intent = sample_intent(payer.pubkey(), sell_token, 1); create_order_pda(&mut svm, &program_id, &payer, &intent); - assert_settlement_error( - settle( - &mut svm, - &program_id, - &payer, - &[InitializedIntent { - intent: &intent, - pulls: &[], - }], - ), - SettlementError::SellTokenOwnerMismatch, + let tx = settle( + &svm, + &program_id, + &payer, + &[InitializedIntent { + intent: &intent, + pulls: &[], + }], ); + assert_settlement_error(send(&mut svm, tx), SettlementError::SellTokenOwnerMismatch); } #[test] @@ -382,18 +344,16 @@ fn rejects_non_token_sell_account() { let intent = sample_intent(payer.pubkey(), non_token, 1); create_order_pda(&mut svm, &program_id, &payer, &intent); - assert_settlement_error( - settle( - &mut svm, - &program_id, - &payer, - &[InitializedIntent { - intent: &intent, - pulls: &[], - }], - ), - SettlementError::SellTokenAccountInvalid, + let tx = settle( + &svm, + &program_id, + &payer, + &[InitializedIntent { + intent: &intent, + pulls: &[], + }], ); + assert_settlement_error(send(&mut svm, tx), SettlementError::SellTokenAccountInvalid); } #[test] @@ -401,22 +361,23 @@ fn rejects_duplicate_orders() { let (mut svm, program_id, payer) = setup(); let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + let tx = settle_and_pay( + &mut svm, + &program_id, + &payer, + &[ + InitializedIntent { + intent: &intent, + pulls: &[], + }, + InitializedIntent { + intent: &intent, + pulls: &[], + }, + ], + ); assert_settlement_error( - settle_and_pay( - &mut svm, - &program_id, - &payer, - &[ - InitializedIntent { - intent: &intent, - pulls: &[], - }, - InitializedIntent { - intent: &intent, - pulls: &[], - }, - ], - ), + send(&mut svm, tx), SettlementError::OrdersNotStrictlyIncreasing, ); } @@ -460,7 +421,7 @@ fn rejects_orders_in_wrong_address_order() { orders.sort_by_key(|&(pda, ..)| std::cmp::Reverse(pda)); let mut data = vec![SettlementInstruction::BeginSettle.discriminator()]; - data.extend_from_slice(&FINALIZE_INDEX.to_be_bytes()); + data.extend_from_slice(&u16::from(FINALIZE_INDEX).to_be_bytes()); data.push(orders.len() as u8); data.extend(orders.iter().map(|&(_, _, _, bump)| bump)); // No transfers: one zero transfer-count byte per order. @@ -483,27 +444,19 @@ fn rejects_orders_in_wrong_address_order() { // One zero-amount push per order, paying each order's buy token account, // aligned with begin's decreasing order. `BeginSettle` checks only the - // destinations, so the sources are placeholders (and the finalize never runs, - // as begin rejects the ordering first). - let placeholder_source = Pubkey::new_unique(); - let finalize = Instruction::from(FinalizeSettleRaw { - program_id, - state_pda: find_state_pda(&program_id).0, - begin_ix_index: BEGIN_INDEX, - source_buffers: &[placeholder_source, placeholder_source], - destinations: &[orders[0].2, orders[1].2], - bumps: &[0, 0], - amounts: &[0, 0], - }); - - let tx = Transaction::new_signed_with_payer( - &[begin, finalize], - Some(&payer.pubkey()), - &[&payer], - svm.latest_blockhash(), + // destinations, so the placeholder pushes never run (begin rejects the + // ordering first). + let tx = build_settlement_with_placeholder_pushes( + &svm, + &program_id, + &payer, + begin, + &[orders[0].2, orders[1].2], + ); + assert_settlement_error( + send(&mut svm, tx), + SettlementError::OrdersNotStrictlyIncreasing, ); - let result = svm.send_transaction(tx).map(|_| ()).map_err(|e| e.err); - assert_settlement_error(result, SettlementError::OrdersNotStrictlyIncreasing); } #[test] @@ -540,18 +493,16 @@ fn rejects_cancelled_order() { ) .expect("placing a cancelled order at its canonical PDA should succeed"); - assert_settlement_error( - settle( - &mut svm, - &program_id, - &payer, - &[InitializedIntent { - intent: &intent, - pulls: &[], - }], - ), - SettlementError::OrderCancelled, + let tx = settle( + &svm, + &program_id, + &payer, + &[InitializedIntent { + intent: &intent, + pulls: &[], + }], ); + assert_settlement_error(send(&mut svm, tx), SettlementError::OrderCancelled); } #[test] @@ -565,18 +516,16 @@ fn rejects_expired_order() { let after_expiration = i64::from(valid_to) + 1; set_unix_timestamp(&mut svm, after_expiration); - assert_settlement_error( - settle( - &mut svm, - &program_id, - &payer, - &[InitializedIntent { - intent: &intent, - pulls: &[], - }], - ), - SettlementError::OrderExpired, + let tx = settle( + &svm, + &program_id, + &payer, + &[InitializedIntent { + intent: &intent, + pulls: &[], + }], ); + assert_settlement_error(send(&mut svm, tx), SettlementError::OrderExpired); } #[test] @@ -589,7 +538,7 @@ fn settles_order_at_exact_valid_to() { .build(); set_unix_timestamp(&mut svm, i64::from(valid_to)); - settle_and_pay( + let tx = settle_and_pay( &mut svm, &program_id, &payer, @@ -597,8 +546,8 @@ fn settles_order_at_exact_valid_to() { intent: &intent, pulls: &[], }], - ) - .expect("an order is still settleable at exactly valid_to"); + ); + send(&mut svm, tx).expect("an order is still settleable at exactly valid_to"); } #[test] @@ -616,7 +565,7 @@ fn pulls_funds_to_destination() { token::create_token_account(&mut svm, &payer, &sell_mint, &Pubkey::new_unique()); let amount = 2_000_000; - settle_and_pay( + let tx = settle_and_pay( &mut svm, &program_id, &payer, @@ -627,8 +576,8 @@ fn pulls_funds_to_destination() { amount, }], }], - ) - .expect("a pull within the approved delegation should succeed"); + ); + send(&mut svm, tx).expect("a pull within the approved delegation should succeed"); assert_eq!(token::balance(&svm, &destination), amount); assert_eq!(token::balance(&svm, &sell_token), initial_amount - amount); @@ -654,7 +603,7 @@ fn pulls_to_multiple_destinations() { let pulled0 = 300_000; let pulled1 = 100_000; - settle_and_pay( + let tx = settle_and_pay( &mut svm, &program_id, &payer, @@ -671,8 +620,8 @@ fn pulls_to_multiple_destinations() { }, ], }], - ) - .expect("multiple pulls from one order should succeed"); + ); + send(&mut svm, tx).expect("multiple pulls from one order should succeed"); assert_eq!(token::balance(&svm, &dest0), pulled0); assert_eq!(token::balance(&svm, &dest1), pulled1); @@ -723,7 +672,7 @@ fn pulls_from_multiple_orders() { let pulled_first = 42_000; let pulled_second = 67_000; - settle_and_pay( + let tx = settle_and_pay( &mut svm, &program_id, &payer, @@ -743,8 +692,8 @@ fn pulls_from_multiple_orders() { }], }, ], - ) - .expect("pulls from several orders should succeed"); + ); + send(&mut svm, tx).expect("pulls from several orders should succeed"); assert_eq!(token::balance(&svm, &dest_first), pulled_first); assert_eq!(token::balance(&svm, &dest_second), pulled_second); @@ -773,39 +722,23 @@ fn zero_pulls_moves_nothing() { let initial_amount = 42_000_000; token::mint_to(&mut svm, &payer, &sell_mint, &sell_token, initial_amount); - // The buy-side buffer must exist for the (zero-amount) push to draw from. - buffer::ensure_buffer_exists(&mut svm, &program_id, &payer, &buy_mint); - // Build the `[begin, finalize]` settlement by hand so the issued token - // instructions can be inspected. Begin settles the order with no pulls; - // finalize pushes a zero amount from the buy buffer to the buy token account. - let begin = Instruction::from(BeginSettle { - program_id, - finalize_ix_index: FINALIZE_INDEX, - orders: &[InitializedIntent { + // `settle_and_pay` builds exactly the settlement this test needs: begin + // settles the order with no pulls, and finalize pushes a zero amount from the + // buy buffer (created on demand) to the buy token account. Because it returns + // the transaction, we can capture its account keys before submitting to + // inspect the issued token instructions. + let tx = settle_and_pay( + &mut svm, + &program_id, + &payer, + &[InitializedIntent { intent: &intent, pulls: &[], }], - }); - let finalize = Instruction::from(FinalizeSettle { - program_id, - begin_ix_index: BEGIN_INDEX, - orders: &[FinalizedIntent { - intent: &intent, - mint: buy_mint, - amount: 0, - }], - }); - let tx = Transaction::new_signed_with_payer( - &[begin, finalize], - Some(&payer.pubkey()), - &[&payer], - svm.latest_blockhash(), ); let account_keys = tx.message.account_keys.clone(); - let transaction = svm - .send_transaction(tx) - .expect("settling without pulling should succeed"); + let transaction = send(&mut svm, tx).expect("settling without pulling should succeed"); // No token instruction references the sell token account (the sell mint's // only account here): the lone token transfer is the buy-side push, which @@ -822,23 +755,22 @@ fn rejects_wrong_state_pda() { let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); let not_the_state_pda = Pubkey::new_unique(); - assert_settlement_error( - send_settlement( - &mut svm, - &program_id, - &payer, - BeginSettleRaw { - program_id, - state_pda: not_the_state_pda, - finalize_ix_index: FINALIZE_INDEX, - order_pdas: &[order_pda], - order_pda_bumps: &[bump], - sell_token_accounts: &[intent.sell_token_account], - pulls: &no_pulls(1), - }, - ), - SettlementError::StateAccountMismatch, + let tx = build_settlement_with_placeholder_pushes( + &svm, + &program_id, + &payer, + BeginSettleRaw { + program_id, + state_pda: not_the_state_pda, + finalize_ix_index: FINALIZE_INDEX.into(), + order_pdas: &[order_pda], + order_pda_bumps: &[bump], + sell_token_accounts: &[intent.sell_token_account], + pulls: &no_pulls(1), + }, + &[], ); + assert_settlement_error(send(&mut svm, tx), SettlementError::StateAccountMismatch); } #[test] @@ -851,7 +783,7 @@ fn rejects_wrong_token_program() { // token-program account out afterwards. let mut begin: Instruction = BeginSettle { program_id, - finalize_ix_index: FINALIZE_INDEX, + finalize_ix_index: FINALIZE_INDEX.into(), orders: &[InitializedIntent { intent: &intent, pulls: &[], @@ -861,10 +793,8 @@ fn rejects_wrong_token_program() { let token_account_index = 2; begin.accounts[token_account_index] = AccountMeta::new_readonly(Pubkey::new_unique(), false); - assert_instruction_error( - send_settlement(&mut svm, &program_id, &payer, begin), - InstructionError::IncorrectProgramId, - ); + let tx = build_settlement_with_placeholder_pushes(&svm, &program_id, &payer, begin, &[]); + assert_instruction_error(send(&mut svm, tx), InstructionError::IncorrectProgramId); } #[test] @@ -884,8 +814,8 @@ fn rejects_pull_delegated_to_incorrect_address() { let destination = token::create_token_account(&mut svm, &payer, &sell_mint, &Pubkey::new_unique()); - let result = settle( - &mut svm, + let tx = settle( + &svm, &program_id, &payer, &[InitializedIntent { @@ -897,7 +827,7 @@ fn rejects_pull_delegated_to_incorrect_address() { }], ); assert_instruction_error( - result, + send(&mut svm, tx), InstructionError::Custom(TokenError::OwnerMismatch as u32), ); } @@ -925,8 +855,8 @@ fn rejects_pull_exceeding_delegation() { let destination = token::create_token_account(&mut svm, &payer, &sell_mint, &Pubkey::new_unique()); - let result = settle( - &mut svm, + let tx = settle( + &svm, &program_id, &payer, &[InitializedIntent { @@ -938,7 +868,7 @@ fn rejects_pull_exceeding_delegation() { }], ); assert_instruction_error( - result, + send(&mut svm, tx), InstructionError::Custom(TokenError::InsufficientFunds as u32), ); assert_eq!(token::balance(&svm, &sell_token), initial_amount); @@ -955,7 +885,7 @@ fn rejects_extra_account() { // A well-formed single-order, no-transfer settlement... let mut begin: Instruction = BeginSettle { program_id, - finalize_ix_index: FINALIZE_INDEX, + finalize_ix_index: FINALIZE_INDEX.into(), orders: &[InitializedIntent { intent: &intent, pulls: &[], @@ -968,8 +898,9 @@ fn rejects_extra_account() { .accounts .push(AccountMeta::new_readonly(Pubkey::new_unique(), false)); + let tx = build_settlement_with_placeholder_pushes(&svm, &program_id, &payer, begin, &[]); assert_settlement_error( - send_settlement(&mut svm, &program_id, &payer, begin), + send(&mut svm, tx), SettlementError::AccountCountNotMatchingOrderCount, ); } diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index 79fa5dd..dbfa8dd 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -9,9 +9,10 @@ pub mod buffer; pub mod lookup_table; pub mod order; pub mod pda; +pub mod settlement; pub mod token; -use litesvm::LiteSVM; +use litesvm::{types::TransactionMetadata, LiteSVM}; use settlement_client::settlement_interface::SettlementError; use settlement_interface::Instruction; use solana_sdk::{ @@ -140,3 +141,9 @@ pub fn signed_tx( svm.latest_blockhash(), ) } + +/// Submit `tx`, surfacing only the transaction-level error on failure (dropping +/// the success metadata's error wrapper). +pub fn send(svm: &mut LiteSVM, tx: Transaction) -> Result { + svm.send_transaction(tx).map_err(|e| e.err) +} diff --git a/programs/settlement/tests/common/settlement.rs b/programs/settlement/tests/common/settlement.rs new file mode 100644 index 0000000..677da44 --- /dev/null +++ b/programs/settlement/tests/common/settlement.rs @@ -0,0 +1,37 @@ +//! The canonical settlement transaction shape shared by the integration tests. +//! +//! Every settlement is a `[BeginSettle, FinalizeSettle]` pair with begin at +//! [`BEGIN_INDEX`] and finalize at [`FINALIZE_INDEX`], each instruction +//! referencing its counterpart by that position. [`settlement_tx`] assembles the +//! pair in that fixed order; callers submit the result with [`send`](super::send). + +use litesvm::LiteSVM; +use settlement_interface::Instruction; +use solana_sdk::{ + signature::{Keypair, Signer}, + transaction::Transaction, +}; + +/// Position of the `BeginSettle` instruction in the transactions built by +/// [`settlement_tx`]. +pub const BEGIN_INDEX: u8 = 0; +/// Position of the `FinalizeSettle` instruction in those transactions. +pub const FINALIZE_INDEX: u8 = 1; + +/// Assemble the canonical two-instruction settlement transaction: `begin` at +/// [`BEGIN_INDEX`], `finalize` at [`FINALIZE_INDEX`], signed by `payer`. Callers +/// build the two instructions and submit the returned transaction with +/// [`send`](super::send). +pub fn settlement_tx( + svm: &LiteSVM, + payer: &Keypair, + begin: impl Into, + finalize: impl Into, +) -> Transaction { + Transaction::new_signed_with_payer( + &[begin.into(), finalize.into()], + Some(&payer.pubkey()), + &[payer], + svm.latest_blockhash(), + ) +} diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs index 845e813..f1fd17f 100644 --- a/programs/settlement/tests/finalize_settle_pushes.rs +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -1,6 +1,11 @@ //! Integration tests for the fund-push list carried by `FinalizeSettle`. -use crate::common::{order::OrderBuilder, setup, to_instruction_error, token}; +use crate::common::{ + order::OrderBuilder, + send, + settlement::{settlement_tx, BEGIN_INDEX, FINALIZE_INDEX}, + setup, to_instruction_error, token, +}; use litesvm::LiteSVM; use settlement_client::instructions::{ BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, @@ -10,29 +15,23 @@ use solana_sdk::{ instruction::{AccountMeta, InstructionError}, program_error::ProgramError, pubkey::Pubkey, - signature::{Keypair, Signer}, + signature::Keypair, transaction::{Transaction, TransactionError}, }; mod common; -/// The following [`send_settlement`] function simulates a settlement and for -/// that hardcodes some instruction indices that will be referenced in the -/// tests. We make those indices more explicit with a constant. -const BEGIN_INDEX: u8 = 0; -const FINALIZE_INDEX: u8 = 1; - -/// Send `[begin, finalize]` signed by `payer`, where `finalize` is a pre-built +/// Build `[begin, finalize]` signed by `payer`, where `finalize` is a pre-built /// `FinalizeSettle` at [`FINALIZE_INDEX`] and `begin` settles `orders` (with no /// pulls) at [`BEGIN_INDEX`], the same orders the finalize is expected to push -/// to. -fn send_settlement( - svm: &mut LiteSVM, +/// to. Submit the returned transaction with [`send`]. +fn build_settlement( + svm: &LiteSVM, program_id: &Pubkey, payer: &Keypair, orders: &[FinalizedIntent], finalize: impl Into, -) -> Result<(), TransactionError> { +) -> Transaction { let begin_orders: Vec = orders .iter() .map(|order| InitializedIntent { @@ -40,49 +39,36 @@ fn send_settlement( pulls: &[], }) .collect(); - let begin = Instruction::from(BeginSettle { + let begin = BeginSettle { program_id: *program_id, finalize_ix_index: FINALIZE_INDEX.into(), orders: &begin_orders, - }); - // Assemble the transaction, confirming each instruction lands at its named - // index to make sure the constants are meaningfully defined. - let mut instructions = Vec::new(); - assert_eq!(instructions.len(), usize::from(BEGIN_INDEX)); - instructions.push(begin); - assert_eq!(instructions.len(), usize::from(FINALIZE_INDEX)); - instructions.push(finalize.into()); - let tx = Transaction::new_signed_with_payer( - &instructions, - Some(&payer.pubkey()), - &[payer], - svm.latest_blockhash(), - ); - // Drop the success metadata, not needed in these tests. - svm.send_transaction(tx).map(|_| ()).map_err(|e| e.err) + }; + settlement_tx(svm, payer, begin, finalize) } -/// Settle `orders` (begin) and push their proceeds (finalize) in a minimal -/// `[BeginSettle, FinalizeSettle]` transaction signed by `payer`. +/// Build a minimal `[BeginSettle, FinalizeSettle]` transaction that settles +/// `orders` (begin) and pushes their proceeds (finalize), signed by `payer`. fn finalize( - svm: &mut LiteSVM, + svm: &LiteSVM, program_id: &Pubkey, payer: &Keypair, orders: &[FinalizedIntent], -) -> Result<(), TransactionError> { +) -> Transaction { let finalize = FinalizeSettle { program_id: *program_id, begin_ix_index: BEGIN_INDEX.into(), orders, }; - send_settlement(svm, program_id, payer, orders, finalize) + build_settlement(svm, program_id, payer, orders, finalize) } #[test] fn finalizes_with_no_pushes() { let (mut svm, program_id, payer) = setup(); - finalize(&mut svm, &program_id, &payer, &[]).expect("a finalize with no pushes should succeed"); + let tx = finalize(&svm, &program_id, &payer, &[]); + send(&mut svm, tx).expect("a finalize with no pushes should succeed"); } #[test] @@ -90,8 +76,8 @@ fn finalizes_with_single_push() { let (mut svm, program_id, payer) = setup(); let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - finalize( - &mut svm, + let tx = finalize( + &svm, &program_id, &payer, &[FinalizedIntent { @@ -99,8 +85,8 @@ fn finalizes_with_single_push() { mint: Pubkey::new_unique(), amount: 1_000, }], - ) - .expect("a single push should parse and be accepted"); + ); + send(&mut svm, tx).expect("a single push should parse and be accepted"); } #[test] @@ -116,8 +102,8 @@ fn finalizes_with_several_pushes_same_mint() { .buy_mint(&mint) .build(); - finalize( - &mut svm, + let tx = finalize( + &svm, &program_id, &payer, &[ @@ -132,8 +118,8 @@ fn finalizes_with_several_pushes_same_mint() { amount: 2_000, }, ], - ) - .expect("several pushes should parse and be accepted"); + ); + send(&mut svm, tx).expect("several pushes should parse and be accepted"); } #[test] @@ -148,8 +134,8 @@ fn finalizes_with_several_pushes_different_mint() { .buy_mint(&mint1) .build(); - finalize( - &mut svm, + let tx = finalize( + &svm, &program_id, &payer, &[ @@ -164,8 +150,8 @@ fn finalizes_with_several_pushes_different_mint() { amount: 2_000, }, ], - ) - .expect("several pushes should parse and be accepted"); + ); + send(&mut svm, tx).expect("several pushes should parse and be accepted"); } #[test] @@ -189,8 +175,9 @@ fn rejects_push_account_count_mismatch() { .accounts .push(AccountMeta::new_readonly(Pubkey::new_unique(), false)); + let tx = build_settlement(&svm, &program_id, &payer, &orders, finalize); assert_eq!( - send_settlement(&mut svm, &program_id, &payer, &orders, finalize), + send(&mut svm, tx), Err(TransactionError::InstructionError( FINALIZE_INDEX, to_instruction_error(SettlementError::AccountCountNotMatchingPushCount), @@ -211,7 +198,8 @@ fn rejects_too_few_accounts() { // ...with one account popped. finalize.accounts.pop(); - let result = send_settlement(&mut svm, &program_id, &payer, &[], finalize); + let tx = build_settlement(&svm, &program_id, &payer, &[], finalize); + let result = send(&mut svm, tx); let Err(TransactionError::InstructionError(index, ix_error)) = result else { panic!("expected an instruction error, got {result:?}"); }; @@ -253,8 +241,9 @@ fn rejects_two_too_few_accounts() { // The paired `Begin` settles no orders, so it never checks the push // destinations: the inconsistency is left for the finalize's own // account-count check to reject. + let tx = build_settlement(&svm, &program_id, &payer, &[], finalize); assert_eq!( - send_settlement(&mut svm, &program_id, &payer, &[], finalize), + send(&mut svm, tx), Err(TransactionError::InstructionError( FINALIZE_INDEX, to_instruction_error(SettlementError::AccountCountNotMatchingPushCount), @@ -281,8 +270,9 @@ fn rejects_partial_push_amount() { // ...with one byte popped, so the trailing amount is no longer a whole `u64`. finalize.data.pop(); + let tx = build_settlement(&svm, &program_id, &payer, &orders, finalize); assert_eq!( - send_settlement(&mut svm, &program_id, &payer, &orders, finalize), + send(&mut svm, tx), Err(TransactionError::InstructionError( FINALIZE_INDEX, InstructionError::InvalidInstructionData, From 8c82ddc9a6201249b84e355fbed584324a87d618 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:52:43 +0200 Subject: [PATCH 13/30] Simplify comment --- programs/settlement/tests/common/order.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/programs/settlement/tests/common/order.rs b/programs/settlement/tests/common/order.rs index 7dc20de..c134209 100644 --- a/programs/settlement/tests/common/order.rs +++ b/programs/settlement/tests/common/order.rs @@ -51,9 +51,7 @@ pub fn create_order_pda( /// /// `build` always creates real sell and buy token accounts. Each side gets its /// own freshly generated mint, so the two differ unless a test pins one with -/// [`OrderBuilder::sell_mint`] / [`OrderBuilder::buy_mint`] — which it needs only -/// to line the mint up with something external, like a buffer or a pull -/// destination. +/// [`OrderBuilder::sell_mint`] / [`OrderBuilder::buy_mint`]. pub struct OrderBuilder<'a> { svm: &'a mut LiteSVM, program_id: &'a Pubkey, From 8799e9d6283dbfb781d98da39497bc52400e24e0 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:59:59 +0200 Subject: [PATCH 14/30] Clarify that the helper isn't that generic --- programs/settlement/tests/begin_settle_orders.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index f1f5c7b..08ba410 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -91,7 +91,7 @@ fn build_settlement_with_placeholder_pushes( /// The finalize carries placeholder pushes matching the orders in count and /// destination, so this clears the push checks and reaches `BeginSettle`'s order /// validation: use it for cases expected to be rejected there. -fn settle( +fn build_settlement_from_orders_with_placeholders( svm: &LiteSVM, program_id: &Pubkey, payer: &Keypair, @@ -324,7 +324,7 @@ fn rejects_sell_token_owner_mismatch() { let intent = sample_intent(payer.pubkey(), sell_token, 1); create_order_pda(&mut svm, &program_id, &payer, &intent); - let tx = settle( + let tx = build_settlement_from_orders_with_placeholders( &svm, &program_id, &payer, @@ -344,7 +344,7 @@ fn rejects_non_token_sell_account() { let intent = sample_intent(payer.pubkey(), non_token, 1); create_order_pda(&mut svm, &program_id, &payer, &intent); - let tx = settle( + let tx = build_settlement_from_orders_with_placeholders( &svm, &program_id, &payer, @@ -493,7 +493,7 @@ fn rejects_cancelled_order() { ) .expect("placing a cancelled order at its canonical PDA should succeed"); - let tx = settle( + let tx = build_settlement_from_orders_with_placeholders( &svm, &program_id, &payer, @@ -516,7 +516,7 @@ fn rejects_expired_order() { let after_expiration = i64::from(valid_to) + 1; set_unix_timestamp(&mut svm, after_expiration); - let tx = settle( + let tx = build_settlement_from_orders_with_placeholders( &svm, &program_id, &payer, @@ -814,7 +814,7 @@ fn rejects_pull_delegated_to_incorrect_address() { let destination = token::create_token_account(&mut svm, &payer, &sell_mint, &Pubkey::new_unique()); - let tx = settle( + let tx = build_settlement_from_orders_with_placeholders( &svm, &program_id, &payer, @@ -855,7 +855,7 @@ fn rejects_pull_exceeding_delegation() { let destination = token::create_token_account(&mut svm, &payer, &sell_mint, &Pubkey::new_unique()); - let tx = settle( + let tx = build_settlement_from_orders_with_placeholders( &svm, &program_id, &payer, From dc54836e1379d1914acbceb9ee388967347169e0 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:36:28 +0900 Subject: [PATCH 15/30] tests refactoring/simplification to one fixture --- .../settlement/tests/begin_settle_orders.rs | 473 +++++++++--------- programs/settlement/tests/common/mod.rs | 17 +- .../settlement/tests/common/settlement.rs | 41 +- .../tests/finalize_settle_pushes.rs | 67 +-- 4 files changed, 291 insertions(+), 307 deletions(-) diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index 08ba410..0781ba3 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -8,18 +8,20 @@ //! `BeginSettle` pairs one push with each order and checks that push pays the //! order's buy token account, so even a settlement expected to be rejected during //! order validation must pair with a finalize whose pushes match the orders in -//! both count and destination. [`settle`] and [`settle_raw`] attach such pushes -//! (with placeholder source, bump, and amount, so they never execute), while -//! [`settle_and_pay`] attaches fully real ones for settlements expected to -//! succeed. Tests rejected before the push checks (wrong token program or state -//! PDA) pair with an empty finalize ([`build_settlement_with_placeholder_pushes`] -//! with no destinations). +//! both count and destination. [`settle_and_pay`] builds exactly such a +//! fully-working settlement, so every test here builds one with it and either +//! sends it unmodified (when the rejection is already baked into the orders or +//! accounts passed in) or mutates its `BeginSettle` instruction in place +//! afterwards (a wrong account, a wrong token program, a wrong state PDA, an +//! extra account). [`rejects_wrong_bump`] is the exception: a wrong bump lives in +//! `BeginSettle`'s instruction data rather than its accounts, so it builds the +//! raw instruction directly instead of mutating a `settle_and_pay` result. use crate::common::{ assert_instruction_error, assert_settlement_error, buffer, create_account, order::{create_order_pda, sample_intent, OrderBuilder}, send, set_unix_timestamp, - settlement::{settlement_tx, BEGIN_INDEX, FINALIZE_INDEX}, + settlement::{BEGIN_INDEX, FINALIZE_INDEX}, setup, token, }; use litesvm::LiteSVM; @@ -33,15 +35,15 @@ use settlement_client::settlement_interface::{ BeginSettle as BeginSettleRaw, FinalizeSettle as FinalizeSettleRaw, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, }, - pda::{order::find_order_pda, state::find_state_pda}, + pda::{buffer::find_buffer_pda, order::find_order_pda, state::find_state_pda}, Instruction, SettlementError, SettlementInstruction, }; use solana_sdk::{ account::Account, instruction::{AccountMeta, InstructionError}, + message::Message, pubkey::Pubkey, signature::{Keypair, Signer}, - transaction::Transaction, }; mod common; @@ -52,69 +54,7 @@ fn no_pulls(n: usize) -> Vec<&'static [Pull]> { vec![&[]; n] } -/// Build `[begin, finalize]` signed by `payer`, where `begin` is pre-built and -/// `finalize` carries one push per `destination` (enough to satisfy -/// `BeginSettle`'s one-push-per-order pairing) and, with each push targeting its -/// order's buy token account, its push-destination check too. The pushes' -/// source, bump, and amount are placeholders: these settlements are expected to -/// be rejected during order validation, so the finalize never runs. -/// -/// With no destinations the finalize settles no pushes; use that form only for -/// cases rejected before `BeginSettle`'s one-push-per-order count check (wrong -/// token program or state PDA), where the empty finalize would otherwise trip -/// that check. -fn build_settlement_with_placeholder_pushes( - svm: &LiteSVM, - program_id: &Pubkey, - payer: &Keypair, - begin: impl Into, - destinations: &[Pubkey], -) -> Transaction { - let push_count = destinations.len(); - let placeholder_sources: Vec = (0..push_count).map(|_| Pubkey::new_unique()).collect(); - let bumps = vec![0u8; push_count]; - let amounts = vec![0u64; push_count]; - let finalize = Instruction::from(FinalizeSettleRaw { - program_id: *program_id, - state_pda: find_state_pda(program_id).0, - begin_ix_index: BEGIN_INDEX.into(), - source_buffers: &placeholder_sources, - destinations, - bumps: &bumps, - amounts: &amounts, - }); - settlement_tx(svm, payer, begin, finalize) -} - -/// Build a minimal `[BeginSettle, FinalizeSettle]` transaction settling `orders` -/// (begin at [`BEGIN_INDEX`], finalize at [`FINALIZE_INDEX`]) signed by `payer`. -/// The finalize carries placeholder pushes matching the orders in count and -/// destination, so this clears the push checks and reaches `BeginSettle`'s order -/// validation: use it for cases expected to be rejected there. -fn build_settlement_from_orders_with_placeholders( - svm: &LiteSVM, - program_id: &Pubkey, - payer: &Keypair, - orders: &[InitializedIntent], -) -> Transaction { - let destinations: Vec = orders - .iter() - .map(|order| order.intent.buy_token_account) - .collect(); - build_settlement_with_placeholder_pushes( - svm, - program_id, - payer, - BeginSettle { - program_id: *program_id, - finalize_ix_index: FINALIZE_INDEX.into(), - orders, - }, - &destinations, - ) -} - -/// Build a `[BeginSettle, FinalizeSettle]` transaction settling `orders` and +/// Build the `[BeginSettle, FinalizeSettle]` instructions settling `orders` and /// paying each one: the finalize pushes a zero amount from each order's canonical /// buy-token buffer to its buy token account, lining up one-to-one with the /// orders so `BeginSettle`'s push pass passes. The buffer for each order's buy @@ -126,7 +66,7 @@ fn settle_and_pay( program_id: &Pubkey, payer: &Keypair, orders: &[InitializedIntent], -) -> Transaction { +) -> Vec { let settled: Vec = orders .iter() .map(|order| { @@ -150,37 +90,7 @@ fn settle_and_pay( begin_ix_index: BEGIN_INDEX.into(), orders: &settled, }; - settlement_tx(svm, payer, begin, finalize) -} - -/// Settle orders described by raw, parallel `(order_pda, sell_token, buy_token, -/// bump)` lists, pulling nothing. Uses the canonical state PDA and SPL Token -/// program so execution reaches the order-validation checks; tests that need a -/// non-canonical state PDA or token program build the instruction directly. The -/// finalize carries placeholder pushes, one per order and aimed at that order's -/// `buy_token`, to clear the push count and destination checks; every caller -/// expects rejection during order validation. Callers rejected before the push -/// destination check (a non-canonical or undecodable order) may pass any -/// `buy_token`. -fn settle_raw( - svm: &LiteSVM, - program_id: &Pubkey, - payer: &Keypair, - order_pdas: &[Pubkey], - sell_token_accounts: &[Pubkey], - buy_token_accounts: &[Pubkey], - bumps: &[u8], -) -> Transaction { - let begin = BeginSettleRaw { - program_id: *program_id, - state_pda: find_state_pda(program_id).0, - finalize_ix_index: FINALIZE_INDEX.into(), - order_pdas, - order_pda_bumps: bumps, - sell_token_accounts, - pulls: &no_pulls(bumps.len()), - }; - build_settlement_with_placeholder_pushes(svm, program_id, payer, begin, buy_token_accounts) + vec![begin.into(), finalize.into()] } #[test] @@ -188,7 +98,7 @@ fn settles_a_single_order() { let (mut svm, program_id, payer) = setup(); let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - let tx = settle_and_pay( + let instructions = settle_and_pay( &mut svm, &program_id, &payer, @@ -197,7 +107,7 @@ fn settles_a_single_order() { pulls: &[], }], ); - send(&mut svm, tx).expect("settlement should succeed"); + send(&mut svm, &payer, instructions).expect("settlement should succeed"); } #[test] @@ -217,8 +127,8 @@ fn settles_multiple_orders() { .iter() .map(|intent| InitializedIntent { intent, pulls: &[] }) .collect(); - let tx = settle_and_pay(&mut svm, &program_id, &payer, &orders); - send(&mut svm, tx).expect("multi-order settlement should succeed"); + let instructions = settle_and_pay(&mut svm, &program_id, &payer, &orders); + send(&mut svm, &payer, instructions).expect("multi-order settlement should succeed"); } #[test] @@ -227,16 +137,35 @@ fn rejects_wrong_bump() { let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); - let tx = settle_raw( - &svm, - &program_id, - &payer, - &[order_pda], - &[intent.sell_token_account], - &[intent.buy_token_account], - &[bump ^ 0x01], + + // The wrong bump lives in `BeginSettle`'s instruction data, which the client + // builder always derives correctly, so build the raw instruction directly to + // inject a bad one. The finalize carries a placeholder push matching the + // order in count and destination, clearing `BeginSettle`'s push checks + // without ever executing (begin rejects the bump first). + let begin = BeginSettleRaw { + program_id, + state_pda: find_state_pda(&program_id).0, + finalize_ix_index: FINALIZE_INDEX.into(), + order_pdas: &[order_pda], + order_pda_bumps: &[bump ^ 0x01], + sell_token_accounts: &[intent.sell_token_account], + pulls: &no_pulls(1), + }; + let finalize = FinalizeSettleRaw { + program_id, + state_pda: find_state_pda(&program_id).0, + begin_ix_index: BEGIN_INDEX.into(), + source_buffers: &[Pubkey::new_unique()], + destinations: &[intent.buy_token_account], + bumps: &[0], + amounts: &[0], + }; + let instructions = vec![begin.into(), finalize.into()]; + assert_settlement_error( + send(&mut svm, &payer, instructions), + SettlementError::OrderNotCanonical, ); - assert_settlement_error(send(&mut svm, tx), SettlementError::OrderNotCanonical); } #[test] @@ -257,16 +186,33 @@ fn rejects_fabricated_program_owned_account() { // address that isn't the canonical order PDA. let fake_order = create_account(&mut svm, &program_id, &body); - let tx = settle_raw( - &svm, + // Build a normal settlement for some real order, then swap its order_pda + // and sell_token accounts for the fabricated ones: `BeginSettle` rejects the + // fabricated order's address before the pushes ever run, so the finalize + // just needs to look like a legitimate settlement. + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + let (real_order_pda, _bump) = find_order_pda(&program_id, &intent.uid()); + let mut instructions = settle_and_pay( + &mut svm, &program_id, &payer, - &[fake_order], - &[sell_token], - &[Pubkey::new_unique()], - &[255], + &[InitializedIntent { + intent: &intent, + pulls: &[], + }], + ); + for meta in instructions[usize::from(BEGIN_INDEX)].accounts.iter_mut() { + if meta.pubkey == real_order_pda { + meta.pubkey = fake_order; + } else if meta.pubkey == intent.sell_token_account { + meta.pubkey = sell_token; + } + } + + assert_settlement_error( + send(&mut svm, &payer, instructions), + SettlementError::OrderNotCanonical, ); - assert_settlement_error(send(&mut svm, tx), SettlementError::OrderNotCanonical); } #[test] @@ -276,18 +222,31 @@ fn rejects_non_order_account_in_order_slot() { let sell_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 - // 199-byte order body, so it's rejected before the canonical-address check. - let tx = settle_raw( - &svm, + // Put a token account in both the order and sell-token slots of a normal + // settlement for some real order. Its 165-byte data can't decode as a + // 199-byte order body, so it's rejected before the canonical-address check, + // and the finalize just needs to look like a legitimate settlement. + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + let (real_order_pda, _bump) = find_order_pda(&program_id, &intent.uid()); + let mut instructions = settle_and_pay( + &mut svm, &program_id, &payer, - &[sell_token], - &[sell_token], - &[Pubkey::new_unique()], - &[255], + &[InitializedIntent { + intent: &intent, + pulls: &[], + }], + ); + for meta in instructions[usize::from(BEGIN_INDEX)].accounts.iter_mut() { + if meta.pubkey == real_order_pda || meta.pubkey == intent.sell_token_account { + meta.pubkey = sell_token; + } + } + + assert_instruction_error( + send(&mut svm, &payer, instructions), + InstructionError::InvalidAccountData, ); - assert_instruction_error(send(&mut svm, tx), InstructionError::InvalidAccountData); } #[test] @@ -297,19 +256,25 @@ fn rejects_sell_token_account_mismatch() { // Supply a different token account than the one the order's intent names. let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); let wrong_sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); - let tx = settle_raw( - &svm, + let mut instructions = settle_and_pay( + &mut svm, &program_id, &payer, - &[order_pda], - &[wrong_sell_token], - &[intent.buy_token_account], - &[bump], + &[InitializedIntent { + intent: &intent, + pulls: &[], + }], ); + let meta = instructions[usize::from(BEGIN_INDEX)] + .accounts + .iter_mut() + .find(|meta| meta.pubkey == intent.sell_token_account) + .expect("BeginSettle should reference the order's sell token account"); + meta.pubkey = wrong_sell_token; + assert_settlement_error( - send(&mut svm, tx), + send(&mut svm, &payer, instructions), SettlementError::SellTokenAccountMismatch, ); } @@ -321,11 +286,14 @@ fn rejects_sell_token_owner_mismatch() { let other_owner = Pubkey::new_unique(); let sell_token = token::create_token_account(&mut svm, &payer, &mint, &other_owner); - let intent = sample_intent(payer.pubkey(), sell_token, 1); + let mut intent = sample_intent(payer.pubkey(), sell_token, 1); + let buy_mint = token::create_mint(&mut svm, &payer); + intent.buy_token_account = + token::create_token_account(&mut svm, &payer, &buy_mint, &payer.pubkey()); create_order_pda(&mut svm, &program_id, &payer, &intent); - let tx = build_settlement_from_orders_with_placeholders( - &svm, + let instructions = settle_and_pay( + &mut svm, &program_id, &payer, &[InitializedIntent { @@ -333,7 +301,10 @@ fn rejects_sell_token_owner_mismatch() { pulls: &[], }], ); - assert_settlement_error(send(&mut svm, tx), SettlementError::SellTokenOwnerMismatch); + assert_settlement_error( + send(&mut svm, &payer, instructions), + SettlementError::SellTokenOwnerMismatch, + ); } #[test] @@ -341,11 +312,14 @@ fn rejects_non_token_sell_account() { let (mut svm, program_id, payer) = setup(); let non_token = Pubkey::new_unique(); - let intent = sample_intent(payer.pubkey(), non_token, 1); + let mut intent = sample_intent(payer.pubkey(), non_token, 1); + let buy_mint = token::create_mint(&mut svm, &payer); + intent.buy_token_account = + token::create_token_account(&mut svm, &payer, &buy_mint, &payer.pubkey()); create_order_pda(&mut svm, &program_id, &payer, &intent); - let tx = build_settlement_from_orders_with_placeholders( - &svm, + let instructions = settle_and_pay( + &mut svm, &program_id, &payer, &[InitializedIntent { @@ -353,7 +327,10 @@ fn rejects_non_token_sell_account() { pulls: &[], }], ); - assert_settlement_error(send(&mut svm, tx), SettlementError::SellTokenAccountInvalid); + assert_settlement_error( + send(&mut svm, &payer, instructions), + SettlementError::SellTokenAccountInvalid, + ); } #[test] @@ -361,7 +338,7 @@ fn rejects_duplicate_orders() { let (mut svm, program_id, payer) = setup(); let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - let tx = settle_and_pay( + let instructions = settle_and_pay( &mut svm, &program_id, &payer, @@ -377,7 +354,7 @@ fn rejects_duplicate_orders() { ], ); assert_settlement_error( - send(&mut svm, tx), + send(&mut svm, &payer, instructions), SettlementError::OrdersNotStrictlyIncreasing, ); } @@ -442,19 +419,33 @@ fn rejects_orders_in_wrong_address_order() { data, }; - // One zero-amount push per order, paying each order's buy token account, - // aligned with begin's decreasing order. `BeginSettle` checks only the - // destinations, so the placeholder pushes never run (begin rejects the - // ordering first). - let tx = build_settlement_with_placeholder_pushes( - &svm, - &program_id, - &payer, - begin, - &[orders[0].2, orders[1].2], - ); + // One real zero-amount push per order, paying each order's buy token + // account, aligned with begin's decreasing order. `BeginSettle` rejects the + // ordering before the pushes ever run, so the finalize only needs to look + // like a legitimate settlement of these two orders. + let mut source_buffers = Vec::with_capacity(orders.len()); + let mut push_bumps = Vec::with_capacity(orders.len()); + let destinations: Vec = orders.iter().map(|&(_, _, buy, _)| buy).collect(); + for &destination in &destinations { + let mint = token::mint_of(&svm, &destination); + buffer::ensure_buffer_exists(&mut svm, &program_id, &payer, &mint); + let (source_buffer, bump) = find_buffer_pda(&program_id, &mint); + source_buffers.push(source_buffer); + push_bumps.push(bump); + } + let amounts = vec![0u64; orders.len()]; + let finalize = Instruction::from(FinalizeSettleRaw { + program_id, + state_pda: find_state_pda(&program_id).0, + begin_ix_index: BEGIN_INDEX.into(), + source_buffers: &source_buffers, + destinations: &destinations, + bumps: &push_bumps, + amounts: &amounts, + }); + let instructions = vec![begin, finalize]; assert_settlement_error( - send(&mut svm, tx), + send(&mut svm, &payer, instructions), SettlementError::OrdersNotStrictlyIncreasing, ); } @@ -470,7 +461,10 @@ fn rejects_cancelled_order() { // clears the provenance check and the cancelled flag is what trips the // rejection. let sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); - let intent = sample_intent(payer.pubkey(), sell_token, 0); + let mut intent = sample_intent(payer.pubkey(), sell_token, 0); + let buy_mint = token::create_mint(&mut svm, &payer); + intent.buy_token_account = + token::create_token_account(&mut svm, &payer, &buy_mint, &payer.pubkey()); let (order_pda, _bump) = find_order_pda(&program_id, &intent.uid()); let data: [u8; EncodedOrderAccount::SIZE] = EncodedOrderAccount::from(OrderAccount { @@ -493,8 +487,8 @@ fn rejects_cancelled_order() { ) .expect("placing a cancelled order at its canonical PDA should succeed"); - let tx = build_settlement_from_orders_with_placeholders( - &svm, + let instructions = settle_and_pay( + &mut svm, &program_id, &payer, &[InitializedIntent { @@ -502,7 +496,10 @@ fn rejects_cancelled_order() { pulls: &[], }], ); - assert_settlement_error(send(&mut svm, tx), SettlementError::OrderCancelled); + assert_settlement_error( + send(&mut svm, &payer, instructions), + SettlementError::OrderCancelled, + ); } #[test] @@ -516,8 +513,8 @@ fn rejects_expired_order() { let after_expiration = i64::from(valid_to) + 1; set_unix_timestamp(&mut svm, after_expiration); - let tx = build_settlement_from_orders_with_placeholders( - &svm, + let instructions = settle_and_pay( + &mut svm, &program_id, &payer, &[InitializedIntent { @@ -525,7 +522,10 @@ fn rejects_expired_order() { pulls: &[], }], ); - assert_settlement_error(send(&mut svm, tx), SettlementError::OrderExpired); + assert_settlement_error( + send(&mut svm, &payer, instructions), + SettlementError::OrderExpired, + ); } #[test] @@ -538,7 +538,7 @@ fn settles_order_at_exact_valid_to() { .build(); set_unix_timestamp(&mut svm, i64::from(valid_to)); - let tx = settle_and_pay( + let instructions = settle_and_pay( &mut svm, &program_id, &payer, @@ -547,7 +547,7 @@ fn settles_order_at_exact_valid_to() { pulls: &[], }], ); - send(&mut svm, tx).expect("an order is still settleable at exactly valid_to"); + send(&mut svm, &payer, instructions).expect("an order is still settleable at exactly valid_to"); } #[test] @@ -565,7 +565,7 @@ fn pulls_funds_to_destination() { token::create_token_account(&mut svm, &payer, &sell_mint, &Pubkey::new_unique()); let amount = 2_000_000; - let tx = settle_and_pay( + let instructions = settle_and_pay( &mut svm, &program_id, &payer, @@ -577,7 +577,8 @@ fn pulls_funds_to_destination() { }], }], ); - send(&mut svm, tx).expect("a pull within the approved delegation should succeed"); + send(&mut svm, &payer, instructions) + .expect("a pull within the approved delegation should succeed"); assert_eq!(token::balance(&svm, &destination), amount); assert_eq!(token::balance(&svm, &sell_token), initial_amount - amount); @@ -603,7 +604,7 @@ fn pulls_to_multiple_destinations() { let pulled0 = 300_000; let pulled1 = 100_000; - let tx = settle_and_pay( + let instructions = settle_and_pay( &mut svm, &program_id, &payer, @@ -621,7 +622,7 @@ fn pulls_to_multiple_destinations() { ], }], ); - send(&mut svm, tx).expect("multiple pulls from one order should succeed"); + send(&mut svm, &payer, instructions).expect("multiple pulls from one order should succeed"); assert_eq!(token::balance(&svm, &dest0), pulled0); assert_eq!(token::balance(&svm, &dest1), pulled1); @@ -672,7 +673,7 @@ fn pulls_from_multiple_orders() { let pulled_first = 42_000; let pulled_second = 67_000; - let tx = settle_and_pay( + let instructions = settle_and_pay( &mut svm, &program_id, &payer, @@ -693,7 +694,7 @@ fn pulls_from_multiple_orders() { }, ], ); - send(&mut svm, tx).expect("pulls from several orders should succeed"); + send(&mut svm, &payer, instructions).expect("pulls from several orders should succeed"); assert_eq!(token::balance(&svm, &dest_first), pulled_first); assert_eq!(token::balance(&svm, &dest_second), pulled_second); @@ -725,10 +726,10 @@ fn zero_pulls_moves_nothing() { // `settle_and_pay` builds exactly the settlement this test needs: begin // settles the order with no pulls, and finalize pushes a zero amount from the - // buy buffer (created on demand) to the buy token account. Because it returns - // the transaction, we can capture its account keys before submitting to - // inspect the issued token instructions. - let tx = settle_and_pay( + // buy buffer (created on demand) to the buy token account. Compile the + // account keys the same way `send` will, so we can inspect the issued token + // instructions against them afterwards. + let instructions = settle_and_pay( &mut svm, &program_id, &payer, @@ -737,8 +738,9 @@ fn zero_pulls_moves_nothing() { pulls: &[], }], ); - let account_keys = tx.message.account_keys.clone(); - let transaction = send(&mut svm, tx).expect("settling without pulling should succeed"); + let account_keys = Message::new(&instructions, Some(&payer.pubkey())).account_keys; + let transaction = + send(&mut svm, &payer, instructions).expect("settling without pulling should succeed"); // No token instruction references the sell token account (the sell mint's // only account here): the lone token transfer is the buy-side push, which @@ -752,25 +754,29 @@ fn rejects_wrong_state_pda() { let (mut svm, program_id, payer) = setup(); let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); - let not_the_state_pda = Pubkey::new_unique(); - - let tx = build_settlement_with_placeholder_pushes( - &svm, + let mut instructions = settle_and_pay( + &mut svm, &program_id, &payer, - BeginSettleRaw { - program_id, - state_pda: not_the_state_pda, - finalize_ix_index: FINALIZE_INDEX.into(), - order_pdas: &[order_pda], - order_pda_bumps: &[bump], - sell_token_accounts: &[intent.sell_token_account], - pulls: &no_pulls(1), - }, - &[], + &[InitializedIntent { + intent: &intent, + pulls: &[], + }], + ); + + // Swap the state PDA account `BeginSettle` references for a bogus one. + let (state_pda, _bump) = find_state_pda(&program_id); + let meta = instructions[usize::from(BEGIN_INDEX)] + .accounts + .iter_mut() + .find(|meta| meta.pubkey == state_pda) + .expect("BeginSettle should reference the state PDA"); + meta.pubkey = Pubkey::new_unique(); + + assert_settlement_error( + send(&mut svm, &payer, instructions), + SettlementError::StateAccountMismatch, ); - assert_settlement_error(send(&mut svm, tx), SettlementError::StateAccountMismatch); } #[test] @@ -778,23 +784,29 @@ fn rejects_wrong_token_program() { let (mut svm, program_id, payer) = setup(); let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - - // The builder always fills in the SPL Token program, so we swap the - // token-program account out afterwards. - let mut begin: Instruction = BeginSettle { - program_id, - finalize_ix_index: FINALIZE_INDEX.into(), - orders: &[InitializedIntent { + let mut instructions = settle_and_pay( + &mut svm, + &program_id, + &payer, + &[InitializedIntent { intent: &intent, pulls: &[], }], - } - .into(); - let token_account_index = 2; - begin.accounts[token_account_index] = AccountMeta::new_readonly(Pubkey::new_unique(), false); + ); - let tx = build_settlement_with_placeholder_pushes(&svm, &program_id, &payer, begin, &[]); - assert_instruction_error(send(&mut svm, tx), InstructionError::IncorrectProgramId); + // Swap the SPL Token program account `BeginSettle` references for a bogus + // one. + let meta = instructions[usize::from(BEGIN_INDEX)] + .accounts + .iter_mut() + .find(|meta| meta.pubkey == SPL_TOKEN_PROGRAM_ID) + .expect("BeginSettle should reference the SPL Token program"); + meta.pubkey = Pubkey::new_unique(); + + assert_instruction_error( + send(&mut svm, &payer, instructions), + InstructionError::IncorrectProgramId, + ); } #[test] @@ -814,8 +826,8 @@ fn rejects_pull_delegated_to_incorrect_address() { let destination = token::create_token_account(&mut svm, &payer, &sell_mint, &Pubkey::new_unique()); - let tx = build_settlement_from_orders_with_placeholders( - &svm, + let instructions = settle_and_pay( + &mut svm, &program_id, &payer, &[InitializedIntent { @@ -827,7 +839,7 @@ fn rejects_pull_delegated_to_incorrect_address() { }], ); assert_instruction_error( - send(&mut svm, tx), + send(&mut svm, &payer, instructions), InstructionError::Custom(TokenError::OwnerMismatch as u32), ); } @@ -855,8 +867,8 @@ fn rejects_pull_exceeding_delegation() { let destination = token::create_token_account(&mut svm, &payer, &sell_mint, &Pubkey::new_unique()); - let tx = build_settlement_from_orders_with_placeholders( - &svm, + let instructions = settle_and_pay( + &mut svm, &program_id, &payer, &[InitializedIntent { @@ -868,7 +880,7 @@ fn rejects_pull_exceeding_delegation() { }], ); assert_instruction_error( - send(&mut svm, tx), + send(&mut svm, &payer, instructions), InstructionError::Custom(TokenError::InsufficientFunds as u32), ); assert_eq!(token::balance(&svm, &sell_token), initial_amount); @@ -882,25 +894,24 @@ fn rejects_extra_account() { let (mut svm, program_id, payer) = setup(); let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - // A well-formed single-order, no-transfer settlement... - let mut begin: Instruction = BeginSettle { - program_id, - finalize_ix_index: FINALIZE_INDEX.into(), - orders: &[InitializedIntent { + let mut instructions = settle_and_pay( + &mut svm, + &program_id, + &payer, + &[InitializedIntent { intent: &intent, pulls: &[], }], - } - .into(); - // ...with one extra account appended, so the account count no longer matches - // the `2n + T` the instruction data implies. - begin + ); + + // Append one extra account to `BeginSettle`, so the account count no longer + // matches the `2n + T` the instruction data implies. + instructions[usize::from(BEGIN_INDEX)] .accounts .push(AccountMeta::new_readonly(Pubkey::new_unique(), false)); - let tx = build_settlement_with_placeholder_pushes(&svm, &program_id, &payer, begin, &[]); assert_settlement_error( - send(&mut svm, tx), + send(&mut svm, &payer, instructions), SettlementError::AccountCountNotMatchingOrderCount, ); } diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index dbfa8dd..ce8dde7 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -142,8 +142,19 @@ pub fn signed_tx( ) } -/// Submit `tx`, surfacing only the transaction-level error on failure (dropping -/// the success metadata's error wrapper). -pub fn send(svm: &mut LiteSVM, tx: Transaction) -> Result { +/// Assemble `instructions` into a transaction signed by `payer` and submit it, +/// surfacing only the transaction-level error on failure (dropping the success +/// metadata's error wrapper). +pub fn send( + svm: &mut LiteSVM, + payer: &Keypair, + instructions: Vec, +) -> Result { + let tx = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer], + svm.latest_blockhash(), + ); svm.send_transaction(tx).map_err(|e| e.err) } diff --git a/programs/settlement/tests/common/settlement.rs b/programs/settlement/tests/common/settlement.rs index 677da44..317d50e 100644 --- a/programs/settlement/tests/common/settlement.rs +++ b/programs/settlement/tests/common/settlement.rs @@ -1,37 +1,14 @@ -//! The canonical settlement transaction shape shared by the integration tests. +//! The canonical settlement instruction positions shared by the integration +//! tests. //! -//! Every settlement is a `[BeginSettle, FinalizeSettle]` pair with begin at -//! [`BEGIN_INDEX`] and finalize at [`FINALIZE_INDEX`], each instruction -//! referencing its counterpart by that position. [`settlement_tx`] assembles the -//! pair in that fixed order; callers submit the result with [`send`](super::send). +//! Every settlement is a `[BeginSettle, FinalizeSettle]` instruction pair with +//! begin at [`BEGIN_INDEX`] and finalize at [`FINALIZE_INDEX`], each instruction +//! referencing its counterpart by that position. Callers assemble the pair as +//! `vec![begin.into(), finalize.into()]` and submit it with +//! [`send`](super::send). -use litesvm::LiteSVM; -use settlement_interface::Instruction; -use solana_sdk::{ - signature::{Keypair, Signer}, - transaction::Transaction, -}; - -/// Position of the `BeginSettle` instruction in the transactions built by -/// [`settlement_tx`]. +/// Position of the `BeginSettle` instruction in the transactions built by the +/// tests in this crate. pub const BEGIN_INDEX: u8 = 0; /// Position of the `FinalizeSettle` instruction in those transactions. pub const FINALIZE_INDEX: u8 = 1; - -/// Assemble the canonical two-instruction settlement transaction: `begin` at -/// [`BEGIN_INDEX`], `finalize` at [`FINALIZE_INDEX`], signed by `payer`. Callers -/// build the two instructions and submit the returned transaction with -/// [`send`](super::send). -pub fn settlement_tx( - svm: &LiteSVM, - payer: &Keypair, - begin: impl Into, - finalize: impl Into, -) -> Transaction { - Transaction::new_signed_with_payer( - &[begin.into(), finalize.into()], - Some(&payer.pubkey()), - &[payer], - svm.latest_blockhash(), - ) -} diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs index f1fd17f..b33e65d 100644 --- a/programs/settlement/tests/finalize_settle_pushes.rs +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -3,10 +3,9 @@ use crate::common::{ order::OrderBuilder, send, - settlement::{settlement_tx, BEGIN_INDEX, FINALIZE_INDEX}, + settlement::{BEGIN_INDEX, FINALIZE_INDEX}, setup, to_instruction_error, token, }; -use litesvm::LiteSVM; use settlement_client::instructions::{ BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, }; @@ -15,23 +14,20 @@ use solana_sdk::{ instruction::{AccountMeta, InstructionError}, program_error::ProgramError, pubkey::Pubkey, - signature::Keypair, - transaction::{Transaction, TransactionError}, + transaction::TransactionError, }; mod common; -/// Build `[begin, finalize]` signed by `payer`, where `finalize` is a pre-built +/// Build the `[begin, finalize]` instructions where `finalize` is a pre-built /// `FinalizeSettle` at [`FINALIZE_INDEX`] and `begin` settles `orders` (with no /// pulls) at [`BEGIN_INDEX`], the same orders the finalize is expected to push -/// to. Submit the returned transaction with [`send`]. +/// to. Submit the result with [`send`]. fn build_settlement( - svm: &LiteSVM, program_id: &Pubkey, - payer: &Keypair, orders: &[FinalizedIntent], finalize: impl Into, -) -> Transaction { +) -> Vec { let begin_orders: Vec = orders .iter() .map(|order| InitializedIntent { @@ -44,31 +40,26 @@ fn build_settlement( finalize_ix_index: FINALIZE_INDEX.into(), orders: &begin_orders, }; - settlement_tx(svm, payer, begin, finalize) + vec![begin.into(), finalize.into()] } -/// Build a minimal `[BeginSettle, FinalizeSettle]` transaction that settles -/// `orders` (begin) and pushes their proceeds (finalize), signed by `payer`. -fn finalize( - svm: &LiteSVM, - program_id: &Pubkey, - payer: &Keypair, - orders: &[FinalizedIntent], -) -> Transaction { +/// Build the minimal `[BeginSettle, FinalizeSettle]` instructions that settle +/// `orders` (begin) and push their proceeds (finalize). +fn finalize(program_id: &Pubkey, orders: &[FinalizedIntent]) -> Vec { let finalize = FinalizeSettle { program_id: *program_id, begin_ix_index: BEGIN_INDEX.into(), orders, }; - build_settlement(svm, program_id, payer, orders, finalize) + build_settlement(program_id, orders, finalize) } #[test] fn finalizes_with_no_pushes() { let (mut svm, program_id, payer) = setup(); - let tx = finalize(&svm, &program_id, &payer, &[]); - send(&mut svm, tx).expect("a finalize with no pushes should succeed"); + let instructions = finalize(&program_id, &[]); + send(&mut svm, &payer, instructions).expect("a finalize with no pushes should succeed"); } #[test] @@ -76,17 +67,15 @@ fn finalizes_with_single_push() { let (mut svm, program_id, payer) = setup(); let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - let tx = finalize( - &svm, + let instructions = finalize( &program_id, - &payer, &[FinalizedIntent { intent: &intent, mint: Pubkey::new_unique(), amount: 1_000, }], ); - send(&mut svm, tx).expect("a single push should parse and be accepted"); + send(&mut svm, &payer, instructions).expect("a single push should parse and be accepted"); } #[test] @@ -102,10 +91,8 @@ fn finalizes_with_several_pushes_same_mint() { .buy_mint(&mint) .build(); - let tx = finalize( - &svm, + let instructions = finalize( &program_id, - &payer, &[ FinalizedIntent { intent: &intent0, @@ -119,7 +106,7 @@ fn finalizes_with_several_pushes_same_mint() { }, ], ); - send(&mut svm, tx).expect("several pushes should parse and be accepted"); + send(&mut svm, &payer, instructions).expect("several pushes should parse and be accepted"); } #[test] @@ -134,10 +121,8 @@ fn finalizes_with_several_pushes_different_mint() { .buy_mint(&mint1) .build(); - let tx = finalize( - &svm, + let instructions = finalize( &program_id, - &payer, &[ FinalizedIntent { intent: &intent0, @@ -151,7 +136,7 @@ fn finalizes_with_several_pushes_different_mint() { }, ], ); - send(&mut svm, tx).expect("several pushes should parse and be accepted"); + send(&mut svm, &payer, instructions).expect("several pushes should parse and be accepted"); } #[test] @@ -175,9 +160,9 @@ fn rejects_push_account_count_mismatch() { .accounts .push(AccountMeta::new_readonly(Pubkey::new_unique(), false)); - let tx = build_settlement(&svm, &program_id, &payer, &orders, finalize); + let instructions = build_settlement(&program_id, &orders, finalize); assert_eq!( - send(&mut svm, tx), + send(&mut svm, &payer, instructions), Err(TransactionError::InstructionError( FINALIZE_INDEX, to_instruction_error(SettlementError::AccountCountNotMatchingPushCount), @@ -198,8 +183,8 @@ fn rejects_too_few_accounts() { // ...with one account popped. finalize.accounts.pop(); - let tx = build_settlement(&svm, &program_id, &payer, &[], finalize); - let result = send(&mut svm, tx); + let instructions = build_settlement(&program_id, &[], finalize); + let result = send(&mut svm, &payer, instructions); let Err(TransactionError::InstructionError(index, ix_error)) = result else { panic!("expected an instruction error, got {result:?}"); }; @@ -241,9 +226,9 @@ fn rejects_two_too_few_accounts() { // The paired `Begin` settles no orders, so it never checks the push // destinations: the inconsistency is left for the finalize's own // account-count check to reject. - let tx = build_settlement(&svm, &program_id, &payer, &[], finalize); + let instructions = build_settlement(&program_id, &[], finalize); assert_eq!( - send(&mut svm, tx), + send(&mut svm, &payer, instructions), Err(TransactionError::InstructionError( FINALIZE_INDEX, to_instruction_error(SettlementError::AccountCountNotMatchingPushCount), @@ -270,9 +255,9 @@ fn rejects_partial_push_amount() { // ...with one byte popped, so the trailing amount is no longer a whole `u64`. finalize.data.pop(); - let tx = build_settlement(&svm, &program_id, &payer, &orders, finalize); + let instructions = build_settlement(&program_id, &orders, finalize); assert_eq!( - send(&mut svm, tx), + send(&mut svm, &payer, instructions), Err(TransactionError::InstructionError( FINALIZE_INDEX, InstructionError::InvalidInstructionData, From da7f43c7d41e4fd60e241cbb4c4ceebd269c20c4 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:38:50 +0200 Subject: [PATCH 16/30] Adjustments for readability --- .../settlement/tests/begin_settle_orders.rs | 231 +++++++++--------- programs/settlement/tests/common/mod.rs | 14 +- .../settlement/tests/common/settlement.rs | 14 -- .../tests/finalize_settle_pushes.rs | 14 +- 4 files changed, 140 insertions(+), 133 deletions(-) delete mode 100644 programs/settlement/tests/common/settlement.rs diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index 0781ba3..860908e 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -13,16 +13,14 @@ //! sends it unmodified (when the rejection is already baked into the orders or //! accounts passed in) or mutates its `BeginSettle` instruction in place //! afterwards (a wrong account, a wrong token program, a wrong state PDA, an -//! extra account). [`rejects_wrong_bump`] is the exception: a wrong bump lives in -//! `BeginSettle`'s instruction data rather than its accounts, so it builds the -//! raw instruction directly instead of mutating a `settle_and_pay` result. +//! extra account). A few tests are the exception and build the raw instruction +//! directly, because what they exercise can't come out of the client builder, +//! whose output is a properly built instruction. use crate::common::{ assert_instruction_error, assert_settlement_error, buffer, create_account, order::{create_order_pda, sample_intent, OrderBuilder}, - send, set_unix_timestamp, - settlement::{BEGIN_INDEX, FINALIZE_INDEX}, - setup, token, + replace_first_matching_account, send, set_unix_timestamp, setup, token, }; use litesvm::LiteSVM; use litesvm_token::spl_token::error::TokenError; @@ -35,19 +33,26 @@ use settlement_client::settlement_interface::{ BeginSettle as BeginSettleRaw, FinalizeSettle as FinalizeSettleRaw, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, }, - pda::{buffer::find_buffer_pda, order::find_order_pda, state::find_state_pda}, + pda::{order::find_order_pda, state::find_state_pda}, Instruction, SettlementError, SettlementInstruction, }; +use settlement_interface::data::intent::OrderIntent; use solana_sdk::{ account::Account, instruction::{AccountMeta, InstructionError}, - message::Message, pubkey::Pubkey, signature::{Keypair, Signer}, + transaction::Transaction, }; mod common; +/// Position of `BeginSettle` in the `[BeginSettle, FinalizeSettle]` pair the +/// tests in this file build; the finalize sits right after it. Kept in sync with +/// the tests that reach into `instructions[BEGIN_INDEX]` to corrupt the begin. +const BEGIN_INDEX: u8 = 0; +const FINALIZE_INDEX: u8 = 1; + /// A list of empty transfer lists, one per order. Used for settling `n` orders /// without pulling any funds. fn no_pulls(n: usize) -> Vec<&'static [Pull]> { @@ -138,15 +143,13 @@ fn rejects_wrong_bump() { let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); - // The wrong bump lives in `BeginSettle`'s instruction data, which the client - // builder always derives correctly, so build the raw instruction directly to - // inject a bad one. The finalize carries a placeholder push matching the - // order in count and destination, clearing `BeginSettle`'s push checks - // without ever executing (begin rejects the bump first). + // Build the raw instruction directly to inject a bad bump. The finalize + // carries placeholder, the transaction is expected to reject before its + // execution. let begin = BeginSettleRaw { program_id, state_pda: find_state_pda(&program_id).0, - finalize_ix_index: FINALIZE_INDEX.into(), + finalize_ix_index: 1, order_pdas: &[order_pda], order_pda_bumps: &[bump ^ 0x01], sell_token_accounts: &[intent.sell_token_account], @@ -155,7 +158,7 @@ fn rejects_wrong_bump() { let finalize = FinalizeSettleRaw { program_id, state_pda: find_state_pda(&program_id).0, - begin_ix_index: BEGIN_INDEX.into(), + begin_ix_index: 0, source_buffers: &[Pubkey::new_unique()], destinations: &[intent.buy_token_account], bumps: &[0], @@ -174,40 +177,41 @@ 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 intent = sample_intent(payer.pubkey(), sell_token, 0); let body: [u8; EncodedOrderAccount::SIZE] = EncodedOrderAccount::from(OrderAccount { cancelled: false, amount_withdrawn: 0, amount_received: 0, created_by: payer.pubkey(), - intent: sample_intent(payer.pubkey(), sell_token, 0), + intent: intent.clone(), }) .into(); // A program-owned account holding a valid order body, but sitting at an // address that isn't the canonical order PDA. let fake_order = create_account(&mut svm, &program_id, &body); - // Build a normal settlement for some real order, then swap its order_pda - // and sell_token accounts for the fabricated ones: `BeginSettle` rejects the - // fabricated order's address before the pushes ever run, so the finalize - // just needs to look like a legitimate settlement. - let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - let (real_order_pda, _bump) = find_order_pda(&program_id, &intent.uid()); - let mut instructions = settle_and_pay( - &mut svm, - &program_id, - &payer, - &[InitializedIntent { - intent: &intent, - pulls: &[], - }], - ); - for meta in instructions[usize::from(BEGIN_INDEX)].accounts.iter_mut() { - if meta.pubkey == real_order_pda { - meta.pubkey = fake_order; - } else if meta.pubkey == intent.sell_token_account { - meta.pubkey = sell_token; - } - } + let (_real_order_pda, bump) = find_order_pda(&program_id, &intent.uid()); + let begin = BeginSettleRaw { + program_id, + state_pda: find_state_pda(&program_id).0, + finalize_ix_index: 1, + order_pdas: &[fake_order], + order_pda_bumps: &[bump], + sell_token_accounts: &[sell_token], + pulls: &no_pulls(1), + }; + // Mostly placeholder values: the transaction will reject before reaching + // this instruction, we just want to make sure that `BeginSettle` validates. + let finalize = FinalizeSettleRaw { + program_id, + state_pda: find_state_pda(&program_id).0, + begin_ix_index: 0, + source_buffers: &[Pubkey::new_unique()], + destinations: &[intent.buy_token_account], + bumps: &[0], + amounts: &[0], + }; + let instructions = vec![begin.into(), finalize.into()]; assert_settlement_error( send(&mut svm, &payer, instructions), @@ -222,26 +226,30 @@ fn rejects_non_order_account_in_order_slot() { let sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); - // Put a token account in both the order and sell-token slots of a normal - // settlement for some real order. Its 165-byte data can't decode as a - // 199-byte order body, so it's rejected before the canonical-address check, - // and the finalize just needs to look like a legitimate settlement. - let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - let (real_order_pda, _bump) = find_order_pda(&program_id, &intent.uid()); - let mut instructions = settle_and_pay( - &mut svm, - &program_id, - &payer, - &[InitializedIntent { - intent: &intent, - pulls: &[], - }], - ); - for meta in instructions[usize::from(BEGIN_INDEX)].accounts.iter_mut() { - if meta.pubkey == real_order_pda || meta.pubkey == intent.sell_token_account { - meta.pubkey = sell_token; - } - } + // Put a token account in the order slot. Its 165-byte data can't decode as a + // 199-byte order body, so it's rejected before the canonical-address check. + // The client builder always references a real order PDA, so build the raw + // instruction by hand; the bump is irrelevant, as the decode fails first. + let begin = BeginSettleRaw { + program_id, + state_pda: find_state_pda(&program_id).0, + finalize_ix_index: 1, + order_pdas: &[sell_token], + order_pda_bumps: &[0], + sell_token_accounts: &[sell_token], + pulls: &no_pulls(1), + }; + // The finalize just carries a placeholder push matching the order in count. + let finalize = FinalizeSettleRaw { + program_id, + state_pda: find_state_pda(&program_id).0, + begin_ix_index: 0, + source_buffers: &[Pubkey::new_unique()], + destinations: &[Pubkey::new_unique()], + bumps: &[0], + amounts: &[0], + }; + let instructions = vec![begin.into(), finalize.into()]; assert_instruction_error( send(&mut svm, &payer, instructions), @@ -266,12 +274,11 @@ fn rejects_sell_token_account_mismatch() { pulls: &[], }], ); - let meta = instructions[usize::from(BEGIN_INDEX)] - .accounts - .iter_mut() - .find(|meta| meta.pubkey == intent.sell_token_account) - .expect("BeginSettle should reference the order's sell token account"); - meta.pubkey = wrong_sell_token; + replace_first_matching_account( + &mut instructions[usize::from(BEGIN_INDEX)], + &intent.sell_token_account, + wrong_sell_token, + ); assert_settlement_error( send(&mut svm, &payer, instructions), @@ -282,14 +289,17 @@ fn rejects_sell_token_account_mismatch() { #[test] fn rejects_sell_token_owner_mismatch() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); + let sell_mint = token::create_mint(&mut svm, &payer); + let buy_mint = token::create_mint(&mut svm, &payer); let other_owner = Pubkey::new_unique(); - let sell_token = token::create_token_account(&mut svm, &payer, &mint, &other_owner); - let mut intent = sample_intent(payer.pubkey(), sell_token, 1); - let buy_mint = token::create_mint(&mut svm, &payer); - intent.buy_token_account = - token::create_token_account(&mut svm, &payer, &buy_mint, &payer.pubkey()); + let sell_token = token::create_token_account(&mut svm, &payer, &sell_mint, &other_owner); + let buy_token = token::create_token_account(&mut svm, &payer, &buy_mint, &payer.pubkey()); + + let intent = OrderIntent { + buy_token_account: buy_token, + ..sample_intent(payer.pubkey(), sell_token, 1) + }; create_order_pda(&mut svm, &program_id, &payer, &intent); let instructions = settle_and_pay( @@ -312,10 +322,13 @@ fn rejects_non_token_sell_account() { let (mut svm, program_id, payer) = setup(); let non_token = Pubkey::new_unique(); - let mut intent = sample_intent(payer.pubkey(), non_token, 1); let buy_mint = token::create_mint(&mut svm, &payer); - intent.buy_token_account = - token::create_token_account(&mut svm, &payer, &buy_mint, &payer.pubkey()); + let buy_token = token::create_token_account(&mut svm, &payer, &buy_mint, &payer.pubkey()); + + let intent = OrderIntent { + buy_token_account: buy_token, + ..sample_intent(payer.pubkey(), non_token, 1) + }; create_order_pda(&mut svm, &program_id, &payer, &intent); let instructions = settle_and_pay( @@ -419,20 +432,13 @@ fn rejects_orders_in_wrong_address_order() { data, }; - // One real zero-amount push per order, paying each order's buy token - // account, aligned with begin's decreasing order. `BeginSettle` rejects the - // ordering before the pushes ever run, so the finalize only needs to look - // like a legitimate settlement of these two orders. - let mut source_buffers = Vec::with_capacity(orders.len()); - let mut push_bumps = Vec::with_capacity(orders.len()); + // One placeholder zero-amount push per order, paying each order's buy token + // account and aligned with begin's decreasing order. `BeginSettle` rejects + // 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(); - for &destination in &destinations { - let mint = token::mint_of(&svm, &destination); - buffer::ensure_buffer_exists(&mut svm, &program_id, &payer, &mint); - let (source_buffer, bump) = find_buffer_pda(&program_id, &mint); - source_buffers.push(source_buffer); - push_bumps.push(bump); - } + let bumps = vec![0u8; orders.len()]; let amounts = vec![0u64; orders.len()]; let finalize = Instruction::from(FinalizeSettleRaw { program_id, @@ -440,7 +446,7 @@ fn rejects_orders_in_wrong_address_order() { begin_ix_index: BEGIN_INDEX.into(), source_buffers: &source_buffers, destinations: &destinations, - bumps: &push_bumps, + bumps: &bumps, amounts: &amounts, }); let instructions = vec![begin, finalize]; @@ -453,20 +459,15 @@ fn rejects_orders_in_wrong_address_order() { #[test] fn rejects_cancelled_order() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); + + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); // There's no cancel instruction yet, and `CreateOrder` always writes an // active order, so write the PDA directly with the `cancelled` flag set. The // account still sits at the canonical PDA holding a matching intent, so it // clears the provenance check and the cancelled flag is what trips the // rejection. - let sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); - let mut intent = sample_intent(payer.pubkey(), sell_token, 0); - let buy_mint = token::create_mint(&mut svm, &payer); - intent.buy_token_account = - token::create_token_account(&mut svm, &payer, &buy_mint, &payer.pubkey()); let (order_pda, _bump) = find_order_pda(&program_id, &intent.uid()); - let data: [u8; EncodedOrderAccount::SIZE] = EncodedOrderAccount::from(OrderAccount { cancelled: true, amount_withdrawn: 0, @@ -726,9 +727,10 @@ fn zero_pulls_moves_nothing() { // `settle_and_pay` builds exactly the settlement this test needs: begin // settles the order with no pulls, and finalize pushes a zero amount from the - // buy buffer (created on demand) to the buy token account. Compile the - // account keys the same way `send` will, so we can inspect the issued token - // instructions against them afterwards. + // buy buffer (created on demand) to the buy token account. Build the + // transaction by hand rather than via `send`, so the account keys used to + // interpret the recorded token instructions come straight from the message + // that was executed instead of a separately compiled copy. let instructions = settle_and_pay( &mut svm, &program_id, @@ -738,9 +740,16 @@ fn zero_pulls_moves_nothing() { pulls: &[], }], ); - let account_keys = Message::new(&instructions, Some(&payer.pubkey())).account_keys; - let transaction = - send(&mut svm, &payer, instructions).expect("settling without pulling should succeed"); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[&payer], + svm.latest_blockhash(), + ); + let account_keys = transaction.message.account_keys.clone(); + let transaction = svm + .send_transaction(transaction) + .expect("settling without pulling should succeed"); // No token instruction references the sell token account (the sell mint's // only account here): the lone token transfer is the buy-side push, which @@ -766,12 +775,11 @@ fn rejects_wrong_state_pda() { // Swap the state PDA account `BeginSettle` references for a bogus one. let (state_pda, _bump) = find_state_pda(&program_id); - let meta = instructions[usize::from(BEGIN_INDEX)] - .accounts - .iter_mut() - .find(|meta| meta.pubkey == state_pda) - .expect("BeginSettle should reference the state PDA"); - meta.pubkey = Pubkey::new_unique(); + replace_first_matching_account( + &mut instructions[usize::from(BEGIN_INDEX)], + &state_pda, + Pubkey::new_unique(), + ); assert_settlement_error( send(&mut svm, &payer, instructions), @@ -796,12 +804,11 @@ fn rejects_wrong_token_program() { // Swap the SPL Token program account `BeginSettle` references for a bogus // one. - let meta = instructions[usize::from(BEGIN_INDEX)] - .accounts - .iter_mut() - .find(|meta| meta.pubkey == SPL_TOKEN_PROGRAM_ID) - .expect("BeginSettle should reference the SPL Token program"); - meta.pubkey = Pubkey::new_unique(); + replace_first_matching_account( + &mut instructions[usize::from(BEGIN_INDEX)], + &SPL_TOKEN_PROGRAM_ID, + Pubkey::new_unique(), + ); assert_instruction_error( send(&mut svm, &payer, instructions), diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index ce8dde7..53eb059 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -9,7 +9,6 @@ pub mod buffer; pub mod lookup_table; pub mod order; pub mod pda; -pub mod settlement; pub mod token; use litesvm::{types::TransactionMetadata, LiteSVM}; @@ -142,6 +141,19 @@ pub fn signed_tx( ) } +/// In `instruction`, repoint the account currently set to `from` at `to`. Tests +/// use it to corrupt one account of an otherwise-valid instruction; it panics if +/// `instruction` doesn't reference `from`, so a stale swap fails loudly rather +/// than silently testing nothing. +pub fn replace_first_matching_account(instruction: &mut Instruction, from: &Pubkey, to: Pubkey) { + let meta = instruction + .accounts + .iter_mut() + .find(|meta| meta.pubkey == *from) + .unwrap_or_else(|| panic!("instruction should reference {from}")); + meta.pubkey = to; +} + /// Assemble `instructions` into a transaction signed by `payer` and submit it, /// surfacing only the transaction-level error on failure (dropping the success /// metadata's error wrapper). diff --git a/programs/settlement/tests/common/settlement.rs b/programs/settlement/tests/common/settlement.rs deleted file mode 100644 index 317d50e..0000000 --- a/programs/settlement/tests/common/settlement.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! The canonical settlement instruction positions shared by the integration -//! tests. -//! -//! Every settlement is a `[BeginSettle, FinalizeSettle]` instruction pair with -//! begin at [`BEGIN_INDEX`] and finalize at [`FINALIZE_INDEX`], each instruction -//! referencing its counterpart by that position. Callers assemble the pair as -//! `vec![begin.into(), finalize.into()]` and submit it with -//! [`send`](super::send). - -/// Position of the `BeginSettle` instruction in the transactions built by the -/// tests in this crate. -pub const BEGIN_INDEX: u8 = 0; -/// Position of the `FinalizeSettle` instruction in those transactions. -pub const FINALIZE_INDEX: u8 = 1; diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs index b33e65d..3e108d2 100644 --- a/programs/settlement/tests/finalize_settle_pushes.rs +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -1,11 +1,6 @@ //! Integration tests for the fund-push list carried by `FinalizeSettle`. -use crate::common::{ - order::OrderBuilder, - send, - settlement::{BEGIN_INDEX, FINALIZE_INDEX}, - setup, to_instruction_error, token, -}; +use crate::common::{order::OrderBuilder, send, setup, to_instruction_error, token}; use settlement_client::instructions::{ BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, }; @@ -19,6 +14,13 @@ use solana_sdk::{ mod common; +/// Position of `BeginSettle` in the `[BeginSettle, FinalizeSettle]` pair the +/// tests in this file build; the finalize sits right after it. Kept in sync with +/// the `assert_eq!(index, FINALIZE_INDEX)` checks that a rejection came from the +/// finalize. +const BEGIN_INDEX: u8 = 0; +const FINALIZE_INDEX: u8 = 1; + /// Build the `[begin, finalize]` instructions where `finalize` is a pre-built /// `FinalizeSettle` at [`FINALIZE_INDEX`] and `begin` settles `orders` (with no /// pulls) at [`BEGIN_INDEX`], the same orders the finalize is expected to push From 7e2f5e0df8c8e0466ac1722af359d324751bffc8 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:45:24 +0200 Subject: [PATCH 17/30] Clarifying comment about the need for `mint` --- client/src/instructions.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 84403d5..7e6f012 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -60,6 +60,9 @@ impl From> for Instruction { /// A settled order whose proceeds are pushed to it: `intent` identifies the /// order (its `buy_token_account` is the push destination), `mint` selects the /// canonical source buffer, and `amount` is the quantity to push. +/// Technically the mint is already included in the intent, but for that we need +/// to read the sell account data on-chain, which makes the builder harder to +/// use. pub struct FinalizedIntent<'a> { pub intent: &'a OrderIntent, pub mint: Pubkey, From 841def6f24c50ef93e3f4da029a19745aeca8dcb Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:46:28 +0200 Subject: [PATCH 18/30] order -> orders --- client/src/instructions.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 7e6f012..8b29c54 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -90,8 +90,8 @@ impl From> for Instruction { // For BeginSettle, sorting can take place in the interface. But the // order PDAs don't appear in the actual FinalizeSettle instruction, so // the sorting can only happen here. - let mut order: Vec = (0..builder.orders.len()).collect(); - order.sort_by_key(|&i| { + let mut orders: Vec = (0..builder.orders.len()).collect(); + orders.sort_by_key(|&i| { find_order_pda(&builder.program_id, &builder.orders[i].intent.uid()).0 }); @@ -99,7 +99,7 @@ impl From> for Instruction { let mut destinations = Vec::with_capacity(builder.orders.len()); let mut bumps = Vec::with_capacity(builder.orders.len()); let mut amounts = Vec::with_capacity(builder.orders.len()); - for &i in &order { + for &i in &orders { let (buffer_pda, bump) = find_buffer_pda(&builder.program_id, &builder.orders[i].mint); source_buffers.push(buffer_pda); destinations.push(builder.orders[i].intent.buy_token_account); From c48a9f1a0b9e7a435f636aa004771c1c5803b7e4 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:47:21 +0200 Subject: [PATCH 19/30] Reuse array length --- client/src/instructions.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 8b29c54..9f2a87f 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -90,15 +90,16 @@ impl From> for Instruction { // For BeginSettle, sorting can take place in the interface. But the // order PDAs don't appear in the actual FinalizeSettle instruction, so // the sorting can only happen here. - let mut orders: Vec = (0..builder.orders.len()).collect(); + let num_orders = builder.orders.len(); + let mut orders: Vec = (0..num_orders).collect(); orders.sort_by_key(|&i| { find_order_pda(&builder.program_id, &builder.orders[i].intent.uid()).0 }); - let mut source_buffers: Vec = Vec::with_capacity(builder.orders.len()); - let mut destinations = Vec::with_capacity(builder.orders.len()); - let mut bumps = Vec::with_capacity(builder.orders.len()); - let mut amounts = Vec::with_capacity(builder.orders.len()); + let mut source_buffers: Vec = Vec::with_capacity(num_orders); + let mut destinations = Vec::with_capacity(num_orders); + let mut bumps = Vec::with_capacity(num_orders); + let mut amounts = Vec::with_capacity(num_orders); for &i in &orders { let (buffer_pda, bump) = find_buffer_pda(&builder.program_id, &builder.orders[i].mint); source_buffers.push(buffer_pda); From 572f55e7f4df644849577acdef1bfed868701f49 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:25:42 +0200 Subject: [PATCH 20/30] Fix endianness --- programs/settlement/src/settle/finalize.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programs/settlement/src/settle/finalize.rs b/programs/settlement/src/settle/finalize.rs index 42804b5..96fba11 100644 --- a/programs/settlement/src/settle/finalize.rs +++ b/programs/settlement/src/settle/finalize.rs @@ -108,7 +108,7 @@ fn push_funds<'a>( push.source_buffer, push.destination, state_pda_account, - u64::from_be_bytes(*push.amount), + u64::from_le_bytes(*push.amount), ) .invoke_signed(core::slice::from_ref(&state_pda_signer))?; } From c14c364f09699bbd8728f93c31a760c8d4268750 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:45:38 +0200 Subject: [PATCH 21/30] Simplification: collect seeds plus bump in helper --- interface/src/pda/state.rs | 7 +++++++ programs/settlement/src/settle/begin.rs | 11 ++++++----- programs/settlement/src/settle/finalize.rs | 11 ++++++----- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/interface/src/pda/state.rs b/interface/src/pda/state.rs index 34d3d64..aebe0dd 100644 --- a/interface/src/pda/state.rs +++ b/interface/src/pda/state.rs @@ -14,6 +14,13 @@ pub fn state_pda_seeds<'a>() -> [&'a [u8]; 1] { [SETTLEMENT_SEED] } +/// Canonical seeds for signing as the settlement state PDA with `bump`. The +/// on-chain settlement handlers use this to construct the CPI signer. +pub fn state_pda_signer_seeds(bump: &[u8; 1]) -> [&[u8]; 2] { + let [seed] = state_pda_seeds(); + [seed, bump] +} + /// Derive the canonical settlement state PDA address (and bump). pub fn find_state_pda(program_id: &Pubkey) -> (Pubkey, u8) { Pubkey::find_program_address(&state_pda_seeds(), program_id) diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 62b9f3f..86ad77c 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -20,7 +20,10 @@ use settlement_interface::{ settle::{BeginSettleInput, SettledOrder, FINALIZE_FIXED_ACCOUNTS}, InstructionInputParsing, }, - pda::{order::order_pda_signer_seeds, state::state_pda_seeds}, + pda::{ + order::order_pda_signer_seeds, + state::{state_pda_seeds, state_pda_signer_seeds}, + }, recover_discriminator, Pubkey, SettlementError, SettlementInstruction, }; @@ -170,15 +173,13 @@ fn settle_orders<'a>( } // Funds are pulled with the state PDA's delegation, so it must be the signer. - let seeds = state_pda_seeds(); - let (state_pda, state_bump) = Address::find_program_address(&seeds, program_id); + let (state_pda, state_bump) = Address::find_program_address(&state_pda_seeds(), program_id); if state_pda_account.address() != &state_pda { return Err(SettlementError::StateAccountMismatch.into()); } - let [seed] = seeds; let state_bump = [state_bump]; - let signer_seeds = [seed, &state_bump].map(Seed::from); + let signer_seeds = state_pda_signer_seeds(&state_bump).map(Seed::from); let state_pda_signer = Signer::from(&signer_seeds); // Orders must be passed strictly increasing by address; this rejects diff --git a/programs/settlement/src/settle/finalize.rs b/programs/settlement/src/settle/finalize.rs index 96fba11..b0650e8 100644 --- a/programs/settlement/src/settle/finalize.rs +++ b/programs/settlement/src/settle/finalize.rs @@ -13,7 +13,10 @@ use settlement_interface::{ settle::{FinalizeSettleInput, Pushes}, InstructionInputParsing, }, - pda::{buffer::buffer_pda_signer_seeds, state::state_pda_seeds}, + pda::{ + buffer::buffer_pda_signer_seeds, + state::{state_pda_seeds, state_pda_signer_seeds}, + }, SettlementError, SettlementInstruction, }; @@ -73,15 +76,13 @@ fn push_funds<'a>( } // The buffers' SPL authority is the state PDA, so it must sign each transfer. - let seeds = state_pda_seeds(); - let (state_pda, state_bump) = Address::find_program_address(&seeds, program_id); + let (state_pda, state_bump) = Address::find_program_address(&state_pda_seeds(), program_id); if state_pda_account.address() != &state_pda { return Err(SettlementError::StateAccountMismatch.into()); } - let [seed] = seeds; let state_bump = [state_bump]; - let signer_seeds = [seed, &state_bump].map(Seed::from); + let signer_seeds = state_pda_signer_seeds(&state_bump).map(Seed::from); let state_pda_signer = Signer::from(&signer_seeds); for push in pushes.iter() { From 17f3cb8d2a1d017a2f584a3447ab93a53b0ba360 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:53:17 +0200 Subject: [PATCH 22/30] Create signer with helper function --- programs/settlement/src/settle/begin.rs | 53 +++++++------------- programs/settlement/src/settle/finalize.rs | 56 ++++++++-------------- programs/settlement/src/settle/mod.rs | 44 +++++++++++++++-- 3 files changed, 78 insertions(+), 75 deletions(-) diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 86ad77c..f0d8623 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -3,7 +3,7 @@ use std::ops::Deref; use pinocchio::{ - cpi::{Seed, Signer}, + cpi::Signer, error::ProgramError, sysvars::{ clock::Clock, @@ -16,20 +16,16 @@ use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; use settlement_interface::{ data::order::EncodedOrderAccount, instruction::{ - create_buffer::SPL_TOKEN_PROGRAM_ID, settle::{BeginSettleInput, SettledOrder, FINALIZE_FIXED_ACCOUNTS}, InstructionInputParsing, }, - pda::{ - order::order_pda_signer_seeds, - state::{state_pda_seeds, state_pda_signer_seeds}, - }, + pda::order::order_pda_signer_seeds, recover_discriminator, Pubkey, SettlementError, SettlementInstruction, }; use crate::processor::is_cpi_call; -use super::validate_counterpart; +use super::{validate_counterpart, validate_token_account, with_state_pda_signer}; pub fn process_begin_settle( program_id: &Address, @@ -68,15 +64,17 @@ pub fn process_begin_settle( let finalize_ix = instructions.load_instruction_at(usize::from(input.finalize_ix_index))?; - settle_orders( - program_id, - input.token_program_account, - input.state_pda_account, - input.orders.iter(), - &finalize_ix, - )?; + validate_token_account(input.token_program_account)?; - Ok(()) + with_state_pda_signer(program_id, input.state_pda_account, |state_pda_signer| { + settle_orders( + program_id, + input.state_pda_account, + state_pda_signer, + input.orders.iter(), + &finalize_ix, + ) + }) } /// The destination address of each push carried by the paired `FinalizeSettle`, @@ -147,10 +145,9 @@ fn validate_no_nested_settlement>( Ok(()) } -/// Validate each order against its push, and pull user funds. This requires: -/// - the legacy SPL Token program; -/// - the canonical state PDA, which signs each transfer as the user's delegate; -/// - orders strictly increasing by address, rejecting duplicates. +/// Validate each order against its push, and pull user funds, signing the pulls +/// as the canonical state PDA (the user's delegate). Orders must be strictly +/// increasing by address, which rejects duplicates. /// /// Each order is paid by exactly one push. The orders and the finalize's pushes /// are both laid out sorted by order PDA, so order `i` is paid by push `i`, and @@ -163,25 +160,11 @@ fn validate_no_nested_settlement>( #[must_use = "ignoring the output may lead to an unintended on-chain state"] fn settle_orders<'a>( program_id: &Address, - token_program_account: &AccountView, state_pda_account: &AccountView, + state_pda_signer: &Signer, orders: impl IntoIterator>, finalize_ix: &IntrospectedInstruction, ) -> ProgramResult { - if token_program_account.address() != &SPL_TOKEN_PROGRAM_ID { - return Err(ProgramError::IncorrectProgramId); - } - - // Funds are pulled with the state PDA's delegation, so it must be the signer. - let (state_pda, state_bump) = Address::find_program_address(&state_pda_seeds(), program_id); - if state_pda_account.address() != &state_pda { - return Err(SettlementError::StateAccountMismatch.into()); - } - - let state_bump = [state_bump]; - let signer_seeds = state_pda_signer_seeds(&state_bump).map(Seed::from); - let state_pda_signer = Signer::from(&signer_seeds); - // Orders must be passed strictly increasing by address; this rejects // duplicates (settling the same order twice) without a separate scan. let mut previous: Option<&Address> = None; @@ -209,7 +192,7 @@ fn settle_orders<'a>( push_destination, now, state_pda_account, - &state_pda_signer, + state_pda_signer, )?; } diff --git a/programs/settlement/src/settle/finalize.rs b/programs/settlement/src/settle/finalize.rs index b0650e8..f9a5340 100644 --- a/programs/settlement/src/settle/finalize.rs +++ b/programs/settlement/src/settle/finalize.rs @@ -1,28 +1,21 @@ //! `FinalizeSettle` instruction handler. use pinocchio::{ - cpi::{Seed, Signer}, - error::ProgramError, - sysvars::instructions::Instructions, - AccountView, Address, ProgramResult, + cpi::Signer, sysvars::instructions::Instructions, AccountView, Address, ProgramResult, }; use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; use settlement_interface::{ instruction::{ - create_buffer::SPL_TOKEN_PROGRAM_ID, settle::{FinalizeSettleInput, Pushes}, InstructionInputParsing, }, - pda::{ - buffer::buffer_pda_signer_seeds, - state::{state_pda_seeds, state_pda_signer_seeds}, - }, + pda::buffer::buffer_pda_signer_seeds, SettlementError, SettlementInstruction, }; use crate::processor::is_cpi_call; -use super::validate_counterpart; +use super::{validate_counterpart, validate_token_account, with_state_pda_signer}; pub fn process_finalize_settle( program_id: &Address, @@ -51,40 +44,29 @@ pub fn process_finalize_settle( // validated the push count and destinations. `push_funds` adds the only // remaining check: each push draws from the canonical buffer for its mint. - push_funds( - program_id, - input.token_program_account, - input.state_pda_account, - input.pushes, - ) + validate_token_account(input.token_program_account)?; + + with_state_pda_signer(program_id, input.state_pda_account, |state_pda_signer| { + push_funds( + program_id, + input.state_pda_account, + state_pda_signer, + input.pushes, + ) + }) } -/// Push each order's proceeds out of the settlement's buffers. Requires the -/// legacy SPL Token program and the canonical state PDA, which signs each -/// transfer as the buffers' SPL authority. Each push's source must be the -/// canonical buffer for its destination's mint; pairing the destination to an -/// order is `BeginSettle`'s job. +/// Push each order's proceeds out of the settlement's buffers, signing each +/// transfer as the canonical state PDA (the buffers' SPL authority). Each push's +/// source must be the canonical buffer for its destination's mint; pairing the +/// destination to an order is `BeginSettle`'s job. #[must_use = "ignoring the output may lead to an unintended on-chain state"] fn push_funds<'a>( program_id: &Address, - token_program_account: &AccountView, state_pda_account: &AccountView, + state_pda_signer: &Signer, pushes: Pushes<'a>, ) -> ProgramResult { - if token_program_account.address() != &SPL_TOKEN_PROGRAM_ID { - return Err(ProgramError::IncorrectProgramId); - } - - // The buffers' SPL authority is the state PDA, so it must sign each transfer. - let (state_pda, state_bump) = Address::find_program_address(&state_pda_seeds(), program_id); - if state_pda_account.address() != &state_pda { - return Err(SettlementError::StateAccountMismatch.into()); - } - - let state_bump = [state_bump]; - let signer_seeds = state_pda_signer_seeds(&state_bump).map(Seed::from); - let state_pda_signer = Signer::from(&signer_seeds); - for push in pushes.iter() { // Read the destination's mint; the borrow ends with this block, before // the transfer reuses the account. @@ -111,7 +93,7 @@ fn push_funds<'a>( state_pda_account, u64::from_le_bytes(*push.amount), ) - .invoke_signed(core::slice::from_ref(&state_pda_signer))?; + .invoke_signed(core::slice::from_ref(state_pda_signer))?; } Ok(()) diff --git a/programs/settlement/src/settle/mod.rs b/programs/settlement/src/settle/mod.rs index 9a652e8..0f59096 100644 --- a/programs/settlement/src/settle/mod.rs +++ b/programs/settlement/src/settle/mod.rs @@ -2,10 +2,16 @@ use std::ops::Deref; -use pinocchio::{sysvars::instructions::Instructions, Address, ProgramResult}; +use pinocchio::{ + cpi::{Seed, Signer}, + error::ProgramError, + sysvars::instructions::Instructions, + AccountView, Address, ProgramResult, +}; use settlement_interface::{ - instruction::settle::recover_counterpart, recover_discriminator, SettlementError, - SettlementInstruction, + instruction::{create_buffer::SPL_TOKEN_PROGRAM_ID, settle::recover_counterpart}, + pda::state::{state_pda_seeds, state_pda_signer_seeds}, + recover_discriminator, SettlementError, SettlementInstruction, }; mod begin; @@ -42,3 +48,35 @@ fn validate_counterpart>( } Ok(()) } + +/// Validate that `token_program_account` is the legacy SPL Token program, which +/// every settlement transfer is issued against. +#[must_use = "ignoring the output may lead to an unintended on-chain state"] +fn validate_token_account(token_program_account: &AccountView) -> ProgramResult { + if token_program_account.address() != &SPL_TOKEN_PROGRAM_ID { + return Err(ProgramError::IncorrectProgramId); + } + Ok(()) +} + +/// Validate that `state_pda_account` is the canonical state PDA and run `f` with +/// a signer for it. Both settlement transfers move funds under the state PDA's +/// authority, so it must sign each of them. +/// +/// The signer only borrows its seed buffers, which are local to this frame; +/// running `f` here rather than returning the signer keeps them alive for as +/// long as `f` needs it. +fn with_state_pda_signer( + program_id: &Address, + state_pda_account: &AccountView, + f: impl FnOnce(&Signer) -> ProgramResult, +) -> ProgramResult { + let (state_pda, state_bump) = Address::find_program_address(&state_pda_seeds(), program_id); + if state_pda_account.address() != &state_pda { + return Err(SettlementError::StateAccountMismatch.into()); + } + + let state_bump = [state_bump]; + let signer_seeds = state_pda_signer_seeds(&state_bump).map(Seed::from); + f(&Signer::from(&signer_seeds)) +} From 0b496e184e112bb10defe0e19ad321fdbc4ad398 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:16:20 +0200 Subject: [PATCH 23/30] Rename generic function to clarify it reverts on begin --- .../settlement/tests/begin_settle_orders.rs | 40 ++++++++++++------- programs/settlement/tests/common/mod.rs | 3 -- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index 59f131e..636c3cd 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -18,9 +18,9 @@ //! whose output is a properly built instruction. use crate::common::{ - assert_instruction_error, assert_settlement_error, buffer, create_account, + assert_instruction_error, buffer, create_account, order::{create_order_pda, sample_intent, OrderBuilder}, - replace_first_matching_account, send, set_unix_timestamp, setup, token, + replace_first_matching_account, send, set_unix_timestamp, setup, to_instruction_error, token, }; use litesvm::LiteSVM; use litesvm_token::spl_token::error::TokenError; @@ -42,7 +42,7 @@ use solana_sdk::{ instruction::{AccountMeta, InstructionError}, pubkey::Pubkey, signature::{Keypair, Signer}, - transaction::Transaction, + transaction::{Transaction, TransactionError}, }; mod common; @@ -53,6 +53,18 @@ mod common; const BEGIN_INDEX: u8 = 0; const FINALIZE_INDEX: u8 = 1; +/// Assert the transaction failed in `BeginSettle` (at [`BEGIN_INDEX`]) with +/// `expected`. +fn assert_begin_error(result: Result, expected: SettlementError) { + assert_eq!( + result.err(), + Some(TransactionError::InstructionError( + BEGIN_INDEX, + to_instruction_error(expected), + )), + ); +} + /// A list of empty transfer lists, one per order. Used for settling `n` orders /// without pulling any funds. fn no_pulls(n: usize) -> Vec<&'static [Pull]> { @@ -165,7 +177,7 @@ fn rejects_wrong_bump() { amounts: &[0], }; let instructions = vec![begin.into(), finalize.into()]; - assert_settlement_error( + assert_begin_error( send(&mut svm, &payer, instructions), SettlementError::OrderNotCanonical, ); @@ -213,7 +225,7 @@ fn rejects_fabricated_program_owned_account() { }; let instructions = vec![begin.into(), finalize.into()]; - assert_settlement_error( + assert_begin_error( send(&mut svm, &payer, instructions), SettlementError::OrderNotCanonical, ); @@ -280,7 +292,7 @@ fn rejects_sell_token_account_mismatch() { wrong_sell_token, ); - assert_settlement_error( + assert_begin_error( send(&mut svm, &payer, instructions), SettlementError::SellTokenAccountMismatch, ); @@ -311,7 +323,7 @@ fn rejects_sell_token_owner_mismatch() { pulls: &[], }], ); - assert_settlement_error( + assert_begin_error( send(&mut svm, &payer, instructions), SettlementError::SellTokenOwnerMismatch, ); @@ -340,7 +352,7 @@ fn rejects_non_token_sell_account() { pulls: &[], }], ); - assert_settlement_error( + assert_begin_error( send(&mut svm, &payer, instructions), SettlementError::SellTokenAccountInvalid, ); @@ -366,7 +378,7 @@ fn rejects_duplicate_orders() { }, ], ); - assert_settlement_error( + assert_begin_error( send(&mut svm, &payer, instructions), SettlementError::OrdersNotStrictlyIncreasing, ); @@ -450,7 +462,7 @@ fn rejects_orders_in_wrong_address_order() { amounts: &amounts, }); let instructions = vec![begin, finalize]; - assert_settlement_error( + assert_begin_error( send(&mut svm, &payer, instructions), SettlementError::OrdersNotStrictlyIncreasing, ); @@ -497,7 +509,7 @@ fn rejects_cancelled_order() { pulls: &[], }], ); - assert_settlement_error( + assert_begin_error( send(&mut svm, &payer, instructions), SettlementError::OrderCancelled, ); @@ -523,7 +535,7 @@ fn rejects_expired_order() { pulls: &[], }], ); - assert_settlement_error( + assert_begin_error( send(&mut svm, &payer, instructions), SettlementError::OrderExpired, ); @@ -781,7 +793,7 @@ fn rejects_wrong_state_pda() { Pubkey::new_unique(), ); - assert_settlement_error( + assert_begin_error( send(&mut svm, &payer, instructions), SettlementError::StateAccountMismatch, ); @@ -917,7 +929,7 @@ fn rejects_extra_account() { .accounts .push(AccountMeta::new_readonly(Pubkey::new_unique(), false)); - assert_settlement_error( + assert_begin_error( send(&mut svm, &payer, instructions), SettlementError::AccountCountNotMatchingOrderCount, ); diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index 53eb059..e2d43c0 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -76,9 +76,6 @@ pub fn assert_instruction_error( Some(TransactionError::InstructionError(0, expected)) ); } -pub fn assert_settlement_error(result: Result, expected: SettlementError) { - assert_instruction_error(result, to_instruction_error(expected)); -} /// Place a fresh, rent-exempt account holding `data` and owned by `owner` at a /// new address, and return it. Lets a test populate an arbitrary account (e.g. From 3933d96ff160d7988ba6a64b8b216f98bf274357 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:49:37 +0200 Subject: [PATCH 24/30] Move tests from Finalize that fail in Begin to Begin --- .../settlement/tests/begin_settle_orders.rs | 85 +++++++++++- programs/settlement/tests/common/mod.rs | 1 + .../settlement/tests/common/settlement.rs | 35 +++++ .../tests/finalize_settle_pushes.rs | 125 +----------------- 4 files changed, 118 insertions(+), 128 deletions(-) create mode 100644 programs/settlement/tests/common/settlement.rs diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index 636c3cd..7f5f179 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -20,7 +20,9 @@ use crate::common::{ assert_instruction_error, buffer, create_account, order::{create_order_pda, sample_intent, OrderBuilder}, - replace_first_matching_account, send, set_unix_timestamp, setup, to_instruction_error, token, + replace_first_matching_account, send, + settlement::{build_settlement, BEGIN_INDEX, FINALIZE_INDEX}, + set_unix_timestamp, setup, to_instruction_error, token, }; use litesvm::LiteSVM; use litesvm_token::spl_token::error::TokenError; @@ -47,12 +49,6 @@ use solana_sdk::{ mod common; -/// Position of `BeginSettle` in the `[BeginSettle, FinalizeSettle]` pair the -/// tests in this file build; the finalize sits right after it. Kept in sync with -/// the tests that reach into `instructions[BEGIN_INDEX]` to corrupt the begin. -const BEGIN_INDEX: u8 = 0; -const FINALIZE_INDEX: u8 = 1; - /// Assert the transaction failed in `BeginSettle` (at [`BEGIN_INDEX`]) with /// `expected`. fn assert_begin_error(result: Result, expected: SettlementError) { @@ -934,3 +930,78 @@ fn rejects_extra_account() { SettlementError::AccountCountNotMatchingOrderCount, ); } + +#[test] +fn rejects_push_to_wrong_destination() { + let (mut svm, program_id, payer) = setup(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + let orders = [FinalizedIntent { + intent: &intent, + mint: Pubkey::new_unique(), + amount: 100, + }]; + + let mut finalize = Instruction::from(FinalizeSettle { + program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders: &orders, + }); + // Redirect the push to an account that isn't the order's buy token account. + // Accounts: `[sysvar, state, token_program, source, destination]`. + let destination_index = 4; + finalize.accounts[destination_index].pubkey = Pubkey::new_unique(); + + let instructions = build_settlement(&program_id, &orders, finalize); + assert_begin_error( + send(&mut svm, &payer, instructions), + SettlementError::PushDestinationMismatch, + ); +} + +#[test] +fn rejects_fewer_pushes_than_orders() { + let (mut svm, program_id, payer) = setup(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + let orders = [FinalizedIntent { + intent: &intent, + mint: Pubkey::new_unique(), + amount: 100, + }]; + + // A finalize carrying no pushes, paired with a begin settling one order. + let finalize = FinalizeSettle { + program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders: &[], + }; + + let instructions = build_settlement(&program_id, &orders, finalize); + assert_begin_error( + send(&mut svm, &payer, instructions), + SettlementError::SettledOrderPushCountMismatch, + ); +} + +#[test] +fn rejects_more_pushes_than_orders() { + let (mut svm, program_id, payer) = setup(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + + // A finalize that pushes to one order, paired with a begin that settles none, + // so the extra push has no order to account for it. + let finalize = FinalizeSettle { + program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders: &[FinalizedIntent { + intent: &intent, + mint: Pubkey::new_unique(), + amount: 0, + }], + }; + + let instructions = build_settlement(&program_id, &[], finalize); + assert_begin_error( + send(&mut svm, &payer, instructions), + SettlementError::SettledOrderPushCountMismatch, + ); +} diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index e2d43c0..6f3d5ca 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -9,6 +9,7 @@ pub mod buffer; pub mod lookup_table; pub mod order; pub mod pda; +pub mod settlement; pub mod token; use litesvm::{types::TransactionMetadata, LiteSVM}; diff --git a/programs/settlement/tests/common/settlement.rs b/programs/settlement/tests/common/settlement.rs new file mode 100644 index 0000000..29599c3 --- /dev/null +++ b/programs/settlement/tests/common/settlement.rs @@ -0,0 +1,35 @@ +//! Scaffolding for building `[BeginSettle, FinalizeSettle]` settlement pairs. + +use settlement_client::instructions::{BeginSettle, FinalizedIntent, InitializedIntent}; +use settlement_interface::Instruction; +use solana_sdk::pubkey::Pubkey; + +/// Positions of the two instructions in the `[BeginSettle, FinalizeSettle]` pair +/// the settlement tests build: begin first, finalize right after it. Each +/// instruction points at the other through its `begin_ix_index`/`finalize_ix_index`. +pub const BEGIN_INDEX: u8 = 0; +pub const FINALIZE_INDEX: u8 = 1; + +/// Build the `[begin, finalize]` instructions where `finalize` is a pre-built +/// `FinalizeSettle` at [`FINALIZE_INDEX`] and `begin` settles `orders` (with no +/// pulls) at [`BEGIN_INDEX`], the same orders the finalize is expected to push +/// to. Submit the result with [`send`](super::send). +pub fn build_settlement( + program_id: &Pubkey, + orders: &[FinalizedIntent], + finalize: impl Into, +) -> Vec { + let begin_orders: Vec = orders + .iter() + .map(|order| InitializedIntent { + intent: order.intent, + pulls: &[], + }) + .collect(); + let begin = BeginSettle { + program_id: *program_id, + finalize_ix_index: FINALIZE_INDEX.into(), + orders: &begin_orders, + }; + vec![begin.into(), finalize.into()] +} diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs index 4518e64..58aaffb 100644 --- a/programs/settlement/tests/finalize_settle_pushes.rs +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -12,11 +12,11 @@ use crate::common::{ buffer, create_account, order::{create_order_pda, sample_intent, OrderBuilder}, - send, setup, to_instruction_error, token, -}; -use settlement_client::instructions::{ - BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, + send, + settlement::{build_settlement, BEGIN_INDEX, FINALIZE_INDEX}, + setup, to_instruction_error, token, }; +use settlement_client::instructions::{FinalizeSettle, FinalizedIntent}; use settlement_client::settlement_interface::{Instruction, SettlementError}; use solana_sdk::{ instruction::InstructionError, program_error::ProgramError, pubkey::Pubkey, signature::Signer, @@ -25,24 +25,6 @@ use solana_sdk::{ mod common; -/// Positions of the two instructions in the `[BeginSettle, FinalizeSettle]` pair -/// the tests in this file build. `assert_begin_error` and `assert_finalize_error` -/// use them to attribute a rejection to the right instruction. -const BEGIN_INDEX: u8 = 0; -const FINALIZE_INDEX: u8 = 1; - -/// Assert the transaction failed in `BeginSettle` (at [`BEGIN_INDEX`]) with -/// `expected`. -fn assert_begin_error(result: Result, expected: SettlementError) { - assert_eq!( - result.err(), - Some(TransactionError::InstructionError( - BEGIN_INDEX, - to_instruction_error(expected), - )), - ); -} - /// Assert the transaction failed in `FinalizeSettle` (at [`FINALIZE_INDEX`]) /// with `expected`. fn assert_finalize_error(result: Result, expected: InstructionError) { @@ -52,30 +34,6 @@ fn assert_finalize_error(result: Result, expected: Instr ); } -/// Build the `[begin, finalize]` instructions where `finalize` is a pre-built -/// `FinalizeSettle` at [`FINALIZE_INDEX`] and `begin` settles `orders` (with no -/// pulls) at [`BEGIN_INDEX`], the same orders the finalize is expected to push -/// to. Submit the result with [`send`]. -fn build_settlement( - program_id: &Pubkey, - orders: &[FinalizedIntent], - finalize: impl Into, -) -> Vec { - let begin_orders: Vec = orders - .iter() - .map(|order| InitializedIntent { - intent: order.intent, - pulls: &[], - }) - .collect(); - let begin = BeginSettle { - program_id: *program_id, - finalize_ix_index: FINALIZE_INDEX.into(), - orders: &begin_orders, - }; - vec![begin.into(), finalize.into()] -} - /// Build the minimal `[BeginSettle, FinalizeSettle]` instructions that settle /// `orders` (begin) and push their proceeds (finalize). fn finalize(program_id: &Pubkey, orders: &[FinalizedIntent]) -> Vec { @@ -204,33 +162,6 @@ fn pushes_several_orders_from_different_buffers() { assert_eq!(token::balance(&svm, &buffer1), funding - amount1); } -#[test] -fn rejects_push_to_wrong_destination() { - let (mut svm, program_id, payer) = setup(); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - let orders = [FinalizedIntent { - intent: &intent, - mint: Pubkey::new_unique(), - amount: 100, - }]; - - let mut finalize = Instruction::from(FinalizeSettle { - program_id, - begin_ix_index: BEGIN_INDEX.into(), - orders: &orders, - }); - // Redirect the push to an account that isn't the order's buy token account. - // Accounts: `[sysvar, state, token_program, source, destination]`. - let destination_index = 4; - finalize.accounts[destination_index].pubkey = Pubkey::new_unique(); - - let instructions = build_settlement(&program_id, &orders, finalize); - assert_begin_error( - send(&mut svm, &payer, instructions), - SettlementError::PushDestinationMismatch, - ); -} - #[test] fn rejects_push_from_non_buffer_source() { let (mut svm, program_id, payer) = setup(); @@ -288,54 +219,6 @@ fn rejects_push_from_substituted_source() { ); } -#[test] -fn rejects_fewer_pushes_than_orders() { - let (mut svm, program_id, payer) = setup(); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - let orders = [FinalizedIntent { - intent: &intent, - mint: Pubkey::new_unique(), - amount: 100, - }]; - - // A finalize carrying no pushes, paired with a begin settling one order. - let finalize = FinalizeSettle { - program_id, - begin_ix_index: BEGIN_INDEX.into(), - orders: &[], - }; - - let instructions = build_settlement(&program_id, &orders, finalize); - assert_begin_error( - send(&mut svm, &payer, instructions), - SettlementError::SettledOrderPushCountMismatch, - ); -} - -#[test] -fn rejects_more_pushes_than_orders() { - let (mut svm, program_id, payer) = setup(); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - - // A finalize that pushes to one order, paired with a begin that settles none, - // so the extra push has no order to account for it. - let finalize = FinalizeSettle { - program_id, - begin_ix_index: BEGIN_INDEX.into(), - orders: &[FinalizedIntent { - intent: &intent, - mint: Pubkey::new_unique(), - amount: 0, - }], - }; - - let instructions = build_settlement(&program_id, &[], finalize); - assert_begin_error( - send(&mut svm, &payer, instructions), - SettlementError::SettledOrderPushCountMismatch, - ); -} - #[test] fn rejects_push_account_count_mismatch() { let (mut svm, program_id, payer) = setup(); From 394288976d37c2a3b86fa758d6ce26400d7f2134 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:12:45 +0200 Subject: [PATCH 25/30] Fix fmt --- programs/settlement/tests/begin_settle_orders.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index 7f5f179..c256728 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -20,9 +20,9 @@ use crate::common::{ assert_instruction_error, buffer, create_account, order::{create_order_pda, sample_intent, OrderBuilder}, - replace_first_matching_account, send, + replace_first_matching_account, send, set_unix_timestamp, settlement::{build_settlement, BEGIN_INDEX, FINALIZE_INDEX}, - set_unix_timestamp, setup, to_instruction_error, token, + setup, to_instruction_error, token, }; use litesvm::LiteSVM; use litesvm_token::spl_token::error::TokenError; From 1c2c02c40a617f2bce73b17fbef3c5e9c58aca2e Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:29:36 +0200 Subject: [PATCH 26/30] Rename test with misleading name: rejects_push_from_non_buffer_source -> rejects_push_if_buffer_does_not_match_mint --- programs/settlement/tests/finalize_settle_pushes.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs index 58aaffb..3ab7657 100644 --- a/programs/settlement/tests/finalize_settle_pushes.rs +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -163,7 +163,7 @@ fn pushes_several_orders_from_different_buffers() { } #[test] -fn rejects_push_from_non_buffer_source() { +fn rejects_push_if_buffer_does_not_match_mint() { let (mut svm, program_id, payer) = setup(); let buy_mint = token::create_mint(&mut svm, &payer); let other_mint = token::create_mint(&mut svm, &payer); From 915dabc7cfc6bc59d636073d4c4208daf6c53947 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:10:27 +0200 Subject: [PATCH 27/30] Move buffer validation to dedicated function --- interface/src/pda/buffer.rs | 60 ++++++++++++++++++++++ programs/settlement/src/settle/finalize.rs | 18 ++----- 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/interface/src/pda/buffer.rs b/interface/src/pda/buffer.rs index 80b1a6d..e81abe6 100644 --- a/interface/src/pda/buffer.rs +++ b/interface/src/pda/buffer.rs @@ -9,9 +9,13 @@ //! settlement state PDA (see [`crate::pda::state`]), the single authority //! controlling every buffer. +use solana_account_view::AccountView; +use solana_address::Address; +use solana_program_error::ProgramError; use solana_pubkey::Pubkey; use crate::pda::SETTLEMENT_SEED; +use crate::SettlementError; /// Trailing seed identifying the buffer PDAs. pub const BUFFER_SEED: &[u8] = b"buffer"; @@ -37,6 +41,25 @@ pub fn find_buffer_pda(program_id: &Pubkey, mint: &Pubkey) -> (Pubkey, u8) { Pubkey::find_program_address(&buffer_pda_seeds(mint.as_array()), program_id) } +/// Confirm `buffer` matches the derived buffer PDA for `mint` and `bump`. +#[must_use = "ignoring the output means ignoring the validation result"] +pub fn validate_buffer_pda( + program_id: &Address, + buffer: &AccountView, + mint: &Address, + bump: u8, +) -> Result<(), ProgramError> { + let derived = Address::create_program_address( + &buffer_pda_signer_seeds(&mint.to_bytes(), &[bump]), + program_id, + ) + .map_err(|_| SettlementError::PushSourceNotBuffer)?; + if buffer.address() != &derived { + return Err(SettlementError::PushSourceNotBuffer.into()); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -51,6 +74,43 @@ mod tests { ); } + #[test] + fn accepts_a_valid_pda() { + let program_id = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let (pda, bump) = find_buffer_pda(&program_id, &mint); + + let buffer = crate::instruction::fixtures::fake_account(pda); + validate_buffer_pda(&program_id, &buffer, &mint, bump) + .expect("the canonical buffer PDA must be accepted"); + } + + #[test] + fn rejects_an_invalid_address() { + let program_id = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let (_, bump) = find_buffer_pda(&program_id, &mint); + + // An account sitting at some other address is not the buffer. + let buffer = crate::instruction::fixtures::fake_account(Pubkey::new_unique()); + let err = validate_buffer_pda(&program_id, &buffer, &mint, bump) + .expect_err("a non-canonical address must be rejected"); + assert_eq!(err, SettlementError::PushSourceNotBuffer.into()); + } + + #[test] + fn rejects_a_wrong_bump() { + let program_id = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let (pda, bump) = find_buffer_pda(&program_id, &mint); + + // The address is canonical but the carried bump doesn't derive it. + let buffer = crate::instruction::fixtures::fake_account(pda); + let err = validate_buffer_pda(&program_id, &buffer, &mint, bump ^ 1) + .expect_err("a wrong bump must be rejected"); + assert_eq!(err, SettlementError::PushSourceNotBuffer.into()); + } + mod proptest { use ::proptest::prelude::*; diff --git a/programs/settlement/src/settle/finalize.rs b/programs/settlement/src/settle/finalize.rs index f9a5340..eae1d1a 100644 --- a/programs/settlement/src/settle/finalize.rs +++ b/programs/settlement/src/settle/finalize.rs @@ -9,7 +9,7 @@ use settlement_interface::{ settle::{FinalizeSettleInput, Pushes}, InstructionInputParsing, }, - pda::buffer::buffer_pda_signer_seeds, + pda::buffer::validate_buffer_pda, SettlementError, SettlementInstruction, }; @@ -42,7 +42,7 @@ pub fn process_finalize_settle( // `BeginSettle` (which the counterpart check above guarantees ran) already // validated the push count and destinations. `push_funds` adds the only - // remaining check: each push draws from the canonical buffer for its mint. + // remaining check: each push draws from the buffer for its mint. validate_token_account(input.token_program_account)?; @@ -58,7 +58,7 @@ pub fn process_finalize_settle( /// Push each order's proceeds out of the settlement's buffers, signing each /// transfer as the canonical state PDA (the buffers' SPL authority). Each push's -/// source must be the canonical buffer for its destination's mint; pairing the +/// source must be the derived buffer for its destination's mint; pairing the /// destination to an order is `BeginSettle`'s job. #[must_use = "ignoring the output may lead to an unintended on-chain state"] fn push_funds<'a>( @@ -75,17 +75,7 @@ fn push_funds<'a>( .map_err(|_| SettlementError::InvalidBuyTokenAccount)?; *destination.mint() }; - // Re-derive the buffer from the carried bump (one hash, not a full - // search). A buffer exists only at its canonical address, so a wrong - // bump yields an address the transfer can't draw from. - let derived = Address::create_program_address( - &buffer_pda_signer_seeds(mint.as_array(), &[push.bump]), - program_id, - ) - .map_err(|_| SettlementError::PushSourceNotBuffer)?; - if push.source_buffer.address() != &derived { - return Err(SettlementError::PushSourceNotBuffer.into()); - } + validate_buffer_pda(program_id, push.source_buffer, &mint, push.bump)?; Transfer::new( push.source_buffer, From 43147d24aa64a6886c0cd449ea5cc4bbdf9b908a Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:13:52 +0200 Subject: [PATCH 28/30] Add tests for checking remaining fixed accounts --- .../tests/finalize_settle_pushes.rs | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs index 3ab7657..ba39815 100644 --- a/programs/settlement/tests/finalize_settle_pushes.rs +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -12,12 +12,15 @@ use crate::common::{ buffer, create_account, order::{create_order_pda, sample_intent, OrderBuilder}, - send, + replace_first_matching_account, send, settlement::{build_settlement, BEGIN_INDEX, FINALIZE_INDEX}, setup, to_instruction_error, token, }; use settlement_client::instructions::{FinalizeSettle, FinalizedIntent}; -use settlement_client::settlement_interface::{Instruction, SettlementError}; +use settlement_client::settlement_interface::{ + instruction::settle::SPL_TOKEN_PROGRAM_ID, pda::state::find_state_pda, Instruction, + SettlementError, +}; use solana_sdk::{ instruction::InstructionError, program_error::ProgramError, pubkey::Pubkey, signature::Signer, transaction::TransactionError, @@ -219,6 +222,59 @@ fn rejects_push_from_substituted_source() { ); } +#[test] +fn rejects_wrong_token_program() { + let (mut svm, program_id, payer) = setup(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + let orders = [FinalizedIntent { + intent: &intent, + mint: Pubkey::new_unique(), + amount: 0, + }]; + + // Swap the SPL Token program account `FinalizeSettle` references for a bogus + // one. `BeginSettle` runs first against its own (untouched) token-program + // account and passes; the finalize's own check is what rejects. + let mut instructions = finalize(&program_id, &orders); + replace_first_matching_account( + &mut instructions[usize::from(FINALIZE_INDEX)], + &SPL_TOKEN_PROGRAM_ID, + Pubkey::new_unique(), + ); + + assert_finalize_error( + send(&mut svm, &payer, instructions), + InstructionError::IncorrectProgramId, + ); +} + +#[test] +fn rejects_wrong_state_pda() { + let (mut svm, program_id, payer) = setup(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); + let orders = [FinalizedIntent { + intent: &intent, + mint: Pubkey::new_unique(), + amount: 0, + }]; + + // Swap the state PDA account `FinalizeSettle` references for a bogus one. + // `BeginSettle`'s own state PDA account is untouched, so it passes; the + // finalize's check is what rejects. + let mut instructions = finalize(&program_id, &orders); + let (state_pda, _bump) = find_state_pda(&program_id); + replace_first_matching_account( + &mut instructions[usize::from(FINALIZE_INDEX)], + &state_pda, + Pubkey::new_unique(), + ); + + assert_finalize_error( + send(&mut svm, &payer, instructions), + to_instruction_error(SettlementError::StateAccountMismatch), + ); +} + #[test] fn rejects_push_account_count_mismatch() { let (mut svm, program_id, payer) = setup(); From db268c7cdaa30f10252c910749448126422423c5 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:57:46 +0200 Subject: [PATCH 29/30] Unnecessary comments --- programs/settlement/tests/finalize_settle_pushes.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs index ba39815..52a06eb 100644 --- a/programs/settlement/tests/finalize_settle_pushes.rs +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -232,9 +232,6 @@ fn rejects_wrong_token_program() { amount: 0, }]; - // Swap the SPL Token program account `FinalizeSettle` references for a bogus - // one. `BeginSettle` runs first against its own (untouched) token-program - // account and passes; the finalize's own check is what rejects. let mut instructions = finalize(&program_id, &orders); replace_first_matching_account( &mut instructions[usize::from(FINALIZE_INDEX)], @@ -258,9 +255,6 @@ fn rejects_wrong_state_pda() { amount: 0, }]; - // Swap the state PDA account `FinalizeSettle` references for a bogus one. - // `BeginSettle`'s own state PDA account is untouched, so it passes; the - // finalize's check is what rejects. let mut instructions = finalize(&program_id, &orders); let (state_pda, _bump) = find_state_pda(&program_id); replace_first_matching_account( From bf1b6834f650f52b1ba1311499ac9ebf3a525237 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:34:30 +0200 Subject: [PATCH 30/30] Rename validation function --- programs/settlement/src/settle/begin.rs | 4 ++-- programs/settlement/src/settle/finalize.rs | 4 ++-- programs/settlement/src/settle/mod.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index c70a1ff..6703447 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -23,7 +23,7 @@ use settlement_interface::{ use crate::processor::is_cpi_call; -use super::{validate_counterpart, validate_token_account, with_state_pda_signer}; +use super::{validate_counterpart, validate_token_program_account, with_state_pda_signer}; pub fn process_begin_settle( program_id: &Address, @@ -62,7 +62,7 @@ pub fn process_begin_settle( let finalize_ix = instructions.load_instruction_at(usize::from(input.finalize_ix_index))?; - validate_token_account(input.token_program_account)?; + validate_token_program_account(input.token_program_account)?; with_state_pda_signer(program_id, input.state_pda_account, |state_pda_signer| { settle_orders( diff --git a/programs/settlement/src/settle/finalize.rs b/programs/settlement/src/settle/finalize.rs index eae1d1a..9026ebe 100644 --- a/programs/settlement/src/settle/finalize.rs +++ b/programs/settlement/src/settle/finalize.rs @@ -15,7 +15,7 @@ use settlement_interface::{ use crate::processor::is_cpi_call; -use super::{validate_counterpart, validate_token_account, with_state_pda_signer}; +use super::{validate_counterpart, validate_token_program_account, with_state_pda_signer}; pub fn process_finalize_settle( program_id: &Address, @@ -44,7 +44,7 @@ pub fn process_finalize_settle( // validated the push count and destinations. `push_funds` adds the only // remaining check: each push draws from the buffer for its mint. - validate_token_account(input.token_program_account)?; + validate_token_program_account(input.token_program_account)?; with_state_pda_signer(program_id, input.state_pda_account, |state_pda_signer| { push_funds( diff --git a/programs/settlement/src/settle/mod.rs b/programs/settlement/src/settle/mod.rs index 0f59096..5776d25 100644 --- a/programs/settlement/src/settle/mod.rs +++ b/programs/settlement/src/settle/mod.rs @@ -52,7 +52,7 @@ fn validate_counterpart>( /// Validate that `token_program_account` is the legacy SPL Token program, which /// every settlement transfer is issued against. #[must_use = "ignoring the output may lead to an unintended on-chain state"] -fn validate_token_account(token_program_account: &AccountView) -> ProgramResult { +fn validate_token_program_account(token_program_account: &AccountView) -> ProgramResult { if token_program_account.address() != &SPL_TOKEN_PROGRAM_ID { return Err(ProgramError::IncorrectProgramId); }