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(),