From 1b41f69ca09a739c1fd0b8296d885ae753aa9983 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:35:32 +0200 Subject: [PATCH 01/23] Add state PDA --- client/src/instructions.rs | 11 +- .../src/{ => instruction}/create_order.rs | 0 interface/src/instruction/initialize.rs | 82 ++++++++++++ interface/src/instruction/mod.rs | 9 ++ interface/src/{ => instruction}/settle.rs | 0 interface/src/lib.rs | 4 +- interface/src/pda/mod.rs | 49 ++++++- interface/src/pda/order.rs | 35 +---- interface/src/pda/state.rs | 29 +++++ programs/settlement/src/create_order.rs | 47 +++---- programs/settlement/src/initialize.rs | 122 ++++++++++++++++++ programs/settlement/src/lib.rs | 5 + programs/settlement/src/processor.rs | 46 ++++++- programs/settlement/src/settle.rs | 3 +- programs/settlement/tests/common/mod.rs | 21 +++ programs/settlement/tests/common/pda.rs | 79 ++++++++++++ programs/settlement/tests/create_order.rs | 105 ++------------- programs/settlement/tests/initialize.rs | 64 +++++++++ 18 files changed, 547 insertions(+), 164 deletions(-) rename interface/src/{ => instruction}/create_order.rs (100%) create mode 100644 interface/src/instruction/initialize.rs create mode 100644 interface/src/instruction/mod.rs rename interface/src/{ => instruction}/settle.rs (100%) create mode 100644 interface/src/pda/state.rs create mode 100644 programs/settlement/src/initialize.rs create mode 100644 programs/settlement/tests/common/pda.rs create mode 100644 programs/settlement/tests/initialize.rs diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 99b0eef..6caabe6 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -7,13 +7,13 @@ use settlement_interface::{ data::intent::{EncodedOrderIntent, OrderIntent}, - pda::order::find_order_pda, + pda::{order::find_order_pda, state::find_state_pda}, Instruction, Pubkey, }; // Reexport the instruction builders that don't change from the interface. // We want the client to provide all instruction builders. -pub use settlement_interface::settle::{begin_settle, finalize_settle}; +pub use settlement_interface::instruction::settle::{begin_settle, finalize_settle}; pub fn create_order( program_id: &Pubkey, @@ -24,7 +24,7 @@ pub fn create_order( let encoded = EncodedOrderIntent::from(intent); let (order_pda, _bump) = find_order_pda(program_id, &encoded.hash()); let intent_bytes: [u8; EncodedOrderIntent::SIZE] = (&encoded).into(); - settlement_interface::create_order::create_order( + settlement_interface::instruction::create_order::create_order( program_id, owner, created_by, @@ -32,3 +32,8 @@ pub fn create_order( &intent_bytes, ) } + +pub fn initialize(program_id: &Pubkey, payer: &Pubkey) -> Instruction { + let (state_pda, _bump) = find_state_pda(program_id); + settlement_interface::instruction::initialize::initialize(program_id, payer, &state_pda) +} diff --git a/interface/src/create_order.rs b/interface/src/instruction/create_order.rs similarity index 100% rename from interface/src/create_order.rs rename to interface/src/instruction/create_order.rs diff --git a/interface/src/instruction/initialize.rs b/interface/src/instruction/initialize.rs new file mode 100644 index 0000000..f15123e --- /dev/null +++ b/interface/src/instruction/initialize.rs @@ -0,0 +1,82 @@ +//! `Initialize` instruction builder. +//! +//! Allocates the singleton settlement state PDA (see [`crate::pda::state`]). + +use solana_instruction::{AccountMeta, Instruction}; +use solana_pubkey::Pubkey; + +pub use solana_system_interface::program::ID as SYSTEM_PROGRAM_ID; + +use crate::SettlementInstruction; + +/// Build an `Initialize` instruction. +/// +/// `payer` funds the new account's rent and signs. It is meant to be the +/// transaction's fee payer: the state is created once at deployment and never +/// deallocated, so there's no need for a dedicated funding account separate +/// from whoever pays for the deployment transaction. +/// +/// `state_pda` must be the canonical PDA returned by +/// [`crate::pda::state::find_state_pda`]; the program derives the bump itself +/// and rejects any other address. +/// +/// The state account is owned by the settlement program. This instruction takes +/// no parameters and succeeds only once: a second call fails because the +/// account already exists. +/// +/// Wire format: just `[discriminator]`, 1 byte. +/// Required accounts: `[payer (W,S), state_pda (W), system_program (R)]`. +/// The system program must be available for the `CreateAccount` CPI but doesn't +/// need to sit at that specific position. +pub fn initialize(program_id: &Pubkey, payer: &Pubkey, state_pda: &Pubkey) -> Instruction { + Instruction { + program_id: *program_id, + accounts: vec![ + AccountMeta::new(*payer, true), + AccountMeta::new(*state_pda, false), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + ], + data: vec![SettlementInstruction::Initialize.discriminator()], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn instruction_data_has_expected_layout() { + let program_id = Pubkey::new_from_array([1; 32]); + let payer = Pubkey::new_from_array([2; 32]); + let state_pda = Pubkey::new_from_array([3; 32]); + + let ix = initialize(&program_id, &payer, &state_pda); + assert_eq!( + ix.data, + vec![SettlementInstruction::Initialize.discriminator()] + ); + } + + #[test] + fn instruction_has_expected_accounts() { + let program_id = Pubkey::new_from_array([1; 32]); + let payer = Pubkey::new_from_array([2; 32]); + let state_pda = Pubkey::new_from_array([3; 32]); + + let ix = initialize(&program_id, &payer, &state_pda); + + assert_eq!(ix.accounts.len(), 3); + // payer: writable, signer (funds the new account's rent) + assert_eq!(ix.accounts[0].pubkey, payer); + assert!(ix.accounts[0].is_writable); + assert!(ix.accounts[0].is_signer); + // state_pda: writable, not signer (the program signs via PDA seeds) + assert_eq!(ix.accounts[1].pubkey, state_pda); + assert!(ix.accounts[1].is_writable); + assert!(!ix.accounts[1].is_signer); + // system program: read-only + assert_eq!(ix.accounts[2].pubkey, SYSTEM_PROGRAM_ID); + assert!(!ix.accounts[2].is_writable); + assert!(!ix.accounts[2].is_signer); + } +} diff --git a/interface/src/instruction/mod.rs b/interface/src/instruction/mod.rs new file mode 100644 index 0000000..88ce0d6 --- /dev/null +++ b/interface/src/instruction/mod.rs @@ -0,0 +1,9 @@ +//! Off-chain instruction builders for the settlement program. +//! +//! Each submodule builds the [`solana_instruction::Instruction`] for specific +//! settlement instructions, encoding their discriminator (see +//! [`crate::SettlementInstruction`]) and laying out the required accounts. + +pub mod create_order; +pub mod initialize; +pub mod settle; diff --git a/interface/src/settle.rs b/interface/src/instruction/settle.rs similarity index 100% rename from interface/src/settle.rs rename to interface/src/instruction/settle.rs diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 9cf529e..7ad58ba 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -4,10 +4,9 @@ pub use solana_instruction::{AccountMeta, Instruction}; use solana_program_error::ProgramError; pub use solana_pubkey::Pubkey; -pub mod create_order; pub mod data; +pub mod instruction; pub mod pda; -pub mod settle; #[derive(Clone, Copy, Debug, Eq, PartialEq, num_enum::TryFromPrimitive)] #[repr(u8)] @@ -19,6 +18,7 @@ pub enum SettlementInstruction { BeginSettle = 0, FinalizeSettle = 1, CreateOrder = 2, + Initialize = 3, } impl SettlementInstruction { diff --git a/interface/src/pda/mod.rs b/interface/src/pda/mod.rs index b697200..06677fd 100644 --- a/interface/src/pda/mod.rs +++ b/interface/src/pda/mod.rs @@ -4,8 +4,51 @@ //! the additional seeds and the derivation helper for one kind of PDA. pub mod order; +pub mod state; -/// First seed of every PDA derived under the settlement program: the state -/// PDA, the per-token buffer PDAs, and the per-order PDAs all share this -/// prefix. +/// First seed of every PDA derived under the settlement program. pub const SETTLEMENT_SEED: &[u8] = b"settlement"; + +#[cfg(test)] +mod tests { + use solana_pubkey::Pubkey; + + /// Assert that the PDA returned by `find_pda` is derived with the canonical + /// bump for the seed scheme `signer_seeds` encapsulates. + /// + /// The canonical bump is the largest value in `0..=255` that yields a valid + /// (off-curve) address: any higher bump must be rejected, and the canonical + /// one must reproduce the derived address. + /// + /// `find_pda` is `find_*_pda` with the program id (and any other + /// parameters) captured. `seeds` are the base seeds of the scheme under + /// test, without a bump; each candidate bump is appended here to form the + /// full signer seeds. + pub(crate) fn assert_canonical_bump(find_pda: F1, seeds: [&[u8]; SIZE]) + where + F1: Fn(&Pubkey) -> (solana_pubkey::Pubkey, u8), + { + let program_id = Pubkey::new_unique(); + let (pda, canonical_bump) = find_pda(&program_id); + + let try_create_address = |bump| { + let bump = [bump]; + let mut signer_seeds = seeds.to_vec(); + signer_seeds.push(&bump); + Pubkey::create_program_address(&signer_seeds, &program_id) + }; + + if let Some(first_invalid_bump) = canonical_bump.checked_add(1) { + for candidate in first_invalid_bump..=u8::MAX { + assert!( + try_create_address(candidate).is_err(), + "bump {candidate} above the canonical bump {canonical_bump} must be invalid", + ); + } + } + + let expected = try_create_address(canonical_bump) + .expect("canonical bump must produce a valid address"); + assert_eq!(pda, expected); + } +} diff --git a/interface/src/pda/order.rs b/interface/src/pda/order.rs index 5ea1473..23e066f 100644 --- a/interface/src/pda/order.rs +++ b/interface/src/pda/order.rs @@ -24,16 +24,6 @@ pub fn order_pda_seeds(uid: &[u8; 32]) -> [&[u8]; 3] { [SETTLEMENT_SEED, uid, ORDER_SEED] } -/// Canonical seeds for signing as the order PDA at `uid` with `bump`. The -/// on-chain `CreateOrder` handler uses this to construct the CPI signer. -/// By design, order PDAs can be created only if it uses the canonical bump. -/// Calling this function with another bump could lead to a theoretically -/// valid PDA that however cannot and should not be instantiated. -pub fn order_pda_signer_seeds<'a>(uid: &'a [u8; 32], bump: &'a [u8; 1]) -> [&'a [u8]; 4] { - let [s0, s1, s2] = order_pda_seeds(uid); - [s0, s1, s2, bump] -} - /// Derive the canonical order PDA address (and bump) for `uid`. /// /// `uid` is the unique identifier of an intent. See @@ -48,29 +38,14 @@ mod tests { #[test] fn find_order_pda_uses_canonical_seeds() { - let program_id = Pubkey::new_from_array([67; 32]); - let uid = [0x42u8; 32]; - - let (pda, bump) = find_order_pda(&program_id, &uid); + let uid = *Pubkey::new_unique().as_array(); - let derive_pda = |candidate| { - Pubkey::create_program_address(&order_pda_signer_seeds(&uid, &[candidate]), &program_id) - }; - - // The canonical bump is the largest value in `0..=255` that yields a - // valid (off-curve) address. Any higher bump must be rejected, and the - // canonical one must reproduce the derived address. - for candidate in (bump + 1)..=u8::MAX { - assert!( - derive_pda(candidate).is_err(), - "bump {candidate} above the canonical bump {bump} must be invalid", - ); - } - let expected = derive_pda(bump).expect("canonical bump must produce a valid address"); - assert_eq!(pda, expected); + crate::pda::tests::assert_canonical_bump( + |program_id| find_order_pda(program_id, &uid), + order_pda_seeds(&uid), + ); } - // Property-based tests, non-deterministic. mod proptest { use ::proptest::prelude::*; diff --git a/interface/src/pda/state.rs b/interface/src/pda/state.rs new file mode 100644 index 0000000..aa311d0 --- /dev/null +++ b/interface/src/pda/state.rs @@ -0,0 +1,29 @@ +//! Settlement state PDA seed and address derivation. +//! +//! There is a single state PDA per settlement program, derived from the bare +//! [`SETTLEMENT_SEED`]. It is the program's central account that stores the +//! state used for solver authentication. + +use solana_pubkey::Pubkey; + +use crate::pda::SETTLEMENT_SEED; + +/// Canonical seed components for the settlement state PDA. +pub fn state_pda_seeds<'a>() -> [&'a [u8]; 1] { + [SETTLEMENT_SEED] +} + +/// 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) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_state_pda_uses_canonical_seeds() { + crate::pda::tests::assert_canonical_bump(find_state_pda, state_pda_seeds()); + } +} diff --git a/programs/settlement/src/create_order.rs b/programs/settlement/src/create_order.rs index 9aa220e..4b0cc48 100644 --- a/programs/settlement/src/create_order.rs +++ b/programs/settlement/src/create_order.rs @@ -1,21 +1,16 @@ //! `CreateOrder` instruction handler. -use pinocchio::{ - cpi::{Seed, Signer}, - error::ProgramError, - AccountView, Address, ProgramResult, -}; -use pinocchio_system::instructions::CreateAccount; +use pinocchio::{error::ProgramError, AccountView, Address, ProgramResult}; use settlement_interface::{ data::{ intent::EncodedOrderIntent, order::{self, EncodedOrderAccount}, }, - pda::order::{order_pda_seeds, order_pda_signer_seeds}, + pda::order::order_pda_seeds, SettlementError, SettlementInstruction, }; -use crate::processor::InstructionInputParsing; +use crate::processor::{create_canonical_pda, InstructionInputParsing}; /// Parsed inputs of a `CreateOrder` instruction. struct CreateOrderInput<'a> { @@ -78,25 +73,17 @@ pub fn process_create_order( return Err(SettlementError::OwnerMismatch.into()); } - // We want to have a single order per uid, so we need to derive the bump - // here. The rest of the code can assume that if an account has data, - // then the bump is valid. - let (_, bump) = Address::find_program_address(&order_pda_seeds(&intent_uid), program_id); - - let bump_seed = [bump]; - let signer_seeds = order_pda_signer_seeds(&intent_uid, &bump_seed).map(Seed::from); - let signer = Signer::from(&signer_seeds[..]); - - // Implicitly, this also checks that `order_pda.address()` matches the - // bump generated above without needing to directly compare addresses. - CreateAccount::with_minimum_balance( + // We want a single order per uid; `create_pda` derives the canonical bump + // and, by signing the creation with the order seeds, rejects any + // `order_pda` that isn't the canonical address. The rest of the code can + // assume that if an account has data, then the bump is valid. + create_canonical_pda( + program_id, created_by, order_pda, EncodedOrderAccount::SIZE as u64, - program_id, - None, - )? - .invoke_signed(&[signer])?; + order_pda_seeds(&intent_uid), + )?; // Note: `intent_bytes` were validated before and are known to represent a valid intent. let mut buffer = order_pda.try_borrow_mut()?; @@ -138,8 +125,14 @@ mod tests { // We used this to test failure conditions where the actual addresses // don't matter. let zero = Address::new_from_array([0; 32]); - settlement_interface::create_order::create_order(&zero, &zero, &zero, &zero, intent_bytes) - .data + settlement_interface::instruction::create_order::create_order( + &zero, + &zero, + &zero, + &zero, + intent_bytes, + ) + .data } fn four_accounts() -> [AccountView; 4] { @@ -159,7 +152,7 @@ mod tests { let order_pda = Address::new_from_array([23; 32]); let intent_bytes = valid_intent_bytes(); - let data = settlement_interface::create_order::create_order( + let data = settlement_interface::instruction::create_order::create_order( &program_id, &owner, &created_by, diff --git a/programs/settlement/src/initialize.rs b/programs/settlement/src/initialize.rs new file mode 100644 index 0000000..ee596c2 --- /dev/null +++ b/programs/settlement/src/initialize.rs @@ -0,0 +1,122 @@ +//! `Initialize` instruction handler. + +use pinocchio::{error::ProgramError, AccountView, Address, ProgramResult}; +use settlement_interface::{pda::state::state_pda_seeds, SettlementInstruction}; + +use crate::processor::{create_canonical_pda, InstructionInputParsing}; + +/// Parsed inputs of an `Initialize` instruction. +struct InitializeInput<'a> { + payer: &'a AccountView, + state_pda: &'a AccountView, +} + +impl<'a> InstructionInputParsing<'a> for InitializeInput<'a> { + const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::Initialize; + + fn parse_body( + instruction_data: &[u8], + accounts: &'a mut [AccountView], + ) -> Result { + if !instruction_data.is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + // Accounts: [payer (W,S), state_pda (W), system_program (R)]. The system + // program needs to be present for the `CreateAccount` CPI but doesn't + // need to be referenced directly and can be at any later position. + let [payer, state_pda, _system, ..] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + Ok(Self { payer, state_pda }) + } +} + +pub fn process_initialize( + program_id: &Address, + accounts: &mut [AccountView], + instruction_data: &[u8], +) -> ProgramResult { + let InitializeInput { payer, state_pda } = InitializeInput::parse(instruction_data, accounts)?; + + create_canonical_pda(program_id, payer, state_pda, 0, state_pda_seeds())?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::fake_account_from_array; + + // Only used in failing tests, where actual data doesn't matter + fn initialize_data() -> Vec { + let zero = Address::new_from_array([0; 32]); + settlement_interface::instruction::initialize::initialize(&zero, &zero, &zero).data + } + + fn three_accounts() -> [AccountView; 3] { + [ + fake_account_from_array([1; 32]), + fake_account_from_array([2; 32]), + fake_account_from_array([3; 32]), + ] + } + + #[test] + fn initialize_input_parses_valid_input() { + let program_id = Address::new_unique(); + let payer = fake_account_from_array([1; 32]); + let state_pda = fake_account_from_array([2; 32]); + let data = settlement_interface::instruction::initialize::initialize( + &program_id, + payer.address(), + state_pda.address(), + ) + .data; + + let system_program = fake_account_from_array([3; 32]); + let mut accounts = [payer, state_pda, system_program]; + + let InitializeInput { + payer: parsed_payer, + state_pda: parsed_state_pda, + } = InitializeInput::parse(&data, &mut accounts).expect("parse should succeed"); + + assert_eq!(parsed_payer.address(), payer.address()); + assert_eq!(parsed_state_pda.address(), state_pda.address()); + } + + #[test] + fn initialize_input_rejects_long_data() { + let mut data = initialize_data(); + data.push(0); // trailing byte + let mut accounts = three_accounts(); + assert_eq!( + InitializeInput::parse(&data, &mut accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn initialize_input_rejects_missing_accounts() { + let data = initialize_data(); + let mut accounts: Vec = three_accounts().into(); + accounts.pop(); + assert_eq!( + InitializeInput::parse(&data, &mut accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + ); + } + + #[test] + fn process_initialize_propagates_parse_error() { + let mut data = initialize_data(); + data.push(0); // make the data too long to trigger a parse error + let mut accounts = three_accounts(); + assert_eq!( + process_initialize(&Address::new_unique(), &mut accounts, &data), + Err(ProgramError::InvalidInstructionData), + ); + } +} diff --git a/programs/settlement/src/lib.rs b/programs/settlement/src/lib.rs index 9871bec..a4fbefd 100644 --- a/programs/settlement/src/lib.rs +++ b/programs/settlement/src/lib.rs @@ -1,6 +1,7 @@ //! On-chain CoW Protocol settlement program. mod create_order; +mod initialize; mod processor; mod settle; @@ -8,6 +9,7 @@ mod settle; mod test_utils; use create_order::process_create_order; +use initialize::process_initialize; use pinocchio::{entrypoint, AccountView, Address, ProgramResult}; use settle::{process_begin_settle, process_finalize_settle}; use settlement_interface::{recover_discriminator, SettlementInstruction}; @@ -30,5 +32,8 @@ pub fn process_instruction( SettlementInstruction::CreateOrder => { process_create_order(program_id, accounts, instruction_data) } + SettlementInstruction::Initialize => { + process_initialize(program_id, accounts, instruction_data) + } } } diff --git a/programs/settlement/src/processor.rs b/programs/settlement/src/processor.rs index cecc651..46bc892 100644 --- a/programs/settlement/src/processor.rs +++ b/programs/settlement/src/processor.rs @@ -1,6 +1,12 @@ -//! Trait for parsing instruction inputs. +//! Shared program plumbing: instruction-input parsing and PDA creation. -use pinocchio::{error::ProgramError, AccountView}; +use pinocchio::{ + address::MAX_SEEDS, + cpi::{Seed, Signer}, + error::ProgramError, + AccountView, Address, ProgramResult, +}; +use pinocchio_system::instructions::CreateAccount; use settlement_interface::{recover_discriminator, SettlementInstruction}; /// Shared components for parsing generic instruction input. @@ -30,6 +36,42 @@ pub trait InstructionInputParsing<'a>: Sized { } } +/// Create the program-owned account at the PDA `pda`, funded by `payer`. +/// +/// `seeds` are the canonical PDA seeds *without* the bump and the canonical +/// bump is derived and appended here. Signing `CreateAccount` with these seeds +/// implicitly checks that `pda` is the canonical address: the runtime grants +/// the PDA signature only for the address the seeds derive, so any other `pda` +/// fails the CPI. +#[must_use = "ignoring the output means processing continues without the PDA having been created"] +pub fn create_canonical_pda( + program_id: &Address, + payer: &AccountView, + pda: &AccountView, + space: u64, + seeds: [&[u8]; N], +) -> ProgramResult { + let (_, bump) = Address::find_program_address(&seeds, program_id); + let bump = [bump]; + + // A PDA has at most `MAX_SEEDS` seeds, so `N` stays well below `usize::MAX` + // and the `N + 1` below cannot overflow. Asserting it in a `const` block + // makes that a compile-time guarantee rather than a runtime risk. + const { assert!(N < MAX_SEEDS, "a PDA has at most MAX_SEEDS seeds") }; + + // The signer needs the base seeds followed by the bump. Stable Rust can't + // name `[Seed; N + 1]`, so collect into a `Vec` sized for exactly that: + // the `N` base seeds plus the trailing bump. + let mut signer_seeds = Vec::with_capacity(const { N + 1 }); + signer_seeds.extend(seeds.iter().map(|seed| Seed::from(*seed))); + signer_seeds.push(Seed::from(&bump[..])); + let signer = Signer::from(&signer_seeds[..]); + + CreateAccount::with_minimum_balance(payer, pda, space, program_id, None)? + .invoke_signed(&[signer])?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/programs/settlement/src/settle.rs b/programs/settlement/src/settle.rs index b147c70..7f84fa3 100644 --- a/programs/settlement/src/settle.rs +++ b/programs/settlement/src/settle.rs @@ -4,7 +4,8 @@ use pinocchio::{ error::ProgramError, sysvars::instructions::Instructions, AccountView, Address, ProgramResult, }; use settlement_interface::{ - recover_discriminator, settle::recover_counterpart, SettlementError, SettlementInstruction, + instruction::settle::recover_counterpart, recover_discriminator, SettlementError, + SettlementInstruction, }; use crate::processor::InstructionInputParsing; diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index 3944813..1e6f624 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -5,12 +5,16 @@ 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 pda; + use litesvm::LiteSVM; use settlement_client::settlement_interface::SettlementError; +use settlement_interface::Instruction; use solana_sdk::{ instruction::InstructionError, pubkey::Pubkey, signature::{Keypair, Signer}, + transaction::Transaction, }; pub const PROGRAM_SO: &str = concat!( @@ -49,3 +53,20 @@ pub fn to_instruction_error(e: SettlementError) -> InstructionError { pub fn lamports(svm: &LiteSVM, address: &Pubkey) -> u64 { svm.get_account(address).map(|a| a.lamports).unwrap_or(0) } + +/// Sign `ix` with `fee_payer` as the transaction fee payer and +/// `owner` as the keypair filling the `owner` slot. Tests pass +/// two distinct keypairs to keep these roles independent. +pub fn signed_tx( + svm: &LiteSVM, + fee_payer: &Keypair, + owner: &Keypair, + ix: Instruction, +) -> Transaction { + Transaction::new_signed_with_payer( + &[ix], + Some(&fee_payer.pubkey()), + &[fee_payer, owner], + svm.latest_blockhash(), + ) +} diff --git a/programs/settlement/tests/common/pda.rs b/programs/settlement/tests/common/pda.rs new file mode 100644 index 0000000..2ccc251 --- /dev/null +++ b/programs/settlement/tests/common/pda.rs @@ -0,0 +1,79 @@ +//! Shared helper for PDA-related tests. + +use litesvm::LiteSVM; +use solana_sdk::{ + instruction::InstructionError, + pubkey::Pubkey, + transaction::{Transaction, TransactionError}, +}; +use solana_system_interface::{error::SystemError, program::ID as SYSTEM_PROGRAM_ID}; + +/// Find a non-canonical PDA for `seeds` under `program_id`: a bump strictly +/// below the canonical one that still derives an off-curve address. Such a PDA +/// is a legitimate derivation for the seed scheme, just not the canonical +/// address the program signs for. Returns the bump and its address. +pub fn find_noncanonical_pda( + program_id: &Pubkey, + seeds: [&[u8]; N], +) -> (u8, Pubkey) { + let (_canonical_pda, canonical_bump) = Pubkey::find_program_address(&seeds, program_id); + (0..canonical_bump) + .rev() + .find_map(|bump| { + let bump_seed = [bump]; + let mut signer_seeds = seeds.to_vec(); + signer_seeds.push(&bump_seed); + Pubkey::create_program_address(&signer_seeds, program_id) + .ok() + .map(|address| (bump, address)) + }) + .expect("seeds must have a non-canonical off-curve bump") +} + +/// Send `tx` and assert it's rejected because `pda` is not the canonical +/// address the program signs for. The runtime grants the PDA signature only +/// for the canonical address, so signing `CreateAccount` for any other `pda` +/// fails the CPI with `PrivilegeEscalation` and leaves `pda` uncreated. +pub fn assert_rejected_as_noncanonical(svm: &mut LiteSVM, tx: Transaction, pda: &Pubkey) { + let err = svm + .send_transaction(tx) + .expect_err("non-canonical PDA must be rejected"); + assert!( + matches!( + err.err, + TransactionError::InstructionError(0, InstructionError::PrivilegeEscalation) + ), + "expected instruction 0 to fail, got {:?}", + err.err, + ); + assert!( + svm.get_account(pda).is_none(), + "rejected PDA must not have been created" + ); +} + +/// Send `tx` (which is expected to recreate an already-existing PDA) and assert +/// it's rejected because the account exists. The `CreateAccount` CPI fails with +/// `AccountAlreadyInUse`; since that custom code is `0` and thus ambiguous +/// with a program-level `Custom(0)`, we also confirm the failing inner +/// instruction really is a system-program call. +pub fn assert_rejected_as_existing(svm: &mut LiteSVM, tx: Transaction) { + // Keep the compiled message's `account_keys` so we can resolve the + // `program_id_index` of the failing inner instruction below. + let account_keys = tx.message.account_keys.clone(); + let err = svm + .send_transaction(tx) + .expect_err("recreating an existing PDA must be rejected"); + + let expected = TransactionError::InstructionError( + 0, + InstructionError::Custom(SystemError::AccountAlreadyInUse as u32), + ); + assert_eq!(err.err, expected); + + let last_cpi = err.meta.inner_instructions[0] + .last() + .expect("system-program CPI should be available"); + let failing_program = account_keys[last_cpi.instruction.program_id_index as usize]; + assert_eq!(failing_program, SYSTEM_PROGRAM_ID); +} diff --git a/programs/settlement/tests/create_order.rs b/programs/settlement/tests/create_order.rs index 64d14c6..99f542e 100644 --- a/programs/settlement/tests/create_order.rs +++ b/programs/settlement/tests/create_order.rs @@ -1,23 +1,19 @@ -use litesvm::LiteSVM; -use settlement_client::settlement_interface::pda::order::ORDER_SEED; use settlement_client::settlement_interface::{ - create_order::create_order, data::{ intent::{EncodedOrderIntent, OrderIntent, OrderKind}, order::{EncodedOrderAccount, OrderAccount}, }, - pda::{order::find_order_pda, SETTLEMENT_SEED}, + instruction::create_order::create_order, + pda::order::{find_order_pda, order_pda_seeds}, SettlementError, }; -use solana_sdk::instruction::{Instruction, InstructionError}; use solana_sdk::{ pubkey::Pubkey, signature::{Keypair, Signer}, transaction::{Transaction, TransactionError}, }; -use solana_system_interface::error::SystemError; -use crate::common::to_instruction_error; +use crate::common::{signed_tx, to_instruction_error}; mod common; @@ -45,18 +41,6 @@ fn encode_and_derive( (bytes, pda) } -/// Sign `ix` with `fee_payer` as the transaction fee payer and -/// `owner` as the keypair filling the `owner` slot. Tests pass -/// two distinct keypairs to keep these roles independent. -fn signed_tx(svm: &LiteSVM, fee_payer: &Keypair, owner: &Keypair, ix: Instruction) -> Transaction { - Transaction::new_signed_with_payer( - &[ix], - Some(&fee_payer.pubkey()), - &[fee_payer, owner], - svm.latest_blockhash(), - ) -} - #[test] fn happy_path_creates_order_pda_with_expected_body() { let (mut svm, program_id, owner) = common::setup(); @@ -201,21 +185,7 @@ fn rejects_arbitrary_wrong_pda() { ); let tx = signed_tx(&svm, &owner, &owner, ix); - let err = svm - .send_transaction(tx) - .expect_err("wrong PDA must be rejected"); - assert!( - matches!( - err.err, - TransactionError::InstructionError(0, InstructionError::PrivilegeEscalation) - ), - "expected instruction 0 to fail, got {:?}", - err.err, - ); - assert!( - svm.get_account(&wrong_pda).is_none(), - "wrong PDA must not have been created" - ); + common::pda::assert_rejected_as_noncanonical(&mut svm, tx, &wrong_pda); } #[test] @@ -226,24 +196,9 @@ fn rejects_non_canonical_bump_pda() { let encoded = EncodedOrderIntent::from(&intent); let bytes: [u8; EncodedOrderIntent::SIZE] = (&encoded).into(); let uid = encoded.hash(); - let (_canonical_pda, canonical_bump) = find_order_pda(&program_id, &uid); - - // Walk bumps below canonical until we find one that also yields an - // off-curve point. That's a legitimate PDA for the program's seeds, but - // not the canonical one. - let (non_canonical_bump, non_canonical_pda) = (0..canonical_bump) - .rev() - .find_map(|bump| { - Pubkey::create_program_address( - &[SETTLEMENT_SEED, &uid, ORDER_SEED, &[bump]], - &program_id, - ) - .ok() - .map(|addr| (bump, addr)) - }) - .expect("sample intent must have a non-canonical off-curve bump"); - - assert_ne!(non_canonical_bump, canonical_bump); + + let (_bump, non_canonical_pda) = + common::pda::find_noncanonical_pda(&program_id, order_pda_seeds(&uid)); let ix = create_order( &program_id, @@ -253,21 +208,7 @@ fn rejects_non_canonical_bump_pda() { &bytes, ); let tx = signed_tx(&svm, &fee_payer, &fee_payer, ix); - let err = svm - .send_transaction(tx) - .expect_err("non-canonical-bump PDA must be rejected"); - assert!( - matches!( - err.err, - TransactionError::InstructionError(0, InstructionError::PrivilegeEscalation) - ), - "expected instruction 0 to fail, got {:?}", - err.err, - ); - assert!( - svm.get_account(&non_canonical_pda).is_none(), - "non-canonical-bump PDA must not have been created" - ); + common::pda::assert_rejected_as_noncanonical(&mut svm, tx, &non_canonical_pda); } #[test] @@ -292,9 +233,6 @@ fn rejects_creating_same_pda_twice() { svm.send_transaction(tx) .expect("first create_order should succeed"); - // The two transactions are otherwise identical; expire the blockhash so - // the second isn't rejected as a duplicate signature instead of a - // duplicate PDA. svm.expire_blockhash(); // For good measure, we change `created_by` to stress that the input @@ -307,32 +245,7 @@ fn rejects_creating_same_pda_twice() { &encoded, ); let tx = signed_tx(&svm, &another_fee_payer, &fee_payer, ix); - // Keep the compiled message's `account_keys` around so we can resolve - // the `program_id_index` of the failing inner instruction below. - let account_keys = tx.message.account_keys.clone(); - let err = svm - .send_transaction(tx) - .expect_err("recreating an existing PDA must be rejected"); - - let expected = TransactionError::InstructionError( - 0, - InstructionError::Custom(SystemError::AccountAlreadyInUse as u32), - ); - assert_eq!(err.err, expected); - - // The check above is a bit misleading: the u32 that's returned there - // is `0`, so this could be by chance a custom error from the current - // program (`Custom(0)`). Here we do an extra sanity check that the - // system program has indeed been called. - let instruction_index = 0; - let last_cpi = err.meta.inner_instructions[instruction_index] - .last() - .expect("system-program CPI should be available"); - let failing_program = account_keys[last_cpi.instruction.program_id_index as usize]; - assert_eq!( - failing_program, - settlement_interface::create_order::SYSTEM_PROGRAM_ID - ); + common::pda::assert_rejected_as_existing(&mut svm, tx); } #[test] diff --git a/programs/settlement/tests/initialize.rs b/programs/settlement/tests/initialize.rs new file mode 100644 index 0000000..3d590fd --- /dev/null +++ b/programs/settlement/tests/initialize.rs @@ -0,0 +1,64 @@ +use settlement_client::instructions::initialize; +use settlement_client::settlement_interface::{ + instruction::initialize::initialize as initialize_ix, pda::state::find_state_pda, +}; +use solana_sdk::{pubkey::Pubkey, signature::Signer}; + +mod common; + +#[test] +fn happy_path_initializes_empty_state_pda() { + let (mut svm, program_id, payer) = common::setup(); + let (state_pda, _bump) = find_state_pda(&program_id); + + // `payer` is both the transaction fee payer and the account funding the + // state PDA's rent. + let ix = initialize(&program_id, &payer.pubkey()); + let tx = common::signed_tx(&svm, &payer, &payer, ix); + svm.send_transaction(tx).expect("initialize should succeed"); + + let account = svm + .get_account(&state_pda) + .expect("state PDA should exist after initialize"); + assert_eq!( + account.owner, program_id, + "state PDA must be owned by the settlement program" + ); + assert!(account.data.is_empty(), "state PDA must be empty"); + + let rent = svm.minimum_balance_for_rent_exemption(0); + assert_eq!( + account.lamports, rent, + "state PDA must hold exactly the rent minimum: {} != {}", + account.lamports, rent, + ); +} + +#[test] +fn rejects_arbitrary_wrong_state_pda() { + let (mut svm, program_id, payer) = common::setup(); + + // The program only signs for the canonical PDA, so the lower-level interface + // builder lets us point the instruction at a deliberately wrong address. + let wrong_pda = Pubkey::new_unique(); + let ix = initialize_ix(&program_id, &payer.pubkey(), &wrong_pda); + let tx = common::signed_tx(&svm, &payer, &payer, ix); + + common::pda::assert_rejected_as_noncanonical(&mut svm, tx, &wrong_pda); +} + +#[test] +fn rejects_initializing_twice() { + let (mut svm, program_id, payer) = common::setup(); + + let ix = initialize(&program_id, &payer.pubkey()); + let tx = common::signed_tx(&svm, &payer, &payer, ix); + svm.send_transaction(tx) + .expect("first initialize should succeed"); + + svm.expire_blockhash(); + + let ix = initialize(&program_id, &payer.pubkey()); + let tx = common::signed_tx(&svm, &payer, &payer, ix); + common::pda::assert_rejected_as_existing(&mut svm, tx); +} From e9ddb24a825fc740e2cd0650552c6fe1b7b3ff08 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:22:43 +0200 Subject: [PATCH 02/23] Add buffer PDAs --- Cargo.lock | 67 ++++++ Cargo.toml | 3 + client/src/instructions.rs | 12 +- interface/Cargo.toml | 1 + interface/src/instruction/create_buffer.rs | 98 +++++++++ interface/src/instruction/mod.rs | 1 + interface/src/lib.rs | 1 + interface/src/pda/buffer.rs | 67 ++++++ interface/src/pda/mod.rs | 1 + interface/src/pda/state.rs | 5 +- programs/settlement/Cargo.toml | 2 + programs/settlement/src/create_buffer.rs | 198 ++++++++++++++++++ programs/settlement/src/create_order.rs | 9 +- programs/settlement/src/initialize.rs | 9 +- programs/settlement/src/lib.rs | 5 + programs/settlement/src/processor.rs | 21 +- programs/settlement/tests/common/mod.rs | 13 ++ programs/settlement/tests/common/token.rs | 12 ++ programs/settlement/tests/create_buffer.rs | 227 +++++++++++++++++++++ 19 files changed, 737 insertions(+), 15 deletions(-) create mode 100644 interface/src/instruction/create_buffer.rs create mode 100644 interface/src/pda/buffer.rs create mode 100644 programs/settlement/src/create_buffer.rs create mode 100644 programs/settlement/tests/common/token.rs create mode 100644 programs/settlement/tests/create_buffer.rs diff --git a/Cargo.lock b/Cargo.lock index 5d2ba55..2f91dcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1502,6 +1502,28 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "litesvm-token" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00114e5646f39f0e9202ce4e6fc0035975a959321dc4b10baf35907220d4dc19" +dependencies = [ + "litesvm", + "smallvec", + "solana-account", + "solana-address 2.6.0", + "solana-keypair", + "solana-program-option", + "solana-program-pack", + "solana-rent 3.1.0", + "solana-signer", + "solana-system-interface 3.2.0", + "solana-transaction", + "solana-transaction-error", + "spl-associated-token-account-interface", + "spl-token-interface", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -1763,6 +1785,18 @@ dependencies = [ "solana-address 2.6.0", ] +[[package]] +name = "pinocchio-token" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825f59c8348e5c2d3fd56432ef927f5819542b3b05fae4f5b6869801113e775e" +dependencies = [ + "solana-account-view", + "solana-address 2.6.0", + "solana-instruction-view", + "solana-program-error", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -2168,8 +2202,10 @@ version = "0.1.0" dependencies = [ "arrayref", "litesvm", + "litesvm-token", "pinocchio", "pinocchio-system", + "pinocchio-token", "settlement-client", "settlement-interface", "solana-sdk", @@ -2197,6 +2233,7 @@ dependencies = [ "solana-sdk-ids", "solana-sha256-hasher", "solana-system-interface 3.2.0", + "spl-token-interface", ] [[package]] @@ -3896,6 +3933,36 @@ dependencies = [ "der", ] +[[package]] +name = "spl-associated-token-account-interface" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6433917b60441d68d99a17e121d9db0ea15a9a69c0e5afa34649cf5ba12612f" +dependencies = [ + "solana-instruction", + "solana-pubkey 3.0.0", +] + +[[package]] +name = "spl-token-interface" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c564ac05a7c8d8b12e988a37d82695b5ba4db376d07ea98bc4882c81f96c7f3" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "num_enum", + "solana-instruction", + "solana-program-error", + "solana-program-option", + "solana-program-pack", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "thiserror 2.0.18", +] + [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index fb514d3..10b9000 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,9 +11,11 @@ edition = "2021" arrayref = "0.3" derive_more = { version = "1", features = ["deref"] } litesvm = "0.12.0" +litesvm-token = "0.12.0" num_enum = "0.7" pinocchio = "0.11.1" pinocchio-system = "0.6" +pinocchio-token = "0.6" proptest = "1" settlement-client = { path = "client" } settlement-interface = { path = "interface" } @@ -24,6 +26,7 @@ solana-sdk = "3" solana-sdk-ids = "3" solana-sha256-hasher = { version = "3", features = ["sha2"] } solana-system-interface = "3" +spl-token-interface = "2" [workspace.lints.clippy] # Promote arithmetic side effect lint to hard error. diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 6caabe6..1c93e34 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -7,7 +7,7 @@ use settlement_interface::{ data::intent::{EncodedOrderIntent, OrderIntent}, - pda::{order::find_order_pda, state::find_state_pda}, + pda::{buffer::find_buffer_pda, order::find_order_pda, state::find_state_pda}, Instruction, Pubkey, }; @@ -33,6 +33,16 @@ pub fn create_order( ) } +pub fn create_buffer(program_id: &Pubkey, payer: &Pubkey, mint: &Pubkey) -> Instruction { + let (buffer_pda, _bump) = find_buffer_pda(program_id, mint); + settlement_interface::instruction::create_buffer::create_buffer( + program_id, + payer, + &buffer_pda, + mint, + ) +} + pub fn initialize(program_id: &Pubkey, payer: &Pubkey) -> Instruction { let (state_pda, _bump) = find_state_pda(program_id); settlement_interface::instruction::initialize::initialize(program_id, payer, &state_pda) diff --git a/interface/Cargo.toml b/interface/Cargo.toml index b209f36..2123b65 100644 --- a/interface/Cargo.toml +++ b/interface/Cargo.toml @@ -13,6 +13,7 @@ solana-pubkey = { workspace = true, features = ["curve25519"] } solana-sdk-ids.workspace = true solana-sha256-hasher.workspace = true solana-system-interface.workspace = true +spl-token-interface.workspace = true [dev-dependencies] proptest.workspace = true diff --git a/interface/src/instruction/create_buffer.rs b/interface/src/instruction/create_buffer.rs new file mode 100644 index 0000000..cfe4809 --- /dev/null +++ b/interface/src/instruction/create_buffer.rs @@ -0,0 +1,98 @@ +//! `CreateBuffer` instruction builder. +//! +//! Allocates the per-token buffer PDA (see [`crate::pda::buffer`]) as an SPL +//! token account and initializes it with the settlement state PDA as its token +//! authority. The token is identified by the `mint` account; the buffer address +//! must be the canonical PDA for that mint. + +use solana_instruction::{AccountMeta, Instruction}; +use solana_pubkey::Pubkey; + +pub use solana_system_interface::program::ID as SYSTEM_PROGRAM_ID; + +use crate::SettlementInstruction; + +/// The SPL Token program. Buffers are created as token accounts owned by this +/// program. +pub use spl_token_interface::ID as SPL_TOKEN_PROGRAM_ID; + +/// Build a `CreateBuffer` instruction for the buffer holding `mint`. +/// +/// `buffer_pda` must be the canonical PDA returned by +/// [`crate::pda::buffer::find_buffer_pda`] for `mint`; the program derives the +/// bump itself and rejects any other address. `payer` funds the new token +/// account's rent. +/// +/// Wire format: `[discriminator = 4]`, 1 byte. The token is implied by the +/// `mint` account, so no further data is needed. +/// Required accounts: +/// `[payer (W,S), buffer_pda (W), mint (R), token_program (R), system_program (R)]`. +/// The first four are read positionally. The system program only has to be +/// present so the `CreateAccount` CPI can dispatch; it isn't read by index. +pub fn create_buffer( + program_id: &Pubkey, + payer: &Pubkey, + buffer_pda: &Pubkey, + mint: &Pubkey, +) -> Instruction { + Instruction { + program_id: *program_id, + accounts: vec![ + AccountMeta::new(*payer, true), + AccountMeta::new(*buffer_pda, false), + AccountMeta::new_readonly(*mint, false), + AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + ], + data: vec![SettlementInstruction::CreateBuffer.discriminator()], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn instruction_data_has_expected_layout() { + let program_id = Pubkey::new_from_array([1; 32]); + let payer = Pubkey::new_from_array([2; 32]); + let buffer_pda = Pubkey::new_from_array([3; 32]); + let mint = Pubkey::new_from_array([4; 32]); + let ix = create_buffer(&program_id, &payer, &buffer_pda, &mint); + assert_eq!( + ix.data, + vec![SettlementInstruction::CreateBuffer.discriminator()] + ); + } + + #[test] + fn instruction_has_expected_accounts() { + let program_id = Pubkey::new_from_array([1; 32]); + let payer = Pubkey::new_from_array([2; 32]); + let buffer_pda = Pubkey::new_from_array([3; 32]); + let mint = Pubkey::new_from_array([4; 32]); + let ix = create_buffer(&program_id, &payer, &buffer_pda, &mint); + + assert_eq!(ix.accounts.len(), 5); + // payer: writable, signer + assert_eq!(ix.accounts[0].pubkey, payer); + assert!(ix.accounts[0].is_writable); + assert!(ix.accounts[0].is_signer); + // buffer_pda: writable, not signer (the program signs via PDA seeds) + assert_eq!(ix.accounts[1].pubkey, buffer_pda); + assert!(ix.accounts[1].is_writable); + assert!(!ix.accounts[1].is_signer); + // mint: read-only + assert_eq!(ix.accounts[2].pubkey, mint); + assert!(!ix.accounts[2].is_writable); + assert!(!ix.accounts[2].is_signer); + // token program: read-only + assert_eq!(ix.accounts[3].pubkey, SPL_TOKEN_PROGRAM_ID); + assert!(!ix.accounts[3].is_writable); + assert!(!ix.accounts[3].is_signer); + // system program: read-only + assert_eq!(ix.accounts[4].pubkey, SYSTEM_PROGRAM_ID); + assert!(!ix.accounts[4].is_writable); + assert!(!ix.accounts[4].is_signer); + } +} diff --git a/interface/src/instruction/mod.rs b/interface/src/instruction/mod.rs index 88ce0d6..e1f47bf 100644 --- a/interface/src/instruction/mod.rs +++ b/interface/src/instruction/mod.rs @@ -4,6 +4,7 @@ //! settlement instructions, encoding their discriminator (see //! [`crate::SettlementInstruction`]) and laying out the required accounts. +pub mod create_buffer; pub mod create_order; pub mod initialize; pub mod settle; diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 7ad58ba..95b33d6 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -19,6 +19,7 @@ pub enum SettlementInstruction { FinalizeSettle = 1, CreateOrder = 2, Initialize = 3, + CreateBuffer = 4, } impl SettlementInstruction { diff --git a/interface/src/pda/buffer.rs b/interface/src/pda/buffer.rs new file mode 100644 index 0000000..75b685f --- /dev/null +++ b/interface/src/pda/buffer.rs @@ -0,0 +1,67 @@ +//! Buffer PDA seed and address derivation. +//! +//! Each buffer is a per-token SPL token account that holds funds on behalf +//! of the settlement program. It lives at a PDA keyed by the token mint, so +//! there is exactly one buffer address per token. +//! +//! The token account stored at this address is initialized by the +//! `CreateBuffer` instruction; its SPL `owner` (token authority) is the +//! settlement state PDA (see [`crate::pda::state`]), the single authority +//! controlling every buffer. + +use solana_pubkey::Pubkey; + +use crate::pda::SETTLEMENT_SEED; + +/// Trailing seed identifying the buffer PDAs. +pub const BUFFER_SEED: &[u8] = b"buffer"; + +/// Canonical seed components for the buffer PDA holding the token `mint`. +/// +/// `mint` is the raw 32-byte token mint address, so the same helper serves +/// both the off-chain builder and the on-chain handler (which holds the mint +/// as an `Address`). +pub fn buffer_pda_seeds(mint: &[u8; 32]) -> [&[u8]; 3] { + [SETTLEMENT_SEED, mint, BUFFER_SEED] +} + +/// 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) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_buffer_pda_uses_canonical_seeds() { + let token = Pubkey::new_unique(); + + crate::pda::tests::assert_canonical_bump( + |program_id| find_buffer_pda(program_id, &token), + buffer_pda_seeds(token.as_array()), + ); + } + + mod proptest { + use ::proptest::prelude::*; + + use super::*; + + proptest! { + #[test] + fn distinct_tokens_yield_distinct_pdas( + program_id in any::<[u8; 32]>(), + token1 in any::<[u8; 32]>(), + token2 in any::<[u8; 32]>(), + ) { + prop_assume!(token1 != token2); + let program_id = Pubkey::new_from_array(program_id); + let (pda1, _) = find_buffer_pda(&program_id, &Pubkey::new_from_array(token1)); + let (pda2, _) = find_buffer_pda(&program_id, &Pubkey::new_from_array(token2)); + prop_assert_ne!(pda1, pda2); + } + } + } +} diff --git a/interface/src/pda/mod.rs b/interface/src/pda/mod.rs index 06677fd..362191e 100644 --- a/interface/src/pda/mod.rs +++ b/interface/src/pda/mod.rs @@ -3,6 +3,7 @@ //! Every PDA shares the [`SETTLEMENT_SEED`] prefix; each submodule defines //! the additional seeds and the derivation helper for one kind of PDA. +pub mod buffer; pub mod order; pub mod state; diff --git a/interface/src/pda/state.rs b/interface/src/pda/state.rs index aa311d0..34d3d64 100644 --- a/interface/src/pda/state.rs +++ b/interface/src/pda/state.rs @@ -1,8 +1,9 @@ //! Settlement state PDA seed and address derivation. //! //! There is a single state PDA per settlement program, derived from the bare -//! [`SETTLEMENT_SEED`]. It is the program's central account that stores the -//! state used for solver authentication. +//! [`SETTLEMENT_SEED`]. It is the program's central account: it manages solver +//! authentication and holds the SPL token authority over every buffer account +//! (see [`crate::pda::buffer`]). use solana_pubkey::Pubkey; diff --git a/programs/settlement/Cargo.toml b/programs/settlement/Cargo.toml index 74799f7..fe44813 100644 --- a/programs/settlement/Cargo.toml +++ b/programs/settlement/Cargo.toml @@ -11,11 +11,13 @@ crate-type = ["cdylib", "lib"] [dependencies] pinocchio = { workspace = true, features = ["cpi"] } pinocchio-system.workspace = true +pinocchio-token.workspace = true settlement-interface.workspace = true [dev-dependencies] arrayref.workspace = true litesvm.workspace = true +litesvm-token.workspace = true settlement-client.workspace = true solana-sdk.workspace = true solana-system-interface.workspace = true diff --git a/programs/settlement/src/create_buffer.rs b/programs/settlement/src/create_buffer.rs new file mode 100644 index 0000000..3ca5448 --- /dev/null +++ b/programs/settlement/src/create_buffer.rs @@ -0,0 +1,198 @@ +//! `CreateBuffer` instruction handler. + +use pinocchio::{error::ProgramError, AccountView, Address, ProgramResult}; +use pinocchio_token::{instructions::InitializeAccount3, state::Account as TokenAccount}; +use settlement_interface::{ + instruction::create_buffer::SPL_TOKEN_PROGRAM_ID, + pda::{buffer::buffer_pda_seeds, state::state_pda_seeds}, + SettlementInstruction, +}; + +use crate::processor::{create_canonical_pda, InstructionInputParsing}; + +/// Parsed inputs of a `CreateBuffer` instruction. +struct CreateBufferInput<'a> { + payer: &'a AccountView, + buffer_pda: &'a mut AccountView, + mint: &'a AccountView, + token_program: &'a AccountView, +} + +impl<'a> InstructionInputParsing<'a> for CreateBufferInput<'a> { + const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::CreateBuffer; + + fn parse_body( + instruction_data: &[u8], + accounts: &'a mut [AccountView], + ) -> Result { + if !instruction_data.is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + // Accounts: [payer (W,S), buffer_pda (W), mint (R), token_program (R), + // system_program (R)]. The system program needs to be present for the + // `CreateAccount` CPI but isn't dereferenced here. + let [payer, buffer_pda, mint, token_program, _system, ..] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + Ok(Self { + payer, + buffer_pda, + mint, + token_program, + }) + } +} + +pub fn process_create_buffer( + program_id: &Address, + accounts: &mut [AccountView], + instruction_data: &[u8], +) -> ProgramResult { + let CreateBufferInput { + payer, + buffer_pda, + mint, + token_program, + } = CreateBufferInput::parse(instruction_data, accounts)?; + + // Only the legacy SPL Token program is supported. The InitializeAccount3 + // CPI targets that program unconditionally; reject a mismatching account + // up front so the caller gets a clear error. + if token_program.address() != &SPL_TOKEN_PROGRAM_ID { + return Err(ProgramError::IncorrectProgramId); + } + + // One buffer per token. `create_canonical_pda` derives the canonical bump + // and, by signing the allocation with the buffer seeds, rejects any + // `buffer_pda` that isn't the canonical address. The buffer is a token + // account, so it's assigned to the SPL Token program rather than to us. + // + // We don't validate `mint` here. `InitializeAccount3` requires a real, + // token-program-owned mint (and special-cases the native mint), so a check + // of our own would be redundant. + let mint_key = mint.address().as_array(); + create_canonical_pda( + program_id, + payer, + buffer_pda, + TokenAccount::LEN as u64, + &SPL_TOKEN_PROGRAM_ID, + buffer_pda_seeds(mint_key), + )?; + + // The buffer's token authority is the settlement state PDA, the single + // authority over every buffer. + let (state_pda, _) = Address::find_program_address(&state_pda_seeds(), program_id); + InitializeAccount3::new(buffer_pda, mint, &state_pda).invoke()?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{fake_account, fake_account_from_array}; + + // Only used for failing tests where the input is irrelevant. + fn create_buffer_data() -> Vec { + let zero = Address::new_from_array([0; 32]); + settlement_interface::instruction::create_buffer::create_buffer(&zero, &zero, &zero, &zero) + .data + } + + fn five_accounts() -> [AccountView; 5] { + [ + fake_account_from_array([1; 32]), + fake_account_from_array([2; 32]), + fake_account_from_array([3; 32]), + fake_account_from_array([4; 32]), + fake_account_from_array([5; 32]), + ] + } + + #[test] + fn create_buffer_input_parses_valid_input() { + let program_id: Address = Address::new_from_array([1; 32]); + let payer: Address = Address::new_from_array([2; 32]); + let buffer_pda = Address::new_from_array([3; 32]); + let mint = Address::new_from_array([4; 32]); + let token_program = Address::new_from_array([5; 32]); + let system_program = fake_account_from_array([6; 32]); + + let data = settlement_interface::instruction::create_buffer::create_buffer( + &program_id, + &payer, + &buffer_pda, + &mint, + ) + .data; + let mut accounts = [ + fake_account(payer), + fake_account(buffer_pda), + fake_account(mint), + fake_account(token_program), + system_program, + ]; + + let CreateBufferInput { + payer: parsed_payer, + buffer_pda: parsed_buffer_pda, + mint: parsed_mint, + token_program: parsed_token_program, + } = CreateBufferInput::parse(&data, &mut accounts).expect("parse should succeed"); + + assert_eq!(*parsed_payer.address(), payer); + assert_eq!(*parsed_buffer_pda.address(), buffer_pda); + assert_eq!(*parsed_mint.address(), mint); + assert_eq!(*parsed_token_program.address(), token_program); + } + + #[test] + fn create_buffer_input_rejects_long_data() { + let mut data = create_buffer_data(); + data.push(0); // trailing byte + let mut accounts = five_accounts(); + assert_eq!( + CreateBufferInput::parse(&data, &mut accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn create_buffer_input_rejects_missing_accounts() { + let data = create_buffer_data(); + let mut accounts: Vec = five_accounts().into(); + accounts.pop(); + assert_eq!( + CreateBufferInput::parse(&data, &mut accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + ); + } + + /// Arbitrary placeholder program id. The failure path exercised below + /// returns before the program id is used for any syscall. + const PROGRAM_ID: Address = Address::new_from_array([1; 32]); + + #[test] + fn process_create_buffer_propagates_error() { + let mut data = create_buffer_data(); + data.push(0); // make the data too long to trigger a parse error + let mut accounts = five_accounts(); + assert_eq!( + process_create_buffer(&PROGRAM_ID, &mut accounts, &data), + Err(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn process_create_buffer_rejects_wrong_token_program() { + let data = create_buffer_data(); + // The fourth account (token program) is not the SPL Token program. + let mut accounts = five_accounts(); + assert_eq!( + process_create_buffer(&PROGRAM_ID, &mut accounts, &data), + Err(ProgramError::IncorrectProgramId), + ); + } +} diff --git a/programs/settlement/src/create_order.rs b/programs/settlement/src/create_order.rs index 4b0cc48..036168f 100644 --- a/programs/settlement/src/create_order.rs +++ b/programs/settlement/src/create_order.rs @@ -73,15 +73,16 @@ pub fn process_create_order( return Err(SettlementError::OwnerMismatch.into()); } - // We want a single order per uid; `create_pda` derives the canonical bump - // and, by signing the creation with the order seeds, rejects any - // `order_pda` that isn't the canonical address. The rest of the code can - // assume that if an account has data, then the bump is valid. + // We want a single order per uid; `create_canonical_pda` derives the + // canonical bump and, by signing the creation with the order seeds, rejects + // any `order_pda` that isn't the canonical address. The rest of the code + // can assume that if an account has data, then the bump is valid. create_canonical_pda( program_id, created_by, order_pda, EncodedOrderAccount::SIZE as u64, + program_id, order_pda_seeds(&intent_uid), )?; diff --git a/programs/settlement/src/initialize.rs b/programs/settlement/src/initialize.rs index ee596c2..5483fd4 100644 --- a/programs/settlement/src/initialize.rs +++ b/programs/settlement/src/initialize.rs @@ -39,7 +39,14 @@ pub fn process_initialize( ) -> ProgramResult { let InitializeInput { payer, state_pda } = InitializeInput::parse(instruction_data, accounts)?; - create_canonical_pda(program_id, payer, state_pda, 0, state_pda_seeds())?; + create_canonical_pda( + program_id, + payer, + state_pda, + 0, + program_id, + state_pda_seeds(), + )?; Ok(()) } diff --git a/programs/settlement/src/lib.rs b/programs/settlement/src/lib.rs index a4fbefd..a07b416 100644 --- a/programs/settlement/src/lib.rs +++ b/programs/settlement/src/lib.rs @@ -1,5 +1,6 @@ //! On-chain CoW Protocol settlement program. +mod create_buffer; mod create_order; mod initialize; mod processor; @@ -8,6 +9,7 @@ mod settle; #[cfg(test)] mod test_utils; +use create_buffer::process_create_buffer; use create_order::process_create_order; use initialize::process_initialize; use pinocchio::{entrypoint, AccountView, Address, ProgramResult}; @@ -35,5 +37,8 @@ pub fn process_instruction( SettlementInstruction::Initialize => { process_initialize(program_id, accounts, instruction_data) } + SettlementInstruction::CreateBuffer => { + process_create_buffer(program_id, accounts, instruction_data) + } } } diff --git a/programs/settlement/src/processor.rs b/programs/settlement/src/processor.rs index 46bc892..b0c9bd2 100644 --- a/programs/settlement/src/processor.rs +++ b/programs/settlement/src/processor.rs @@ -36,19 +36,26 @@ pub trait InstructionInputParsing<'a>: Sized { } } -/// Create the program-owned account at the PDA `pda`, funded by `payer`. +/// Create the account at the PDA `pda`, assigned to `owner` and funded by +/// `payer`. /// -/// `seeds` are the canonical PDA seeds *without* the bump and the canonical -/// bump is derived and appended here. Signing `CreateAccount` with these seeds -/// implicitly checks that `pda` is the canonical address: the runtime grants -/// the PDA signature only for the address the seeds derive, so any other `pda` -/// fails the CPI. +/// `seeds` are the canonical PDA seeds *without* the bump; the canonical bump +/// is derived under `program_id` and appended here. Signing `CreateAccount` +/// with these seeds implicitly checks that `pda` is the canonical address: the +/// runtime grants the PDA signature only for the address the seeds derive, so +/// any other `pda` fails the CPI. +/// +/// `owner` is the program the new account is assigned to. It is usually +/// `program_id` (a program-owned PDA), but differs when the account must be +/// owned by another program, as for example a buffer token account owned by the +/// SPL Token program. #[must_use = "ignoring the output means processing continues without the PDA having been created"] pub fn create_canonical_pda( program_id: &Address, payer: &AccountView, pda: &AccountView, space: u64, + owner: &Address, seeds: [&[u8]; N], ) -> ProgramResult { let (_, bump) = Address::find_program_address(&seeds, program_id); @@ -67,7 +74,7 @@ pub fn create_canonical_pda( signer_seeds.push(Seed::from(&bump[..])); let signer = Signer::from(&signer_seeds[..]); - CreateAccount::with_minimum_balance(payer, pda, space, program_id, None)? + CreateAccount::with_minimum_balance(payer, pda, space, owner, None)? .invoke_signed(&[signer])?; Ok(()) } diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index 1e6f624..ed6021a 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -6,11 +6,13 @@ )] pub mod pda; +pub mod token; use litesvm::LiteSVM; use settlement_client::settlement_interface::SettlementError; use settlement_interface::Instruction; use solana_sdk::{ + account::Account, instruction::InstructionError, pubkey::Pubkey, signature::{Keypair, Signer}, @@ -54,6 +56,17 @@ pub fn lamports(svm: &LiteSVM, address: &Pubkey) -> u64 { svm.get_account(address).map(|a| a.lamports).unwrap_or(0) } +/// Assert that `account` holds exactly the rent-exempt minimum for its current +/// data size. The size is taken from `account.data` rather than passed in, so +/// the check can't drift from the account it's checking. +pub fn assert_rent_exempt(svm: &LiteSVM, account: &Account) { + let rent = svm.minimum_balance_for_rent_exemption(account.data.len()); + assert_eq!( + account.lamports, rent, + "account must hold exactly its rent-exempt minimum", + ); +} + /// Sign `ix` with `fee_payer` as the transaction fee payer and /// `owner` as the keypair filling the `owner` slot. Tests pass /// two distinct keypairs to keep these roles independent. diff --git a/programs/settlement/tests/common/token.rs b/programs/settlement/tests/common/token.rs new file mode 100644 index 0000000..3202539 --- /dev/null +++ b/programs/settlement/tests/common/token.rs @@ -0,0 +1,12 @@ +//! SPL Token helpers for the settlement integration tests. + +use litesvm::LiteSVM; +use litesvm_token::CreateMint; +use solana_sdk::{pubkey::Pubkey, signature::Keypair}; + +/// Create a fresh mint owned by `payer` and return its address. +pub fn create_mint(svm: &mut LiteSVM, payer: &Keypair) -> Pubkey { + CreateMint::new(svm, payer) + .send() + .expect("mint creation should succeed") +} diff --git a/programs/settlement/tests/create_buffer.rs b/programs/settlement/tests/create_buffer.rs new file mode 100644 index 0000000..47e2db6 --- /dev/null +++ b/programs/settlement/tests/create_buffer.rs @@ -0,0 +1,227 @@ +use litesvm_token::{ + get_spl_account, + spl_token::{ + native_mint, + state::{Account as TokenAccount, AccountState}, + }, +}; +use settlement_client::instructions::create_buffer; +use settlement_client::settlement_interface::{ + instruction::create_buffer::{create_buffer as create_buffer_ix, SPL_TOKEN_PROGRAM_ID}, + pda::{ + buffer::{buffer_pda_seeds, find_buffer_pda}, + state::find_state_pda, + }, +}; +use solana_sdk::{ + instruction::InstructionError, program_pack::Pack, pubkey::Pubkey, signature::Signer, + transaction::TransactionError, +}; + +mod common; + +#[test] +fn happy_path_creates_initialized_buffer_token_account() { + let (mut svm, program_id, payer) = common::setup(); + let mint = common::token::create_mint(&mut svm, &payer); + let (buffer_pda, _bump) = find_buffer_pda(&program_id, &mint); + let (state_pda, _) = find_state_pda(&program_id); + + let ix = create_buffer(&program_id, &payer.pubkey(), &mint); + let tx = common::signed_tx(&svm, &payer, &payer, ix); + svm.send_transaction(tx) + .expect("create_buffer should succeed"); + + let account = svm + .get_account(&buffer_pda) + .expect("buffer PDA should exist after create_buffer"); + assert_eq!( + account.owner, SPL_TOKEN_PROGRAM_ID, + "buffer must be owned by the SPL Token program" + ); + assert_eq!( + account.data.len(), + TokenAccount::LEN, + "buffer must be sized to a token account", + ); + + common::assert_rent_exempt(&svm, &account); + + let TokenAccount { + mint: token_mint, + owner, + amount, + delegate, + state, + is_native, + delegated_amount, + close_authority, + } = get_spl_account::(&svm, &buffer_pda) + .expect("buffer must be an initialized token account"); + assert_eq!(token_mint, mint, "buffer must track the given mint"); + assert_eq!( + owner, state_pda, + "buffer authority must be the settlement state PDA" + ); + assert_eq!(amount, 0, "a fresh buffer must hold no tokens"); + assert!(delegate.is_none(), "a fresh buffer must have no delegate"); + assert_eq!( + state, + AccountState::Initialized, + "buffer must be an initialized token account" + ); + assert!( + is_native.is_none(), + "a buffer for a regular mint must not be native" + ); + assert_eq!( + delegated_amount, 0, + "a fresh buffer must have no delegated amount" + ); + assert!( + close_authority.is_none(), + "a fresh buffer must have no close authority" + ); +} + +#[test] +fn happy_path_creates_native_token_buffer() { + // The native mint is special-cased by the token program: it's recognized by + // key (no mint-account validation) and the buffer is initialized as a + // wrapped-SOL account. Since we fund exactly the rent-exempt minimum, the + // wrapped balance starts at zero. + let (mut svm, program_id, payer) = common::setup(); + let (buffer_pda, _bump) = find_buffer_pda(&program_id, &native_mint::ID); + + let ix = create_buffer(&program_id, &payer.pubkey(), &native_mint::ID); + let tx = common::signed_tx(&svm, &payer, &payer, ix); + svm.send_transaction(tx) + .expect("create_buffer for the native mint should succeed"); + + let token_account = get_spl_account::(&svm, &buffer_pda) + .expect("buffer must be an initialized token account"); + assert_eq!( + token_account.mint, + native_mint::ID, + "buffer must track the native mint" + ); + assert!( + token_account.is_native(), + "a native-mint buffer must be marked native" + ); + assert_eq!( + token_account.amount, 0, + "a native buffer funded at the rent minimum starts with zero wrapped balance" + ); +} + +#[test] +fn rejects_arbitrary_wrong_buffer_pda() { + let (mut svm, program_id, payer) = common::setup(); + let mint = common::token::create_mint(&mut svm, &payer); + + let wrong_pda = Pubkey::new_unique(); + let ix = create_buffer_ix(&program_id, &payer.pubkey(), &wrong_pda, &mint); + let tx = common::signed_tx(&svm, &payer, &payer, ix); + + common::pda::assert_rejected_as_noncanonical(&mut svm, tx, &wrong_pda); +} + +#[test] +fn rejects_non_canonical_bump_pda() { + let (mut svm, program_id, payer) = common::setup(); + let mint = common::token::create_mint(&mut svm, &payer); + + // A buffer derivation that is valid for the seeds but not the canonical + // address the program signs for. + let (_bump, non_canonical_pda) = + common::pda::find_noncanonical_pda(&program_id, buffer_pda_seeds(mint.as_array())); + + let ix = create_buffer_ix(&program_id, &payer.pubkey(), &non_canonical_pda, &mint); + let tx = common::signed_tx(&svm, &payer, &payer, ix); + common::pda::assert_rejected_as_noncanonical(&mut svm, tx, &non_canonical_pda); +} + +#[test] +fn rejects_non_spl_token_program() { + let (mut svm, program_id, payer) = common::setup(); + let mint = common::token::create_mint(&mut svm, &payer); + let (buffer_pda, _bump) = find_buffer_pda(&program_id, &mint); + + // Swap the token-program account for an arbitrary key. + let mut ix = create_buffer(&program_id, &payer.pubkey(), &mint); + let token_program_index = 3; + assert_eq!( + ix.accounts[token_program_index].pubkey, SPL_TOKEN_PROGRAM_ID, + "sanity: should replace token program" + ); + ix.accounts[token_program_index].pubkey = Pubkey::new_unique(); + let tx = common::signed_tx(&svm, &payer, &payer, ix); + + let err = svm + .send_transaction(tx) + .expect_err("a non-SPL-Token program must be rejected"); + assert!( + matches!( + err.err, + TransactionError::InstructionError(0, InstructionError::IncorrectProgramId) + ), + "expected instruction 0 to fail with IncorrectProgramId, got {:?}", + err.err, + ); + assert!( + svm.get_account(&buffer_pda).is_none(), + "buffer must not have been created" + ); +} + +#[test] +fn rejects_invalid_mint() { + let (mut svm, program_id, payer) = common::setup(); + + // An account that isn't an initialized SPL mint. The handler derives the + // buffer PDA from it and delegates mint validation to InitializeAccount3, + // which rejects it: a non-mint account isn't owned by the token program, so + // the CPI fails with IncorrectProgramId after the buffer was allocated, + // reverting the whole instruction. + let not_a_mint = Pubkey::new_unique(); + let (buffer_pda, _bump) = find_buffer_pda(&program_id, ¬_a_mint); + + let ix = create_buffer(&program_id, &payer.pubkey(), ¬_a_mint); + let tx = common::signed_tx(&svm, &payer, &payer, ix); + + let err = svm + .send_transaction(tx) + .expect_err("a non-mint account must be rejected"); + // Expected failing line: + // https://github.com/solana-program/token/blob/7ed1aa8d9eb6d54c0084a9e8475c56a0a868b5bd/program/src/processor.rs#L115 + assert!( + matches!( + err.err, + TransactionError::InstructionError(0, InstructionError::IncorrectProgramId) + ), + "expected instruction 0 to fail on the invalid mint, got {:?}", + err.err, + ); + assert!( + svm.get_account(&buffer_pda).is_none(), + "buffer must not have been created when the mint is invalid", + ); +} + +#[test] +fn rejects_creating_same_buffer_twice() { + let (mut svm, program_id, payer) = common::setup(); + let mint = common::token::create_mint(&mut svm, &payer); + + let ix = create_buffer(&program_id, &payer.pubkey(), &mint); + let tx = common::signed_tx(&svm, &payer, &payer, ix); + svm.send_transaction(tx) + .expect("first create_buffer should succeed"); + + svm.expire_blockhash(); + + let ix = create_buffer(&program_id, &payer.pubkey(), &mint); + let tx = common::signed_tx(&svm, &payer, &payer, ix); + common::pda::assert_rejected_as_existing(&mut svm, tx); +} From 22eac68a88079a1d58782841a0e3aa121b9e31bd Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:34:16 +0200 Subject: [PATCH 03/23] Move test tools to own feature --- interface/Cargo.toml | 7 ++ interface/src/data/intent.rs | 132 +++++++++++----------- interface/src/data/order.rs | 77 +++++++------ programs/settlement/Cargo.toml | 1 + programs/settlement/src/create_order.rs | 11 +- programs/settlement/tests/create_order.rs | 11 +- 6 files changed, 120 insertions(+), 119 deletions(-) diff --git a/interface/Cargo.toml b/interface/Cargo.toml index b209f36..e3ba7f7 100644 --- a/interface/Cargo.toml +++ b/interface/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true arrayref.workspace = true derive_more.workspace = true num_enum.workspace = true +proptest = { workspace = true, optional = true } solana-instruction.workspace = true solana-program-error.workspace = true solana-pubkey = { workspace = true, features = ["curve25519"] } @@ -17,5 +18,11 @@ solana-system-interface.workspace = true [dev-dependencies] proptest.workspace = true +[features] +# Exposes the test fixtures and proptest strategies under `data::*::fixtures` so +# other crates can build and fuzz against sample intents and order accounts. +# Enabled here by `cfg(test)` for this crate's own tests. +test-fixtures = ["dep:proptest"] + [lints] workspace = true diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index fd0a168..a945ac5 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -258,24 +258,20 @@ impl OrderIntent { } } -#[cfg(test)] -pub(in crate::data) mod tests { - use super::*; +#[cfg(any(test, feature = "test-fixtures"))] +pub mod fixtures { + use proptest::{prelude::*, strategy::Union}; - const ALL_ORDER_KINDS: [OrderKind; 2] = [OrderKind::Sell, OrderKind::Buy]; + use super::{EncodedOrderIntent, OrderIntent, OrderKind, Pubkey}; - // Full Cartesian product of `OrderKind × bool` for tests that need to - // exercise every shape an `OrderIntent` can take on these axes. - fn all_kind_and_fillable() -> impl Iterator { - ALL_ORDER_KINDS - .into_iter() - .flat_map(|kind| core::iter::repeat(kind).zip([false, true])) - } + /// Every valid [`OrderKind`]. + pub const ALL_ORDER_KINDS: [OrderKind; 2] = [OrderKind::Sell, OrderKind::Buy]; - // Hand-picked example used for both the roundtrip and the digest - // regression. Distinct pubkeys, non-zero amounts, `valid_to` with both - // halves set, recognizable `app_data` pattern. - pub(in crate::data) fn sample_intent(kind: OrderKind, partially_fillable: bool) -> OrderIntent { + // Hardcoded but verified in a sanity-check test. + pub const KIND_OFFSET: usize = 116; + pub const PARTIALLY_FILLABLE_OFFSET: usize = KIND_OFFSET + EncodedOrderIntent::W_KIND; + + pub fn sample_intent(kind: OrderKind, partially_fillable: bool) -> OrderIntent { OrderIntent { owner: Pubkey::new_from_array([0x11; 32]), buy_token_account: Pubkey::new_from_array([0x22; 32]), @@ -289,6 +285,55 @@ pub(in crate::data) mod tests { } } + /// Any valid [`OrderKind`]. + pub fn arb_order_kind() -> impl Strategy { + Union::new(ALL_ORDER_KINDS.map(Just)) + } + + /// Any valid [`OrderIntent`]. + pub fn arb_order_intent() -> impl Strategy { + ( + any::<[u8; 32]>(), + any::<[u8; 32]>(), + any::<[u8; 32]>(), + any::(), + any::(), + any::(), + arb_order_kind(), + any::(), + any::<[u8; 32]>(), + ) + .prop_map( + |(owner, buy_tok, sell_tok, sell_amount, buy_amount, valid_to, kind, pf, app)| { + OrderIntent { + owner: Pubkey::new_from_array(owner), + buy_token_account: Pubkey::new_from_array(buy_tok), + sell_token_account: Pubkey::new_from_array(sell_tok), + sell_amount, + buy_amount, + valid_to, + kind, + partially_fillable: pf, + app_data: app, + } + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::fixtures::{sample_intent, KIND_OFFSET, PARTIALLY_FILLABLE_OFFSET}; + use super::*; + + // Full Cartesian product of `OrderKind × bool` for tests that need to + // exercise every shape an `OrderIntent` can take on these axes. + fn all_kind_and_fillable() -> impl Iterator { + fixtures::ALL_ORDER_KINDS + .into_iter() + .flat_map(|kind| core::iter::repeat(kind).zip([false, true])) + } + // Pin each width to the size of the `OrderIntent` field it encodes. The // widths summing to `SIZE` is enforced separately, at compile time, by the // `array_refs!` / `mut_array_refs!` invocations in the codec. @@ -348,11 +393,6 @@ pub(in crate::data) mod tests { } } - // Hardcoded but verified in a sanity-check test. - pub(in crate::data) const KIND_OFFSET: usize = 116; - pub(in crate::data) const PARTIALLY_FILLABLE_OFFSET: usize = - KIND_OFFSET + EncodedOrderIntent::W_KIND; - #[test] fn sanity_check_offsets() { fn first_differing_byte(lhs: &[u8], rhs: &[u8]) -> Option { @@ -450,16 +490,14 @@ pub(in crate::data) mod tests { } // Property-based tests, non-deterministic. - pub(in crate::data) mod proptest { - use ::proptest::{prelude::*, strategy::Union, test_runner::TestCaseError}; + mod proptest { + use ::proptest::{prelude::*, test_runner::TestCaseError}; + use super::super::fixtures::{ + arb_order_intent, arb_order_kind, KIND_OFFSET, PARTIALLY_FILLABLE_OFFSET, + }; use super::*; - // Any valid `OrderKind`. - pub(in crate::data) fn arb_order_kind() -> impl Strategy { - Union::new(ALL_ORDER_KINDS.map(Just)) - } - // Any byte not decoding to a valid order type. fn arb_bad_order_kind_byte() -> impl Strategy { 2u8..=255 @@ -470,46 +508,6 @@ pub(in crate::data) mod tests { 2u8..=255 } - // Any valid `OrderIntent`. - pub(in crate::data) fn arb_order_intent() -> impl Strategy { - ( - any::<[u8; 32]>(), - any::<[u8; 32]>(), - any::<[u8; 32]>(), - any::(), - any::(), - any::(), - arb_order_kind(), - any::(), - any::<[u8; 32]>(), - ) - .prop_map( - |( - owner, - buy_tok, - sell_tok, - sell_amount, - buy_amount, - valid_to, - kind, - pf, - app, - )| { - OrderIntent { - owner: Pubkey::new_from_array(owner), - buy_token_account: Pubkey::new_from_array(buy_tok), - sell_token_account: Pubkey::new_from_array(sell_tok), - sell_amount, - buy_amount, - valid_to, - kind, - partially_fillable: pf, - app_data: app, - } - }, - ) - } - proptest! { // For any `OrderIntent`, encoding an intent into an encoded // intent and then decoding it with `decode_and_hash()` returns diff --git a/interface/src/data/order.rs b/interface/src/data/order.rs index ef257d6..eacfb67 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -163,18 +163,22 @@ impl TryFrom for OrderAccount { } } -#[cfg(test)] -mod tests { - use core::mem::size_of; +#[cfg(any(test, feature = "test-fixtures"))] +pub mod fixtures { + use proptest::prelude::*; + use super::{OrderAccount, Pubkey}; use crate::data::intent::{ - tests::{sample_intent, KIND_OFFSET, PARTIALLY_FILLABLE_OFFSET}, + fixtures::{arb_order_intent, sample_intent}, OrderKind, }; - use super::*; + // Hardcoded but verified in a sanity-check test. + pub const CANCELLED_OFFSET: usize = 0; + pub const INTENT_OFFSET: usize = 49; - fn sample_account(cancelled: bool) -> OrderAccount { + /// Hand-picked example order account wrapping [`sample_intent`]. + pub fn sample_account(cancelled: bool) -> OrderAccount { OrderAccount { cancelled, amount_withdrawn: 0x0112_2334_4556_6778, @@ -184,6 +188,38 @@ mod tests { } } + /// Any valid [`OrderAccount`]. + pub fn arb_order_account() -> impl Strategy { + ( + any::(), + any::(), + any::(), + any::<[u8; 32]>(), + arb_order_intent(), + ) + .prop_map( + |(cancelled, amount_withdrawn, amount_received, created_by, intent)| OrderAccount { + cancelled, + amount_withdrawn, + amount_received, + created_by: Pubkey::new_from_array(created_by), + intent, + }, + ) + } +} + +#[cfg(test)] +mod tests { + use core::mem::size_of; + + use super::fixtures::{sample_account, CANCELLED_OFFSET, INTENT_OFFSET}; + use super::*; + use crate::data::intent::{ + fixtures::{sample_intent, KIND_OFFSET, PARTIALLY_FILLABLE_OFFSET}, + OrderKind, + }; + // Pin each width to the size of the `OrderAccount` field it encodes. The // widths summing to `SIZE` is enforced separately, at compile time, by the // `array_refs!` / `mut_array_refs!` invocations in the codec. @@ -230,10 +266,6 @@ mod tests { } } - // Hardcoded but verified in a sanity-check test. - const CANCELLED_OFFSET: usize = 0; - const INTENT_OFFSET: usize = 49; - #[test] fn sanity_check_offsets() { fn first_differing_byte(lhs: &[u8], rhs: &[u8]) -> Option { @@ -329,31 +361,8 @@ mod tests { mod proptest { use ::proptest::{prelude::*, test_runner::TestCaseError}; - use crate::data::intent::tests::proptest::{arb_order_intent, arb_order_kind}; - use super::*; - - // Any valid `OrderAccount`. - fn arb_order_account() -> impl Strategy { - ( - any::(), - any::(), - any::(), - any::<[u8; 32]>(), - arb_order_intent(), - ) - .prop_map( - |(cancelled, amount_withdrawn, amount_received, created_by, intent)| { - OrderAccount { - cancelled, - amount_withdrawn, - amount_received, - created_by: Pubkey::new_from_array(created_by), - intent, - } - }, - ) - } + use crate::data::{intent::fixtures::arb_order_kind, order::fixtures::arb_order_account}; proptest! { // For any `OrderAccount`, encode then decode returns the same diff --git a/programs/settlement/Cargo.toml b/programs/settlement/Cargo.toml index 74799f7..3802794 100644 --- a/programs/settlement/Cargo.toml +++ b/programs/settlement/Cargo.toml @@ -17,6 +17,7 @@ settlement-interface.workspace = true arrayref.workspace = true litesvm.workspace = true settlement-client.workspace = true +settlement-interface = { workspace = true, features = ["test-fixtures"] } solana-sdk.workspace = true solana-system-interface.workspace = true diff --git a/programs/settlement/src/create_order.rs b/programs/settlement/src/create_order.rs index 9aa220e..a1d3e5a 100644 --- a/programs/settlement/src/create_order.rs +++ b/programs/settlement/src/create_order.rs @@ -110,7 +110,7 @@ pub fn process_create_order( #[cfg(test)] mod tests { - use settlement_interface::data::intent::{OrderIntent, OrderKind}; + use settlement_interface::data::intent::{fixtures::sample_intent, OrderIntent, OrderKind}; use pinocchio::account::RuntimeAccount; @@ -122,14 +122,7 @@ mod tests { fn valid_intent_bytes() -> [u8; EncodedOrderIntent::SIZE] { (&EncodedOrderIntent::from(&OrderIntent { owner: DEFAULT_OWNER, - buy_token_account: Address::new_from_array([0x22; 32]), - sell_token_account: Address::new_from_array([0x33; 32]), - sell_amount: 0x0123_4567_89ab_cdef, - buy_amount: 0xfedc_ba98_7654_3210, - valid_to: 0xdead_beef, - kind: OrderKind::Sell, - partially_fillable: true, - app_data: [0x44; 32], + ..sample_intent(OrderKind::Sell, true) })) .into() } diff --git a/programs/settlement/tests/create_order.rs b/programs/settlement/tests/create_order.rs index 64d14c6..e108817 100644 --- a/programs/settlement/tests/create_order.rs +++ b/programs/settlement/tests/create_order.rs @@ -3,7 +3,7 @@ use settlement_client::settlement_interface::pda::order::ORDER_SEED; use settlement_client::settlement_interface::{ create_order::create_order, data::{ - intent::{EncodedOrderIntent, OrderIntent, OrderKind}, + intent::{fixtures, EncodedOrderIntent, OrderIntent, OrderKind}, order::{EncodedOrderAccount, OrderAccount}, }, pda::{order::find_order_pda, SETTLEMENT_SEED}, @@ -24,14 +24,7 @@ mod common; fn sample_intent(owner: Pubkey) -> OrderIntent { OrderIntent { owner, - buy_token_account: Pubkey::new_from_array([0x22; 32]), - sell_token_account: Pubkey::new_from_array([0x33; 32]), - sell_amount: 1_000_000, - buy_amount: 2_000_000, - valid_to: 0xdead_beef, - kind: OrderKind::Sell, - partially_fillable: true, - app_data: [0x44; 32], + ..fixtures::sample_intent(OrderKind::Sell, true) } } From c557c8872e45fcaf66e556cf89dc72303de3e712 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:28:57 +0200 Subject: [PATCH 04/23] Add orders to BeginSettle --- Cargo.lock | 67 ++++ Cargo.toml | 2 + client/src/instructions.rs | 34 +- interface/src/data/intent.rs | 8 +- interface/src/data/order.rs | 31 +- interface/src/lib.rs | 18 + interface/src/settle.rs | 120 +++++- programs/settlement/Cargo.toml | 3 + programs/settlement/src/create_order.rs | 2 +- programs/settlement/src/processor.rs | 6 +- programs/settlement/src/settle.rs | 356 ++++++++++++++++-- .../settlement/tests/begin_settle_orders.rs | 304 +++++++++++++++ programs/settlement/tests/common/mod.rs | 50 +++ programs/settlement/tests/common/token.rs | 26 ++ .../tests/matching_begin_finalize.rs | 6 +- .../settlement/tests/program_deployment.rs | 2 +- 16 files changed, 969 insertions(+), 66 deletions(-) create mode 100644 programs/settlement/tests/begin_settle_orders.rs create mode 100644 programs/settlement/tests/common/token.rs diff --git a/Cargo.lock b/Cargo.lock index 5d2ba55..a5d9e04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1502,6 +1502,28 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "litesvm-token" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00114e5646f39f0e9202ce4e6fc0035975a959321dc4b10baf35907220d4dc19" +dependencies = [ + "litesvm", + "smallvec", + "solana-account", + "solana-address 2.6.0", + "solana-keypair", + "solana-program-option", + "solana-program-pack", + "solana-rent 3.1.0", + "solana-signer", + "solana-system-interface 3.2.0", + "solana-transaction", + "solana-transaction-error", + "spl-associated-token-account-interface", + "spl-token-interface", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -1763,6 +1785,18 @@ dependencies = [ "solana-address 2.6.0", ] +[[package]] +name = "pinocchio-token" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825f59c8348e5c2d3fd56432ef927f5819542b3b05fae4f5b6869801113e775e" +dependencies = [ + "solana-account-view", + "solana-address 2.6.0", + "solana-instruction-view", + "solana-program-error", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -2168,8 +2202,11 @@ version = "0.1.0" dependencies = [ "arrayref", "litesvm", + "litesvm-token", "pinocchio", "pinocchio-system", + "pinocchio-token", + "proptest", "settlement-client", "settlement-interface", "solana-sdk", @@ -3896,6 +3933,36 @@ dependencies = [ "der", ] +[[package]] +name = "spl-associated-token-account-interface" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6433917b60441d68d99a17e121d9db0ea15a9a69c0e5afa34649cf5ba12612f" +dependencies = [ + "solana-instruction", + "solana-pubkey 3.0.0", +] + +[[package]] +name = "spl-token-interface" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c564ac05a7c8d8b12e988a37d82695b5ba4db376d07ea98bc4882c81f96c7f3" +dependencies = [ + "arrayref", + "bytemuck", + "num-derive", + "num-traits", + "num_enum", + "solana-instruction", + "solana-program-error", + "solana-program-option", + "solana-program-pack", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "thiserror 2.0.18", +] + [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index fb514d3..e1e6fee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,9 +11,11 @@ edition = "2021" arrayref = "0.3" derive_more = { version = "1", features = ["deref"] } litesvm = "0.12.0" +litesvm-token = "0.12.0" num_enum = "0.7" pinocchio = "0.11.1" pinocchio-system = "0.6" +pinocchio-token = "0.6" proptest = "1" settlement-client = { path = "client" } settlement-interface = { path = "interface" } diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 99b0eef..b9d3dca 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -13,7 +13,39 @@ 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::settle::{begin_settle, finalize_settle}; +pub use settlement_interface::settle::finalize_settle; + +/// Build a `BeginSettle` instruction settling the specified orders. +pub fn begin_settle( + program_id: &Pubkey, + finalize_ix_index: u16, + intents: &[OrderIntent], +) -> Instruction { + let mut orders: Vec<(Pubkey, Pubkey, u8)> = intents + .iter() + .map(|intent| { + let (order_pda, bump) = find_order_pda(program_id, &intent.uid()); + (order_pda, intent.sell_token_account, bump) + }) + .collect(); + orders.sort_by_key(|(order_pda, _, _)| *order_pda); + + let mut order_pdas = Vec::with_capacity(orders.len()); + let mut sell_token_accounts = Vec::with_capacity(orders.len()); + let mut bumps = Vec::with_capacity(orders.len()); + for (order_pda, sell_token_account, bump) in orders { + order_pdas.push(order_pda); + sell_token_accounts.push(sell_token_account); + bumps.push(bump); + } + settlement_interface::settle::begin_settle( + program_id, + finalize_ix_index, + &order_pdas, + &sell_token_accounts, + &bumps, + ) +} pub fn create_order( program_id: &Pubkey, diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index a945ac5..be8ae1b 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -118,7 +118,7 @@ impl EncodedOrderIntent { /// Canonical hash of the bytes. pub fn hash(&self) -> [u8; 32] { - solana_sha256_hasher::hashv(&[self.as_slice()]).to_bytes() + hash_bytes(&self.0) } /// Decode raw bytes to an [`OrderIntent`] and compute the UID in one shot. @@ -134,11 +134,15 @@ impl EncodedOrderIntent { // inputs that pass validation. Any normalization added to the `From` // or `TryFrom` impls later would break this and the UID would silently // diverge from `OrderIntent::uid()`. - let uid = solana_sha256_hasher::hashv(&[bytes.as_slice()]).to_bytes(); + let uid = hash_bytes(bytes); Ok((intent, uid)) } } +pub fn hash_bytes(bytes: &[u8; EncodedOrderIntent::SIZE]) -> [u8; 32] { + solana_sha256_hasher::hashv(&[bytes.as_slice()]).to_bytes() +} + impl From<&EncodedOrderIntent> for [u8; EncodedOrderIntent::SIZE] { fn from(encoded: &EncodedOrderIntent) -> Self { encoded.0 diff --git a/interface/src/data/order.rs b/interface/src/data/order.rs index eacfb67..0d4c9e3 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -25,7 +25,7 @@ use derive_more::Deref; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; -use crate::data::intent::{EncodedOrderIntent, OrderIntent}; +use crate::data::intent::{self, EncodedOrderIntent, OrderIntent}; /// Idiomatic representation of an order PDA's body. #[derive(Clone, Debug, Eq, PartialEq)] @@ -79,6 +79,26 @@ impl EncodedOrderAccount { const W_INTENT: usize = EncodedOrderIntent::SIZE; pub const SIZE: usize = 199; + + /// Decode the account body and compute the embedded intent's UID in one + /// shot, mirroring [`EncodedOrderIntent::decode_and_hash`]. Decoding + /// validates the intent; returns [`ProgramError::InvalidAccountData`] on a + /// decode error. + pub fn decode_and_hash( + bytes: &[u8; Self::SIZE], + ) -> Result<(OrderAccount, [u8; 32]), ProgramError> { + let order_account = OrderAccount::try_from(*bytes)?; + // The order UID is the hash of the intent's canonical bytes. Decoding + // succeeded, so the intent slot already holds those exact bytes: hash + // them in place rather than using `intent.uid()` to avoid re-encoding. + let (_, raw_intent) = array_refs![ + bytes, + EncodedOrderAccount::SIZE - EncodedOrderAccount::W_INTENT, + EncodedOrderAccount::W_INTENT + ]; + let intent_uid = intent::hash_bytes(raw_intent); + Ok((order_account, intent_uid)) + } } /// Writes the canonical [`EncodedOrderAccount`] encoding of the given fields @@ -392,6 +412,15 @@ mod tests { .map_err(|e| TestCaseError::fail(format!("decode failed: {e:?}")))?; prop_assert_eq!(*EncodedOrderAccount::from(account), bytes); } + + #[test] + fn consistent_decode_and_hash(account in arb_order_account()) { + let encoded = EncodedOrderAccount::from(account.clone()); + let (decoded, hash) = EncodedOrderAccount::decode_and_hash(&encoded) + .map_err(|e| TestCaseError::fail(format!("decode failed: {e:?}")))?; + prop_assert_eq!(hash, account.intent.uid()); + prop_assert_eq!(decoded, account); + } } } } diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 9cf529e..c99b187 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -65,6 +65,24 @@ pub enum SettlementError { /// `CreateOrder` instruction wasn't signed by the created `OrderIntent` /// owner. OwnerMismatch = 7, + /// A `BeginSettle` order account doesn't sit at the canonical order PDA + /// derived from the intent it stores and the supplied bump. + OrderNotCanonical = 8, + /// `BeginSettle`'s order accounts aren't passed strictly increasing by + /// address. + OrdersNotStrictlyIncreasing = 9, + /// A `BeginSettle` sell token account doesn't match the + /// `sell_token_account` recorded in the order's intent. + SellTokenAccountMismatch = 10, + /// A `BeginSettle` sell token account isn't a valid SPL token account + /// (wrong data length or not owned by the token program). + SellTokenAccountInvalid = 11, + /// A `BeginSettle` sell token account's SPL owner isn't the order's intent + /// owner. + SellTokenOwnerMismatch = 12, + /// `BeginSettle`'s order accounts don't form exactly one + /// `(order_pda, sell_token_account)` pair per order bump. + AccountCountNotMatchingBumps = 13, } impl From for u32 { diff --git a/interface/src/settle.rs b/interface/src/settle.rs index f00257e..a5283d1 100644 --- a/interface/src/settle.rs +++ b/interface/src/settle.rs @@ -11,15 +11,48 @@ pub use solana_sdk_ids::sysvar::instructions::ID as INSTRUCTIONS_SYSVAR_ID; use crate::SettlementInstruction; -pub fn begin_settle(program_id: &Pubkey, finalize_ix_index: u16) -> Instruction { +/// Build a `BeginSettle` instruction settling the orders described by the three +/// parallel lists: `order_pdas[i]` is the canonical order PDA (see +/// [`crate::pda::order`]), `sell_token_accounts[i]` its sell token account, and +/// `bumps[i]` the canonical PDA bump. The three slices are expected to have the +/// same length; the builder zips them and stops at the shortest. +/// +/// Wire format: +/// `[discriminator=0][finalize_ix_index: u16 BE][bump...]`, one `bump` byte per +/// order. +/// Accounts: +/// `[instructions_sysvar (R), (order_pda (R), sell_token_account (R))...]`, one +// pair of accounts per order. +/// +/// The program requires the order PDAs to be strictly increasing by address. +/// The builder assumes the input already satisfies this: it emits the bumps and +/// account metas in the order given, without reordering. +pub fn begin_settle( + program_id: &Pubkey, + finalize_ix_index: u16, + order_pdas: &[Pubkey], + sell_token_accounts: &[Pubkey], + bumps: &[u8], +) -> Instruction { + let orders = order_pdas.iter().zip(sell_token_accounts).zip(bumps); + + let mut data = [ + &[SettlementInstruction::BeginSettle.discriminator()], + &finalize_ix_index.to_be_bytes()[..], + ] + .concat(); + + let mut accounts = vec![AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false)]; + for ((order_pda, sell_token_account), bump) in orders { + data.push(*bump); + accounts.push(AccountMeta::new_readonly(*order_pda, false)); + accounts.push(AccountMeta::new_readonly(*sell_token_account, false)); + } + Instruction { program_id: *program_id, - accounts: vec![AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false)], - data: [ - &[SettlementInstruction::BeginSettle.discriminator()], - &finalize_ix_index.to_be_bytes()[..], - ] - .concat(), + accounts, + data, } } @@ -36,17 +69,16 @@ pub fn finalize_settle(program_id: &Pubkey, begin_ix_index: u16) -> Instruction } /// Reads the first two bytes of a byte slice (instruction data) and -/// interprets them as a big-endian u16. +/// interprets them as a big-endian u16, returning it together with the +/// remaining bytes to parse. /// It's meant to be used for BeginSettle and FinalizeSettle to extract the /// counterpart index, that is, the index linking that instruction to the /// opposite instruction which is encoded as the first /// 2 bytes of the instruction data: `[0x13, 0x37]` → `0x1337`. -/// Trailing bytes are ignored, so it can be used with instruction input -/// directly. /// Returns `InvalidInstructionData` if fewer than two bytes are provided. -pub fn recover_counterpart(instruction_data: &[u8]) -> Result { +pub fn recover_counterpart(instruction_data: &[u8]) -> Result<(u16, &[u8]), ProgramError> { match instruction_data { - [b1, b2, ..] => Ok(u16::from_be_bytes([*b1, *b2])), + [b1, b2, rest @ ..] => Ok((u16::from_be_bytes([*b1, *b2]), rest)), _ => Err(ProgramError::InvalidInstructionData), } } @@ -72,21 +104,21 @@ mod tests { } #[test] - fn ignores_trailing_bytes() { + fn returns_trailing_bytes() { assert_eq!( recover_counterpart(&[ 0x13, // counterpart index 0x37, // counterpart index - 42, // unused + 42, // trailing ]), - Ok(0x1337), + Ok((0x1337, [42].as_slice())), ); } #[test] fn expected_encoding_begin_settle() { let program_id = Pubkey::new_unique(); - let ix = begin_settle(&program_id, 0x1337); + let ix = begin_settle(&program_id, 0x1337, &[], &[], &[]); assert_eq!( ix.data, [ @@ -95,6 +127,56 @@ mod tests { 0x37 ] ); + // No orders: only the instructions sysvar is referenced. + assert_eq!(ix.accounts.len(), 1); + assert_eq!(ix.accounts[0].pubkey, INSTRUCTIONS_SYSVAR_ID); + assert!(!ix.accounts[0].is_writable); + assert!(!ix.accounts[0].is_signer); + } + + #[test] + fn begin_settle_emits_unsorted_orders() { + let program_id = Pubkey::new_unique(); + // Two orders, sorted in the wrong way. Bad sorting stresses that the + // instruction builder doesn't validate the input. + let low_order_pda = Pubkey::new_from_array([0xb; 32]); + let low_sell_token_account = Pubkey::new_from_array([0xbb; 32]); + let low_bump = 0xbb; + let high_order_pda = Pubkey::new_from_array([0xa; 32]); + let high_sell_token_account = Pubkey::new_from_array([0xaa; 32]); + let high_bump = 0xaa; + let ix = begin_settle( + &program_id, + 0x1337, + &[low_order_pda, high_order_pda], + &[low_sell_token_account, high_sell_token_account], + &[low_bump, high_bump], + ); + + assert_eq!( + ix.data, + [ + SettlementInstruction::BeginSettle.discriminator(), + 0x13, + 0x37, + low_bump, + high_bump, + ], + ); + + let expected: Vec = vec![ + INSTRUCTIONS_SYSVAR_ID, + low_order_pda, + low_sell_token_account, + high_order_pda, + high_sell_token_account, + ]; + let actual: Vec = ix.accounts.iter().map(|meta| meta.pubkey).collect(); + assert_eq!(actual, expected); + assert!(ix + .accounts + .iter() + .all(|meta| !meta.is_writable && !meta.is_signer)); } #[test] @@ -109,5 +191,11 @@ mod tests { 0x37 ] ); + + // Only the instructions sysvar is referenced. + assert_eq!(ix.accounts.len(), 1); + assert_eq!(ix.accounts[0].pubkey, INSTRUCTIONS_SYSVAR_ID); + assert!(!ix.accounts[0].is_writable); + assert!(!ix.accounts[0].is_signer); } } diff --git a/programs/settlement/Cargo.toml b/programs/settlement/Cargo.toml index 3802794..e2e4683 100644 --- a/programs/settlement/Cargo.toml +++ b/programs/settlement/Cargo.toml @@ -11,11 +11,14 @@ crate-type = ["cdylib", "lib"] [dependencies] pinocchio = { workspace = true, features = ["cpi"] } pinocchio-system.workspace = true +pinocchio-token.workspace = true settlement-interface.workspace = true [dev-dependencies] arrayref.workspace = true litesvm.workspace = true +litesvm-token.workspace = true +proptest.workspace = true settlement-client.workspace = true settlement-interface = { workspace = true, features = ["test-fixtures"] } solana-sdk.workspace = true diff --git a/programs/settlement/src/create_order.rs b/programs/settlement/src/create_order.rs index a1d3e5a..656376f 100644 --- a/programs/settlement/src/create_order.rs +++ b/programs/settlement/src/create_order.rs @@ -29,7 +29,7 @@ impl<'a> InstructionInputParsing<'a> for CreateOrderInput<'a> { const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::CreateOrder; fn parse_body( - instruction_data: &[u8], + instruction_data: &'a [u8], accounts: &'a mut [AccountView], ) -> Result { // Body (discriminator already stripped): exactly the 150 intent bytes. diff --git a/programs/settlement/src/processor.rs b/programs/settlement/src/processor.rs index cecc651..42e7d87 100644 --- a/programs/settlement/src/processor.rs +++ b/programs/settlement/src/processor.rs @@ -13,12 +13,12 @@ pub trait InstructionInputParsing<'a>: Sized { const DISCRIMINATOR: SettlementInstruction; fn parse_body( - instruction_data: &[u8], + instruction_data: &'a [u8], accounts: &'a mut [AccountView], ) -> Result; fn parse( - instruction_data: &[u8], + instruction_data: &'a [u8], accounts: &'a mut [AccountView], ) -> Result { match recover_discriminator(instruction_data)? { @@ -41,7 +41,7 @@ mod tests { const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::BeginSettle; fn parse_body( - _instruction_data: &[u8], + _instruction_data: &'a [u8], _accounts: &'a mut [AccountView], ) -> Result { Ok(Self {}) diff --git a/programs/settlement/src/settle.rs b/programs/settlement/src/settle.rs index b147c70..a52ec2b 100644 --- a/programs/settlement/src/settle.rs +++ b/programs/settlement/src/settle.rs @@ -1,22 +1,63 @@ //! `BeginSettle`/`FinalizeSettle` instruction handlers. +use std::ops::Deref; + use pinocchio::{ error::ProgramError, sysvars::instructions::Instructions, AccountView, Address, ProgramResult, }; +use pinocchio_token::state::Account as TokenAccount; use settlement_interface::{ - recover_discriminator, settle::recover_counterpart, SettlementError, SettlementInstruction, + data::order::EncodedOrderAccount, pda::order::order_pda_signer_seeds, recover_discriminator, + settle::recover_counterpart, Pubkey, SettlementError, SettlementInstruction, }; use crate::processor::InstructionInputParsing; +/// A single settled order, resulted from parsing `BeginSettle`. +struct SettledOrder<'a> { + order_pda: &'a AccountView, + sell_token_account: &'a AccountView, + bump: u8, +} + +/// Struct storing account and bumps from parsing the input of BeginSettle. +/// We want parsing to provide the data in an ergonomic format. This struct +/// implements `Iterator`, which returns all available information for each +/// order without further parsing steps. The parsing step that created this +/// struct guarantees that there aren't missing elements or that they are +/// assigned incorrectly. +struct SettledOrders<'a> { + accounts: &'a [[AccountView; 2]], + bumps: &'a [u8], +} + +impl<'a> Iterator for SettledOrders<'a> { + type Item = SettledOrder<'a>; + + fn next(&mut self) -> Option { + let ([order_pda, sell_token_account], accounts) = self.accounts.split_first()?; + let (&bump, bumps) = self.bumps.split_first()?; + self.accounts = accounts; + self.bumps = bumps; + Some(SettledOrder { + order_pda, + sell_token_account, + bump, + }) + } +} + /// Parsed inputs of a `BeginSettle` 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 bumps is consistent. struct BeginSettleInput<'a> { finalize_ix_index: u16, instructions_sysvar_account: &'a AccountView, + /// The settled orders, each paired with its order PDA's canonical bump. + orders: SettledOrders<'a>, } /// This implementation defines how instruction bytes and accounts are laid out @@ -26,15 +67,29 @@ impl<'a> InstructionInputParsing<'a> for BeginSettleInput<'a> { const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::BeginSettle; fn parse_body( - instruction_data: &[u8], + instruction_data: &'a [u8], accounts: &'a mut [AccountView], ) -> Result { - let finalize_ix_index = recover_counterpart(instruction_data)?; - let instructions_sysvar_account = - accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + // The remaining bytes after recovering the counterpart are the bumps. + let (finalize_ix_index, bumps) = recover_counterpart(instruction_data)?; + + // Accounts: the instructions sysvar followed by one + // `(order_pda, sell_token_account)` pair per bump. + let (instructions_sysvar_account, order_accounts) = accounts + .split_first() + .ok_or(ProgramError::NotEnoughAccountKeys)?; + let (account_pairs, remainder) = order_accounts.as_chunks::<2>(); + if !remainder.is_empty() || account_pairs.len() != bumps.len() { + return Err(SettlementError::AccountCountNotMatchingBumps.into()); + } + Ok(Self { finalize_ix_index, instructions_sysvar_account, + orders: SettledOrders { + accounts: account_pairs, + bumps, + }, }) } } @@ -57,7 +112,7 @@ impl<'a> InstructionInputParsing<'a> for FinalizeSettleInput<'a> { instruction_data: &[u8], accounts: &'a mut [AccountView], ) -> Result { - let begin_ix_index = recover_counterpart(instruction_data)?; + let (begin_ix_index, _) = recover_counterpart(instruction_data)?; let instructions_sysvar_account = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; Ok(Self { @@ -81,11 +136,6 @@ pub fn process_begin_settle( let instructions = Instructions::try_from(input.instructions_sysvar_account)?; let current_index = instructions.load_current_index(); - // Ordering: the counterpart `FinalizeSettle` must sit strictly after us. - if input.finalize_ix_index <= current_index { - return Err(SettlementError::FinalizeBeforeInitialize.into()); - } - // Reciprocity: the input index is a finalize_settle instruction and that // instruction points to the current one. validate_counterpart( @@ -96,12 +146,36 @@ pub fn process_begin_settle( SettlementInstruction::FinalizeSettle, )?; - // Nesting check: no BeginSettle/FinalizeSettle of this program may appear - // strictly between `current_index` and `finalize_ix_index`. + validate_no_nested_settlement( + program_id, + &instructions, + current_index, + input.finalize_ix_index, + )?; + + validate_settled_orders(program_id, input.orders)?; + + Ok(()) +} + +/// 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. +#[must_use = "skipping the nesting check silently accepts overlapping settle pairs"] +fn validate_no_nested_settlement>( + program_id: &Address, + instructions: &Instructions, + current_index: u16, + finalize_ix_index: u16, +) -> ProgramResult { + if finalize_ix_index <= current_index { + return Err(SettlementError::FinalizeBeforeInitialize.into()); + } + let search_start = current_index .checked_add(1) .expect("the finalize index is tested to be larger, no overflow can happen"); - for i in search_start..input.finalize_ix_index { + for i in search_start..finalize_ix_index { let inner = instructions.load_instruction_at(usize::from(i))?; // Skip instructions belonging to a different program. if inner.get_program_id() != program_id { @@ -126,6 +200,72 @@ pub fn process_begin_settle( Ok(()) } +/// For each order, this checks that the order account was created by this +/// program and that its sell token account is the one the order's owner +/// controls. +fn validate_settled_orders<'a>( + program_id: &Address, + orders: impl Iterator>, +) -> ProgramResult { + // 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; + + for SettledOrder { + order_pda, + sell_token_account, + bump, + } in orders + { + // Decode the order body. Reading is safe regardless of who owns the + // account; the canonical-address check below is what proves provenance. + // The borrow is released at the end of this block, before any other + // account is touched. + let (intent, uid) = { + let data = order_pda.try_borrow()?; + let bytes: &[u8; EncodedOrderAccount::SIZE] = (&*data) + .try_into() + .map_err(|_| ProgramError::InvalidAccountData)?; + let (account, uid) = EncodedOrderAccount::decode_and_hash(bytes)?; + (account.intent, uid) + }; + + // Only at this point we can validate that the PDA is indeed a valid + // order PDA by seeing its address matches the computed one. + let bump_seed = [bump]; + let derived = + Address::create_program_address(&order_pda_signer_seeds(&uid, &bump_seed), program_id) + .map_err(|_| SettlementError::OrderNotCanonical)?; + if &derived != order_pda.address() { + return Err(SettlementError::OrderNotCanonical.into()); + } + + if previous.is_some_and(|previous| order_pda.address() <= previous) { + return Err(SettlementError::OrdersNotStrictlyIncreasing.into()); + } + previous = Some(order_pda.address()); + + // 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) { + return Err(SettlementError::SellTokenAccountMismatch.into()); + } + // `from_account_view` confirms this is a real SPL token account (right + // length, owned by the token program) before we read its owner. + let token_account = TokenAccount::from_account_view(sell_token_account) + .map_err(|_| SettlementError::SellTokenAccountInvalid)?; + if !address_matches_pubkey(token_account.owner(), &intent.owner) { + return Err(SettlementError::SellTokenOwnerMismatch.into()); + } + } + + Ok(()) +} + +fn address_matches_pubkey(address: &Address, pubkey: &Pubkey) -> bool { + address.as_array() == &pubkey.to_bytes() +} + pub fn process_finalize_settle( program_id: &Address, accounts: &mut [AccountView], @@ -154,7 +294,7 @@ pub fn process_finalize_settle( /// back at the current instruction. Ordering (before/after) is the caller's /// responsibility. #[must_use = "skipping the counterpart check silently accepts an invalid settle pair"] -fn validate_counterpart>( +fn validate_counterpart>( program_id: &Address, instructions: &Instructions, current_index: u16, @@ -170,7 +310,7 @@ fn validate_counterpart>( let counterpart_ix_data = counterpart_ix.get_instruction_data(); let (their_discriminator, remaining_data) = recover_discriminator(counterpart_ix_data) .map_err(|_| SettlementError::InvalidCounterpartDiscriminator)?; - let their_counterpart_ix = recover_counterpart(remaining_data) + let (their_counterpart_ix, _) = recover_counterpart(remaining_data) .map_err(|_| SettlementError::InvalidCounterpartCounterpart)?; if their_discriminator != expected_discriminator || their_counterpart_ix != current_index { return Err(SettlementError::MismatchedCounterpartDiscriminator.into()); @@ -181,7 +321,12 @@ fn validate_counterpart>( #[cfg(test)] mod tests { use super::*; - use crate::test_utils::fake_account; + use crate::test_utils::{fake_account, fake_account_from_array}; + use ::proptest::{prelude::*, test_runner::TestCaseError}; + use settlement_interface::{ + data::intent::fixtures::arb_order_intent, pda::order::find_order_pda, + settle::INSTRUCTIONS_SYSVAR_ID, + }; #[test] fn begin_settle_input_parses_valid_input() { @@ -192,9 +337,14 @@ mod tests { 0x13, 0x37, ]; - let parsed = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); - assert_eq!(parsed.finalize_ix_index, 0x1337); - assert_eq!(parsed.instructions_sysvar_account.address(), &address); + let BeginSettleInput { + finalize_ix_index, + instructions_sysvar_account, + orders, + } = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); + assert_eq!(finalize_ix_index, 0x1337); + assert_eq!(instructions_sysvar_account.address(), &address); + assert_eq!(orders.count(), 0); } #[test] @@ -206,10 +356,12 @@ mod tests { 0x13, 0x37, ]; - let parsed = - FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); - assert_eq!(parsed.begin_ix_index, 0x1337); - assert_eq!(parsed.instructions_sysvar_account.address(), &address); + 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(), &address); } #[test] @@ -253,19 +405,90 @@ mod tests { } #[test] - fn begin_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 begin_settle_input_parses_order_bumps_and_pairs() { + let sysvar = Address::new_from_array([1u8; 32]); + let order_pda = Address::new_from_array([2u8; 32]); + let sell_token = Address::new_from_array([3u8; 32]); + let mut accounts = [ + fake_account(sysvar), + fake_account(order_pda), + fake_account(sell_token), + ]; let data = [ SettlementInstruction::BeginSettle.discriminator(), - 0x13, // used - 0x37, // used - 42, // extra + 0x13, // finalize index hi + 0x37, // finalize index lo + 0xab, // one order's bump + ]; + let BeginSettleInput { + finalize_ix_index, + instructions_sysvar_account, + mut orders, + } = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); + assert_eq!(finalize_ix_index, 0x1337); + assert_eq!(instructions_sysvar_account.address(), &sysvar); + + let order = orders.next().expect("one settled order"); + assert_eq!(order.order_pda.address(), &order_pda); + assert_eq!(order.sell_token_account.address(), &sell_token); + assert_eq!(order.bump, 0xab); + assert!(orders.next().is_none()); + } + + #[test] + fn begin_settle_input_pairs_every_order_with_its_bump() { + const ORDER_COUNT: usize = 16; + + let mut expected: Vec<(Address, Address, u8)> = Vec::new(); + for i in 0..ORDER_COUNT { + let order_pda = Address::new_from_array([i as u8; 32]); + let sell_token = Address::new_from_array([(i + ORDER_COUNT) as u8; 32]); + let bump: u8 = (i + 2 * ORDER_COUNT) as u8; + expected.push((order_pda, sell_token, bump)); + } + + // The sysvar (`[0xff; 32]`) differs from every order/token address above. + let mut accounts = vec![(fake_account_from_array([0xff; 32]))]; + let mut data = vec![ + SettlementInstruction::BeginSettle.discriminator(), + 0x13, + 0x37, ]; + for &(order_pda, sell_token, bump) in &expected { + accounts.push(fake_account(order_pda)); + accounts.push(fake_account(sell_token)); + data.push(bump); + } + let parsed = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); - assert_eq!(parsed.finalize_ix_index, 0x1337); - assert_eq!(parsed.instructions_sysvar_account.address(), &first_address); + let orders: Vec<_> = parsed.orders.collect(); + + assert_eq!(orders.len(), ORDER_COUNT); + for (order, (order_pda, sell_token, bump)) in orders.iter().zip(&expected) { + assert_eq!(order.order_pda.address(), order_pda); + assert_eq!(order.sell_token_account.address(), sell_token); + assert_eq!(order.bump, *bump); + } + } + + #[test] + fn begin_settle_input_rejects_account_count_mismatch() { + // One bump expects two order accounts; only one is supplied (together + // with the sysvar account). + let mut accounts = [ + fake_account_from_array([1u8; 32]), + fake_account_from_array([2u8; 32]), + ]; + let data = [ + SettlementInstruction::BeginSettle.discriminator(), + 0, + 0, + 0xab, // one bump + ]; + assert_eq!( + BeginSettleInput::parse(&data, &mut accounts).err(), + Some(SettlementError::AccountCountNotMatchingBumps.into()), + ); } #[test] @@ -279,9 +502,66 @@ mod tests { 0x37, // used 42, // extra ]; - let parsed = - FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); - assert_eq!(parsed.begin_ix_index, 0x1337); - assert_eq!(parsed.instructions_sysvar_account.address(), &first_address); + 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); + } + + proptest! { + // The client's `begin_settle` builder derives each order's PDA from its + // intent, sorts the orders by PDA address, and lays out the accounts and + // bumps so that the on-chain parser recovers exactly those orders. + #[test] + fn client_begin_settle_derives_orders_from_intents( + finalize_ix_index in any::(), + intents in prop::collection::vec(arb_order_intent(), 1..=5), + ) { + let program_id = Pubkey::new_unique(); + let ix = settlement_client::instructions::begin_settle( + &program_id, + finalize_ix_index, + &intents, + ); + + // Expected orders: each intent's canonical PDA paired with its sell + // token account and bump, sorted by PDA address (the builder's order). + let mut expected: Vec<(Pubkey, Pubkey, u8)> = intents + .iter() + .map(|intent| { + let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); + (order_pda, intent.sell_token_account, bump) + }) + .collect(); + expected.sort_by_key(|(order_pda, _, _)| *order_pda); + + + let mut accounts: Vec = ix + .accounts + .iter() + .map(|meta| fake_account_from_array(meta.pubkey.to_bytes())) + .collect(); + let parsed = BeginSettleInput::parse(&ix.data, &mut accounts) + .map_err(|e| TestCaseError::fail(format!("parse failed: {e:?}")))?; + + prop_assert_eq!(parsed.finalize_ix_index, finalize_ix_index); + prop_assert!(address_matches_pubkey( + parsed.instructions_sysvar_account.address(), + &INSTRUCTIONS_SYSVAR_ID, + )); + + let parsed_orders: Vec<_> = parsed.orders.collect(); + prop_assert_eq!(parsed_orders.len(), expected.len()); + for (order, (order_pda, sell_token, bump)) in parsed_orders.iter().zip(&expected) { + prop_assert!(address_matches_pubkey(order.order_pda.address(), order_pda)); + prop_assert!(address_matches_pubkey( + order.sell_token_account.address(), + sell_token, + )); + prop_assert_eq!(order.bump, *bump); + } + } } } diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs new file mode 100644 index 0000000..648fd89 --- /dev/null +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -0,0 +1,304 @@ +//! 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. + +use crate::common::{ + assert_instruction_error, assert_settlement_error, create_account, setup, signed_tx, token, +}; +use litesvm::LiteSVM; +use settlement_client::instructions::{begin_settle, create_order, finalize_settle}; +use settlement_client::settlement_interface::{ + data::{ + intent::{OrderIntent, OrderKind}, + order::{EncodedOrderAccount, OrderAccount}, + }, + pda::order::find_order_pda, + settle::begin_settle as raw_begin_settle, + Instruction, SettlementError, +}; +use solana_sdk::{ + instruction::InstructionError, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, +}; + +mod common; + +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 = create_order(program_id, &owner.pubkey(), &owner.pubkey(), intent); + let tx = signed_tx(svm, owner, owner, ix); + svm.send_transaction(tx) + .expect("create_order should succeed"); +} + +/// Mint a valid order on-chain, distinct per `salt`, selling from a fresh token +/// account owned by `payer`, and return its intent. +fn settleable_order( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + mint: &Pubkey, + salt: u8, +) -> OrderIntent { + let sell_token = token::create_token_account(svm, payer, mint, &payer.pubkey()); + let intent = sample_intent(payer.pubkey(), sell_token, salt); + create_order_pda(svm, program_id, payer, &intent); + intent +} + +/// Send `[begin, finalize_settle(..)]` signed by `payer`, where `begin` is a +/// pre-built `BeginSettle` instruction. +fn send_settlement( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + begin: Instruction, +) -> Result<(), TransactionError> { + let finalize = finalize_settle(program_id, 0); + let tx = Transaction::new_signed_with_payer( + &[begin, finalize], + Some(&payer.pubkey()), + &[payer], + svm.latest_blockhash(), + ); + svm.send_transaction(tx).map(|_| ()).map_err(|e| e.err) +} + +#[test] +fn settles_a_single_order() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + + let intent = settleable_order(&mut svm, &program_id, &payer, &mint, 0); + send_settlement( + &mut svm, + &program_id, + &payer, + begin_settle(&program_id, 1, &[intent]), + ) + .expect("settlement should succeed"); +} + +#[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(settleable_order(&mut svm, &program_id, &payer, &mint, salt)); + } + + send_settlement( + &mut svm, + &program_id, + &payer, + begin_settle(&program_id, 1, &intents), + ) + .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 = settleable_order(&mut svm, &program_id, &payer, &mint, 0); + let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); + assert_settlement_error( + send_settlement( + &mut svm, + &program_id, + &payer, + raw_begin_settle( + &program_id, + 1, + &[order_pda], + &[intent.sell_token_account], + &[bump ^ 0x01], + ), + ), + SettlementError::OrderNotCanonical, + ); +} + +#[test] +fn rejects_fabricated_program_owned_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()); + 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), + }) + .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); + + assert_settlement_error( + send_settlement( + &mut svm, + &program_id, + &payer, + raw_begin_settle(&program_id, 1, &[fake_order], &[sell_token], &[255]), + ), + SettlementError::OrderNotCanonical, + ); +} + +#[test] +fn rejects_non_order_account_in_order_slot() { + 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()); + + // 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( + send_settlement( + &mut svm, + &program_id, + &payer, + raw_begin_settle(&program_id, 1, &[sell_token], &[sell_token], &[255]), + ), + InstructionError::InvalidAccountData, + ); +} + +#[test] +fn rejects_sell_token_account_mismatch() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + + // Supply a different token account than the one the order's intent names. + let intent = settleable_order(&mut svm, &program_id, &payer, &mint, 0); + 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( + send_settlement( + &mut svm, + &program_id, + &payer, + raw_begin_settle(&program_id, 1, &[order_pda], &[wrong_sell_token], &[bump]), + ), + SettlementError::SellTokenAccountMismatch, + ); +} + +#[test] +fn rejects_sell_token_owner_mismatch() { + let (mut svm, program_id, payer) = setup(); + let 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 intent = sample_intent(payer.pubkey(), sell_token, 1); + create_order_pda(&mut svm, &program_id, &payer, &intent); + + assert_settlement_error( + send_settlement( + &mut svm, + &program_id, + &payer, + begin_settle(&program_id, 1, &[intent]), + ), + SettlementError::SellTokenOwnerMismatch, + ); +} + +#[test] +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); + create_order_pda(&mut svm, &program_id, &payer, &intent); + + assert_settlement_error( + send_settlement( + &mut svm, + &program_id, + &payer, + begin_settle(&program_id, 1, &[intent]), + ), + SettlementError::SellTokenAccountInvalid, + ); +} + +#[test] +fn rejects_duplicate_orders() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + + let intent = settleable_order(&mut svm, &program_id, &payer, &mint, 0); + assert_settlement_error( + send_settlement( + &mut svm, + &program_id, + &payer, + begin_settle(&program_id, 1, &[intent.clone(), intent]), + ), + SettlementError::OrdersNotStrictlyIncreasing, + ); +} + +#[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 = settleable_order(&mut svm, &program_id, &payer, &mint, 0); + let second = settleable_order(&mut svm, &program_id, &payer, &mint, 1); + + let (first_pda, first_bump) = find_order_pda(&program_id, &first.uid()); + let (second_pda, second_bump) = find_order_pda(&program_id, &second.uid()); + let mut orders = [ + (first_pda, first_bump, first.sell_token_account), + (second_pda, second_bump, second.sell_token_account), + ]; + orders.sort_by_key(|(pda, _, _)| *pda); + let [(lo_pda, lo_bump, lo_token), (hi_pda, hi_bump, hi_token)] = orders; + + assert_settlement_error( + send_settlement( + &mut svm, + &program_id, + &payer, + raw_begin_settle( + &program_id, + 1, + &[hi_pda, lo_pda], + &[hi_token, lo_token], + &[hi_bump, lo_bump], + ), + ), + SettlementError::OrdersNotStrictlyIncreasing, + ); +} diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index 3944813..893355b 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -5,12 +5,17 @@ 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 token; + use litesvm::LiteSVM; use settlement_client::settlement_interface::SettlementError; +use settlement_interface::Instruction; use solana_sdk::{ + account::Account, instruction::InstructionError, pubkey::Pubkey, signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, }; pub const PROGRAM_SO: &str = concat!( @@ -44,8 +49,53 @@ pub fn to_instruction_error(e: SettlementError) -> InstructionError { InstructionError::Custom(e.into()) } +pub fn assert_instruction_error(result: Result<(), TransactionError>, expected: InstructionError) { + assert_eq!(result, Err(TransactionError::InstructionError(0, expected))); +} +pub fn assert_settlement_error(result: Result<(), TransactionError>, 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. +/// program-owned, with a crafted body or a deliberately wrong size or owner) +/// directly, bypassing the runtime. +pub fn create_account(svm: &mut LiteSVM, owner: &Pubkey, data: &[u8]) -> Pubkey { + let address = Pubkey::new_unique(); + let lamports = svm.minimum_balance_for_rent_exemption(data.len()); + svm.set_account( + address, + Account { + lamports, + data: data.to_vec(), + owner: *owner, + executable: false, + rent_epoch: 0, + }, + ) + .expect("set_account should succeed"); + address +} + /// Read the lamports balance of an account, or 0 if the account doesn't /// exist. pub fn lamports(svm: &LiteSVM, address: &Pubkey) -> u64 { svm.get_account(address).map(|a| a.lamports).unwrap_or(0) } + +/// Sign `ix` with `fee_payer` as the transaction fee payer and +/// `owner` as the keypair filling the `owner` slot. Tests pass +/// two distinct keypairs to keep these roles independent. +pub fn signed_tx( + svm: &LiteSVM, + fee_payer: &Keypair, + owner: &Keypair, + ix: Instruction, +) -> Transaction { + Transaction::new_signed_with_payer( + &[ix], + Some(&fee_payer.pubkey()), + &[fee_payer, owner], + svm.latest_blockhash(), + ) +} diff --git a/programs/settlement/tests/common/token.rs b/programs/settlement/tests/common/token.rs new file mode 100644 index 0000000..e093428 --- /dev/null +++ b/programs/settlement/tests/common/token.rs @@ -0,0 +1,26 @@ +//! SPL Token helpers for the settlement integration tests. + +use litesvm::LiteSVM; +use litesvm_token::{CreateAccount, CreateMint}; +use solana_sdk::{pubkey::Pubkey, signature::Keypair}; + +/// Create a fresh mint owned by `payer` and return its address. +pub fn create_mint(svm: &mut LiteSVM, payer: &Keypair) -> Pubkey { + CreateMint::new(svm, payer) + .send() + .expect("mint creation should succeed") +} + +/// Create an initialized SPL token account for `mint` whose SPL owner is +/// `owner`, funded by `payer`, and return its address. +pub fn create_token_account( + svm: &mut LiteSVM, + payer: &Keypair, + mint: &Pubkey, + owner: &Pubkey, +) -> Pubkey { + CreateAccount::new(svm, payer, mint) + .owner(owner) + .send() + .expect("token account creation should succeed") +} diff --git a/programs/settlement/tests/matching_begin_finalize.rs b/programs/settlement/tests/matching_begin_finalize.rs index 2e10aae..3f00ac5 100644 --- a/programs/settlement/tests/matching_begin_finalize.rs +++ b/programs/settlement/tests/matching_begin_finalize.rs @@ -35,7 +35,7 @@ fn run_sequence( let instructions: Vec = sequence .iter() .map(|spec| match spec { - AbstractInstruction::Init(idx) => begin_settle(program_id, *idx), + AbstractInstruction::Init(idx) => begin_settle(program_id, *idx, &[]), AbstractInstruction::Fin(idx) => finalize_settle(program_id, *idx), // 0-lamport self-transfer: a side-effect-free instruction that // (unlike Compute Budget) Solana allows to appear multiple times @@ -129,7 +129,7 @@ fn invalid_sequences() { fn rejects_non_instructions_sysvar_account_at_position_zero() { let (mut svm, program_id, payer) = common::setup(); - let mut begin = begin_settle(&program_id, 1); + let mut begin = begin_settle(&program_id, 1, &[]); begin.accounts[0] = AccountMeta::new_readonly(payer.pubkey(), false); let finalize = finalize_settle(&program_id, 0); @@ -156,7 +156,7 @@ fn rejects_non_instructions_sysvar_account_at_position_zero() { fn rejects_counterpart_instruction_in_different_program() { let (mut svm, program_id, payer) = common::setup(); - let begin = begin_settle(&program_id, 1); + let begin = begin_settle(&program_id, 1, &[]); // We build a transaction that looks like a valid finalize_settle but // calling a different program. It doesn't really matter what program // we use here because execution isn't expected to reach this point. diff --git a/programs/settlement/tests/program_deployment.rs b/programs/settlement/tests/program_deployment.rs index 06082ca..03bb735 100644 --- a/programs/settlement/tests/program_deployment.rs +++ b/programs/settlement/tests/program_deployment.rs @@ -29,7 +29,7 @@ fn program_can_be_invoked() { // Indices encode the BeginSettle/FinalizeSettle pair // `Begin` at 0 → finalize_ix=1, `Finalize` at 1 → begin_ix=0. &[ - begin_settle(&program_id, 1), + begin_settle(&program_id, 1, &[]), finalize_settle(&program_id, 0), ], Some(&payer.pubkey()), From c4db140c5aaf50ad3cf9f153334c8c67a69e5633 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:01:58 +0200 Subject: [PATCH 05/23] Fix name in comment --- programs/settlement/src/create_order.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/programs/settlement/src/create_order.rs b/programs/settlement/src/create_order.rs index 4b0cc48..04ccc0d 100644 --- a/programs/settlement/src/create_order.rs +++ b/programs/settlement/src/create_order.rs @@ -73,10 +73,10 @@ pub fn process_create_order( return Err(SettlementError::OwnerMismatch.into()); } - // We want a single order per uid; `create_pda` derives the canonical bump - // and, by signing the creation with the order seeds, rejects any - // `order_pda` that isn't the canonical address. The rest of the code can - // assume that if an account has data, then the bump is valid. + // We want a single order per uid; `create_canonical_pda` derives the + // canonical bump and, by signing the creation with the order seeds, rejects + // any `order_pda` that isn't the canonical address. The rest of the code + // can assume that if an account has data, then the bump is valid. create_canonical_pda( program_id, created_by, From af2862b25c5daedfb4670cee4d4f5096a768c302 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:07:08 +0200 Subject: [PATCH 06/23] space -> size --- programs/settlement/src/processor.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/programs/settlement/src/processor.rs b/programs/settlement/src/processor.rs index 46bc892..19e0ff8 100644 --- a/programs/settlement/src/processor.rs +++ b/programs/settlement/src/processor.rs @@ -48,7 +48,7 @@ pub fn create_canonical_pda( program_id: &Address, payer: &AccountView, pda: &AccountView, - space: u64, + size: u64, seeds: [&[u8]; N], ) -> ProgramResult { let (_, bump) = Address::find_program_address(&seeds, program_id); @@ -67,7 +67,7 @@ pub fn create_canonical_pda( signer_seeds.push(Seed::from(&bump[..])); let signer = Signer::from(&signer_seeds[..]); - CreateAccount::with_minimum_balance(payer, pda, space, program_id, None)? + CreateAccount::with_minimum_balance(payer, pda, size, program_id, None)? .invoke_signed(&[signer])?; Ok(()) } From f788b8a60fdf4717f2c0890746a307ab58675fc1 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:13:15 +0200 Subject: [PATCH 07/23] n_accounts -> fake_sequential_accounts:: --- programs/settlement/src/create_order.rs | 31 +++++++++++-------------- programs/settlement/src/initialize.rs | 19 ++++++--------- programs/settlement/src/test_utils.rs | 5 ++++ 3 files changed, 26 insertions(+), 29 deletions(-) diff --git a/programs/settlement/src/create_order.rs b/programs/settlement/src/create_order.rs index 04ccc0d..b00e7ac 100644 --- a/programs/settlement/src/create_order.rs +++ b/programs/settlement/src/create_order.rs @@ -102,10 +102,16 @@ mod tests { use pinocchio::account::RuntimeAccount; use super::*; - use crate::test_utils::{fake_account, fake_account_from, fake_account_from_array}; + use crate::test_utils::{ + fake_account, fake_account_from, fake_account_from_array, fake_sequential_accounts, + }; const DEFAULT_OWNER: Address = Address::new_from_array([0x11; 32]); + /// Number of accounts `CreateOrder` expects: owner, created_by, order PDA, + /// and the system program. + const NUM_ACCOUNTS: usize = 4; + fn valid_intent_bytes() -> [u8; EncodedOrderIntent::SIZE] { (&EncodedOrderIntent::from(&OrderIntent { owner: DEFAULT_OWNER, @@ -135,15 +141,6 @@ mod tests { .data } - fn four_accounts() -> [AccountView; 4] { - [ - fake_account_from_array([1; 32]), - fake_account_from_array([2; 32]), - fake_account_from_array([3; 32]), - fake_account_from_array([4; 32]), - ] - } - #[test] fn create_order_input_parses_valid_input() { let program_id = Address::new_from_array([21; 32]); @@ -185,7 +182,7 @@ mod tests { let intent_bytes = valid_intent_bytes(); let mut data = default_order_data(&intent_bytes); data.pop(); - let mut accounts = four_accounts(); + let mut accounts = fake_sequential_accounts::(); assert_eq!( CreateOrderInput::parse(&data, &mut accounts).err(), Some(ProgramError::InvalidInstructionData), @@ -197,7 +194,7 @@ mod tests { let intent_bytes = valid_intent_bytes(); let mut data = default_order_data(&intent_bytes); data.push(0); // trailing byte - let mut accounts = four_accounts(); + let mut accounts = fake_sequential_accounts::(); assert_eq!( CreateOrderInput::parse(&data, &mut accounts).err(), Some(ProgramError::InvalidInstructionData), @@ -208,7 +205,7 @@ mod tests { fn create_order_input_rejects_missing_accounts() { let intent_bytes = valid_intent_bytes(); let data = default_order_data(&intent_bytes); - let mut accounts: Vec = four_accounts().into(); + let mut accounts: Vec = fake_sequential_accounts::().into(); accounts.pop(); assert_eq!( CreateOrderInput::parse(&data, &mut accounts).err(), @@ -227,7 +224,7 @@ mod tests { let mut data = default_order_data(&intent_bytes); // We generate a parse error by having less bytes than necessary. data.pop(); - let mut accounts = four_accounts(); + let mut accounts = fake_sequential_accounts::(); assert_eq!( process_create_order(&PROGRAM_ID, &mut accounts, &data), @@ -258,7 +255,7 @@ mod tests { intent_bytes[kind_offset] = 0x42; let data = default_order_data(&intent_bytes); - let mut accounts = four_accounts(); + let mut accounts = fake_sequential_accounts::(); assert_eq!( process_create_order(&PROGRAM_ID, &mut accounts, &data), @@ -275,7 +272,7 @@ mod tests { // Test setup: owner is not a signer. assert!(!owner_account.is_signer()); - let mut accounts = four_accounts(); + let mut accounts = fake_sequential_accounts::(); accounts[0] = owner_account; assert_eq!( @@ -297,7 +294,7 @@ mod tests { // Test setup: owner doesn't match. assert_ne!(owner_runtime_account.address, DEFAULT_OWNER); - let mut accounts = four_accounts(); + let mut accounts = fake_sequential_accounts::(); accounts[0] = fake_account_from(owner_runtime_account); assert_eq!( diff --git a/programs/settlement/src/initialize.rs b/programs/settlement/src/initialize.rs index ee596c2..b36fe85 100644 --- a/programs/settlement/src/initialize.rs +++ b/programs/settlement/src/initialize.rs @@ -47,7 +47,10 @@ pub fn process_initialize( #[cfg(test)] mod tests { use super::*; - use crate::test_utils::fake_account_from_array; + use crate::test_utils::{fake_account_from_array, fake_sequential_accounts}; + + /// Number of accounts `Initialize` expects: payer, state PDA, system program. + const NUM_ACCOUNTS: usize = 3; // Only used in failing tests, where actual data doesn't matter fn initialize_data() -> Vec { @@ -55,14 +58,6 @@ mod tests { settlement_interface::instruction::initialize::initialize(&zero, &zero, &zero).data } - fn three_accounts() -> [AccountView; 3] { - [ - fake_account_from_array([1; 32]), - fake_account_from_array([2; 32]), - fake_account_from_array([3; 32]), - ] - } - #[test] fn initialize_input_parses_valid_input() { let program_id = Address::new_unique(); @@ -91,7 +86,7 @@ mod tests { fn initialize_input_rejects_long_data() { let mut data = initialize_data(); data.push(0); // trailing byte - let mut accounts = three_accounts(); + let mut accounts = fake_sequential_accounts::(); assert_eq!( InitializeInput::parse(&data, &mut accounts).err(), Some(ProgramError::InvalidInstructionData), @@ -101,7 +96,7 @@ mod tests { #[test] fn initialize_input_rejects_missing_accounts() { let data = initialize_data(); - let mut accounts: Vec = three_accounts().into(); + let mut accounts: Vec = fake_sequential_accounts::().into(); accounts.pop(); assert_eq!( InitializeInput::parse(&data, &mut accounts).err(), @@ -113,7 +108,7 @@ mod tests { fn process_initialize_propagates_parse_error() { let mut data = initialize_data(); data.push(0); // make the data too long to trigger a parse error - let mut accounts = three_accounts(); + let mut accounts = fake_sequential_accounts::(); assert_eq!( process_initialize(&Address::new_unique(), &mut accounts, &data), Err(ProgramError::InvalidInstructionData), diff --git a/programs/settlement/src/test_utils.rs b/programs/settlement/src/test_utils.rs index dc1e565..032f370 100644 --- a/programs/settlement/src/test_utils.rs +++ b/programs/settlement/src/test_utils.rs @@ -48,3 +48,8 @@ pub fn fake_account(address: Address) -> AccountView { pub fn fake_account_from_array(address_array: [u8; 32]) -> AccountView { fake_account(Address::new_from_array(address_array)) } + +/// Build `N` fake accounts with sequential addresses (`[1; 32]`, `[2; 32]`, …). +pub fn fake_sequential_accounts() -> [AccountView; N] { + core::array::from_fn(|i| fake_account_from_array([(i as u8).wrapping_add(1); 32])) +} From c78d5946469ff6d68a6cffef6ef0cde8b4832a5b Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:29:55 +0200 Subject: [PATCH 08/23] Fix formatting --- programs/settlement/src/processor.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/programs/settlement/src/processor.rs b/programs/settlement/src/processor.rs index b6ba065..9302e4e 100644 --- a/programs/settlement/src/processor.rs +++ b/programs/settlement/src/processor.rs @@ -74,8 +74,7 @@ pub fn create_canonical_pda( signer_seeds.push(Seed::from(&bump[..])); let signer = Signer::from(&signer_seeds[..]); - CreateAccount::with_minimum_balance(payer, pda, size, owner, None)? - .invoke_signed(&[signer])?; + CreateAccount::with_minimum_balance(payer, pda, size, owner, None)?.invoke_signed(&[signer])?; Ok(()) } From 5d68fb6a80b70c7224937724a48f22e4a78578fe Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:40:45 +0200 Subject: [PATCH 09/23] super::super:: simplification --- interface/src/data/intent.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index a945ac5..bd0e0e5 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -493,10 +493,10 @@ mod tests { mod proptest { use ::proptest::{prelude::*, test_runner::TestCaseError}; - use super::super::fixtures::{ + use super::*; + use crate::data::intent::fixtures::{ arb_order_intent, arb_order_kind, KIND_OFFSET, PARTIALLY_FILLABLE_OFFSET, }; - use super::*; // Any byte not decoding to a valid order type. fn arb_bad_order_kind_byte() -> impl Strategy { From e86bb438e83c2f55ae6cff2b884b72044b2cd2bc Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:29:45 +0200 Subject: [PATCH 10/23] [u8; 32] -> Hash --- Cargo.lock | 5 +++-- Cargo.toml | 1 + interface/Cargo.toml | 1 + interface/src/data/intent.rs | 15 +++++++-------- interface/src/data/order.rs | 5 ++--- interface/src/pda/order.rs | 15 ++++++++------- programs/settlement/tests/create_order.rs | 2 +- 7 files changed, 23 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 74e2b6b..46f7c3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1511,7 +1511,7 @@ dependencies = [ "litesvm", "smallvec", "solana-account", - "solana-address 2.6.0", + "solana-address 2.6.1", "solana-keypair", "solana-program-option", "solana-program-pack", @@ -1792,7 +1792,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "825f59c8348e5c2d3fd56432ef927f5819542b3b05fae4f5b6869801113e775e" dependencies = [ "solana-account-view", - "solana-address 2.6.0", + "solana-address 2.6.1", "solana-instruction-view", "solana-program-error", ] @@ -2228,6 +2228,7 @@ dependencies = [ "derive_more", "num_enum", "proptest", + "solana-hash 3.1.0", "solana-instruction", "solana-program-error", "solana-pubkey 3.0.0", diff --git a/Cargo.toml b/Cargo.toml index 8cf893b..f066511 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ pinocchio-token = "0.6" proptest = "1" settlement-client = { path = "client" } settlement-interface = { path = "interface" } +solana-hash = "3" solana-instruction = "3" solana-program-error = "3" solana-pubkey = "3" diff --git a/interface/Cargo.toml b/interface/Cargo.toml index e3ba7f7..5bf7365 100644 --- a/interface/Cargo.toml +++ b/interface/Cargo.toml @@ -8,6 +8,7 @@ arrayref.workspace = true derive_more.workspace = true num_enum.workspace = true proptest = { workspace = true, optional = true } +solana-hash.workspace = true solana-instruction.workspace = true solana-program-error.workspace = true solana-pubkey = { workspace = true, features = ["curve25519"] } diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index 6c17389..8e9b4cc 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -19,6 +19,7 @@ use core::mem::size_of; use arrayref::{array_refs, mut_array_refs}; use derive_more::Deref; +use solana_hash::Hash; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; @@ -117,7 +118,7 @@ impl EncodedOrderIntent { pub const SIZE: usize = 150; /// Canonical hash of the bytes. - pub fn hash(&self) -> [u8; 32] { + pub fn hash(&self) -> Hash { hash_bytes(&self.0) } @@ -125,9 +126,7 @@ impl EncodedOrderIntent { /// Returns [`ProgramError::InvalidInstructionData`] for an out-of-range /// `kind` or `partially_fillable` byte; every other byte combination /// decodes. - pub fn decode_and_hash( - bytes: &[u8; Self::SIZE], - ) -> Result<(OrderIntent, [u8; 32]), ProgramError> { + pub fn decode_and_hash(bytes: &[u8; Self::SIZE]) -> Result<(OrderIntent, Hash), ProgramError> { let intent = OrderIntent::try_from(bytes)?; // The UID is the SHA-256 of the input bytes. Hashing the input // (no re-encode) is correct because encode/decode is a bijection on @@ -139,8 +138,8 @@ impl EncodedOrderIntent { } } -pub fn hash_bytes(bytes: &[u8; EncodedOrderIntent::SIZE]) -> [u8; 32] { - solana_sha256_hasher::hashv(&[bytes.as_slice()]).to_bytes() +pub fn hash_bytes(bytes: &[u8; EncodedOrderIntent::SIZE]) -> Hash { + solana_sha256_hasher::hashv(&[bytes.as_slice()]) } impl From<&EncodedOrderIntent> for [u8; EncodedOrderIntent::SIZE] { @@ -257,7 +256,7 @@ impl OrderIntent { /// SHA-256 of the canonical bytes. Doubles as the order UID and the /// middle seed of the order PDA. On SBF this compiles to a single /// `sol_sha256` syscall; off-target it goes through the `sha2` crate. - pub fn uid(&self) -> [u8; 32] { + pub fn uid(&self) -> Hash { EncodedOrderIntent::from(self).hash() } } @@ -450,7 +449,7 @@ mod tests { 0xce, 0x43, 0x6f, 0x8f, 0x53, 0xe2, 0xb7, 0xa1, 0xd9, 0x68, 0xac, 0xb0, 0x8f, 0x79, 0xd3, 0xc1, 0x23, 0x1d, ]; - assert_eq!(intent.uid(), expected); + assert_eq!(intent.uid(), Hash::from(expected)); } #[test] diff --git a/interface/src/data/order.rs b/interface/src/data/order.rs index 0d4c9e3..a5acd53 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -22,6 +22,7 @@ use core::mem::size_of; use arrayref::{array_refs, mut_array_refs}; use derive_more::Deref; +use solana_hash::Hash; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; @@ -84,9 +85,7 @@ impl EncodedOrderAccount { /// shot, mirroring [`EncodedOrderIntent::decode_and_hash`]. Decoding /// validates the intent; returns [`ProgramError::InvalidAccountData`] on a /// decode error. - pub fn decode_and_hash( - bytes: &[u8; Self::SIZE], - ) -> Result<(OrderAccount, [u8; 32]), ProgramError> { + pub fn decode_and_hash(bytes: &[u8; Self::SIZE]) -> Result<(OrderAccount, Hash), ProgramError> { let order_account = OrderAccount::try_from(*bytes)?; // The order UID is the hash of the intent's canonical bytes. Decoding // succeeded, so the intent slot already holds those exact bytes: hash diff --git a/interface/src/pda/order.rs b/interface/src/pda/order.rs index 5ea1473..2e7a799 100644 --- a/interface/src/pda/order.rs +++ b/interface/src/pda/order.rs @@ -12,6 +12,7 @@ //! For every valid [`crate::data::intent::OrderIntent`], there exists only //! a single valid PDA representing that intent. +use solana_hash::Hash; use solana_pubkey::Pubkey; use crate::pda::SETTLEMENT_SEED; @@ -20,8 +21,8 @@ use crate::pda::SETTLEMENT_SEED; pub const ORDER_SEED: &[u8] = b"order"; /// Canonical seed components for the order PDA at `uid`. -pub fn order_pda_seeds(uid: &[u8; 32]) -> [&[u8]; 3] { - [SETTLEMENT_SEED, uid, ORDER_SEED] +pub fn order_pda_seeds(uid: &Hash) -> [&[u8]; 3] { + [SETTLEMENT_SEED, uid.as_ref(), ORDER_SEED] } /// Canonical seeds for signing as the order PDA at `uid` with `bump`. The @@ -29,7 +30,7 @@ pub fn order_pda_seeds(uid: &[u8; 32]) -> [&[u8]; 3] { /// By design, order PDAs can be created only if it uses the canonical bump. /// Calling this function with another bump could lead to a theoretically /// valid PDA that however cannot and should not be instantiated. -pub fn order_pda_signer_seeds<'a>(uid: &'a [u8; 32], bump: &'a [u8; 1]) -> [&'a [u8]; 4] { +pub fn order_pda_signer_seeds<'a>(uid: &'a Hash, bump: &'a [u8; 1]) -> [&'a [u8]; 4] { let [s0, s1, s2] = order_pda_seeds(uid); [s0, s1, s2, bump] } @@ -38,7 +39,7 @@ pub fn order_pda_signer_seeds<'a>(uid: &'a [u8; 32], bump: &'a [u8; 1]) -> [&'a /// /// `uid` is the unique identifier of an intent. See /// [`crate::data::intent::OrderIntent::uid`]. -pub fn find_order_pda(program_id: &Pubkey, uid: &[u8; 32]) -> (Pubkey, u8) { +pub fn find_order_pda(program_id: &Pubkey, uid: &Hash) -> (Pubkey, u8) { Pubkey::find_program_address(&order_pda_seeds(uid), program_id) } @@ -49,7 +50,7 @@ mod tests { #[test] fn find_order_pda_uses_canonical_seeds() { let program_id = Pubkey::new_from_array([67; 32]); - let uid = [0x42u8; 32]; + let uid = Hash::new_from_array([0x42u8; 32]); let (pda, bump) = find_order_pda(&program_id, &uid); @@ -85,8 +86,8 @@ mod tests { ) { prop_assume!(uid1 != uid2); let program_id = Pubkey::new_from_array(program_id); - let (pda1, _) = find_order_pda(&program_id, &uid1); - let (pda2, _) = find_order_pda(&program_id, &uid2); + let (pda1, _) = find_order_pda(&program_id, &Hash::new_from_array(uid1)); + let (pda2, _) = find_order_pda(&program_id, &Hash::new_from_array(uid2)); prop_assert_ne!(pda1, pda2); } } diff --git a/programs/settlement/tests/create_order.rs b/programs/settlement/tests/create_order.rs index e108817..fd11ce6 100644 --- a/programs/settlement/tests/create_order.rs +++ b/programs/settlement/tests/create_order.rs @@ -228,7 +228,7 @@ fn rejects_non_canonical_bump_pda() { .rev() .find_map(|bump| { Pubkey::create_program_address( - &[SETTLEMENT_SEED, &uid, ORDER_SEED, &[bump]], + &[SETTLEMENT_SEED, uid.as_ref(), ORDER_SEED, &[bump]], &program_id, ) .ok() From 159267411c8dfc3c30e4fbe1f000d8d9437ba1aa Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:37:56 +0200 Subject: [PATCH 11/23] Remove mention of zipping in doc comment --- interface/src/settle.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/interface/src/settle.rs b/interface/src/settle.rs index a5283d1..4285d32 100644 --- a/interface/src/settle.rs +++ b/interface/src/settle.rs @@ -14,8 +14,8 @@ use crate::SettlementInstruction; /// Build a `BeginSettle` instruction settling the orders described by the three /// parallel lists: `order_pdas[i]` is the canonical order PDA (see /// [`crate::pda::order`]), `sell_token_accounts[i]` its sell token account, and -/// `bumps[i]` the canonical PDA bump. The three slices are expected to have the -/// same length; the builder zips them and stops at the shortest. +/// `bumps[i]` the canonical PDA bump. The three slices are assumed to have the +/// same length but this is not enforced in the builder. /// /// Wire format: /// `[discriminator=0][finalize_ix_index: u16 BE][bump...]`, one `bump` byte per From b18e23cba4ba849cac52704201d9a3e5f593c219 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:44:07 +0200 Subject: [PATCH 12/23] decode_and_hash error bubbles up --- interface/src/data/order.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/interface/src/data/order.rs b/interface/src/data/order.rs index a5acd53..2130523 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -347,6 +347,18 @@ mod tests { assert_eq!(err, ProgramError::InvalidAccountData); } + #[test] + fn decode_and_hash_catches_errors() { + let mut bytes: [u8; EncodedOrderAccount::SIZE] = + EncodedOrderAccount::from(sample_account(false)).into(); + // Corrupt the `cancelled` byte to an out-of-range value so the + // underlying `try_from` rejects it. + bytes[CANCELLED_OFFSET] = 0xff; + let err = EncodedOrderAccount::decode_and_hash(&bytes) + .expect_err("decode_and_hash must propagate the try_from error"); + assert_eq!(err, ProgramError::InvalidAccountData); + } + #[test] fn direct_write_account_matches_order_account_decoding() { let cancelled = true; From 585fd184df73fc5bca498727eb8335ddf972114f Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:50:57 +0200 Subject: [PATCH 13/23] Don't allocate vecs in `begin_settle` --- interface/src/settle.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/interface/src/settle.rs b/interface/src/settle.rs index 4285d32..c7f13cf 100644 --- a/interface/src/settle.rs +++ b/interface/src/settle.rs @@ -34,20 +34,22 @@ pub fn begin_settle( sell_token_accounts: &[Pubkey], bumps: &[u8], ) -> Instruction { - let orders = order_pdas.iter().zip(sell_token_accounts).zip(bumps); - - let mut data = [ - &[SettlementInstruction::BeginSettle.discriminator()], + let data = [ + &[SettlementInstruction::BeginSettle.discriminator()][..], &finalize_ix_index.to_be_bytes()[..], + bumps, ] .concat(); - let mut accounts = vec![AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false)]; - for ((order_pda, sell_token_account), bump) in orders { - data.push(*bump); - accounts.push(AccountMeta::new_readonly(*order_pda, false)); - accounts.push(AccountMeta::new_readonly(*sell_token_account, false)); - } + let accounts = std::iter::once(INSTRUCTIONS_SYSVAR_ID) + .chain( + order_pdas + .iter() + .zip(sell_token_accounts) + .flat_map(|(order_pda, sell_token_account)| [*order_pda, *sell_token_account]), + ) + .map(|pubkey| AccountMeta::new_readonly(pubkey, false)) + .collect(); Instruction { program_id: *program_id, From d09903c7c1964c51d8a32744cb8ddc00e1788e7a Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:28:01 +0200 Subject: [PATCH 14/23] Comment on `process_initialize` checks --- programs/settlement/src/initialize.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/programs/settlement/src/initialize.rs b/programs/settlement/src/initialize.rs index b36fe85..88828a3 100644 --- a/programs/settlement/src/initialize.rs +++ b/programs/settlement/src/initialize.rs @@ -39,6 +39,12 @@ pub fn process_initialize( ) -> ProgramResult { let InitializeInput { payer, state_pda } = InitializeInput::parse(instruction_data, accounts)?; + // There are no explicit account guards here: `create_canonical_pda` rejects + // any `state_pda` other than the address those seeds derive and guards + // re-init., `CreateAccount` itself assigns. + // The system program is invoked by its fixed address, so the account in that + // system program is invoked by its slot is never referenced directly. + create_canonical_pda(program_id, payer, state_pda, 0, state_pda_seeds())?; Ok(()) From 7c8f236a78863b1a9b9cb5f3cf54d9b8a1089741 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:30:27 +0200 Subject: [PATCH 15/23] Test different rent payer --- programs/settlement/tests/initialize.rs | 29 ++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/programs/settlement/tests/initialize.rs b/programs/settlement/tests/initialize.rs index 3d590fd..8686647 100644 --- a/programs/settlement/tests/initialize.rs +++ b/programs/settlement/tests/initialize.rs @@ -2,7 +2,10 @@ use settlement_client::instructions::initialize; use settlement_client::settlement_interface::{ instruction::initialize::initialize as initialize_ix, pda::state::find_state_pda, }; -use solana_sdk::{pubkey::Pubkey, signature::Signer}; +use solana_sdk::{ + pubkey::Pubkey, + signature::{Keypair, Signer}, +}; mod common; @@ -34,6 +37,30 @@ fn happy_path_initializes_empty_state_pda() { ); } +#[test] +fn funding_payer_can_differ_from_fee_payer() { + let (mut svm, program_id, fee_payer) = common::setup(); + let (_, _bump) = find_state_pda(&program_id); + + let funder = Keypair::new(); + let funder_airdrop = 1_000_000_000; + svm.airdrop(&funder.pubkey(), funder_airdrop) + .expect("airdrop to funder should succeed"); + + let ix = initialize(&program_id, &funder.pubkey()); + let tx = common::signed_tx(&svm, &fee_payer, &funder, ix); + svm.send_transaction(tx).expect("initialize should succeed"); + + // The rent came out of the funder, not the fee payer: the funder paid no + // transaction fee, so its balance dropped by exactly the PDA rent. + let rent = svm.minimum_balance_for_rent_exemption(0); + assert_eq!( + common::lamports(&svm, &funder.pubkey()), + funder_airdrop - rent, + "funder should have paid exactly the PDA rent", + ); +} + #[test] fn rejects_arbitrary_wrong_state_pda() { let (mut svm, program_id, payer) = common::setup(); From be64fd75cda335ba98e411c0f89c4aa68ebfcef1 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:40:23 +0200 Subject: [PATCH 16/23] Fix phrasing --- programs/settlement/src/initialize.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programs/settlement/src/initialize.rs b/programs/settlement/src/initialize.rs index 88828a3..4471143 100644 --- a/programs/settlement/src/initialize.rs +++ b/programs/settlement/src/initialize.rs @@ -41,7 +41,7 @@ pub fn process_initialize( // There are no explicit account guards here: `create_canonical_pda` rejects // any `state_pda` other than the address those seeds derive and guards - // re-init., `CreateAccount` itself assigns. + // re-init. // The system program is invoked by its fixed address, so the account in that // system program is invoked by its slot is never referenced directly. From 0a97292833f8b2ef393412d2a5496e43ccdfea3e Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:40:43 +0200 Subject: [PATCH 17/23] Fix phrasing, again --- programs/settlement/src/initialize.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programs/settlement/src/initialize.rs b/programs/settlement/src/initialize.rs index 4471143..11a9435 100644 --- a/programs/settlement/src/initialize.rs +++ b/programs/settlement/src/initialize.rs @@ -41,7 +41,7 @@ pub fn process_initialize( // There are no explicit account guards here: `create_canonical_pda` rejects // any `state_pda` other than the address those seeds derive and guards - // re-init. + // against re-init. // The system program is invoked by its fixed address, so the account in that // system program is invoked by its slot is never referenced directly. From febfcb27e2bfe1f20c9e9779b639d2f518171f4a Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:57:48 +0200 Subject: [PATCH 18/23] Comment clarification --- interface/src/pda/buffer.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/interface/src/pda/buffer.rs b/interface/src/pda/buffer.rs index 75b685f..cfc505c 100644 --- a/interface/src/pda/buffer.rs +++ b/interface/src/pda/buffer.rs @@ -16,7 +16,8 @@ use crate::pda::SETTLEMENT_SEED; /// Trailing seed identifying the buffer PDAs. pub const BUFFER_SEED: &[u8] = b"buffer"; -/// Canonical seed components for the buffer PDA holding the token `mint`. +/// Canonical seed components for the buffer PDA holding the specified `mint` +/// token. /// /// `mint` is the raw 32-byte token mint address, so the same helper serves /// both the off-chain builder and the on-chain handler (which holds the mint From 4a2448f6c8aa09b8160965a3a195c8392881be9a Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:13:24 +0200 Subject: [PATCH 19/23] Improve readability of canonical PDA creation --- programs/settlement/src/create_buffer.rs | 15 +++--- programs/settlement/src/create_order.rs | 17 +++--- programs/settlement/src/initialize.rs | 15 +++--- programs/settlement/src/processor.rs | 68 +++++++++++++----------- 4 files changed, 63 insertions(+), 52 deletions(-) diff --git a/programs/settlement/src/create_buffer.rs b/programs/settlement/src/create_buffer.rs index 16e1c52..583cc4e 100644 --- a/programs/settlement/src/create_buffer.rs +++ b/programs/settlement/src/create_buffer.rs @@ -8,7 +8,7 @@ use settlement_interface::{ SettlementInstruction, }; -use crate::processor::{create_canonical_pda, InstructionInputParsing}; +use crate::processor::{CanonicalPda, InstructionInputParsing}; /// Parsed inputs of a `CreateBuffer` instruction. struct CreateBufferInput<'a> { @@ -72,14 +72,15 @@ pub fn process_create_buffer( // token-program-owned mint (and special-cases the native mint), so a check // of our own would be redundant. let mint_key = mint.address().as_array(); - create_canonical_pda( + CanonicalPda { program_id, payer, - buffer_pda, - TokenAccount::LEN as u64, - &SPL_TOKEN_PROGRAM_ID, - buffer_pda_seeds(mint_key), - )?; + pda: buffer_pda, + size: TokenAccount::LEN as u64, + owner: &SPL_TOKEN_PROGRAM_ID, + seeds: buffer_pda_seeds(mint_key), + } + .create()?; // The buffer's token authority is the settlement state PDA, the single // authority over every buffer. diff --git a/programs/settlement/src/create_order.rs b/programs/settlement/src/create_order.rs index 445808f..32c2f14 100644 --- a/programs/settlement/src/create_order.rs +++ b/programs/settlement/src/create_order.rs @@ -10,7 +10,7 @@ use settlement_interface::{ SettlementError, SettlementInstruction, }; -use crate::processor::{create_canonical_pda, InstructionInputParsing}; +use crate::processor::{CanonicalPda, InstructionInputParsing}; /// Parsed inputs of a `CreateOrder` instruction. struct CreateOrderInput<'a> { @@ -77,14 +77,15 @@ pub fn process_create_order( // canonical bump and, by signing the creation with the order seeds, rejects // any `order_pda` that isn't the canonical address. The rest of the code // can assume that if an account has data, then the bump is valid. - create_canonical_pda( + CanonicalPda { program_id, - created_by, - order_pda, - EncodedOrderAccount::SIZE as u64, - program_id, - order_pda_seeds(&intent_uid), - )?; + payer: created_by, + pda: order_pda, + size: EncodedOrderAccount::SIZE as u64, + owner: program_id, + seeds: order_pda_seeds(&intent_uid), + } + .create()?; // Note: `intent_bytes` were validated before and are known to represent a valid intent. let mut buffer = order_pda.try_borrow_mut()?; diff --git a/programs/settlement/src/initialize.rs b/programs/settlement/src/initialize.rs index 582f5c2..e4f9b5a 100644 --- a/programs/settlement/src/initialize.rs +++ b/programs/settlement/src/initialize.rs @@ -3,7 +3,7 @@ use pinocchio::{error::ProgramError, AccountView, Address, ProgramResult}; use settlement_interface::{pda::state::state_pda_seeds, SettlementInstruction}; -use crate::processor::{create_canonical_pda, InstructionInputParsing}; +use crate::processor::{CanonicalPda, InstructionInputParsing}; /// Parsed inputs of an `Initialize` instruction. struct InitializeInput<'a> { @@ -45,14 +45,15 @@ pub fn process_initialize( // The system program is invoked by its fixed address, so the account in that // system program is invoked by its slot is never referenced directly. - create_canonical_pda( + CanonicalPda { program_id, payer, - state_pda, - 0, - program_id, - state_pda_seeds(), - )?; + pda: state_pda, + size: 0, + owner: program_id, + seeds: state_pda_seeds(), + } + .create()?; Ok(()) } diff --git a/programs/settlement/src/processor.rs b/programs/settlement/src/processor.rs index 9302e4e..3396b06 100644 --- a/programs/settlement/src/processor.rs +++ b/programs/settlement/src/processor.rs @@ -36,46 +36,54 @@ pub trait InstructionInputParsing<'a>: Sized { } } -/// Create the account at the PDA `pda`, assigned to `owner` and funded by -/// `payer`. +/// Description of a canonical PDA to create: the account at `pda`, assigned to +/// `owner` and funded by `payer`. /// /// `seeds` are the canonical PDA seeds *without* the bump; the canonical bump -/// is derived under `program_id` and appended here. Signing `CreateAccount` -/// with these seeds implicitly checks that `pda` is the canonical address: the -/// runtime grants the PDA signature only for the address the seeds derive, so -/// any other `pda` fails the CPI. +/// is derived under `program_id` and appended in [`create`]. Signing +/// `CreateAccount` with these seeds implicitly checks that `pda` is the +/// canonical address: the runtime grants the PDA signature only for the address +/// the seeds derive, so any other `pda` fails the CPI. /// /// `owner` is the program the new account is assigned to. It is usually /// `program_id` (a program-owned PDA), but differs when the account must be /// owned by another program, as for example a buffer token account owned by the /// SPL Token program. -#[must_use = "ignoring the output means processing continues without the PDA having been created"] -pub fn create_canonical_pda( - program_id: &Address, - payer: &AccountView, - pda: &AccountView, - size: u64, - owner: &Address, - seeds: [&[u8]; N], -) -> ProgramResult { - let (_, bump) = Address::find_program_address(&seeds, program_id); - let bump = [bump]; +pub struct CanonicalPda<'a, const N: usize> { + pub program_id: &'a Address, + pub payer: &'a AccountView, + pub pda: &'a AccountView, + pub size: u64, + pub owner: &'a Address, + pub seeds: [&'a [u8]; N], +} + +impl CanonicalPda<'_, N> { + /// Create the described account, funding it from `payer` and signing the + /// allocation with the canonical seeds. + #[must_use = "ignoring the output means processing continues without the PDA having been created"] + pub fn create(self) -> ProgramResult { + let (_, bump) = Address::find_program_address(&self.seeds, self.program_id); + let bump = [bump]; - // A PDA has at most `MAX_SEEDS` seeds, so `N` stays well below `usize::MAX` - // and the `N + 1` below cannot overflow. Asserting it in a `const` block - // makes that a compile-time guarantee rather than a runtime risk. - const { assert!(N < MAX_SEEDS, "a PDA has at most MAX_SEEDS seeds") }; + // A PDA has at most `MAX_SEEDS` seeds, so `N` stays well below + // `usize::MAX` and the `N + 1` below cannot overflow. Asserting it in a + // `const` block makes that a compile-time guarantee rather than a + // runtime risk. + const { assert!(N < MAX_SEEDS, "a PDA has at most MAX_SEEDS seeds") }; - // The signer needs the base seeds followed by the bump. Stable Rust can't - // name `[Seed; N + 1]`, so collect into a `Vec` sized for exactly that: - // the `N` base seeds plus the trailing bump. - let mut signer_seeds = Vec::with_capacity(const { N + 1 }); - signer_seeds.extend(seeds.iter().map(|seed| Seed::from(*seed))); - signer_seeds.push(Seed::from(&bump[..])); - let signer = Signer::from(&signer_seeds[..]); + // The signer needs the base seeds followed by the bump. Stable Rust + // can't name `[Seed; N + 1]`, so collect into a `Vec` sized for exactly + // that: the `N` base seeds plus the trailing bump. + let mut signer_seeds = Vec::with_capacity(const { N + 1 }); + signer_seeds.extend(self.seeds.iter().map(|seed| Seed::from(*seed))); + signer_seeds.push(Seed::from(&bump[..])); + let signer = Signer::from(&signer_seeds[..]); - CreateAccount::with_minimum_balance(payer, pda, size, owner, None)?.invoke_signed(&[signer])?; - Ok(()) + CreateAccount::with_minimum_balance(self.payer, self.pda, self.size, self.owner, None)? + .invoke_signed(&[signer])?; + Ok(()) + } } #[cfg(test)] From 9984f63fccdb531666b788c4e902fbd45ae7ce4c Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:22:28 +0200 Subject: [PATCH 20/23] Test that buffers can receive funds --- programs/settlement/tests/common/token.rs | 43 +++++++++++++++++++++- programs/settlement/tests/create_buffer.rs | 36 +++++++++++++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/programs/settlement/tests/common/token.rs b/programs/settlement/tests/common/token.rs index 3202539..4fd524b 100644 --- a/programs/settlement/tests/common/token.rs +++ b/programs/settlement/tests/common/token.rs @@ -1,7 +1,7 @@ //! SPL Token helpers for the settlement integration tests. use litesvm::LiteSVM; -use litesvm_token::CreateMint; +use litesvm_token::{CreateAssociatedTokenAccount, CreateMint, MintTo, Transfer}; use solana_sdk::{pubkey::Pubkey, signature::Keypair}; /// Create a fresh mint owned by `payer` and return its address. @@ -10,3 +10,44 @@ pub fn create_mint(svm: &mut LiteSVM, payer: &Keypair) -> Pubkey { .send() .expect("mint creation should succeed") } + +/// Create `owner`'s associated token account for `mint` and return its address. +pub fn create_token_account( + svm: &mut LiteSVM, + payer: &Keypair, + owner: &Pubkey, + mint: &Pubkey, +) -> Pubkey { + CreateAssociatedTokenAccount::new(svm, payer, mint) + .owner(owner) + .send() + .expect("associated token account creation should succeed") +} + +/// Mint `amount` of `mint` into `destination`, signed by `payer` as the mint +/// authority. `payer` must be the authority `create_mint` assigned to the mint. +pub fn mint_to( + svm: &mut LiteSVM, + payer: &Keypair, + mint: &Pubkey, + destination: &Pubkey, + amount: u64, +) { + MintTo::new(svm, payer, mint, destination, amount) + .send() + .expect("mint_to should succeed"); +} + +/// Transfer `amount` of `mint` from `owner`'s associated token account into +/// `destination`, signed by `owner` as the source authority. +pub fn transfer( + svm: &mut LiteSVM, + owner: &Keypair, + mint: &Pubkey, + destination: &Pubkey, + amount: u64, +) { + Transfer::new(svm, owner, mint, destination, amount) + .send() + .expect("transfer should succeed"); +} diff --git a/programs/settlement/tests/create_buffer.rs b/programs/settlement/tests/create_buffer.rs index 47e2db6..3614384 100644 --- a/programs/settlement/tests/create_buffer.rs +++ b/programs/settlement/tests/create_buffer.rs @@ -14,7 +14,10 @@ use settlement_client::settlement_interface::{ }, }; use solana_sdk::{ - instruction::InstructionError, program_pack::Pack, pubkey::Pubkey, signature::Signer, + instruction::InstructionError, + program_pack::Pack, + pubkey::Pubkey, + signature::{Keypair, Signer}, transaction::TransactionError, }; @@ -84,6 +87,37 @@ fn happy_path_creates_initialized_buffer_token_account() { ); } +#[test] +fn buffer_can_receive_tokens() { + let (mut svm, program_id, payer) = common::setup(); + let mint = common::token::create_mint(&mut svm, &payer); + let (buffer_pda, _bump) = find_buffer_pda(&program_id, &mint); + + let ix = create_buffer(&program_id, &payer.pubkey(), &mint); + let tx = common::signed_tx(&svm, &payer, &payer, ix); + svm.send_transaction(tx) + .expect("create_buffer should succeed"); + + // Fund a sender by minting into its own token account, then have the sender + // transfer those tokens into the buffer. + let sender = Keypair::new(); + svm.airdrop(&sender.pubkey(), 1_000_000_000) + .expect("airdrop to sender should succeed"); + let sender_account = + common::token::create_token_account(&mut svm, &sender, &sender.pubkey(), &mint); + + let amount = 1_000; + common::token::mint_to(&mut svm, &payer, &mint, &sender_account, amount); + common::token::transfer(&mut svm, &sender, &mint, &buffer_pda, amount); + + let token_account = get_spl_account::(&svm, &buffer_pda) + .expect("buffer must be an initialized token account"); + assert_eq!( + token_account.amount, amount, + "buffer must hold the tokens transferred to it" + ); +} + #[test] fn happy_path_creates_native_token_buffer() { // The native mint is special-cased by the token program: it's recognized by From decf38059f0f43125365e4519182def7db3e5c81 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:45:55 +0200 Subject: [PATCH 21/23] Use IntoIter instead of Iter --- programs/settlement/src/settle.rs | 41 +++++++++++++++++-------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/programs/settlement/src/settle.rs b/programs/settlement/src/settle.rs index 9f434af..b3be20a 100644 --- a/programs/settlement/src/settle.rs +++ b/programs/settlement/src/settle.rs @@ -23,7 +23,7 @@ struct SettledOrder<'a> { /// Struct storing account and bumps from parsing the input of BeginSettle. /// We want parsing to provide the data in an ergonomic format. This struct -/// implements `Iterator`, which returns all available information for each +/// implements `IntoIterator`, yielding all available information for each /// order without further parsing steps. The parsing step that created this /// struct guarantees that there aren't missing elements or that they are /// assigned incorrectly. @@ -32,19 +32,23 @@ struct SettledOrders<'a> { bumps: &'a [u8], } -impl<'a> Iterator for SettledOrders<'a> { +impl<'a> IntoIterator for SettledOrders<'a> { type Item = SettledOrder<'a>; - - fn next(&mut self) -> Option { - let ([order_pda, sell_token_account], accounts) = self.accounts.split_first()?; - let (&bump, bumps) = self.bumps.split_first()?; - self.accounts = accounts; - self.bumps = bumps; - Some(SettledOrder { - order_pda, - sell_token_account, - bump, - }) + type IntoIter = std::iter::Map< + std::iter::Zip, std::slice::Iter<'a, u8>>, + fn((&'a [AccountView; 2], &'a u8)) -> SettledOrder<'a>, + >; + + fn into_iter(self) -> Self::IntoIter { + // A non-capturing closure coerced to a function pointer so the iterator + // type stays nameable in `IntoIter` above. + let pair_to_order: fn((&'a [AccountView; 2], &'a u8)) -> SettledOrder<'a> = + |([order_pda, sell_token_account], &bump)| SettledOrder { + order_pda, + sell_token_account, + bump, + }; + self.accounts.iter().zip(self.bumps).map(pair_to_order) } } @@ -206,7 +210,7 @@ fn validate_no_nested_settlement>( /// controls. fn validate_settled_orders<'a>( program_id: &Address, - orders: impl Iterator>, + orders: impl IntoIterator>, ) -> ProgramResult { // Orders must be passed strictly increasing by address; this rejects // duplicates (settling the same order twice) without a separate scan. @@ -345,7 +349,7 @@ mod tests { } = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); assert_eq!(finalize_ix_index, 0x1337); assert_eq!(instructions_sysvar_account.address(), &address); - assert_eq!(orders.count(), 0); + assert_eq!(orders.into_iter().count(), 0); } #[test] @@ -424,11 +428,12 @@ mod tests { let BeginSettleInput { finalize_ix_index, instructions_sysvar_account, - mut orders, + orders, } = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); assert_eq!(finalize_ix_index, 0x1337); assert_eq!(instructions_sysvar_account.address(), &sysvar); + let mut orders = orders.into_iter(); let order = orders.next().expect("one settled order"); assert_eq!(order.order_pda.address(), &order_pda); assert_eq!(order.sell_token_account.address(), &sell_token); @@ -462,7 +467,7 @@ mod tests { } let parsed = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); - let orders: Vec<_> = parsed.orders.collect(); + let orders: Vec<_> = parsed.orders.into_iter().collect(); assert_eq!(orders.len(), ORDER_COUNT); for (order, (order_pda, sell_token, bump)) in orders.iter().zip(&expected) { @@ -553,7 +558,7 @@ mod tests { &INSTRUCTIONS_SYSVAR_ID, )); - let parsed_orders: Vec<_> = parsed.orders.collect(); + let parsed_orders: Vec<_> = parsed.orders.into_iter().collect(); prop_assert_eq!(parsed_orders.len(), expected.len()); for (order, (order_pda, sell_token, bump)) in parsed_orders.iter().zip(&expected) { prop_assert!(address_matches_pubkey(order.order_pda.address(), order_pda)); From 94f24a37dca43b3791e07f65b30a952a6f9ab9d3 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:54:39 +0200 Subject: [PATCH 22/23] Simplify expression --- programs/settlement/src/settle.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/programs/settlement/src/settle.rs b/programs/settlement/src/settle.rs index b3be20a..44572ce 100644 --- a/programs/settlement/src/settle.rs +++ b/programs/settlement/src/settle.rs @@ -237,9 +237,8 @@ fn validate_settled_orders<'a>( // Only at this point we can validate that the PDA is indeed a valid // order PDA by seeing its address matches the computed one. - let bump_seed = [bump]; let derived = - Address::create_program_address(&order_pda_signer_seeds(&uid, &bump_seed), program_id) + Address::create_program_address(&order_pda_signer_seeds(&uid, &[bump]), program_id) .map_err(|_| SettlementError::OrderNotCanonical)?; if &derived != order_pda.address() { return Err(SettlementError::OrderNotCanonical.into()); From 76f9e467c88f7954553280a801fe870e4ea043f8 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:34:32 +0200 Subject: [PATCH 23/23] Sort orders in interface --- client/src/instructions.rs | 23 ++++----- interface/src/instruction/settle.rs | 48 ++++++++++--------- programs/settlement/src/settle.rs | 4 +- .../settlement/tests/begin_settle_orders.rs | 46 +++++++++++------- 4 files changed, 65 insertions(+), 56 deletions(-) diff --git a/client/src/instructions.rs b/client/src/instructions.rs index b6c97c7..6156251 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -15,27 +15,20 @@ use settlement_interface::{ // We want the client to provide all instruction builders. pub use settlement_interface::instruction::settle::finalize_settle; -/// Build a `BeginSettle` instruction settling the specified orders. +/// Build a `BeginSettle` instruction settling the specified orders. The orders +/// may be supplied in any order; the interface builder sorts them by PDA address. pub fn begin_settle( program_id: &Pubkey, finalize_ix_index: u16, intents: &[OrderIntent], ) -> Instruction { - let mut orders: Vec<(Pubkey, Pubkey, u8)> = intents - .iter() - .map(|intent| { - let (order_pda, bump) = find_order_pda(program_id, &intent.uid()); - (order_pda, intent.sell_token_account, bump) - }) - .collect(); - orders.sort_by_key(|(order_pda, _, _)| *order_pda); - - let mut order_pdas = Vec::with_capacity(orders.len()); - let mut sell_token_accounts = Vec::with_capacity(orders.len()); - let mut bumps = Vec::with_capacity(orders.len()); - for (order_pda, sell_token_account, bump) in orders { + let mut order_pdas = Vec::with_capacity(intents.len()); + let mut sell_token_accounts = Vec::with_capacity(intents.len()); + let mut bumps = Vec::with_capacity(intents.len()); + for intent in intents { + let (order_pda, bump) = find_order_pda(program_id, &intent.uid()); order_pdas.push(order_pda); - sell_token_accounts.push(sell_token_account); + sell_token_accounts.push(intent.sell_token_account); bumps.push(bump); } settlement_interface::instruction::settle::begin_settle( diff --git a/interface/src/instruction/settle.rs b/interface/src/instruction/settle.rs index c7f13cf..56c5c0a 100644 --- a/interface/src/instruction/settle.rs +++ b/interface/src/instruction/settle.rs @@ -25,8 +25,9 @@ use crate::SettlementInstruction; // pair of accounts per order. /// /// The program requires the order PDAs to be strictly increasing by address. -/// The builder assumes the input already satisfies this: it emits the bumps and -/// account metas in the order given, without reordering. +/// This builder establishes that ordering for the caller: it sorts the orders by +/// PDA address (carrying each order's sell token account and bump along) before +/// emitting them. pub fn begin_settle( program_id: &Pubkey, finalize_ix_index: u16, @@ -34,19 +35,21 @@ pub fn begin_settle( sell_token_accounts: &[Pubkey], bumps: &[u8], ) -> Instruction { - let data = [ - &[SettlementInstruction::BeginSettle.discriminator()][..], - &finalize_ix_index.to_be_bytes()[..], - bumps, - ] - .concat(); + // Sort the three parallel lists together by order PDA address via a shared + // permutation, so each order keeps its own sell token account and bump. + let mut order: Vec = (0..order_pdas.len()).collect(); + order.sort_by_key(|&i| order_pdas[i]); + + let data = std::iter::once(SettlementInstruction::BeginSettle.discriminator()) + .chain(finalize_ix_index.to_be_bytes()) + .chain(order.iter().map(|&i| bumps[i])) + .collect(); let accounts = std::iter::once(INSTRUCTIONS_SYSVAR_ID) .chain( - order_pdas + order .iter() - .zip(sell_token_accounts) - .flat_map(|(order_pda, sell_token_account)| [*order_pda, *sell_token_account]), + .flat_map(|&i| [order_pdas[i], sell_token_accounts[i]]), ) .map(|pubkey| AccountMeta::new_readonly(pubkey, false)) .collect(); @@ -137,24 +140,25 @@ mod tests { } #[test] - fn begin_settle_emits_unsorted_orders() { + fn begin_settle_sorts_orders_by_pda() { let program_id = Pubkey::new_unique(); - // Two orders, sorted in the wrong way. Bad sorting stresses that the - // instruction builder doesn't validate the input. - let low_order_pda = Pubkey::new_from_array([0xb; 32]); - let low_sell_token_account = Pubkey::new_from_array([0xbb; 32]); - let low_bump = 0xbb; - let high_order_pda = Pubkey::new_from_array([0xa; 32]); - let high_sell_token_account = Pubkey::new_from_array([0xaa; 32]); + // Two orders supplied in descending PDA order. All the other parameters + // are chosen to sort in the opposite order. + let high_order_pda = Pubkey::new_from_array([0xbb; 32]); + let high_sell_token_account = Pubkey::new_from_array([0xa0; 32]); let high_bump = 0xaa; + let low_order_pda = Pubkey::new_from_array([0xaa; 32]); + let low_sell_token_account = Pubkey::new_from_array([0xb0; 32]); + let low_bump = 0xbb; let ix = begin_settle( &program_id, 0x1337, - &[low_order_pda, high_order_pda], - &[low_sell_token_account, high_sell_token_account], - &[low_bump, high_bump], + &[high_order_pda, low_order_pda], + &[high_sell_token_account, low_sell_token_account], + &[high_bump, low_bump], ); + // Bumps follow the sorted order: the low PDA's bump comes first. assert_eq!( ix.data, [ diff --git a/programs/settlement/src/settle.rs b/programs/settlement/src/settle.rs index 44572ce..0cbb47c 100644 --- a/programs/settlement/src/settle.rs +++ b/programs/settlement/src/settle.rs @@ -517,8 +517,8 @@ mod tests { proptest! { // The client's `begin_settle` builder derives each order's PDA from its - // intent, sorts the orders by PDA address, and lays out the accounts and - // bumps so that the on-chain parser recovers exactly those orders. + // intent and forwards to the interface builder so that the on-chain + // parser recovers exactly those orders. #[test] fn client_begin_settle_derives_orders_from_intents( finalize_ix_index in any::(), diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index 848461e..1c005b4 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -15,9 +15,9 @@ use settlement_client::settlement_interface::{ intent::{OrderIntent, OrderKind}, order::{EncodedOrderAccount, OrderAccount}, }, - instruction::settle::begin_settle as raw_begin_settle, + instruction::settle::{begin_settle as begin_settle_from_pdas, INSTRUCTIONS_SYSVAR_ID}, pda::order::find_order_pda, - Instruction, SettlementError, + AccountMeta, Instruction, SettlementError, SettlementInstruction, }; use solana_sdk::{ instruction::InstructionError, @@ -131,7 +131,7 @@ fn rejects_wrong_bump() { &mut svm, &program_id, &payer, - raw_begin_settle( + begin_settle_from_pdas( &program_id, 1, &[order_pda], @@ -166,7 +166,7 @@ fn rejects_fabricated_program_owned_account() { &mut svm, &program_id, &payer, - raw_begin_settle(&program_id, 1, &[fake_order], &[sell_token], &[255]), + begin_settle_from_pdas(&program_id, 1, &[fake_order], &[sell_token], &[255]), ), SettlementError::OrderNotCanonical, ); @@ -186,7 +186,7 @@ fn rejects_non_order_account_in_order_slot() { &mut svm, &program_id, &payer, - raw_begin_settle(&program_id, 1, &[sell_token], &[sell_token], &[255]), + begin_settle_from_pdas(&program_id, 1, &[sell_token], &[sell_token], &[255]), ), InstructionError::InvalidAccountData, ); @@ -206,7 +206,7 @@ fn rejects_sell_token_account_mismatch() { &mut svm, &program_id, &payer, - raw_begin_settle(&program_id, 1, &[order_pda], &[wrong_sell_token], &[bump]), + begin_settle_from_pdas(&program_id, 1, &[order_pda], &[wrong_sell_token], &[bump]), ), SettlementError::SellTokenAccountMismatch, ); @@ -279,25 +279,37 @@ fn rejects_orders_in_wrong_address_order() { let (first_pda, first_bump) = find_order_pda(&program_id, &first.uid()); 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 builder would sort them, so build by hand: + // data is `[discriminator, finalize_ix_index (BE), bump-per-order...]` and + // accounts are `[instructions_sysvar, (order_pda, sell_token_account)...]`. let mut orders = [ - (first_pda, first_bump, first.sell_token_account), - (second_pda, second_bump, second.sell_token_account), + (first_pda, first.sell_token_account, first_bump), + (second_pda, second.sell_token_account, second_bump), ]; - orders.sort_by_key(|(pda, _, _)| *pda); - let [(lo_pda, lo_bump, lo_token), (hi_pda, hi_bump, hi_token)] = orders; + 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(orders.iter().map(|&(_, _, bump)| bump)); + + let mut accounts = vec![AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false)]; + for (order_pda, sell_token_account, _) in orders { + accounts.push(AccountMeta::new_readonly(order_pda, false)); + accounts.push(AccountMeta::new_readonly(sell_token_account, false)); + } assert_settlement_error( send_settlement( &mut svm, &program_id, &payer, - raw_begin_settle( - &program_id, - 1, - &[hi_pda, lo_pda], - &[hi_token, lo_token], - &[hi_bump, lo_bump], - ), + Instruction { + program_id, + accounts, + data, + }, ), SettlementError::OrdersNotStrictlyIncreasing, );