From e31ceb7c2b8808a1622c2ef512924425c4f73d75 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:47:34 +0200 Subject: [PATCH 1/6] Refactor: move ix parsing to interface --- Cargo.lock | 3 + Cargo.toml | 2 + client/Cargo.toml | 4 + client/src/instructions.rs | 68 +++ interface/Cargo.toml | 6 + interface/src/instruction/create_buffer.rs | 186 ++++++ interface/src/instruction/create_order.rs | 148 +++++ interface/src/instruction/initialize.rs | 93 +++ interface/src/instruction/mod.rs | 122 ++++ interface/src/instruction/settle.rs | 568 +++++++++++++++++- programs/settlement/src/create_buffer.rs | 192 +------ programs/settlement/src/create_order.rs | 155 +---- programs/settlement/src/initialize.rs | 95 +-- programs/settlement/src/lib.rs | 3 - programs/settlement/src/processor.rs | 55 +- programs/settlement/src/settle.rs | 640 +-------------------- programs/settlement/src/test_utils.rs | 55 -- 17 files changed, 1235 insertions(+), 1160 deletions(-) delete mode 100644 programs/settlement/src/test_utils.rs diff --git a/Cargo.lock b/Cargo.lock index a3ea60d..9957e2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2224,6 +2224,7 @@ dependencies = [ name = "settlement-client" version = "0.1.0" dependencies = [ + "proptest", "settlement-interface", ] @@ -2236,6 +2237,8 @@ dependencies = [ "hex-literal", "num_enum", "proptest", + "solana-account-view", + "solana-address 2.6.1", "solana-hash 3.1.0", "solana-instruction", "solana-program-error", diff --git a/Cargo.toml b/Cargo.toml index 25b9066..1a40590 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,8 @@ pinocchio-token = "0.6" proptest = "1" settlement-client = { path = "client" } settlement-interface = { path = "interface" } +solana-account-view = "2" +solana-address = "2" solana-hash = "3" solana-instruction = "3" solana-program-error = "3" diff --git a/client/Cargo.toml b/client/Cargo.toml index 8517f14..81d3643 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -6,5 +6,9 @@ edition.workspace = true [dependencies] settlement-interface.workspace = true +[dev-dependencies] +proptest.workspace = true +settlement-interface = { workspace = true, features = ["test-fixtures"] } + [lints] workspace = true diff --git a/client/src/instructions.rs b/client/src/instructions.rs index dc96cc4..7bc64bb 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -82,3 +82,71 @@ 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) } + +#[cfg(test)] +mod tests { + use super::*; + use ::proptest::{prelude::*, test_runner::TestCaseError}; + use settlement_interface::{ + data::intent::fixtures::arb_order_intent, + instruction::{ + fixtures::fake_account_from_array, + settle::{BeginSettleInput, INSTRUCTIONS_SYSVAR_ID}, + InstructionInputParsing, + }, + pda::order::find_order_pda, + }; + + proptest! { + // `begin_settle` derives each order's PDA from its intent and forwards to + // the interface builder so that the on-chain parser recovers exactly + // those orders. + #[test] + fn 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(); + // No pulls here: this test only checks that orders are derived and + // laid out correctly. + let orders: Vec = intents + .iter() + .map(|intent| SettledOrder { intent, pulls: &[] }) + .collect(); + let ix = begin_settle(&program_id, finalize_ix_index, &orders); + + // 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_eq!( + parsed.instructions_sysvar_account.address(), + &INSTRUCTIONS_SYSVAR_ID, + ); + + let parsed_orders: Vec<_> = parsed.orders.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_eq!(order.order_pda.address(), order_pda); + prop_assert_eq!(order.sell_token_account.address(), sell_token); + prop_assert_eq!(order.bump, *bump); + } + } + } +} diff --git a/interface/Cargo.toml b/interface/Cargo.toml index 4de3f1e..39163ad 100644 --- a/interface/Cargo.toml +++ b/interface/Cargo.toml @@ -8,6 +8,12 @@ arrayref.workspace = true derive_more.workspace = true num_enum.workspace = true proptest = { workspace = true, optional = true } +# `copy` makes `AccountView`/`Address` `Copy`, matching the program: it builds +# against Pinocchio (default `copy` features on), whose own `copy` forwards to +# this package as well: +# https://github.com/anza-xyz/pinocchio/blob/8758463e1bbddecc34fc6dccc7259091eacbb157/sdk/Cargo.toml#L27-L29 +solana-account-view = { workspace = true, features = ["copy"] } +solana-address.workspace = true solana-hash.workspace = true solana-instruction.workspace = true solana-program-error.workspace = true diff --git a/interface/src/instruction/create_buffer.rs b/interface/src/instruction/create_buffer.rs index 3483377..6006693 100644 --- a/interface/src/instruction/create_buffer.rs +++ b/interface/src/instruction/create_buffer.rs @@ -5,11 +5,14 @@ //! token authority. Each token is identified by its `mint` account; the buffer //! address must be the canonical PDA for that mint. +use solana_account_view::AccountView; use solana_instruction::{AccountMeta, Instruction}; +use solana_program_error::ProgramError; use solana_pubkey::Pubkey; pub use solana_system_interface::program::ID as SYSTEM_PROGRAM_ID; +use super::InstructionInputParsing; use crate::SettlementInstruction; /// The SPL Token program. Buffers are created as token accounts owned by this @@ -52,9 +55,192 @@ pub fn create_buffers( } } +/// Parsed inputs of a `CreateBuffer` instruction. +pub struct CreateBufferInput<'a> { + pub payer: &'a AccountView, + pub token_program: &'a AccountView, + /// One `[buffer_pda, mint]` pair per buffer to create. + pub buffers: &'a [[AccountView; 2]], +} + +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), system_program (R), token_program (R), + // (buffer_pda (W), mint (R))...]. The three shared accounts come first; + // the per-buffer pairs follow, one pair per buffer. The system program + // needs to be present for the `CreateAccount` CPI but isn't dereferenced + // here. + let [payer, _system, token_program, rest @ ..] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + // Group the trailing accounts into `[buffer_pda, mint]` pairs. Each + // buffer needs both, so a stray odd account left over is a malformed + // instruction. There must be at least one pair: an instruction that + // creates no buffers is rejected as a likely encoding issue. + let rest: &'a [AccountView] = rest; + let (buffers, remainder) = rest.as_chunks::<2>(); + if !remainder.is_empty() || buffers.is_empty() { + return Err(ProgramError::NotEnoughAccountKeys); + } + + Ok(Self { + payer, + token_program, + buffers, + }) + } +} + +/// Test scaffolding for `CreateBuffer` parsing and handling, shared by this +/// crate's tests and the settlement program's via the `test-fixtures` feature. +#[cfg(any(test, feature = "test-fixtures"))] +pub mod fixtures { + use solana_address::Address; + + use super::create_buffers; + + /// Number of accounts that don't depend on the number of buffers created: + /// payer, system program, and token program. + pub const NUM_SHARED_ACCOUNTS: usize = 3; + + /// `CreateBuffer` instruction data with placeholder addresses, for failure + /// cases where the input is irrelevant. + pub fn create_buffer_data() -> Vec { + let zero = Address::new_from_array([0; 32]); + create_buffers(&zero, &zero, &[(zero, zero)]).data + } +} + #[cfg(test)] mod tests { + use super::fixtures::{create_buffer_data, NUM_SHARED_ACCOUNTS}; use super::*; + use crate::instruction::fixtures::{ + fake_account, fake_account_from_array, fake_sequential_accounts, + }; + use solana_address::Address; + + #[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 system_program = fake_account_from_array([4; 32]); + let token_program = Address::new_from_array([3; 32]); + let buffer_pda = Address::new_from_array([5; 32]); + let mint = Address::new_from_array([6; 32]); + + let data = create_buffers(&program_id, &payer, &[(buffer_pda, mint)]).data; + let mut accounts = [ + fake_account(payer), + system_program, + fake_account(token_program), + fake_account(buffer_pda), + fake_account(mint), + ]; + + let CreateBufferInput { + payer: parsed_payer, + token_program: parsed_token_program, + buffers, + } = CreateBufferInput::parse(&data, &mut accounts).expect("parse should succeed"); + + assert_eq!(*parsed_payer.address(), payer); + assert_eq!(*parsed_token_program.address(), token_program); + assert_eq!(buffers.len(), 1, "one buffer is one (pda, mint) pair"); + assert_eq!(*buffers[0][0].address(), buffer_pda); + assert_eq!(*buffers[0][1].address(), mint); + } + + #[test] + fn create_buffer_input_parses_multiple_buffers() { + let program_id = Address::new_from_array([1; 32]); + let payer = Address::new_from_array([2; 32]); + let token_program = Address::new_from_array([3; 32]); + let buffer_a = Address::new_from_array([5; 32]); + let mint_a = Address::new_from_array([6; 32]); + let buffer_b = Address::new_from_array([7; 32]); + let mint_b = Address::new_from_array([8; 32]); + + let data = create_buffers( + &program_id, + &payer, + &[(buffer_a, mint_a), (buffer_b, mint_b)], + ) + .data; + let mut accounts = [ + fake_account(payer), + fake_account_from_array([4; 32]), + fake_account(token_program), + fake_account(buffer_a), + fake_account(mint_a), + fake_account(buffer_b), + fake_account(mint_b), + ]; + + let CreateBufferInput { buffers, .. } = + CreateBufferInput::parse(&data, &mut accounts).expect("parse should succeed"); + + assert_eq!( + buffers[0].each_ref().map(|a| *a.address()), + [buffer_a, mint_a] + ); + assert_eq!( + buffers[1].each_ref().map(|a| *a.address()), + [buffer_b, mint_b] + ); + } + + #[test] + fn create_buffer_input_rejects_zero_buffers() { + let data = vec![SettlementInstruction::CreateBuffer.discriminator()]; + // Only the three shared accounts, no (pda, mint) pairs. + let mut accounts = fake_sequential_accounts::(); + assert_eq!( + CreateBufferInput::parse(&data, &mut accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + "an instruction that creates no buffers is rejected", + ); + } + + #[test] + fn create_buffer_input_rejects_long_data() { + let mut data = create_buffer_data(); + data.push(0); // trailing byte + assert_eq!( + CreateBufferInput::parse(&data, &mut []).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn create_buffer_input_rejects_missing_accounts() { + let data = create_buffer_data(); + // Fewer than the three shared accounts. + let mut accounts = fake_sequential_accounts::<{ NUM_SHARED_ACCOUNTS - 1 }>(); + assert_eq!( + CreateBufferInput::parse(&data, &mut accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + ); + } + + #[test] + fn create_buffer_input_rejects_odd_pair_accounts() { + let data = create_buffer_data(); + // Three shared accounts plus a dangling account that can't form a pair. + let mut accounts = fake_sequential_accounts::<4>(); + assert_eq!( + CreateBufferInput::parse(&data, &mut accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + ); + } #[test] fn instruction_data_has_expected_layout() { diff --git a/interface/src/instruction/create_order.rs b/interface/src/instruction/create_order.rs index eb91434..21c94df 100644 --- a/interface/src/instruction/create_order.rs +++ b/interface/src/instruction/create_order.rs @@ -4,11 +4,14 @@ //! initial body bytes; the PDA's storage layout lives in //! [`crate::data::order::EncodedOrderAccount`]. +use solana_account_view::AccountView; use solana_instruction::{AccountMeta, Instruction}; +use solana_program_error::ProgramError; use solana_pubkey::Pubkey; pub use solana_system_interface::program::ID as SYSTEM_PROGRAM_ID; +use super::InstructionInputParsing; use crate::{data::intent::EncodedOrderIntent, SettlementInstruction}; /// Build a `CreateOrder` instruction. @@ -62,9 +65,154 @@ pub fn create_order( } } +/// Parsed inputs of a `CreateOrder` instruction. +pub struct CreateOrderInput<'a> { + pub intent_bytes: [u8; EncodedOrderIntent::SIZE], + pub owner: &'a AccountView, + pub created_by: &'a AccountView, + pub order_pda: &'a mut AccountView, +} + +impl<'a> InstructionInputParsing<'a> for CreateOrderInput<'a> { + const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::CreateOrder; + + fn parse_body( + instruction_data: &'a [u8], + accounts: &'a mut [AccountView], + ) -> Result { + // Body (discriminator already stripped): exactly the 150 intent bytes. + if instruction_data.len() != EncodedOrderIntent::SIZE { + return Err(ProgramError::InvalidInstructionData); + } + // Accounts: [owner (S), created_by (W,S), order_pda (W), some other + // account]. We check that there are four accounts because the + // instruction needs to specify `SYSTEM_PROGRAM_ID` as one of the + // signers. It doesn't have to be the fourth though. + let [owner, created_by, order_pda, _, ..] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + let intent_bytes: [u8; EncodedOrderIntent::SIZE] = + instruction_data.try_into().expect("length checked above"); + + Ok(Self { + intent_bytes, + owner, + created_by, + order_pda, + }) + } +} + +/// Test scaffolding for `CreateOrder` parsing and handling, shared by this +/// crate's tests and the settlement program's via the `test-fixtures` feature. +#[cfg(any(test, feature = "test-fixtures"))] +pub mod fixtures { + use solana_address::Address; + + use super::create_order; + use crate::data::intent::{ + fixtures::sample_intent, EncodedOrderIntent, OrderIntent, OrderKind, + }; + + /// Owner baked into [`valid_intent_bytes`]' sample intent. + pub const DEFAULT_OWNER: Address = Address::new_from_array([0x11; 32]); + + /// Number of accounts `CreateOrder` expects: owner, created_by, order PDA, + /// and the system program. + pub const NUM_ACCOUNTS: usize = 4; + + /// Canonical 150-byte intent payload for a valid sell order owned by + /// [`DEFAULT_OWNER`]. + pub fn valid_intent_bytes() -> [u8; EncodedOrderIntent::SIZE] { + (&EncodedOrderIntent::from(&OrderIntent { + owner: DEFAULT_OWNER, + ..sample_intent(OrderKind::Sell, true) + })) + .into() + } + + /// `CreateOrder` instruction data carrying `intent_bytes`, with placeholder + /// addresses for failure cases where the actual addresses don't matter. + pub fn default_order_data(intent_bytes: &[u8; EncodedOrderIntent::SIZE]) -> Vec { + let zero = Address::new_from_array([0; 32]); + create_order(&zero, &zero, &zero, &zero, intent_bytes).data + } +} + #[cfg(test)] mod tests { + use super::fixtures::{default_order_data, valid_intent_bytes, NUM_ACCOUNTS}; use super::*; + use crate::instruction::fixtures::{ + fake_account, fake_account_from_array, fake_sequential_accounts, + }; + use solana_address::Address; + + #[test] + fn create_order_input_parses_valid_input() { + let program_id = Address::new_from_array([21; 32]); + let owner = Address::new_from_array([22; 32]); + let created_by = Address::new_from_array([24; 32]); + let order_pda = Address::new_from_array([23; 32]); + let intent_bytes = valid_intent_bytes(); + + let data = create_order(&program_id, &owner, &created_by, &order_pda, &intent_bytes).data; + let mut accounts = [ + fake_account(owner), + fake_account(created_by), + fake_account(order_pda), + fake_account_from_array([4; 32]), + ]; + + let CreateOrderInput { + intent_bytes: derived_intent_bytes, + owner: derived_owner, + created_by: derived_created_by, + order_pda: derived_order_pda, + } = CreateOrderInput::parse(&data, &mut accounts).expect("parse should succeed"); + + assert_eq!(derived_intent_bytes, intent_bytes); + assert_eq!(*derived_order_pda.address(), order_pda); + assert_eq!(*derived_owner.address(), owner); + assert_eq!(*derived_created_by.address(), created_by); + } + + #[test] + fn create_order_input_rejects_short_data() { + let intent_bytes = valid_intent_bytes(); + let mut data = default_order_data(&intent_bytes); + data.pop(); + let mut accounts = fake_sequential_accounts::(); + assert_eq!( + CreateOrderInput::parse(&data, &mut accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn create_order_input_rejects_long_data() { + let intent_bytes = valid_intent_bytes(); + let mut data = default_order_data(&intent_bytes); + data.push(0); // trailing byte + let mut accounts = fake_sequential_accounts::(); + assert_eq!( + CreateOrderInput::parse(&data, &mut accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn create_order_input_rejects_missing_accounts() { + let intent_bytes = valid_intent_bytes(); + let data = default_order_data(&intent_bytes); + let mut accounts: Vec = fake_sequential_accounts::().into(); + accounts.pop(); + assert_eq!( + CreateOrderInput::parse(&data, &mut accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + ); + } #[test] fn instruction_data_has_expected_layout() { diff --git a/interface/src/instruction/initialize.rs b/interface/src/instruction/initialize.rs index f15123e..36e2ab0 100644 --- a/interface/src/instruction/initialize.rs +++ b/interface/src/instruction/initialize.rs @@ -2,11 +2,14 @@ //! //! Allocates the singleton settlement state PDA (see [`crate::pda::state`]). +use solana_account_view::AccountView; use solana_instruction::{AccountMeta, Instruction}; +use solana_program_error::ProgramError; use solana_pubkey::Pubkey; pub use solana_system_interface::program::ID as SYSTEM_PROGRAM_ID; +use super::InstructionInputParsing; use crate::SettlementInstruction; /// Build an `Initialize` instruction. @@ -40,9 +43,99 @@ pub fn initialize(program_id: &Pubkey, payer: &Pubkey, state_pda: &Pubkey) -> In } } +/// Parsed inputs of an `Initialize` instruction. +pub struct InitializeInput<'a> { + pub payer: &'a AccountView, + pub 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 }) + } +} + +/// Test scaffolding for `Initialize` parsing and handling, shared by this +/// crate's tests and the settlement program's via the `test-fixtures` feature. +#[cfg(any(test, feature = "test-fixtures"))] +pub mod fixtures { + use solana_address::Address; + + use super::initialize; + + /// Number of accounts `Initialize` expects: payer, state PDA, system program. + pub const NUM_ACCOUNTS: usize = 3; + + /// `Initialize` instruction data with placeholder addresses, for failure + /// cases where the actual addresses don't matter. + pub fn initialize_data() -> Vec { + let zero = Address::new_from_array([0; 32]); + initialize(&zero, &zero, &zero).data + } +} + #[cfg(test)] mod tests { + use super::fixtures::{initialize_data, NUM_ACCOUNTS}; use super::*; + use crate::instruction::fixtures::{fake_account_from_array, fake_sequential_accounts}; + use solana_address::Address; + + #[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 = 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 = fake_sequential_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 = fake_sequential_accounts::().into(); + accounts.pop(); + assert_eq!( + InitializeInput::parse(&data, &mut accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + ); + } #[test] fn instruction_data_has_expected_layout() { diff --git a/interface/src/instruction/mod.rs b/interface/src/instruction/mod.rs index e1f47bf..3c53184 100644 --- a/interface/src/instruction/mod.rs +++ b/interface/src/instruction/mod.rs @@ -4,7 +4,129 @@ //! settlement instructions, encoding their discriminator (see //! [`crate::SettlementInstruction`]) and laying out the required accounts. +use solana_account_view::AccountView; +use solana_program_error::ProgramError; + +use crate::{recover_discriminator, SettlementInstruction}; + pub mod create_buffer; pub mod create_order; pub mod initialize; pub mod settle; + +/// Shared components for parsing generic instruction input. +/// +/// Implementations declare which [`SettlementInstruction`] discriminator they +/// belong to and parse the remaining instruction data and accounts. The +/// discriminator check is shared via the default [`parse`] implementation; an +/// impl only needs to provide [`parse_body`]. +pub trait InstructionInputParsing<'a>: Sized { + const DISCRIMINATOR: SettlementInstruction; + + fn parse_body( + instruction_data: &'a [u8], + accounts: &'a mut [AccountView], + ) -> Result; + + fn parse( + instruction_data: &'a [u8], + accounts: &'a mut [AccountView], + ) -> Result { + match recover_discriminator(instruction_data)? { + (discriminator, remaining_data) if discriminator == Self::DISCRIMINATOR => { + Self::parse_body(remaining_data, accounts) + } + _ => Err(ProgramError::InvalidInstructionData), + } + } +} + +/// Account-building scaffolding shared by the parser unit tests in this crate +/// and the settlement program's own tests. +/// +/// Exposed under the `test-fixtures` feature (and unconditionally for this +/// crate's own `cargo test`) so both crates can build [`AccountView`]s without +/// duplicating the unsafe initializer below. +#[cfg(any(test, feature = "test-fixtures"))] +pub mod fixtures { + use solana_account_view::{AccountView, RuntimeAccount}; + use solana_address::Address; + + /// Build an `AccountView` based on the input `RuntimeAccount` and whose + /// data region is empty. + /// + /// This is trickier to do than it should be. There's no safe initializer for + /// `AccountView` in Pinocchio. The only initializer is: + /// https://docs.rs/solana-account-view/2.0.0/solana_account_view/struct.AccountView.html#method.new_unchecked + /// + /// `AccountView::new_unchecked` requires (1) a pointer to an initialized + /// `RuntimeAccount`, (2) immediately followed by exactly `data_len` bytes of + /// data. We satisfy (1) via `Box::new(RuntimeAccount::default())` (every + /// field is zero-initialized, then we overwrite `address`), and (2) by + /// setting `data_len = 0` so the trailing-data clause is vacuously true + /// regardless of what's actually in memory after the box. + /// + /// [`Box::leak`] keeps the backing alive for the rest of the test process: + /// a dropped `Box` or a returned stack slot would leave the pointer + /// dangling. We ignore the memory leak since this function is only intended to + /// use in tests. + /// https://doc.rust-lang.org/std/boxed/struct.Box.html#method.leak + /// + /// Every `AccountView` method is safe to call on the result. Header + /// accessors read fields out of the `RuntimeAccount`. Data-region accessors + /// hand out a zero-length slice, which [`core::slice::from_raw_parts`] (the + /// primitive underneath them) defines as sound for any non-null, aligned + /// pointer. This is true for us because the pointer itself comes boxed data + /// and not some manual allocation. + /// https://docs.rs/crate/solana-account-view/2.0.0/source/src/lib.rs#98-295 + /// https://doc.rust-lang.org/beta/core/slice/fn.from_raw_parts.html + pub fn fake_account_from(runtime_account: RuntimeAccount) -> AccountView { + let backing = Box::leak(Box::new(runtime_account)); + unsafe { AccountView::new_unchecked(backing as *mut RuntimeAccount) } + } + + pub fn fake_account(address: Address) -> AccountView { + fake_account_from(RuntimeAccount { + address, + ..Default::default() + }) + } + + 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])) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn input_parsing_rejects_different_discriminator() { + struct TestInputParsing {} + impl<'a> InstructionInputParsing<'a> for TestInputParsing { + const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::BeginSettle; + + fn parse_body( + _instruction_data: &'a [u8], + _accounts: &'a mut [AccountView], + ) -> Result { + Ok(Self {}) + } + } + + let mut data = [0; 42]; + let different_discriminator = SettlementInstruction::CreateOrder; + assert_ne!(TestInputParsing::DISCRIMINATOR, different_discriminator); + data[0] = different_discriminator.discriminator(); + assert_eq!( + TestInputParsing::parse(&data, &mut []).err(), + Some(ProgramError::InvalidInstructionData), + ); + } +} diff --git a/interface/src/instruction/settle.rs b/interface/src/instruction/settle.rs index 8e48711..25ab951 100644 --- a/interface/src/instruction/settle.rs +++ b/interface/src/instruction/settle.rs @@ -3,6 +3,7 @@ use std::vec; +use solana_account_view::AccountView; use solana_instruction::{AccountMeta, Instruction}; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; @@ -10,7 +11,8 @@ use solana_pubkey::Pubkey; pub use solana_sdk_ids::sysvar::instructions::ID as INSTRUCTIONS_SYSVAR_ID; pub use spl_token_interface::ID as SPL_TOKEN_PROGRAM_ID; -use crate::SettlementInstruction; +use super::InstructionInputParsing; +use crate::{SettlementError, SettlementInstruction}; /// A single transfer made when settling an order: `amount` tokens sent from the /// order's sell token account to `destination`. @@ -24,7 +26,7 @@ pub struct Pull { /// parallel lists: /// - `order_pdas[i]` is the canonical order PDA (see [`crate::pda::order`]) /// - `order_pda_bumps[i]` is the bump of the canonical order PDA -/// - `sell_token_accounts[i]` its sell token account of the order, +/// - `sell_token_accounts[i]` is the order's sell token account, /// - `pulls[i]` the list of [`Pull`]s to perform from that order's sell token /// account, each sending an amount from the `i`-th order sell token account /// to a destination. @@ -129,10 +131,212 @@ pub fn recover_counterpart(instruction_data: &[u8]) -> Result<(u16, &[u8]), Prog } } +/// A single settled order, resulted from parsing `BeginSettle`, together with +/// the funds to pull from its sell token account. +pub struct SettledOrder<'a> { + pub order_pda: &'a AccountView, + pub sell_token_account: &'a AccountView, + pub bump: u8, + /// Destination accounts for this order's transfers. + pub destinations: &'a [AccountView], + /// Transfer amounts (big-endian `u64`), one per destination. + pub amounts: &'a [[u8; 8]], +} + +/// Struct storing accounts, bumps, transfer counts, and amounts from parsing the +/// input of BeginSettle. The parsing step that created this struct guarantees +/// that there aren't missing elements or that they are assigned incorrectly. +pub struct SettledOrders<'a> { + /// Order accounts, laid out per order as + /// [order_accounts_1, order_accounts_2, ...] where + /// - each order_accounts is a series of accounts: + /// `order_pda_N, sell_token_account_N, destination_N_1, destination_N_2, ..., destination_N_M` + /// - and M is `counts[N]` + order_accounts: &'a [AccountView], + bumps: &'a [u8], + /// One transfer count per order, parallel to `bumps`. + counts: &'a [u8], + /// Transfer amounts (big-endian `u64`), shared across orders and + /// handed out `count` at a time. + amounts: &'a [[u8; 8]], +} + +impl<'a> SettledOrders<'a> { + /// Returns an iterator yielding one [`SettledOrder`] per step. + #[allow( + clippy::arithmetic_side_effects, + reason = "offsets are bounded by tx limits" + )] + pub fn iter(&self) -> impl Iterator> + '_ { + let order_count = self.bumps.len(); + let mut i = 0usize; + let mut account_offset = 0usize; + let mut amount_offset = 0usize; + std::iter::from_fn(move || { + if i >= order_count { + return None; + } + let bump = self.bumps[i]; + let count = usize::from(self.counts[i]); + i += 1; + + let order_pda = &self.order_accounts[account_offset]; + let sell_token_account = &self.order_accounts[account_offset + 1]; + let dest_start = account_offset + 2; + let dest_end = dest_start + count; + let destinations = &self.order_accounts[dest_start..dest_end]; + account_offset = dest_end; + + let amount_end = amount_offset + count; + let amounts = &self.amounts[amount_offset..amount_end]; + amount_offset = amount_end; + + Some(SettledOrder { + order_pda, + sell_token_account, + bump, + destinations, + amounts, + }) + }) + } +} + +/// 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 and that the number of +/// accounts and bumps is consistent. +pub struct BeginSettleInput<'a> { + pub finalize_ix_index: u16, + pub instructions_sysvar_account: &'a AccountView, + pub state_pda_account: &'a AccountView, + pub token_program_account: &'a AccountView, + pub orders: SettledOrders<'a>, +} + +/// This implementation defines how instruction bytes and accounts are laid out +/// in the transaction. It's the source of truth for deciding where the data +/// is stored. +impl<'a> InstructionInputParsing<'a> for BeginSettleInput<'a> { + const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::BeginSettle; + + fn parse_body( + instruction_data: &'a [u8], + accounts: &'a mut [AccountView], + ) -> Result { + let (finalize_ix_index, body) = recover_counterpart(instruction_data)?; + + let [instructions_sysvar_account, state_pda_account, token_program_account, order_accounts @ ..] = + accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + // The leading byte is the order count `n`; the bumps and counts each take + // `n` bytes and the remaining bytes are the amounts. `T` (total transfers) + // is the number of 8-byte amounts. Too few bytes for the order count, the + // bumps, or the counts, or a trailing amount that isn't a whole `u64`, + // means the data can't be parsed into the pull layout at all. + let (&order_count, body) = body + .split_first() + .ok_or(ProgramError::InvalidInstructionData)?; + let order_count = usize::from(order_count); + let take = |s: &'a [u8]| { + s.split_at_checked(order_count) + .ok_or(ProgramError::InvalidInstructionData) + }; + let (bumps, body) = take(body)?; + let (counts, amount_bytes) = take(body)?; + let (amounts, []) = amount_bytes.as_chunks::<8>() else { + return Err(ProgramError::InvalidInstructionData); + }; + let transfer_count = amounts.len(); + + // Each order contributes its order PDA, sell token account, and one + // destination per transfer, so the order accounts count is `2n + T`. + let expected_accounts = order_count + .checked_mul(2) + .and_then(|two_n| two_n.checked_add(transfer_count)) + .ok_or(ProgramError::InvalidInstructionData)?; + if order_accounts.len() != expected_accounts { + return Err(SettlementError::AccountCountNotMatchingOrderCount.into()); + } + + // The transfer counts must sum to `T` so that every destination account + // is matched to exactly one amount and the order accounts are consumed + // exactly by the iterator. + let counts_sum: usize = counts.iter().map(|&c| usize::from(c)).sum(); + if counts_sum != transfer_count { + return Err(SettlementError::TransferCountMismatch.into()); + } + + Ok(Self { + finalize_ix_index, + instructions_sysvar_account, + state_pda_account, + token_program_account, + orders: SettledOrders { + order_accounts, + bumps, + counts, + amounts, + }, + }) + } +} + +/// Parsed inputs (instruction-data fields + relevant accounts) of a +/// `FinalizeSettle` instruction. +/// +/// Strictly the raw extracted form. Fields are read from `instruction_data` and +/// `accounts` but **not validated** against runtime context except confirming +/// that the discriminator matches the desired input. +pub struct FinalizeSettleInput<'a> { + pub begin_ix_index: u16, + pub instructions_sysvar_account: &'a AccountView, +} + +impl<'a> InstructionInputParsing<'a> for FinalizeSettleInput<'a> { + const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::FinalizeSettle; + + fn parse_body( + instruction_data: &[u8], + accounts: &'a mut [AccountView], + ) -> Result { + let (begin_ix_index, _) = recover_counterpart(instruction_data)?; + let instructions_sysvar_account = + accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + Ok(Self { + begin_ix_index, + instructions_sysvar_account, + }) + } +} + #[cfg(test)] mod tests { use super::*; + use crate::instruction::fixtures::{ + fake_account, fake_account_from_array, fake_sequential_accounts, + }; use hex_literal::hex; + use solana_address::Address; + + /// The fixed accounts every `BeginSettle` carries before its order accounts: + /// the instructions sysvar, the settlement state PDA, and the token program. + const FIXED_ACCOUNTS: usize = 3; + + /// Builds an instruction-data byte vector from a list of field chunks, so a + /// test can spell out the wire layout one field per line without repeating + /// the `&[..][..]` slicing. Each chunk is anything sliceable to `[u8]` (a + /// byte array, a `Vec`, the result of `to_be_bytes()`, ...). + macro_rules! ix_data { + ($($chunk:expr),* $(,)?) => { + [$(&$chunk[..]),*].concat() + }; + } #[test] fn rejects_empty_payload() { @@ -355,4 +559,364 @@ mod tests { assert!(!ix.accounts[0].is_writable); assert!(!ix.accounts[0].is_signer); } + + #[test] + fn begin_settle_input_parses_valid_input() { + let sysvar = Address::new_from_array([0x42u8; 32]); + // The state-PDA and token-program slots are reserved but not surfaced. + let state = Address::new_from_array([0x43u8; 32]); + let token_program = Address::new_from_array([0x44u8; 32]); + let mut accounts = [ + fake_account(sysvar), + fake_account(state), + fake_account(token_program), + ]; + let data = ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + [0x13, 0x37], // finalize index + [0x00], // order count + ]; + let BeginSettleInput { + finalize_ix_index, + instructions_sysvar_account, + orders, + token_program_account, + state_pda_account, + } = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); + assert_eq!(finalize_ix_index, 0x1337); + assert_eq!(instructions_sysvar_account.address(), &sysvar); + assert_eq!(orders.iter().count(), 0); + assert_eq!(token_program_account.address(), &token_program); + assert_eq!(state_pda_account.address(), &state); + } + + #[test] + fn finalize_settle_input_parses_valid_input() { + let address = Address::new_from_array([0x42u8; 32]); + let mut accounts = [fake_account(address)]; + let data = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [0x13, 0x37], // begin index + ]; + 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] + fn begin_settle_input_rejects_different_discriminator() { + let data = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [0, 0], // finalize index + ]; + let mut accounts: [AccountView; 0] = []; + assert_eq!( + BeginSettleInput::parse(&data, &mut accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn finalize_settle_input_rejects_different_discriminator() { + let data = ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + [0, 0], // begin index + ]; + let mut accounts: [AccountView; 0] = []; + assert_eq!( + FinalizeSettleInput::parse(&data, &mut accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn begin_settle_input_rejects_empty_accounts() { + let data = ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + [0, 0], // finalize index + ]; + let mut accounts: [AccountView; 0] = []; + assert_eq!( + BeginSettleInput::parse(&data, &mut accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + ); + } + + #[test] + fn finalize_settle_input_rejects_empty_accounts() { + let data = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [0, 0], // begin index + ]; + let mut accounts: [AccountView; 0] = []; + assert_eq!( + FinalizeSettleInput::parse(&data, &mut accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + ); + } + + #[test] + fn begin_settle_input_parses_order_bumps_and_pairs() { + let sysvar = Address::new_from_array([1u8; 32]); + let state = Address::new_from_array([0xa1u8; 32]); + let token_program = Address::new_from_array([0xa2u8; 32]); + 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(state), + fake_account(token_program), + fake_account(order_pda), + fake_account(sell_token), + ]; + let data = ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + [0x13, 0x37], // finalize index + [0x01], // order count + [0xab], // one order's bump + [0x00], // that order's transfer count + ]; + let BeginSettleInput { + finalize_ix_index, + instructions_sysvar_account, + orders, + state_pda_account, + token_program_account, + } = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); + assert_eq!(finalize_ix_index, 0x1337); + assert_eq!(instructions_sysvar_account.address(), &sysvar); + assert_eq!(token_program_account.address(), &token_program); + assert_eq!(state_pda_account.address(), &state); + + let mut orders = orders.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); + assert_eq!(order.bump, 0xab); + assert_eq!(order.destinations.len(), 0); + assert!(orders.next().is_none()); + } + + #[test] + fn begin_settle_input_parses_transfers() { + let sysvar = Address::new_from_array([1u8; 32]); + let state = Address::new_from_array([0xa1u8; 32]); + let token_program = Address::new_from_array([0xa2u8; 32]); + let order_pda = Address::new_from_array([2u8; 32]); + let sell_token = Address::new_from_array([3u8; 32]); + let dest0 = Address::new_from_array([4u8; 32]); + let dest1 = Address::new_from_array([5u8; 32]); + let mut accounts = [ + fake_account(sysvar), + fake_account(state), + fake_account(token_program), + fake_account(order_pda), + fake_account(sell_token), + fake_account(dest0), + fake_account(dest1), + ]; + let data = ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + [0x13, 0x37], // finalize index + [0x01], // order count + [0xab], // bump + [0x02], // transfer count + 0x1122u64.to_be_bytes(), + 0x3344u64.to_be_bytes(), + ]; + + let BeginSettleInput { orders, .. } = + BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); + + let mut orders = orders.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); + assert_eq!(order.bump, 0xab); + let transfers: Vec<(&Address, u64)> = order + .destinations + .iter() + .zip(order.amounts) + .map(|(destination, amount)| (destination.address(), u64::from_be_bytes(*amount))) + .collect(); + assert_eq!(transfers, vec![(&dest0, 0x1122), (&dest1, 0x3344)]); + 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 three fixed accounts (`[0xff..]`, `[0xfe..]`, `[0xfd..]`) differ + // from every order/token address above. + let mut accounts = vec![ + fake_account_from_array([0xff; 32]), + fake_account_from_array([0xfe; 32]), + fake_account_from_array([0xfd; 32]), + ]; + let mut bumps = Vec::new(); + for &(order_pda, sell_token, bump) in &expected { + accounts.push(fake_account(order_pda)); + accounts.push(fake_account(sell_token)); + bumps.push(bump); + } + // Grouped data: discriminator, finalize index, order count, all bumps, + // then all transfer counts (every order has zero transfers). + let data = ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + [0x13, 0x37], // finalize index + [ORDER_COUNT as u8], // order count + bumps, + [0u8; ORDER_COUNT], + ]; + + let parsed = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); + let orders: Vec<_> = parsed.orders.iter().collect(); + + assert_eq!(orders.len(), ORDER_COUNT); + for (order, (order_pda, sell_token, bump)) in orders.iter().zip(&expected) { + assert_eq!(order.order_pda.address(), order_pda); + assert_eq!(order.sell_token_account.address(), sell_token); + assert_eq!(order.bump, *bump); + assert_eq!(order.destinations.len(), 0); + } + } + + #[test] + fn begin_settle_input_rejects_account_count_mismatch() { + // The body declares one order with no transfers, which needs exactly two + // order accounts (its order PDA and sell token account). Only one order + // account is supplied after the fixed accounts, so the number of accounts + // doesn't match the `2n + T` the body implies. + let mut accounts = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 1 }>(); + let data = ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + [0, 0], // finalize index + [0x01], // order count + [0xab], // the order's bump + [0x00], // the order's transfer count + ]; + assert_eq!( + BeginSettleInput::parse(&data, &mut accounts).err(), + Some(SettlementError::AccountCountNotMatchingOrderCount.into()), + ); + } + + #[test] + fn begin_settle_input_rejects_counts_not_summing_to_destinations() { + // One order whose two destination accounts (plus its order PDA and sell + // token account) make the lengths recover T = 2 transfers, but the + // transfer-count byte claims only one. + let mut accounts = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 4 }>(); + let data = ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + [0, 0], // finalize index + [0x01], // order count + [0xab], // bump + [0x01], // count says one, but two amounts/destinations exist + 0u64.to_be_bytes(), + 0u64.to_be_bytes(), + ]; + assert_eq!( + BeginSettleInput::parse(&data, &mut accounts).err(), + Some(SettlementError::TransferCountMismatch.into()), + ); + } + + #[test] + fn begin_settle_input_rejects_missing_order_count() { + // The body carries the finalize index but no order-count byte, so the + // pull layout can't even begin to be parsed. + let mut accounts = fake_sequential_accounts::(); + let data = ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + [0, 0], // finalize index + ]; + assert_eq!( + BeginSettleInput::parse(&data, &mut accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn begin_settle_input_rejects_body_too_short_for_bumps() { + // The order count claims two orders, but only one bump byte follows, so + // the bumps can't be split off. + let mut accounts = fake_sequential_accounts::(); + let data = ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + [0, 0], // finalize index + [0x02], // order count: two orders... + [0xab], // ...but only one bump byte + ]; + assert_eq!( + BeginSettleInput::parse(&data, &mut accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn begin_settle_input_rejects_body_too_short_for_counts() { + // One order with its bump, but no transfer-count byte after it, so the + // counts can't be split off. + let mut accounts = fake_sequential_accounts::(); + let data = ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + [0, 0], // finalize index + [0x01], // order count + [0xab], // the order's bump, with no transfer count after it + ]; + assert_eq!( + BeginSettleInput::parse(&data, &mut accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn begin_settle_input_rejects_partial_amount() { + // One order with no transfers, but four trailing bytes that don't form a + // whole `u64` amount. + let mut accounts = fake_sequential_accounts::(); + let data = ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + [0, 0], // finalize index + [0x01], // order count + [0xab], // bump + [0x00], // transfer count + [0x11, 0x22, 0x33, 0x44], // a partial (4-byte) amount + ]; + assert_eq!( + BeginSettleInput::parse(&data, &mut accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn finalize_settle_input_ignores_extra_parameters() { + let first_address = Address::new_from_array([1u8; 32]); + let second_address = Address::new_from_array([2u8; 32]); + let mut accounts = [fake_account(first_address), fake_account(second_address)]; + let data = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [0x13, 0x37], // begin index + [42], // extra + ]; + 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); + } } diff --git a/programs/settlement/src/create_buffer.rs b/programs/settlement/src/create_buffer.rs index f0e5b31..bfa8c85 100644 --- a/programs/settlement/src/create_buffer.rs +++ b/programs/settlement/src/create_buffer.rs @@ -3,67 +3,25 @@ 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, + instruction::{ + create_buffer::{CreateBufferInput, SPL_TOKEN_PROGRAM_ID}, + InstructionInputParsing, + }, pda::{buffer::buffer_pda_seeds, state::state_pda_seeds}, - SettlementInstruction, }; -use crate::processor::{CanonicalPda, InstructionInputParsing}; +use crate::processor::CanonicalPda; struct CreateBufferEntry { buffer_pda: AccountView, mint: AccountView, } -/// Read one slice element into a [`CreateBufferEntry`]. +/// Read one slice element into a [`CreateBufferEntry`]. fn read_buffer_entry(&[buffer_pda, mint]: &[AccountView; 2]) -> CreateBufferEntry { CreateBufferEntry { buffer_pda, mint } } -/// Parsed inputs of a `CreateBuffer` instruction. -struct CreateBufferInput<'a> { - payer: &'a AccountView, - token_program: &'a AccountView, - /// One `[buffer_pda, mint]` pair per buffer to create. - buffers: &'a [[AccountView; 2]], -} - -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), system_program (R), token_program (R), - // (buffer_pda (W), mint (R))...]. The three shared accounts come first; - // the per-buffer pairs follow, one pair per buffer. The system program - // needs to be present for the `CreateAccount` CPI but isn't dereferenced - // here. - let [payer, _system, token_program, rest @ ..] = accounts else { - return Err(ProgramError::NotEnoughAccountKeys); - }; - // Group the trailing accounts into `[buffer_pda, mint]` pairs. Each - // buffer needs both, so a stray odd account left over is a malformed - // instruction. There must be at least one pair: an instruction that - // creates no buffers is rejected as a likely encoding issue. - let rest: &'a [AccountView] = rest; - let (buffers, remainder) = rest.as_chunks::<2>(); - if !remainder.is_empty() || buffers.is_empty() { - return Err(ProgramError::NotEnoughAccountKeys); - } - - Ok(Self { - payer, - token_program, - buffers, - }) - } -} - pub fn process_create_buffer( program_id: &Address, accounts: &mut [AccountView], @@ -116,140 +74,10 @@ pub fn process_create_buffer( #[cfg(test)] mod tests { use super::*; - use crate::test_utils::{fake_account, fake_account_from_array, fake_sequential_accounts}; - - /// Number of accounts that don't depend on the number of buffers created. - const NUM_SHARED_ACCOUNTS: usize = 3; - - // 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_buffers( - &zero, - &zero, - &[(zero, zero)], - ) - .data - } - - #[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 system_program = fake_account_from_array([4; 32]); - let token_program = Address::new_from_array([3; 32]); - let buffer_pda = Address::new_from_array([5; 32]); - let mint = Address::new_from_array([6; 32]); - - let data = settlement_interface::instruction::create_buffer::create_buffers( - &program_id, - &payer, - &[(buffer_pda, mint)], - ) - .data; - let mut accounts = [ - fake_account(payer), - system_program, - fake_account(token_program), - fake_account(buffer_pda), - fake_account(mint), - ]; - - let CreateBufferInput { - payer: parsed_payer, - token_program: parsed_token_program, - buffers, - } = CreateBufferInput::parse(&data, &mut accounts).expect("parse should succeed"); - - assert_eq!(*parsed_payer.address(), payer); - assert_eq!(*parsed_token_program.address(), token_program); - assert_eq!(buffers.len(), 1, "one buffer is one (pda, mint) pair"); - assert_eq!(*buffers[0][0].address(), buffer_pda); - assert_eq!(*buffers[0][1].address(), mint); - } - - #[test] - fn create_buffer_input_parses_multiple_buffers() { - let program_id = Address::new_from_array([1; 32]); - let payer = Address::new_from_array([2; 32]); - let token_program = Address::new_from_array([3; 32]); - let buffer_a = Address::new_from_array([5; 32]); - let mint_a = Address::new_from_array([6; 32]); - let buffer_b = Address::new_from_array([7; 32]); - let mint_b = Address::new_from_array([8; 32]); - - let data = settlement_interface::instruction::create_buffer::create_buffers( - &program_id, - &payer, - &[(buffer_a, mint_a), (buffer_b, mint_b)], - ) - .data; - let mut accounts = [ - fake_account(payer), - fake_account_from_array([4; 32]), - fake_account(token_program), - fake_account(buffer_a), - fake_account(mint_a), - fake_account(buffer_b), - fake_account(mint_b), - ]; - - let CreateBufferInput { buffers, .. } = - CreateBufferInput::parse(&data, &mut accounts).expect("parse should succeed"); - - assert_eq!( - buffers[0].each_ref().map(|a| *a.address()), - [buffer_a, mint_a] - ); - assert_eq!( - buffers[1].each_ref().map(|a| *a.address()), - [buffer_b, mint_b] - ); - } - - #[test] - fn create_buffer_input_rejects_zero_buffers() { - let data = vec![SettlementInstruction::CreateBuffer.discriminator()]; - // Only the three shared accounts, no (pda, mint) pairs. - let mut accounts = fake_sequential_accounts::(); - assert_eq!( - CreateBufferInput::parse(&data, &mut accounts).err(), - Some(ProgramError::NotEnoughAccountKeys), - "an instruction that creates no buffers is rejected", - ); - } - - #[test] - fn create_buffer_input_rejects_long_data() { - let mut data = create_buffer_data(); - data.push(0); // trailing byte - assert_eq!( - CreateBufferInput::parse(&data, &mut []).err(), - Some(ProgramError::InvalidInstructionData), - ); - } - - #[test] - fn create_buffer_input_rejects_missing_accounts() { - let data = create_buffer_data(); - // Fewer than the three shared accounts. - let mut accounts = fake_sequential_accounts::<{ NUM_SHARED_ACCOUNTS - 1 }>(); - assert_eq!( - CreateBufferInput::parse(&data, &mut accounts).err(), - Some(ProgramError::NotEnoughAccountKeys), - ); - } - - #[test] - fn create_buffer_input_rejects_odd_pair_accounts() { - let data = create_buffer_data(); - // Three shared accounts plus a dangling account that can't form a pair. - let mut accounts = fake_sequential_accounts::<4>(); - assert_eq!( - CreateBufferInput::parse(&data, &mut accounts).err(), - Some(ProgramError::NotEnoughAccountKeys), - ); - } + use settlement_interface::instruction::create_buffer::fixtures::{ + create_buffer_data, NUM_SHARED_ACCOUNTS, + }; + use settlement_interface::instruction::fixtures::fake_sequential_accounts; /// Arbitrary placeholder program id. The failure path exercised below /// returns before the program id is used for any syscall. diff --git a/programs/settlement/src/create_order.rs b/programs/settlement/src/create_order.rs index 530f9c7..ce73cdf 100644 --- a/programs/settlement/src/create_order.rs +++ b/programs/settlement/src/create_order.rs @@ -6,50 +6,12 @@ use settlement_interface::{ intent::EncodedOrderIntent, order::{self, EncodedOrderAccount}, }, + instruction::{create_order::CreateOrderInput, InstructionInputParsing}, pda::order::order_pda_seeds, - SettlementError, SettlementInstruction, + SettlementError, }; -use crate::processor::{CanonicalPda, InstructionInputParsing}; - -/// Parsed inputs of a `CreateOrder` instruction. -struct CreateOrderInput<'a> { - intent_bytes: [u8; EncodedOrderIntent::SIZE], - owner: &'a AccountView, - created_by: &'a AccountView, - order_pda: &'a mut AccountView, -} - -impl<'a> InstructionInputParsing<'a> for CreateOrderInput<'a> { - const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::CreateOrder; - - fn parse_body( - instruction_data: &'a [u8], - accounts: &'a mut [AccountView], - ) -> Result { - // Body (discriminator already stripped): exactly the 150 intent bytes. - if instruction_data.len() != EncodedOrderIntent::SIZE { - return Err(ProgramError::InvalidInstructionData); - } - // Accounts: [owner (S), created_by (W,S), order_pda (W), some other - // account]. We check that there are four accounts because the - // instruction needs to specify `SYSTEM_PROGRAM_ID` as one of the - // signers. It doesn't have to be the fourth though. - let [owner, created_by, order_pda, _, ..] = accounts else { - return Err(ProgramError::NotEnoughAccountKeys); - }; - - let intent_bytes: [u8; EncodedOrderIntent::SIZE] = - instruction_data.try_into().expect("length checked above"); - - Ok(Self { - intent_bytes, - owner, - created_by, - order_pda, - }) - } -} +use crate::processor::CanonicalPda; pub fn process_create_order( program_id: &Address, @@ -99,114 +61,17 @@ pub fn process_create_order( #[cfg(test)] mod tests { - use settlement_interface::data::intent::{fixtures::sample_intent, OrderIntent, OrderKind}; + use settlement_interface::data::intent::{OrderIntent, OrderKind}; + use settlement_interface::instruction::create_order::fixtures::{ + default_order_data, valid_intent_bytes, DEFAULT_OWNER, NUM_ACCOUNTS, + }; + use settlement_interface::instruction::fixtures::{ + fake_account, fake_account_from, fake_sequential_accounts, + }; use pinocchio::account::RuntimeAccount; use super::*; - 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, - ..sample_intent(OrderKind::Sell, true) - })) - .into() - } - - fn default_order_data(intent_bytes: &[u8; EncodedOrderIntent::SIZE]) -> Vec { - // We used this to test failure conditions where the actual addresses - // don't matter. - let zero = Address::new_from_array([0; 32]); - settlement_interface::instruction::create_order::create_order( - &zero, - &zero, - &zero, - &zero, - intent_bytes, - ) - .data - } - - #[test] - fn create_order_input_parses_valid_input() { - let program_id = Address::new_from_array([21; 32]); - let owner = Address::new_from_array([22; 32]); - let created_by = Address::new_from_array([24; 32]); - let order_pda = Address::new_from_array([23; 32]); - let intent_bytes = valid_intent_bytes(); - - let data = settlement_interface::instruction::create_order::create_order( - &program_id, - &owner, - &created_by, - &order_pda, - &intent_bytes, - ) - .data; - let mut accounts = [ - fake_account(owner), - fake_account(created_by), - fake_account(order_pda), - fake_account_from_array([4; 32]), - ]; - - let CreateOrderInput { - intent_bytes: derived_intent_bytes, - owner: derived_owner, - created_by: derived_created_by, - order_pda: derived_order_pda, - } = CreateOrderInput::parse(&data, &mut accounts).expect("parse should succeed"); - - assert_eq!(derived_intent_bytes, intent_bytes); - assert_eq!(*derived_order_pda.address(), order_pda); - assert_eq!(*derived_owner.address(), owner); - assert_eq!(*derived_created_by.address(), created_by); - } - - #[test] - fn create_order_input_rejects_short_data() { - let intent_bytes = valid_intent_bytes(); - let mut data = default_order_data(&intent_bytes); - data.pop(); - let mut accounts = fake_sequential_accounts::(); - assert_eq!( - CreateOrderInput::parse(&data, &mut accounts).err(), - Some(ProgramError::InvalidInstructionData), - ); - } - - #[test] - fn create_order_input_rejects_long_data() { - let intent_bytes = valid_intent_bytes(); - let mut data = default_order_data(&intent_bytes); - data.push(0); // trailing byte - let mut accounts = fake_sequential_accounts::(); - assert_eq!( - CreateOrderInput::parse(&data, &mut accounts).err(), - Some(ProgramError::InvalidInstructionData), - ); - } - - #[test] - fn create_order_input_rejects_missing_accounts() { - let intent_bytes = valid_intent_bytes(); - let data = default_order_data(&intent_bytes); - let mut accounts: Vec = fake_sequential_accounts::().into(); - accounts.pop(); - assert_eq!( - CreateOrderInput::parse(&data, &mut accounts).err(), - Some(ProgramError::NotEnoughAccountKeys), - ); - } /// Arbitrary placeholder program id for handler-level tests. The /// failure paths exercised below return before the program id is used diff --git a/programs/settlement/src/initialize.rs b/programs/settlement/src/initialize.rs index e4f9b5a..2ccf19e 100644 --- a/programs/settlement/src/initialize.rs +++ b/programs/settlement/src/initialize.rs @@ -1,36 +1,12 @@ //! `Initialize` instruction handler. -use pinocchio::{error::ProgramError, AccountView, Address, ProgramResult}; -use settlement_interface::{pda::state::state_pda_seeds, SettlementInstruction}; +use pinocchio::{AccountView, Address, ProgramResult}; +use settlement_interface::{ + instruction::{initialize::InitializeInput, InstructionInputParsing}, + pda::state::state_pda_seeds, +}; -use crate::processor::{CanonicalPda, 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 }) - } -} +use crate::processor::CanonicalPda; pub fn process_initialize( program_id: &Address, @@ -61,62 +37,9 @@ pub fn process_initialize( #[cfg(test)] mod tests { use super::*; - 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 { - let zero = Address::new_from_array([0; 32]); - settlement_interface::instruction::initialize::initialize(&zero, &zero, &zero).data - } - - #[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 = fake_sequential_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 = fake_sequential_accounts::().into(); - accounts.pop(); - assert_eq!( - InitializeInput::parse(&data, &mut accounts).err(), - Some(ProgramError::NotEnoughAccountKeys), - ); - } + use pinocchio::error::ProgramError; + use settlement_interface::instruction::fixtures::fake_sequential_accounts; + use settlement_interface::instruction::initialize::fixtures::{initialize_data, NUM_ACCOUNTS}; #[test] fn process_initialize_propagates_parse_error() { diff --git a/programs/settlement/src/lib.rs b/programs/settlement/src/lib.rs index a07b416..f5558e9 100644 --- a/programs/settlement/src/lib.rs +++ b/programs/settlement/src/lib.rs @@ -6,9 +6,6 @@ mod initialize; mod processor; mod settle; -#[cfg(test)] -mod test_utils; - use create_buffer::process_create_buffer; use create_order::process_create_order; use initialize::process_initialize; diff --git a/programs/settlement/src/processor.rs b/programs/settlement/src/processor.rs index 4e0417b..8b2f9d3 100644 --- a/programs/settlement/src/processor.rs +++ b/programs/settlement/src/processor.rs @@ -1,44 +1,15 @@ -//! Shared program plumbing: instruction-input parsing and PDA creation. +//! Shared program plumbing: canonical PDA creation. use pinocchio::{ address::MAX_SEEDS, cpi::{Seed, Signer}, - error::ProgramError, AccountView, Address, ProgramResult, }; use pinocchio_system::instructions::CreateAccount; -use settlement_interface::{recover_discriminator, SettlementInstruction}; use solana_instruction::{syscalls::get_stack_height, TRANSACTION_LEVEL_STACK_HEIGHT}; -/// Shared components for parsing generic instruction input. -/// -/// Implementations declare which [`SettlementInstruction`] discriminator they -/// belong to and parse the remaining instruction data and accounts. The -/// discriminator check is shared via the default [`parse`] implementation; an -/// impl only needs to provide [`parse_body`]. -pub trait InstructionInputParsing<'a>: Sized { - const DISCRIMINATOR: SettlementInstruction; - - fn parse_body( - instruction_data: &'a [u8], - accounts: &'a mut [AccountView], - ) -> Result; - - fn parse( - instruction_data: &'a [u8], - accounts: &'a mut [AccountView], - ) -> Result { - match recover_discriminator(instruction_data)? { - (discriminator, remaining_data) if discriminator == Self::DISCRIMINATOR => { - Self::parse_body(remaining_data, accounts) - } - _ => Err(ProgramError::InvalidInstructionData), - } - } -} - /// Description of a canonical PDA to create: the account at `pda`, assigned to /// `owner` and funded by `payer`. /// @@ -97,30 +68,6 @@ pub fn is_cpi_call() -> bool { mod tests { use super::*; - #[test] - fn input_parsing_rejects_different_discriminator() { - struct TestInputParsing {} - impl<'a> InstructionInputParsing<'a> for TestInputParsing { - const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::BeginSettle; - - fn parse_body( - _instruction_data: &'a [u8], - _accounts: &'a mut [AccountView], - ) -> Result { - Ok(Self {}) - } - } - - let mut data = [0; 42]; - let different_discriminator = SettlementInstruction::CreateOrder; - assert_ne!(TestInputParsing::DISCRIMINATOR, different_discriminator); - data[0] = different_discriminator.discriminator(); - assert_eq!( - TestInputParsing::parse(&data, &mut []).err(), - Some(ProgramError::InvalidInstructionData), - ); - } - #[test] fn is_cpi_false_outside_solana_lib() { assert!(!is_cpi_call()); diff --git a/programs/settlement/src/settle.rs b/programs/settlement/src/settle.rs index 799f20f..691b9c6 100644 --- a/programs/settlement/src/settle.rs +++ b/programs/settlement/src/settle.rs @@ -11,196 +11,16 @@ use pinocchio::{ use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; use settlement_interface::{ data::order::EncodedOrderAccount, - instruction::{create_buffer::SPL_TOKEN_PROGRAM_ID, settle::recover_counterpart}, + instruction::{ + create_buffer::SPL_TOKEN_PROGRAM_ID, + settle::{recover_counterpart, BeginSettleInput, FinalizeSettleInput, SettledOrder}, + InstructionInputParsing, + }, pda::{order::order_pda_signer_seeds, state::state_pda_seeds}, recover_discriminator, Pubkey, SettlementError, SettlementInstruction, }; -use crate::processor::{is_cpi_call, InstructionInputParsing}; - -/// A single settled order, resulted from parsing `BeginSettle`, together with -/// the funds to pull from its sell token account. -struct SettledOrder<'a> { - order_pda: &'a AccountView, - sell_token_account: &'a AccountView, - bump: u8, - /// Destination accounts for this order's transfers. - destinations: &'a [AccountView], - /// Transfer amounts (big-endian `u64`), one per destination. - amounts: &'a [[u8; 8]], -} - -/// Struct storing accounts, bumps, transfer counts, and amounts from parsing the -/// input of BeginSettle. The parsing step that created this struct guarantees -/// that there aren't missing elements or that they are assigned incorrectly. -struct SettledOrders<'a> { - /// Order accounts, laid out per order as - /// [order_accounts_1, order_accounts_2, ...] where - /// - each order_accounts is a series of accounts: - /// `order_pda_N, sell_token_account_N, destination_N_1, destination_N_2, ..., destination_N_M` - /// - and M is `counts[N]` - order_accounts: &'a [AccountView], - bumps: &'a [u8], - /// One transfer count per order, parallel to `bumps`. - counts: &'a [u8], - /// Transfer amounts (big-endian `u64`), shared across orders and - /// handed out `count` at a time. - amounts: &'a [[u8; 8]], -} - -impl<'a> SettledOrders<'a> { - /// Returns an iterator yielding one [`SettledOrder`] per step. - #[allow( - clippy::arithmetic_side_effects, - reason = "offsets are bounded by tx limits" - )] - fn iter(&self) -> impl Iterator> + '_ { - let order_count = self.bumps.len(); - let mut i = 0usize; - let mut account_offset = 0usize; - let mut amount_offset = 0usize; - std::iter::from_fn(move || { - if i >= order_count { - return None; - } - let bump = self.bumps[i]; - let count = usize::from(self.counts[i]); - i += 1; - - let order_pda = &self.order_accounts[account_offset]; - let sell_token_account = &self.order_accounts[account_offset + 1]; - let dest_start = account_offset + 2; - let dest_end = dest_start + count; - let destinations = &self.order_accounts[dest_start..dest_end]; - account_offset = dest_end; - - let amount_end = amount_offset + count; - let amounts = &self.amounts[amount_offset..amount_end]; - amount_offset = amount_end; - - Some(SettledOrder { - order_pda, - sell_token_account, - bump, - destinations, - amounts, - }) - }) - } -} - -/// 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 and that the number of -/// accounts and bumps is consistent. -struct BeginSettleInput<'a> { - finalize_ix_index: u16, - instructions_sysvar_account: &'a AccountView, - state_pda_account: &'a AccountView, - token_program_account: &'a AccountView, - orders: SettledOrders<'a>, -} - -/// This implementation defines how instruction bytes and accounts are laid out -/// in the transaction. It's the source of truth for deciding where the data -/// is stored. -impl<'a> InstructionInputParsing<'a> for BeginSettleInput<'a> { - const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::BeginSettle; - - fn parse_body( - instruction_data: &'a [u8], - accounts: &'a mut [AccountView], - ) -> Result { - let (finalize_ix_index, body) = recover_counterpart(instruction_data)?; - - let [instructions_sysvar_account, state_pda_account, token_program_account, order_accounts @ ..] = - accounts - else { - return Err(ProgramError::NotEnoughAccountKeys); - }; - - // The leading byte is the order count `n`; the bumps and counts each take - // `n` bytes and the remaining bytes are the amounts. `T` (total transfers) - // is the number of 8-byte amounts. Too few bytes for the order count, the - // bumps, or the counts, or a trailing amount that isn't a whole `u64`, - // means the data can't be parsed into the pull layout at all. - let (&order_count, body) = body - .split_first() - .ok_or(ProgramError::InvalidInstructionData)?; - let order_count = usize::from(order_count); - let take = |s: &'a [u8]| { - s.split_at_checked(order_count) - .ok_or(ProgramError::InvalidInstructionData) - }; - let (bumps, body) = take(body)?; - let (counts, amount_bytes) = take(body)?; - let (amounts, []) = amount_bytes.as_chunks::<8>() else { - return Err(ProgramError::InvalidInstructionData); - }; - let transfer_count = amounts.len(); - - // Each order contributes its order PDA, sell token account, and one - // destination per transfer, so the order accounts count is `2n + T`. - let expected_accounts = order_count - .checked_mul(2) - .and_then(|two_n| two_n.checked_add(transfer_count)) - .ok_or(ProgramError::InvalidInstructionData)?; - if order_accounts.len() != expected_accounts { - return Err(SettlementError::AccountCountNotMatchingOrderCount.into()); - } - - // The transfer counts must sum to `T` so that every destination account - // is matched to exactly one amount and the order accounts are consumed - // exactly by the iterator. - let counts_sum: usize = counts.iter().map(|&c| usize::from(c)).sum(); - if counts_sum != transfer_count { - return Err(SettlementError::TransferCountMismatch.into()); - } - - Ok(Self { - finalize_ix_index, - instructions_sysvar_account, - state_pda_account, - token_program_account, - orders: SettledOrders { - order_accounts, - bumps, - counts, - amounts, - }, - }) - } -} - -/// Parsed inputs (instruction-data fields + relevant accounts) of a -/// `FinalizeSettle` instruction. -/// -/// Strictly the raw extracted form. Fields are read from `instruction_data` and -/// `accounts` but **not validated** against runtime context except confirming -/// that the discriminator matches the desired input. -struct FinalizeSettleInput<'a> { - begin_ix_index: u16, - instructions_sysvar_account: &'a AccountView, -} - -impl<'a> InstructionInputParsing<'a> for FinalizeSettleInput<'a> { - const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::FinalizeSettle; - - fn parse_body( - instruction_data: &[u8], - accounts: &'a mut [AccountView], - ) -> Result { - let (begin_ix_index, _) = recover_counterpart(instruction_data)?; - let instructions_sysvar_account = - accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; - Ok(Self { - begin_ix_index, - instructions_sysvar_account, - }) - } -} +use crate::processor::is_cpi_call; pub fn process_begin_settle( program_id: &Address, @@ -338,7 +158,7 @@ fn pull_funds<'a>( Ok(()) } -/// Validate a singe order and process its pulls. +/// Validate a single order and process its pulls. /// This checks that the order is valid and settleable. Once the order passes /// those checks, its pulls are executed. #[must_use = "ignoring the output may lead to an unintended on-chain state"] @@ -480,449 +300,3 @@ fn validate_counterpart>( } Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_utils::{fake_account, fake_account_from_array, fake_sequential_accounts}; - use ::proptest::{prelude::*, test_runner::TestCaseError}; - use settlement_interface::{ - data::intent::fixtures::arb_order_intent, instruction::settle::INSTRUCTIONS_SYSVAR_ID, - pda::order::find_order_pda, - }; - - /// The fixed accounts every `BeginSettle` carries before its order accounts: - /// the instructions sysvar, the settlement state PDA, and the token program. - const FIXED_ACCOUNTS: usize = 3; - - /// Builds an instruction-data byte vector from a list of field chunks, so a - /// test can spell out the wire layout one field per line without repeating - /// the `&[..][..]` slicing. Each chunk is anything sliceable to `[u8]` (a - /// byte array, a `Vec`, the result of `to_be_bytes()`, ...). - macro_rules! ix_data { - ($($chunk:expr),* $(,)?) => { - [$(&$chunk[..]),*].concat() - }; - } - - #[test] - fn begin_settle_input_parses_valid_input() { - let sysvar = Address::new_from_array([0x42u8; 32]); - // The state-PDA and token-program slots are reserved but not surfaced. - let state = Address::new_from_array([0x43u8; 32]); - let token_program = Address::new_from_array([0x44u8; 32]); - let mut accounts = [ - fake_account(sysvar), - fake_account(state), - fake_account(token_program), - ]; - let data = ix_data![ - [SettlementInstruction::BeginSettle.discriminator()], - [0x13, 0x37], // finalize index - [0x00], // order count - ]; - let BeginSettleInput { - finalize_ix_index, - instructions_sysvar_account, - orders, - token_program_account, - state_pda_account, - } = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); - assert_eq!(finalize_ix_index, 0x1337); - assert_eq!(instructions_sysvar_account.address(), &sysvar); - assert_eq!(orders.iter().count(), 0); - assert_eq!(token_program_account.address(), &token_program); - assert_eq!(state_pda_account.address(), &state); - } - - #[test] - fn finalize_settle_input_parses_valid_input() { - let address = Address::new_from_array([0x42u8; 32]); - let mut accounts = [fake_account(address)]; - let data = ix_data![ - [SettlementInstruction::FinalizeSettle.discriminator()], - [0x13, 0x37], // begin index - ]; - 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] - fn begin_settle_input_rejects_different_discriminator() { - let data = ix_data![ - [SettlementInstruction::FinalizeSettle.discriminator()], - [0, 0], // finalize index - ]; - let mut accounts: [AccountView; 0] = []; - assert_eq!( - BeginSettleInput::parse(&data, &mut accounts).err(), - Some(ProgramError::InvalidInstructionData), - ); - } - - #[test] - fn finalize_settle_input_rejects_different_discriminator() { - let data = ix_data![ - [SettlementInstruction::BeginSettle.discriminator()], - [0, 0], // begin index - ]; - let mut accounts: [AccountView; 0] = []; - assert_eq!( - FinalizeSettleInput::parse(&data, &mut accounts).err(), - Some(ProgramError::InvalidInstructionData), - ); - } - - #[test] - fn begin_settle_input_rejects_empty_accounts() { - let data = ix_data![ - [SettlementInstruction::BeginSettle.discriminator()], - [0, 0], // finalize index - ]; - let mut accounts: [AccountView; 0] = []; - assert_eq!( - BeginSettleInput::parse(&data, &mut accounts).err(), - Some(ProgramError::NotEnoughAccountKeys), - ); - } - - #[test] - fn finalize_settle_input_rejects_empty_accounts() { - let data = ix_data![ - [SettlementInstruction::FinalizeSettle.discriminator()], - [0, 0], // begin index - ]; - let mut accounts: [AccountView; 0] = []; - assert_eq!( - FinalizeSettleInput::parse(&data, &mut accounts).err(), - Some(ProgramError::NotEnoughAccountKeys), - ); - } - - #[test] - fn begin_settle_input_parses_order_bumps_and_pairs() { - let sysvar = Address::new_from_array([1u8; 32]); - let state = Address::new_from_array([0xa1u8; 32]); - let token_program = Address::new_from_array([0xa2u8; 32]); - 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(state), - fake_account(token_program), - fake_account(order_pda), - fake_account(sell_token), - ]; - let data = ix_data![ - [SettlementInstruction::BeginSettle.discriminator()], - [0x13, 0x37], // finalize index - [0x01], // order count - [0xab], // one order's bump - [0x00], // that order's transfer count - ]; - let BeginSettleInput { - finalize_ix_index, - instructions_sysvar_account, - orders, - state_pda_account, - token_program_account, - } = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); - assert_eq!(finalize_ix_index, 0x1337); - assert_eq!(instructions_sysvar_account.address(), &sysvar); - assert_eq!(token_program_account.address(), &token_program); - assert_eq!(state_pda_account.address(), &state); - - let mut orders = orders.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); - assert_eq!(order.bump, 0xab); - assert_eq!(order.destinations.len(), 0); - assert!(orders.next().is_none()); - } - - #[test] - fn begin_settle_input_parses_transfers() { - let sysvar = Address::new_from_array([1u8; 32]); - let state = Address::new_from_array([0xa1u8; 32]); - let token_program = Address::new_from_array([0xa2u8; 32]); - let order_pda = Address::new_from_array([2u8; 32]); - let sell_token = Address::new_from_array([3u8; 32]); - let dest0 = Address::new_from_array([4u8; 32]); - let dest1 = Address::new_from_array([5u8; 32]); - let mut accounts = [ - fake_account(sysvar), - fake_account(state), - fake_account(token_program), - fake_account(order_pda), - fake_account(sell_token), - fake_account(dest0), - fake_account(dest1), - ]; - let data = ix_data![ - [SettlementInstruction::BeginSettle.discriminator()], - [0x13, 0x37], // finalize index - [0x01], // order count - [0xab], // bump - [0x02], // transfer count - 0x1122u64.to_be_bytes(), - 0x3344u64.to_be_bytes(), - ]; - - let BeginSettleInput { orders, .. } = - BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); - - let mut orders = orders.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); - assert_eq!(order.bump, 0xab); - let transfers: Vec<(&Address, u64)> = order - .destinations - .iter() - .zip(order.amounts) - .map(|(destination, amount)| (destination.address(), u64::from_be_bytes(*amount))) - .collect(); - assert_eq!(transfers, vec![(&dest0, 0x1122), (&dest1, 0x3344)]); - 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 three fixed accounts (`[0xff..]`, `[0xfe..]`, `[0xfd..]`) differ - // from every order/token address above. - let mut accounts = vec![ - fake_account_from_array([0xff; 32]), - fake_account_from_array([0xfe; 32]), - fake_account_from_array([0xfd; 32]), - ]; - let mut bumps = Vec::new(); - for &(order_pda, sell_token, bump) in &expected { - accounts.push(fake_account(order_pda)); - accounts.push(fake_account(sell_token)); - bumps.push(bump); - } - // Grouped data: discriminator, finalize index, order count, all bumps, - // then all transfer counts (every order has zero transfers). - let data = ix_data![ - [SettlementInstruction::BeginSettle.discriminator()], - [0x13, 0x37], // finalize index - [ORDER_COUNT as u8], // order count - bumps, - [0u8; ORDER_COUNT], - ]; - - let parsed = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); - let orders: Vec<_> = parsed.orders.iter().collect(); - - assert_eq!(orders.len(), ORDER_COUNT); - for (order, (order_pda, sell_token, bump)) in orders.iter().zip(&expected) { - assert_eq!(order.order_pda.address(), order_pda); - assert_eq!(order.sell_token_account.address(), sell_token); - assert_eq!(order.bump, *bump); - assert_eq!(order.destinations.len(), 0); - } - } - - #[test] - fn begin_settle_input_rejects_account_count_mismatch() { - // The body declares one order with no transfers, which needs exactly two - // order accounts (its order PDA and sell token account). Only one order - // account is supplied after the fixed accounts, so the number of accounts - // doesn't match the `2n + T` the body implies. - let mut accounts = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 1 }>(); - let data = ix_data![ - [SettlementInstruction::BeginSettle.discriminator()], - [0, 0], // finalize index - [0x01], // order count - [0xab], // the order's bump - [0x00], // the order's transfer count - ]; - assert_eq!( - BeginSettleInput::parse(&data, &mut accounts).err(), - Some(SettlementError::AccountCountNotMatchingOrderCount.into()), - ); - } - - #[test] - fn begin_settle_input_rejects_counts_not_summing_to_destinations() { - // One order whose two destination accounts (plus its order PDA and sell - // token account) make the lengths recover T = 2 transfers, but the - // transfer-count byte claims only one. - let mut accounts = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 4 }>(); - let data = ix_data![ - [SettlementInstruction::BeginSettle.discriminator()], - [0, 0], // finalize index - [0x01], // order count - [0xab], // bump - [0x01], // count says one, but two amounts/destinations exist - 0u64.to_be_bytes(), - 0u64.to_be_bytes(), - ]; - assert_eq!( - BeginSettleInput::parse(&data, &mut accounts).err(), - Some(SettlementError::TransferCountMismatch.into()), - ); - } - - #[test] - fn begin_settle_input_rejects_missing_order_count() { - // The body carries the finalize index but no order-count byte, so the - // pull layout can't even begin to be parsed. - let mut accounts = fake_sequential_accounts::(); - let data = ix_data![ - [SettlementInstruction::BeginSettle.discriminator()], - [0, 0], // finalize index - ]; - assert_eq!( - BeginSettleInput::parse(&data, &mut accounts).err(), - Some(ProgramError::InvalidInstructionData), - ); - } - - #[test] - fn begin_settle_input_rejects_body_too_short_for_bumps() { - // The order count claims two orders, but only one bump byte follows, so - // the bumps can't be split off. - let mut accounts = fake_sequential_accounts::(); - let data = ix_data![ - [SettlementInstruction::BeginSettle.discriminator()], - [0, 0], // finalize index - [0x02], // order count: two orders... - [0xab], // ...but only one bump byte - ]; - assert_eq!( - BeginSettleInput::parse(&data, &mut accounts).err(), - Some(ProgramError::InvalidInstructionData), - ); - } - - #[test] - fn begin_settle_input_rejects_body_too_short_for_counts() { - // One order with its bump, but no transfer-count byte after it, so the - // counts can't be split off. - let mut accounts = fake_sequential_accounts::(); - let data = ix_data![ - [SettlementInstruction::BeginSettle.discriminator()], - [0, 0], // finalize index - [0x01], // order count - [0xab], // the order's bump, with no transfer count after it - ]; - assert_eq!( - BeginSettleInput::parse(&data, &mut accounts).err(), - Some(ProgramError::InvalidInstructionData), - ); - } - - #[test] - fn begin_settle_input_rejects_partial_amount() { - // One order with no transfers, but four trailing bytes that don't form a - // whole `u64` amount. - let mut accounts = fake_sequential_accounts::(); - let data = ix_data![ - [SettlementInstruction::BeginSettle.discriminator()], - [0, 0], // finalize index - [0x01], // order count - [0xab], // bump - [0x00], // transfer count - [0x11, 0x22, 0x33, 0x44], // a partial (4-byte) amount - ]; - assert_eq!( - BeginSettleInput::parse(&data, &mut accounts).err(), - Some(ProgramError::InvalidInstructionData), - ); - } - - #[test] - fn finalize_settle_input_ignores_extra_parameters() { - let first_address = Address::new_from_array([1u8; 32]); - let second_address = Address::new_from_array([2u8; 32]); - let mut accounts = [fake_account(first_address), fake_account(second_address)]; - let data = ix_data![ - [SettlementInstruction::FinalizeSettle.discriminator()], - [0x13, 0x37], // begin index - [42], // extra - ]; - 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 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::(), - intents in prop::collection::vec(arb_order_intent(), 1..=5), - ) { - let program_id = Pubkey::new_unique(); - // No pulls here: this test only checks that orders are derived and - // laid out correctly. - let orders: Vec = intents - .iter() - .map(|intent| settlement_client::instructions::SettledOrder { intent, pulls: &[] }) - .collect(); - let ix = settlement_client::instructions::begin_settle( - &program_id, - finalize_ix_index, - &orders, - ); - - // 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.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)); - prop_assert!(address_matches_pubkey( - order.sell_token_account.address(), - sell_token, - )); - prop_assert_eq!(order.bump, *bump); - } - } - } -} diff --git a/programs/settlement/src/test_utils.rs b/programs/settlement/src/test_utils.rs deleted file mode 100644 index 032f370..0000000 --- a/programs/settlement/src/test_utils.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! Shared scaffolding for the settlement program's unit tests. -//! -//! These functions aren't imported by the program directly, they are only used -//! in unit tests. - -use pinocchio::{account::RuntimeAccount, AccountView, Address}; - -/// Build an `AccountView` based on the input `RuntimeAccount` and whose -/// data region is empty. -/// -/// This is trickier to do than it should be. There's no safe initializer for -/// `AccountView` in Pinocchio. The only initializer is: -/// https://docs.rs/solana-account-view/2.0.0/solana_account_view/struct.AccountView.html#method.new_unchecked -/// -/// `AccountView::new_unchecked` requires (1) a pointer to an initialized -/// `RuntimeAccount`, (2) immediately followed by exactly `data_len` bytes of -/// data. We satisfy (1) via `Box::new(RuntimeAccount::default())` (every -/// field is zero-initialized, then we overwrite `address`), and (2) by -/// setting `data_len = 0` so the trailing-data clause is vacuously true -/// regardless of what's actually in memory after the box. -/// -/// [`Box::leak`] keeps the backing alive for the rest of the test process: -/// a dropped `Box` or a returned stack slot would leave the pointer -/// dangling. We ignore the memory leak since this function is only intended to -/// use in tests. -/// https://doc.rust-lang.org/std/boxed/struct.Box.html#method.leak -/// -/// Every `AccountView` method is safe to call on the result. Header -/// accessors read fields out of the `RuntimeAccount`. Data-region accessors -/// hand out a zero-length slice, which [`core::slice::from_raw_parts`] (the -/// primitive underneath them) defines as sound for any non-null, aligned -/// pointer. This is true for us because the pointer itself comes boxed data -/// and not some manual allocation. -/// https://docs.rs/crate/solana-account-view/2.0.0/source/src/lib.rs#98-295 -/// https://doc.rust-lang.org/beta/core/slice/fn.from_raw_parts.html -pub fn fake_account_from(runtime_account: RuntimeAccount) -> AccountView { - let backing = Box::leak(Box::new(runtime_account)); - unsafe { AccountView::new_unchecked(backing as *mut RuntimeAccount) } -} - -pub fn fake_account(address: Address) -> AccountView { - fake_account_from(RuntimeAccount { - address, - ..Default::default() - }) -} - -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 6faf912aedbe3e733a550638e609f2f33dd80fb1 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:31:43 +0200 Subject: [PATCH 2/6] Refactor: split begin/finalize instructions to their own file --- .../{settle.rs => settle/begin.rs} | 186 +----------------- interface/src/instruction/settle/finalize.rs | 142 +++++++++++++ interface/src/instruction/settle/mod.rs | 78 ++++++++ .../src/{settle.rs => settle/begin.rs} | 63 +----- programs/settlement/src/settle/finalize.rs | 39 ++++ programs/settlement/src/settle/mod.rs | 48 +++++ 6 files changed, 316 insertions(+), 240 deletions(-) rename interface/src/instruction/{settle.rs => settle/begin.rs} (81%) create mode 100644 interface/src/instruction/settle/finalize.rs create mode 100644 interface/src/instruction/settle/mod.rs rename programs/settlement/src/{settle.rs => settle/begin.rs} (77%) create mode 100644 programs/settlement/src/settle/finalize.rs create mode 100644 programs/settlement/src/settle/mod.rs diff --git a/interface/src/instruction/settle.rs b/interface/src/instruction/settle/begin.rs similarity index 81% rename from interface/src/instruction/settle.rs rename to interface/src/instruction/settle/begin.rs index 25ab951..f23c9d2 100644 --- a/interface/src/instruction/settle.rs +++ b/interface/src/instruction/settle/begin.rs @@ -1,5 +1,4 @@ -//! `BeginSettle`/`FinalizeSettle` instruction tools, the instructions-sysvar -//! account ID they all reference, and the off-chain instruction builders. +//! Off-chain builder and input parsing for the `BeginSettle` instruction. use std::vec; @@ -8,12 +7,11 @@ use solana_instruction::{AccountMeta, Instruction}; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; -pub use solana_sdk_ids::sysvar::instructions::ID as INSTRUCTIONS_SYSVAR_ID; -pub use spl_token_interface::ID as SPL_TOKEN_PROGRAM_ID; - -use super::InstructionInputParsing; +use crate::instruction::InstructionInputParsing; use crate::{SettlementError, SettlementInstruction}; +use super::{recover_counterpart, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID}; + /// A single transfer made when settling an order: `amount` tokens sent from the /// order's sell token account to `destination`. #[derive(Clone, Debug, Eq, PartialEq)] @@ -104,33 +102,6 @@ pub fn begin_settle( } } -pub fn finalize_settle(program_id: &Pubkey, begin_ix_index: u16) -> Instruction { - Instruction { - program_id: *program_id, - accounts: vec![AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false)], - data: [ - &[SettlementInstruction::FinalizeSettle.discriminator()], - &begin_ix_index.to_be_bytes()[..], - ] - .concat(), - } -} - -/// Reads the first two bytes of a byte slice (instruction data) and -/// 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`. -/// Returns `InvalidInstructionData` if fewer than two bytes are provided. -pub fn recover_counterpart(instruction_data: &[u8]) -> Result<(u16, &[u8]), ProgramError> { - match instruction_data { - [b1, b2, rest @ ..] => Ok((u16::from_be_bytes([*b1, *b2]), rest)), - _ => Err(ProgramError::InvalidInstructionData), - } -} - /// A single settled order, resulted from parsing `BeginSettle`, together with /// the funds to pull from its sell token account. pub struct SettledOrder<'a> { @@ -287,40 +258,13 @@ impl<'a> InstructionInputParsing<'a> for BeginSettleInput<'a> { } } -/// Parsed inputs (instruction-data fields + relevant accounts) of a -/// `FinalizeSettle` instruction. -/// -/// Strictly the raw extracted form. Fields are read from `instruction_data` and -/// `accounts` but **not validated** against runtime context except confirming -/// that the discriminator matches the desired input. -pub struct FinalizeSettleInput<'a> { - pub begin_ix_index: u16, - pub instructions_sysvar_account: &'a AccountView, -} - -impl<'a> InstructionInputParsing<'a> for FinalizeSettleInput<'a> { - const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::FinalizeSettle; - - fn parse_body( - instruction_data: &[u8], - accounts: &'a mut [AccountView], - ) -> Result { - let (begin_ix_index, _) = recover_counterpart(instruction_data)?; - let instructions_sysvar_account = - accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; - Ok(Self { - begin_ix_index, - instructions_sysvar_account, - }) - } -} - #[cfg(test)] mod tests { use super::*; use crate::instruction::fixtures::{ fake_account, fake_account_from_array, fake_sequential_accounts, }; + use crate::instruction::settle::tests::ix_data; use hex_literal::hex; use solana_address::Address; @@ -328,46 +272,6 @@ mod tests { /// the instructions sysvar, the settlement state PDA, and the token program. const FIXED_ACCOUNTS: usize = 3; - /// Builds an instruction-data byte vector from a list of field chunks, so a - /// test can spell out the wire layout one field per line without repeating - /// the `&[..][..]` slicing. Each chunk is anything sliceable to `[u8]` (a - /// byte array, a `Vec`, the result of `to_be_bytes()`, ...). - macro_rules! ix_data { - ($($chunk:expr),* $(,)?) => { - [$(&$chunk[..]),*].concat() - }; - } - - #[test] - fn rejects_empty_payload() { - assert_eq!( - recover_counterpart(&[]), - Err(ProgramError::InvalidInstructionData), - ); - } - - #[test] - fn rejects_too_short_payload() { - assert_eq!( - recover_counterpart(&[42]), - Err(ProgramError::InvalidInstructionData), - ); - } - - #[test] - fn returns_trailing_bytes() { - assert_eq!( - recover_counterpart( - &[ - &hex!("1337")[..], // counterpart index - &[42][..], // trailing - ] - .concat() - ), - Ok((0x1337, [42].as_slice())), - ); - } - #[test] fn expected_encoding_begin_settle_no_orders() { let program_id = Pubkey::new_unique(); @@ -540,26 +444,6 @@ mod tests { assert!(ix.accounts.iter().all(|account| !account.is_signer)); } - #[test] - fn expected_encoding_finalize_settle() { - let program_id = Pubkey::new_unique(); - let ix = finalize_settle(&program_id, 0x1337); - assert_eq!( - ix.data, - [ - &[SettlementInstruction::FinalizeSettle.discriminator()][..], - &hex!("1337")[..], // counterpart index - ] - .concat(), - ); - - // 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_input_parses_valid_input() { let sysvar = Address::new_from_array([0x42u8; 32]); @@ -590,22 +474,6 @@ mod tests { assert_eq!(state_pda_account.address(), &state); } - #[test] - fn finalize_settle_input_parses_valid_input() { - let address = Address::new_from_array([0x42u8; 32]); - let mut accounts = [fake_account(address)]; - let data = ix_data![ - [SettlementInstruction::FinalizeSettle.discriminator()], - [0x13, 0x37], // begin index - ]; - 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] fn begin_settle_input_rejects_different_discriminator() { let data = ix_data![ @@ -619,19 +487,6 @@ mod tests { ); } - #[test] - fn finalize_settle_input_rejects_different_discriminator() { - let data = ix_data![ - [SettlementInstruction::BeginSettle.discriminator()], - [0, 0], // begin index - ]; - let mut accounts: [AccountView; 0] = []; - assert_eq!( - FinalizeSettleInput::parse(&data, &mut accounts).err(), - Some(ProgramError::InvalidInstructionData), - ); - } - #[test] fn begin_settle_input_rejects_empty_accounts() { let data = ix_data![ @@ -645,19 +500,6 @@ mod tests { ); } - #[test] - fn finalize_settle_input_rejects_empty_accounts() { - let data = ix_data![ - [SettlementInstruction::FinalizeSettle.discriminator()], - [0, 0], // begin index - ]; - let mut accounts: [AccountView; 0] = []; - assert_eq!( - FinalizeSettleInput::parse(&data, &mut accounts).err(), - Some(ProgramError::NotEnoughAccountKeys), - ); - } - #[test] fn begin_settle_input_parses_order_bumps_and_pairs() { let sysvar = Address::new_from_array([1u8; 32]); @@ -901,22 +743,4 @@ mod tests { Some(ProgramError::InvalidInstructionData), ); } - - #[test] - fn finalize_settle_input_ignores_extra_parameters() { - let first_address = Address::new_from_array([1u8; 32]); - let second_address = Address::new_from_array([2u8; 32]); - let mut accounts = [fake_account(first_address), fake_account(second_address)]; - let data = ix_data![ - [SettlementInstruction::FinalizeSettle.discriminator()], - [0x13, 0x37], // begin index - [42], // extra - ]; - 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); - } } diff --git a/interface/src/instruction/settle/finalize.rs b/interface/src/instruction/settle/finalize.rs new file mode 100644 index 0000000..f209080 --- /dev/null +++ b/interface/src/instruction/settle/finalize.rs @@ -0,0 +1,142 @@ +//! Off-chain builder and input parsing for the `FinalizeSettle` instruction. + +use std::vec; + +use solana_account_view::AccountView; +use solana_instruction::{AccountMeta, Instruction}; +use solana_program_error::ProgramError; +use solana_pubkey::Pubkey; + +use crate::instruction::InstructionInputParsing; +use crate::SettlementInstruction; + +use super::{recover_counterpart, INSTRUCTIONS_SYSVAR_ID}; + +pub fn finalize_settle(program_id: &Pubkey, begin_ix_index: u16) -> Instruction { + Instruction { + program_id: *program_id, + accounts: vec![AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false)], + data: [ + &[SettlementInstruction::FinalizeSettle.discriminator()], + &begin_ix_index.to_be_bytes()[..], + ] + .concat(), + } +} + +/// Parsed inputs (instruction-data fields + relevant accounts) of a +/// `FinalizeSettle` instruction. +/// +/// Strictly the raw extracted form. Fields are read from `instruction_data` and +/// `accounts` but **not validated** against runtime context except confirming +/// that the discriminator matches the desired input. +pub struct FinalizeSettleInput<'a> { + pub begin_ix_index: u16, + pub instructions_sysvar_account: &'a AccountView, +} + +impl<'a> InstructionInputParsing<'a> for FinalizeSettleInput<'a> { + const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::FinalizeSettle; + + fn parse_body( + instruction_data: &[u8], + accounts: &'a mut [AccountView], + ) -> Result { + let (begin_ix_index, _) = recover_counterpart(instruction_data)?; + let instructions_sysvar_account = + accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + Ok(Self { + begin_ix_index, + instructions_sysvar_account, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::instruction::fixtures::fake_account; + use crate::instruction::settle::tests::ix_data; + use hex_literal::hex; + use solana_address::Address; + + #[test] + fn expected_encoding_finalize_settle() { + let program_id = Pubkey::new_unique(); + let ix = finalize_settle(&program_id, 0x1337); + assert_eq!( + ix.data, + [ + &[SettlementInstruction::FinalizeSettle.discriminator()][..], + &hex!("1337")[..], // counterpart index + ] + .concat(), + ); + + // 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 finalize_settle_input_parses_valid_input() { + let address = Address::new_from_array([0x42u8; 32]); + let mut accounts = [fake_account(address)]; + let data = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [0x13, 0x37], // begin index + ]; + 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] + fn finalize_settle_input_rejects_different_discriminator() { + let data = ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + [0, 0], // begin index + ]; + let mut accounts: [AccountView; 0] = []; + assert_eq!( + FinalizeSettleInput::parse(&data, &mut accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn finalize_settle_input_rejects_empty_accounts() { + let data = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [0, 0], // begin index + ]; + let mut accounts: [AccountView; 0] = []; + assert_eq!( + FinalizeSettleInput::parse(&data, &mut accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + ); + } + + #[test] + fn finalize_settle_input_ignores_extra_parameters() { + let first_address = Address::new_from_array([1u8; 32]); + let second_address = Address::new_from_array([2u8; 32]); + let mut accounts = [fake_account(first_address), fake_account(second_address)]; + let data = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [0x13, 0x37], // begin index + [42], // extra + ]; + 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); + } +} diff --git a/interface/src/instruction/settle/mod.rs b/interface/src/instruction/settle/mod.rs new file mode 100644 index 0000000..090bebf --- /dev/null +++ b/interface/src/instruction/settle/mod.rs @@ -0,0 +1,78 @@ +//! `BeginSettle`/`FinalizeSettle` instruction tools, the instructions-sysvar +//! account ID they all reference, and the off-chain instruction builders. +//! +//! The per-instruction builders and parsing live in the [`begin`] and +//! [`finalize`] submodules; this module holds the shared logic. + +use solana_program_error::ProgramError; + +pub use solana_sdk_ids::sysvar::instructions::ID as INSTRUCTIONS_SYSVAR_ID; +pub use spl_token_interface::ID as SPL_TOKEN_PROGRAM_ID; + +mod begin; +mod finalize; + +pub use begin::{begin_settle, BeginSettleInput, Pull, SettledOrder, SettledOrders}; +pub use finalize::{finalize_settle, FinalizeSettleInput}; + +/// Reads the first two bytes of a byte slice (instruction data) and +/// 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`. +/// Returns `InvalidInstructionData` if fewer than two bytes are provided. +pub fn recover_counterpart(instruction_data: &[u8]) -> Result<(u16, &[u8]), ProgramError> { + match instruction_data { + [b1, b2, rest @ ..] => Ok((u16::from_be_bytes([*b1, *b2]), rest)), + _ => Err(ProgramError::InvalidInstructionData), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use hex_literal::hex; + + /// Builds an instruction-data byte vector from a list of field chunks, so a + /// test can spell out the wire layout one field per line without repeating + /// the `&[..][..]` slicing. Each chunk is anything sliceable to `[u8]` (a + /// byte array, a `Vec`, the result of `to_be_bytes()`, ...). + macro_rules! ix_data { + ($($chunk:expr),* $(,)?) => { + [$(&$chunk[..]),*].concat() + }; + } + pub(crate) use ix_data; + + #[test] + fn rejects_empty_payload() { + assert_eq!( + recover_counterpart(&[]), + Err(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn rejects_too_short_payload() { + assert_eq!( + recover_counterpart(&[42]), + Err(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn returns_trailing_bytes() { + assert_eq!( + recover_counterpart( + &[ + &hex!("1337")[..], // counterpart index + &[42][..], // trailing + ] + .concat() + ), + Ok((0x1337, [42].as_slice())), + ); + } +} diff --git a/programs/settlement/src/settle.rs b/programs/settlement/src/settle/begin.rs similarity index 77% rename from programs/settlement/src/settle.rs rename to programs/settlement/src/settle/begin.rs index 691b9c6..3fc2e8e 100644 --- a/programs/settlement/src/settle.rs +++ b/programs/settlement/src/settle/begin.rs @@ -1,4 +1,4 @@ -//! `BeginSettle`/`FinalizeSettle` instruction handlers. +//! `BeginSettle` instruction handler. use std::ops::Deref; @@ -13,7 +13,7 @@ use settlement_interface::{ data::order::EncodedOrderAccount, instruction::{ create_buffer::SPL_TOKEN_PROGRAM_ID, - settle::{recover_counterpart, BeginSettleInput, FinalizeSettleInput, SettledOrder}, + settle::{BeginSettleInput, SettledOrder}, InstructionInputParsing, }, pda::{order::order_pda_signer_seeds, state::state_pda_seeds}, @@ -22,6 +22,8 @@ use settlement_interface::{ use crate::processor::is_cpi_call; +use super::validate_counterpart; + pub fn process_begin_settle( program_id: &Address, accounts: &mut [AccountView], @@ -243,60 +245,3 @@ fn process_order( 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], - instruction_data: &[u8], -) -> ProgramResult { - if is_cpi_call() { - return Err(SettlementError::CalledViaCpi.into()); - } - - let input = FinalizeSettleInput::parse(instruction_data, accounts)?; - let instructions = Instructions::try_from(input.instructions_sysvar_account)?; - let current_index = instructions.load_current_index(); - - // Reciprocity: the input index is a begin_settle instruction and that - // instruction points to the current one. - validate_counterpart( - program_id, - &instructions, - current_index, - input.begin_ix_index, - SettlementInstruction::BeginSettle, - ) - - // Some checks are carried out by `BeginSettle` and we don't repeat them - // under the assumption that the counterpart exists and, since it's a - // `BeginSettle`, it performs the checks. -} - -/// Load the counterpart instruction at `counterpart_index` and verify it -/// belongs to `program_id`, carries `expected_discriminator`, and points -/// back at the current instruction. Ordering (before/after) is the caller's -/// responsibility. -#[must_use = "ignoring the output may lead to an unintended on-chain state"] -fn validate_counterpart>( - program_id: &Address, - instructions: &Instructions, - current_index: u16, - counterpart_index: u16, - expected_discriminator: SettlementInstruction, -) -> ProgramResult { - let counterpart_ix = instructions - .load_instruction_at(usize::from(counterpart_index)) - .map_err(|_| SettlementError::MissingCounterpartInstruction)?; - if counterpart_ix.get_program_id() != program_id { - return Err(SettlementError::CounterpartIsExternal.into()); - } - 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) - .map_err(|_| SettlementError::InvalidCounterpartCounterpart)?; - if their_discriminator != expected_discriminator || their_counterpart_ix != current_index { - return Err(SettlementError::MismatchedCounterpartDiscriminator.into()); - } - Ok(()) -} diff --git a/programs/settlement/src/settle/finalize.rs b/programs/settlement/src/settle/finalize.rs new file mode 100644 index 0000000..403e2db --- /dev/null +++ b/programs/settlement/src/settle/finalize.rs @@ -0,0 +1,39 @@ +//! `FinalizeSettle` instruction handler. + +use pinocchio::{sysvars::instructions::Instructions, AccountView, Address, ProgramResult}; +use settlement_interface::{ + instruction::{settle::FinalizeSettleInput, InstructionInputParsing}, + SettlementError, SettlementInstruction, +}; + +use crate::processor::is_cpi_call; + +use super::validate_counterpart; + +pub fn process_finalize_settle( + program_id: &Address, + accounts: &mut [AccountView], + instruction_data: &[u8], +) -> ProgramResult { + if is_cpi_call() { + return Err(SettlementError::CalledViaCpi.into()); + } + + let input = FinalizeSettleInput::parse(instruction_data, accounts)?; + let instructions = Instructions::try_from(input.instructions_sysvar_account)?; + let current_index = instructions.load_current_index(); + + // Reciprocity: the input index is a begin_settle instruction and that + // instruction points to the current one. + validate_counterpart( + program_id, + &instructions, + current_index, + input.begin_ix_index, + SettlementInstruction::BeginSettle, + ) + + // Some checks are carried out by `BeginSettle` and we don't repeat them + // under the assumption that the counterpart exists and, since it's a + // `BeginSettle`, it performs the checks. +} diff --git a/programs/settlement/src/settle/mod.rs b/programs/settlement/src/settle/mod.rs new file mode 100644 index 0000000..ef5fed7 --- /dev/null +++ b/programs/settlement/src/settle/mod.rs @@ -0,0 +1,48 @@ +//! `BeginSettle`/`FinalizeSettle` instruction handlers. +//! +//! The handlers themselves live in the [`begin`] and [`finalize`] submodules; +//! this module holds the reciprocity check they share and re-exports their +//! entry points. + +use std::ops::Deref; + +use pinocchio::{sysvars::instructions::Instructions, Address, ProgramResult}; +use settlement_interface::{ + instruction::settle::recover_counterpart, recover_discriminator, SettlementError, + SettlementInstruction, +}; + +mod begin; +mod finalize; + +pub use begin::process_begin_settle; +pub use finalize::process_finalize_settle; + +/// Load the counterpart instruction at `counterpart_index` and verify it +/// belongs to `program_id`, carries `expected_discriminator`, and points +/// back at the current instruction. Ordering (before/after) is the caller's +/// responsibility. +#[must_use = "ignoring the output may lead to an unintended on-chain state"] +fn validate_counterpart>( + program_id: &Address, + instructions: &Instructions, + current_index: u16, + counterpart_index: u16, + expected_discriminator: SettlementInstruction, +) -> ProgramResult { + let counterpart_ix = instructions + .load_instruction_at(usize::from(counterpart_index)) + .map_err(|_| SettlementError::MissingCounterpartInstruction)?; + if counterpart_ix.get_program_id() != program_id { + return Err(SettlementError::CounterpartIsExternal.into()); + } + 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) + .map_err(|_| SettlementError::InvalidCounterpartCounterpart)?; + if their_discriminator != expected_discriminator || their_counterpart_ix != current_index { + return Err(SettlementError::MismatchedCounterpartDiscriminator.into()); + } + Ok(()) +} From 93ceb4c59ca3f0e0e79d5603a90eaf52a0e73a64 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:04:54 +0200 Subject: [PATCH 3/6] Refactor: use builder structs instead of builder functions for instruction --- client/src/instructions.rs | 147 +++++++++------ interface/src/instruction/create_buffer.rs | 103 +++++++---- interface/src/instruction/create_order.rs | 84 ++++++--- interface/src/instruction/initialize.rs | 60 ++++-- interface/src/instruction/settle/begin.rs | 171 ++++++++++-------- interface/src/instruction/settle/finalize.rs | 38 +++- interface/src/instruction/settle/mod.rs | 4 +- .../settlement/tests/begin_settle_orders.rs | 86 +++++---- programs/settlement/tests/create_buffer.rs | 102 +++++++++-- programs/settlement/tests/create_order.rs | 107 ++++++----- programs/settlement/tests/initialize.rs | 35 +++- .../tests/matching_begin_finalize.rs | 60 +++++- .../settlement/tests/program_deployment.rs | 15 +- 13 files changed, 683 insertions(+), 329 deletions(-) diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 7bc64bb..1d81a4a 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -13,7 +13,7 @@ use settlement_interface::{ // Reexport the instruction builders that don't change from the interface. // We want the client to provide all instruction builders. -pub use settlement_interface::instruction::settle::{finalize_settle, Pull}; +pub use settlement_interface::instruction::settle::{FinalizeSettle, Pull}; /// An order to settle together with the funds to pull from it: `intent` /// identifies the order and `pulls` lists the [`Pull`]s to make from its sell @@ -23,64 +23,100 @@ pub struct SettledOrder<'a> { pub pulls: &'a [Pull], } -/// Build a `BeginSettle` instruction settling the given orders. -pub fn begin_settle( - program_id: &Pubkey, - finalize_ix_index: u16, - orders: &[SettledOrder], -) -> Instruction { - 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()); - let mut pull_lists: Vec<&[Pull]> = Vec::with_capacity(orders.len()); - for order in orders { - let (order_pda, bump) = find_order_pda(program_id, &order.intent.uid()); - order_pdas.push(order_pda); - sell_token_accounts.push(order.intent.sell_token_account); - bumps.push(bump); - pull_lists.push(order.pulls); +/// Builder for a `BeginSettle` instruction settling the given orders. +pub struct BeginSettle<'a> { + pub program_id: Pubkey, + pub finalize_ix_index: u16, + pub orders: &'a [SettledOrder<'a>], +} + +impl BeginSettle<'_> { + pub fn instruction(self) -> Instruction { + let mut order_pdas = Vec::with_capacity(self.orders.len()); + let mut sell_token_accounts = Vec::with_capacity(self.orders.len()); + let mut bumps = Vec::with_capacity(self.orders.len()); + let mut pull_lists: Vec<&[Pull]> = Vec::with_capacity(self.orders.len()); + for order in self.orders { + let (order_pda, bump) = find_order_pda(&self.program_id, &order.intent.uid()); + order_pdas.push(order_pda); + sell_token_accounts.push(order.intent.sell_token_account); + bumps.push(bump); + pull_lists.push(order.pulls); + } + let (state_pda, _bump) = find_state_pda(&self.program_id); + settlement_interface::instruction::settle::BeginSettle { + program_id: self.program_id, + state_pda, + finalize_ix_index: self.finalize_ix_index, + order_pdas: &order_pdas, + order_pda_bumps: &bumps, + sell_token_accounts: &sell_token_accounts, + pulls: &pull_lists, + } + .instruction() + } +} + +pub struct CreateOrder<'a> { + pub program_id: Pubkey, + pub owner: Pubkey, + pub created_by: Pubkey, + pub intent: &'a OrderIntent, +} + +impl CreateOrder<'_> { + pub fn instruction(self) -> Instruction { + let encoded = EncodedOrderIntent::from(self.intent); + let (order_pda, _bump) = find_order_pda(&self.program_id, &encoded.hash()); + let intent_bytes: [u8; EncodedOrderIntent::SIZE] = (&encoded).into(); + settlement_interface::instruction::create_order::CreateOrder { + program_id: self.program_id, + owner: self.owner, + created_by: self.created_by, + order_pda, + intent_bytes, + } + .instruction() } - let (state_pda, _bump) = find_state_pda(program_id); - settlement_interface::instruction::settle::begin_settle( - program_id, - &state_pda, - finalize_ix_index, - &order_pdas, - &bumps, - &sell_token_accounts, - &pull_lists, - ) } -pub fn create_order( - program_id: &Pubkey, - owner: &Pubkey, - created_by: &Pubkey, - intent: &OrderIntent, -) -> Instruction { - 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::instruction::create_order::create_order( - program_id, - owner, - created_by, - &order_pda, - &intent_bytes, - ) +pub struct CreateBuffers<'a> { + pub program_id: Pubkey, + pub payer: Pubkey, + pub mints: &'a [Pubkey], } -pub fn create_buffers(program_id: &Pubkey, payer: &Pubkey, mints: &[Pubkey]) -> Instruction { - let buffers: Vec<(Pubkey, Pubkey)> = mints - .iter() - .map(|mint| (find_buffer_pda(program_id, mint).0, *mint)) - .collect(); - settlement_interface::instruction::create_buffer::create_buffers(program_id, payer, &buffers) +impl CreateBuffers<'_> { + pub fn instruction(self) -> Instruction { + let buffers: Vec<(Pubkey, Pubkey)> = self + .mints + .iter() + .map(|mint| (find_buffer_pda(&self.program_id, mint).0, *mint)) + .collect(); + settlement_interface::instruction::create_buffer::CreateBuffers { + program_id: self.program_id, + payer: self.payer, + buffers: &buffers, + } + .instruction() + } } -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) +pub struct Initialize { + pub program_id: Pubkey, + pub payer: Pubkey, +} + +impl Initialize { + pub fn instruction(self) -> Instruction { + let (state_pda, _bump) = find_state_pda(&self.program_id); + settlement_interface::instruction::initialize::Initialize { + program_id: self.program_id, + payer: self.payer, + state_pda, + } + .instruction() + } } #[cfg(test)] @@ -113,7 +149,12 @@ mod tests { .iter() .map(|intent| SettledOrder { intent, pulls: &[] }) .collect(); - let ix = begin_settle(&program_id, finalize_ix_index, &orders); + let ix = BeginSettle { + program_id, + finalize_ix_index, + orders: &orders, + } + .instruction(); // Expected orders: each intent's canonical PDA paired with its sell // token account and bump, sorted by PDA address (the builder's order). diff --git a/interface/src/instruction/create_buffer.rs b/interface/src/instruction/create_buffer.rs index 6006693..e3ebf03 100644 --- a/interface/src/instruction/create_buffer.rs +++ b/interface/src/instruction/create_buffer.rs @@ -19,7 +19,7 @@ use crate::SettlementInstruction; /// program. pub use spl_token_interface::ID as SPL_TOKEN_PROGRAM_ID; -/// Build a `CreateBuffer` instruction that creates one buffer per +/// Builder for a `CreateBuffer` instruction that creates one buffer per /// `(buffer_pda, mint)` pair in `buffers`. /// /// Each `buffer_pda` must be the canonical PDA returned by @@ -34,24 +34,28 @@ pub use spl_token_interface::ID as SPL_TOKEN_PROGRAM_ID; /// The three shared accounts come first and are read positionally; the /// per-buffer pairs follow. The system program only has to be present so the /// `CreateAccount` CPI can dispatch; it isn't read by index. -pub fn create_buffers( - program_id: &Pubkey, - payer: &Pubkey, - buffers: &[(Pubkey, Pubkey)], -) -> Instruction { - let mut accounts = vec![ - AccountMeta::new(*payer, true), - AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), - AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), - ]; - for (buffer_pda, mint) in buffers { - accounts.push(AccountMeta::new(*buffer_pda, false)); - accounts.push(AccountMeta::new_readonly(*mint, false)); - } - Instruction { - program_id: *program_id, - accounts, - data: vec![SettlementInstruction::CreateBuffer.discriminator()], +pub struct CreateBuffers<'a> { + pub program_id: Pubkey, + pub payer: Pubkey, + pub buffers: &'a [(Pubkey, Pubkey)], +} + +impl CreateBuffers<'_> { + pub fn instruction(self) -> Instruction { + let mut accounts = vec![ + AccountMeta::new(self.payer, true), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), + ]; + for (buffer_pda, mint) in self.buffers { + accounts.push(AccountMeta::new(*buffer_pda, false)); + accounts.push(AccountMeta::new_readonly(*mint, false)); + } + Instruction { + program_id: self.program_id, + accounts, + data: vec![SettlementInstruction::CreateBuffer.discriminator()], + } } } @@ -105,7 +109,7 @@ impl<'a> InstructionInputParsing<'a> for CreateBufferInput<'a> { pub mod fixtures { use solana_address::Address; - use super::create_buffers; + use super::CreateBuffers; /// Number of accounts that don't depend on the number of buffers created: /// payer, system program, and token program. @@ -115,7 +119,13 @@ pub mod fixtures { /// cases where the input is irrelevant. pub fn create_buffer_data() -> Vec { let zero = Address::new_from_array([0; 32]); - create_buffers(&zero, &zero, &[(zero, zero)]).data + CreateBuffers { + program_id: zero, + payer: zero, + buffers: &[(zero, zero)], + } + .instruction() + .data } } @@ -137,7 +147,13 @@ mod tests { let buffer_pda = Address::new_from_array([5; 32]); let mint = Address::new_from_array([6; 32]); - let data = create_buffers(&program_id, &payer, &[(buffer_pda, mint)]).data; + let data = CreateBuffers { + program_id, + payer, + buffers: &[(buffer_pda, mint)], + } + .instruction() + .data; let mut accounts = [ fake_account(payer), system_program, @@ -169,11 +185,12 @@ mod tests { let buffer_b = Address::new_from_array([7; 32]); let mint_b = Address::new_from_array([8; 32]); - let data = create_buffers( - &program_id, - &payer, - &[(buffer_a, mint_a), (buffer_b, mint_b)], - ) + let data = CreateBuffers { + program_id, + payer, + buffers: &[(buffer_a, mint_a), (buffer_b, mint_b)], + } + .instruction() .data; let mut accounts = [ fake_account(payer), @@ -248,7 +265,12 @@ mod tests { 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_buffers(&program_id, &payer, &[(buffer_pda, mint)]); + let ix = CreateBuffers { + program_id, + payer, + buffers: &[(buffer_pda, mint)], + } + .instruction(); assert_eq!( ix.data, vec![SettlementInstruction::CreateBuffer.discriminator()] @@ -261,7 +283,12 @@ mod tests { 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_buffers(&program_id, &payer, &[(buffer_pda, mint)]); + let ix = CreateBuffers { + program_id, + payer, + buffers: &[(buffer_pda, mint)], + } + .instruction(); assert_eq!(ix.accounts.len(), 5); // payer: writable, signer @@ -294,11 +321,12 @@ mod tests { let mint_a = Pubkey::new_from_array([4; 32]); let buffer_b = Pubkey::new_from_array([5; 32]); let mint_b = Pubkey::new_from_array([6; 32]); - let ix = create_buffers( - &program_id, - &payer, - &[(buffer_a, mint_a), (buffer_b, mint_b)], - ); + let ix = CreateBuffers { + program_id, + payer, + buffers: &[(buffer_a, mint_a), (buffer_b, mint_b)], + } + .instruction(); // Three shared accounts followed by two (buffer, mint) pairs. assert_eq!(ix.accounts.len(), 3 + 2 * 2); @@ -316,7 +344,12 @@ mod tests { fn empty_buffers_has_only_shared_accounts() { let program_id = Pubkey::new_from_array([1; 32]); let payer = Pubkey::new_from_array([2; 32]); - let ix = create_buffers(&program_id, &payer, &[]); + let ix = CreateBuffers { + program_id, + payer, + buffers: &[], + } + .instruction(); assert_eq!(ix.accounts.len(), 3); } } diff --git a/interface/src/instruction/create_order.rs b/interface/src/instruction/create_order.rs index 21c94df..4e2ec72 100644 --- a/interface/src/instruction/create_order.rs +++ b/interface/src/instruction/create_order.rs @@ -14,7 +14,7 @@ pub use solana_system_interface::program::ID as SYSTEM_PROGRAM_ID; use super::InstructionInputParsing; use crate::{data::intent::EncodedOrderIntent, SettlementInstruction}; -/// Build a `CreateOrder` instruction. +/// Builder for a `CreateOrder` instruction. /// /// `intent_bytes` is the canonical byte encoding (see /// [`EncodedOrderIntent`]). `order_pda` must be the canonical PDA returned @@ -42,26 +42,30 @@ use crate::{data::intent::EncodedOrderIntent, SettlementInstruction}; /// `[owner (S), created_by (W,S), order_pda (W), system_program (R)]`. /// The system program needs to be available but doesn't need to be at that /// specific position in the instruction, unlike the others. -pub fn create_order( - program_id: &Pubkey, - owner: &Pubkey, - created_by: &Pubkey, - order_pda: &Pubkey, - intent_bytes: &[u8; EncodedOrderIntent::SIZE], -) -> Instruction { - let mut data = Vec::with_capacity(1 + EncodedOrderIntent::SIZE); - data.push(SettlementInstruction::CreateOrder.discriminator()); - data.extend_from_slice(intent_bytes); +pub struct CreateOrder { + pub program_id: Pubkey, + pub owner: Pubkey, + pub created_by: Pubkey, + pub order_pda: Pubkey, + pub intent_bytes: [u8; EncodedOrderIntent::SIZE], +} + +impl CreateOrder { + pub fn instruction(self) -> Instruction { + let mut data = Vec::with_capacity(1 + EncodedOrderIntent::SIZE); + data.push(SettlementInstruction::CreateOrder.discriminator()); + data.extend_from_slice(&self.intent_bytes); - Instruction { - program_id: *program_id, - accounts: vec![ - AccountMeta::new_readonly(*owner, true), - AccountMeta::new(*created_by, true), - AccountMeta::new(*order_pda, false), - AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), - ], - data, + Instruction { + program_id: self.program_id, + accounts: vec![ + AccountMeta::new_readonly(self.owner, true), + AccountMeta::new(self.created_by, true), + AccountMeta::new(self.order_pda, false), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + ], + data, + } } } @@ -110,7 +114,7 @@ impl<'a> InstructionInputParsing<'a> for CreateOrderInput<'a> { pub mod fixtures { use solana_address::Address; - use super::create_order; + use super::CreateOrder; use crate::data::intent::{ fixtures::sample_intent, EncodedOrderIntent, OrderIntent, OrderKind, }; @@ -136,7 +140,15 @@ pub mod fixtures { /// addresses for failure cases where the actual addresses don't matter. pub fn default_order_data(intent_bytes: &[u8; EncodedOrderIntent::SIZE]) -> Vec { let zero = Address::new_from_array([0; 32]); - create_order(&zero, &zero, &zero, &zero, intent_bytes).data + CreateOrder { + program_id: zero, + owner: zero, + created_by: zero, + order_pda: zero, + intent_bytes: *intent_bytes, + } + .instruction() + .data } } @@ -157,7 +169,15 @@ mod tests { let order_pda = Address::new_from_array([23; 32]); let intent_bytes = valid_intent_bytes(); - let data = create_order(&program_id, &owner, &created_by, &order_pda, &intent_bytes).data; + let data = CreateOrder { + program_id, + owner, + created_by, + order_pda, + intent_bytes, + } + .instruction() + .data; let mut accounts = [ fake_account(owner), fake_account(created_by), @@ -222,7 +242,14 @@ mod tests { let order_pda = Pubkey::new_from_array([3; 32]); let intent_bytes = [0x42u8; EncodedOrderIntent::SIZE]; - let ix = create_order(&program_id, &owner, &created_by, &order_pda, &intent_bytes); + let ix = CreateOrder { + program_id, + owner, + created_by, + order_pda, + intent_bytes, + } + .instruction(); assert_eq!(ix.data.len(), 1 + EncodedOrderIntent::SIZE); assert_eq!( @@ -240,7 +267,14 @@ mod tests { let order_pda = Pubkey::new_from_array([3; 32]); let intent_bytes = [0u8; EncodedOrderIntent::SIZE]; - let ix = create_order(&program_id, &owner, &created_by, &order_pda, &intent_bytes); + let ix = CreateOrder { + program_id, + owner, + created_by, + order_pda, + intent_bytes, + } + .instruction(); assert_eq!(ix.accounts.len(), 4); // owner: read-only, signer (authenticates the order; doesn't pay rent) diff --git a/interface/src/instruction/initialize.rs b/interface/src/instruction/initialize.rs index 36e2ab0..d397ba4 100644 --- a/interface/src/instruction/initialize.rs +++ b/interface/src/instruction/initialize.rs @@ -12,7 +12,7 @@ pub use solana_system_interface::program::ID as SYSTEM_PROGRAM_ID; use super::InstructionInputParsing; use crate::SettlementInstruction; -/// Build an `Initialize` instruction. +/// Builder for 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 @@ -31,15 +31,23 @@ use crate::SettlementInstruction; /// 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()], +pub struct Initialize { + pub program_id: Pubkey, + pub payer: Pubkey, + pub state_pda: Pubkey, +} + +impl Initialize { + pub fn instruction(self) -> Instruction { + Instruction { + program_id: self.program_id, + accounts: vec![ + AccountMeta::new(self.payer, true), + AccountMeta::new(self.state_pda, false), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + ], + data: vec![SettlementInstruction::Initialize.discriminator()], + } } } @@ -76,7 +84,7 @@ impl<'a> InstructionInputParsing<'a> for InitializeInput<'a> { pub mod fixtures { use solana_address::Address; - use super::initialize; + use super::Initialize; /// Number of accounts `Initialize` expects: payer, state PDA, system program. pub const NUM_ACCOUNTS: usize = 3; @@ -85,7 +93,13 @@ pub mod fixtures { /// cases where the actual addresses don't matter. pub fn initialize_data() -> Vec { let zero = Address::new_from_array([0; 32]); - initialize(&zero, &zero, &zero).data + Initialize { + program_id: zero, + payer: zero, + state_pda: zero, + } + .instruction() + .data } } @@ -101,7 +115,13 @@ mod tests { 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 = initialize(&program_id, payer.address(), state_pda.address()).data; + let data = Initialize { + program_id, + payer: *payer.address(), + state_pda: *state_pda.address(), + } + .instruction() + .data; let system_program = fake_account_from_array([3; 32]); let mut accounts = [payer, state_pda, system_program]; @@ -143,7 +163,12 @@ mod tests { 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); + let ix = Initialize { + program_id, + payer, + state_pda, + } + .instruction(); assert_eq!( ix.data, vec![SettlementInstruction::Initialize.discriminator()] @@ -156,7 +181,12 @@ mod tests { 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); + let ix = Initialize { + program_id, + payer, + state_pda, + } + .instruction(); assert_eq!(ix.accounts.len(), 3); // payer: writable, signer (funds the new account's rent) diff --git a/interface/src/instruction/settle/begin.rs b/interface/src/instruction/settle/begin.rs index f23c9d2..fe27de6 100644 --- a/interface/src/instruction/settle/begin.rs +++ b/interface/src/instruction/settle/begin.rs @@ -20,7 +20,7 @@ pub struct Pull { pub amount: u64, } -/// Build a `BeginSettle` instruction settling the orders described by the +/// Builder for a `BeginSettle` instruction settling the orders described by the /// parallel lists: /// - `order_pdas[i]` is the canonical order PDA (see [`crate::pda::order`]) /// - `order_pda_bumps[i]` is the bump of the canonical order PDA @@ -43,62 +43,76 @@ pub struct Pull { /// This builder establishes that ordering for the caller: it sorts the orders by /// PDA address, carrying each order's sell token account, bump, transfer count, /// amounts, and destination metas before emitting them. -pub fn begin_settle( - program_id: &Pubkey, - state_pda: &Pubkey, - finalize_ix_index: u16, - order_pdas: &[Pubkey], - order_pda_bumps: &[u8], - sell_token_accounts: &[Pubkey], - pulls: &[&[Pull]], -) -> Instruction { - // Sort the parallel lists together by order PDA address via a shared - // permutation, so each order keeps its own sell token account, bump, and - // pulls (transfer count, amounts, and destination metas). - let mut order: Vec = (0..order_pdas.len()).collect(); - order.sort_by_key(|&i| order_pdas[i]); - - let counts: Vec = order.iter().map(|&i| pulls[i].len() as u8).collect(); - let amounts: Vec = order - .iter() - .flat_map(|&i| pulls[i].iter()) - .flat_map(|pull| pull.amount.to_be_bytes()) - .collect(); - let data = [ - &[SettlementInstruction::BeginSettle.discriminator()][..], - &finalize_ix_index.to_be_bytes()[..], - &[order_pdas.len() as u8][..], - &order +pub struct BeginSettle<'a> { + pub program_id: Pubkey, + pub state_pda: Pubkey, + pub finalize_ix_index: u16, + pub order_pdas: &'a [Pubkey], + pub order_pda_bumps: &'a [u8], + pub sell_token_accounts: &'a [Pubkey], + pub pulls: &'a [&'a [Pull]], +} + +impl BeginSettle<'_> { + pub fn instruction(self) -> Instruction { + let BeginSettle { + program_id, + state_pda, + finalize_ix_index, + order_pdas, + order_pda_bumps, + sell_token_accounts, + pulls, + } = self; + + // Sort the parallel lists together by order PDA address via a shared + // permutation, so each order keeps its own sell token account, bump, and + // pulls (transfer count, amounts, and destination metas). + let mut order: Vec = (0..order_pdas.len()).collect(); + order.sort_by_key(|&i| order_pdas[i]); + + let counts: Vec = order.iter().map(|&i| pulls[i].len() as u8).collect(); + let amounts: Vec = order .iter() - .map(|&i| order_pda_bumps[i]) - .collect::>()[..], - &counts[..], - &amounts[..], - ] - .concat(); - - // Read-only accounts for instruction introspection, settlement state, and - // the SPL token program. - let mut accounts = vec![ - AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false), - AccountMeta::new_readonly(*state_pda, false), - AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), - ]; - for &i in &order { - // Read-only account for the order. - accounts.push(AccountMeta::new_readonly(order_pdas[i], false)); - // Writable accounts settling the order: its sell token account and the - // recipient of each transfer. - accounts.push(AccountMeta::new(sell_token_accounts[i], false)); - for pull in pulls[i] { - accounts.push(AccountMeta::new(pull.destination, false)); + .flat_map(|&i| pulls[i].iter()) + .flat_map(|pull| pull.amount.to_be_bytes()) + .collect(); + let data = [ + &[SettlementInstruction::BeginSettle.discriminator()][..], + &finalize_ix_index.to_be_bytes()[..], + &[order_pdas.len() as u8][..], + &order + .iter() + .map(|&i| order_pda_bumps[i]) + .collect::>()[..], + &counts[..], + &amounts[..], + ] + .concat(); + + // Read-only accounts for instruction introspection, settlement state, and + // the SPL token program. + let mut accounts = vec![ + AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false), + AccountMeta::new_readonly(state_pda, false), + AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), + ]; + for &i in &order { + // Read-only account for the order. + accounts.push(AccountMeta::new_readonly(order_pdas[i], false)); + // Writable accounts settling the order: its sell token account and the + // recipient of each transfer. + accounts.push(AccountMeta::new(sell_token_accounts[i], false)); + for pull in pulls[i] { + accounts.push(AccountMeta::new(pull.destination, false)); + } } - } - Instruction { - program_id: *program_id, - accounts, - data, + Instruction { + program_id, + accounts, + data, + } } } @@ -280,7 +294,16 @@ mod tests { program_id: ix_program_id, accounts, data, - } = begin_settle(&program_id, &state_pda, 0x1337, &[], &[], &[], &[]); + } = BeginSettle { + program_id, + state_pda, + finalize_ix_index: 0x1337, + order_pdas: &[], + order_pda_bumps: &[], + sell_token_accounts: &[], + pulls: &[], + } + .instruction(); assert_eq!(ix_program_id, program_id); assert_eq!( data, @@ -315,15 +338,16 @@ mod tests { 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, - &state_pda, - 0x1337, - &[high_order_pda, low_order_pda], - &[high_bump, low_bump], - &[high_sell_token_account, low_sell_token_account], - &[&[], &[]], - ); + let ix = BeginSettle { + program_id, + state_pda, + finalize_ix_index: 0x1337, + order_pdas: &[high_order_pda, low_order_pda], + order_pda_bumps: &[high_bump, low_bump], + sell_token_accounts: &[high_sell_token_account, low_sell_token_account], + pulls: &[&[], &[]], + } + .instruction(); // Bumps follow the sorted order: the low PDA's bump comes first. assert_eq!( @@ -377,14 +401,14 @@ mod tests { let dest_b0 = Pubkey::new_from_array([0x07; 32]); // Order A has two transfers, order B has one. - let ix = begin_settle( - &program_id, - &state_pda, - 0x1337, - &[order_a, order_b], - &[0xa1, 0xb1], - &[sell_a, sell_b], - &[ + let ix = BeginSettle { + program_id, + state_pda, + finalize_ix_index: 0x1337, + order_pdas: &[order_a, order_b], + order_pda_bumps: &[0xa1, 0xb1], + sell_token_accounts: &[sell_a, sell_b], + pulls: &[ &[ Pull { destination: dest_a0, @@ -400,7 +424,8 @@ mod tests { amount: 0x0506, }], ], - ); + } + .instruction(); assert_eq!( ix.data, diff --git a/interface/src/instruction/settle/finalize.rs b/interface/src/instruction/settle/finalize.rs index f209080..4db6b85 100644 --- a/interface/src/instruction/settle/finalize.rs +++ b/interface/src/instruction/settle/finalize.rs @@ -12,15 +12,29 @@ use crate::SettlementInstruction; use super::{recover_counterpart, INSTRUCTIONS_SYSVAR_ID}; -pub fn finalize_settle(program_id: &Pubkey, begin_ix_index: u16) -> Instruction { - Instruction { - program_id: *program_id, - accounts: vec![AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false)], - data: [ - &[SettlementInstruction::FinalizeSettle.discriminator()], - &begin_ix_index.to_be_bytes()[..], - ] - .concat(), +/// Builder for a `FinalizeSettle` instruction. +/// +/// `begin_ix_index` is the index of the paired `BeginSettle` instruction in the +/// same transaction. +/// +/// Wire format: `[discriminator, begin_ix_index: u16 BE]`, 3 bytes. +/// Required accounts: `[instructions_sysvar (R)]`. +pub struct FinalizeSettle { + pub program_id: Pubkey, + pub begin_ix_index: u16, +} + +impl FinalizeSettle { + pub fn instruction(self) -> Instruction { + Instruction { + program_id: self.program_id, + accounts: vec![AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false)], + data: [ + &[SettlementInstruction::FinalizeSettle.discriminator()], + &self.begin_ix_index.to_be_bytes()[..], + ] + .concat(), + } } } @@ -63,7 +77,11 @@ mod tests { #[test] fn expected_encoding_finalize_settle() { let program_id = Pubkey::new_unique(); - let ix = finalize_settle(&program_id, 0x1337); + let ix = FinalizeSettle { + program_id, + begin_ix_index: 0x1337, + } + .instruction(); assert_eq!( ix.data, [ diff --git a/interface/src/instruction/settle/mod.rs b/interface/src/instruction/settle/mod.rs index 090bebf..6fe0184 100644 --- a/interface/src/instruction/settle/mod.rs +++ b/interface/src/instruction/settle/mod.rs @@ -12,8 +12,8 @@ pub use spl_token_interface::ID as SPL_TOKEN_PROGRAM_ID; mod begin; mod finalize; -pub use begin::{begin_settle, BeginSettleInput, Pull, SettledOrder, SettledOrders}; -pub use finalize::{finalize_settle, FinalizeSettleInput}; +pub use begin::{BeginSettle, BeginSettleInput, Pull, SettledOrder, SettledOrders}; +pub use finalize::{FinalizeSettle, FinalizeSettleInput}; /// Reads the first two bytes of a byte slice (instruction data) and /// interprets them as a big-endian u16, returning it together with the diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index ee105c1..fc237b0 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -11,7 +11,7 @@ use crate::common::{ }; use litesvm::{types::TransactionMetadata, LiteSVM}; use settlement_client::instructions::{ - begin_settle, create_order, finalize_settle, Pull, SettledOrder, + BeginSettle, CreateOrder, FinalizeSettle, Pull, SettledOrder, }; use settlement_client::settlement_interface::{ data::{ @@ -19,7 +19,7 @@ use settlement_client::settlement_interface::{ order::{EncodedOrderAccount, OrderAccount}, }, instruction::settle::{ - begin_settle as raw_begin_settle, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, + BeginSettle as RawBeginSettle, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, }, pda::{order::find_order_pda, state::find_state_pda}, Instruction, SettlementError, SettlementInstruction, @@ -58,7 +58,13 @@ fn sample_intent(owner: Pubkey, sell_token_account: Pubkey, salt: u8) -> OrderIn /// 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 ix = CreateOrder { + program_id: *program_id, + owner: owner.pubkey(), + created_by: owner.pubkey(), + intent, + } + .instruction(); let tx = signed_tx(svm, owner, owner, ix); svm.send_transaction(tx) .expect("create_order should succeed"); @@ -117,7 +123,11 @@ fn send_settlement( payer: &Keypair, begin: Instruction, ) -> Result { - let finalize = finalize_settle(program_id, 0); + let finalize = FinalizeSettle { + program_id: *program_id, + begin_ix_index: 0, + } + .instruction(); let tx = Transaction::new_signed_with_payer( &[begin, finalize], Some(&payer.pubkey()), @@ -135,7 +145,17 @@ fn settle( payer: &Keypair, orders: &[SettledOrder], ) -> Result { - send_settlement(svm, program_id, payer, begin_settle(program_id, 1, orders)) + send_settlement( + svm, + program_id, + payer, + BeginSettle { + program_id: *program_id, + finalize_ix_index: 1, + orders, + } + .instruction(), + ) } /// Settle orders described by raw, parallel `(order_pda, sell_token, bump)` @@ -150,15 +170,16 @@ fn settle_raw( sell_token_accounts: &[Pubkey], bumps: &[u8], ) -> Result { - let begin = raw_begin_settle( - program_id, - &find_state_pda(program_id).0, - 1, + let begin = RawBeginSettle { + program_id: *program_id, + state_pda: find_state_pda(program_id).0, + finalize_ix_index: 1, order_pdas, - bumps, + order_pda_bumps: bumps, sell_token_accounts, - &no_pulls(bumps.len()), - ); + pulls: &no_pulls(bumps.len()), + } + .instruction(); send_settlement(svm, program_id, payer, begin) } @@ -713,15 +734,16 @@ fn rejects_wrong_state_pda() { &mut svm, &program_id, &payer, - raw_begin_settle( - &program_id, - ¬_the_state_pda, - 1, - &[order_pda], - &[bump], - &[intent.sell_token_account], - &no_pulls(1), - ), + RawBeginSettle { + program_id, + state_pda: not_the_state_pda, + finalize_ix_index: 1, + order_pdas: &[order_pda], + order_pda_bumps: &[bump], + sell_token_accounts: &[intent.sell_token_account], + pulls: &no_pulls(1), + } + .instruction(), ), SettlementError::StateAccountMismatch, ); @@ -736,14 +758,15 @@ fn rejects_wrong_token_program() { // The builder always fills in the SPL Token program, so we swap the // token-program account out afterwards. - let mut begin = begin_settle( - &program_id, - 1, - &[SettledOrder { + let mut begin = BeginSettle { + program_id, + finalize_ix_index: 1, + orders: &[SettledOrder { intent: &intent, pulls: &[], }], - ); + } + .instruction(); let token_account_index = 2; begin.accounts[token_account_index] = AccountMeta::new_readonly(Pubkey::new_unique(), false); @@ -834,14 +857,15 @@ fn rejects_extra_account() { let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); // A well-formed single-order, no-transfer settlement... - let mut begin = begin_settle( - &program_id, - 1, - &[SettledOrder { + let mut begin = BeginSettle { + program_id, + finalize_ix_index: 1, + orders: &[SettledOrder { intent: &intent, pulls: &[], }], - ); + } + .instruction(); // ...with one extra account appended, so the account count no longer matches // the `2n + T` the instruction data implies. begin diff --git a/programs/settlement/tests/create_buffer.rs b/programs/settlement/tests/create_buffer.rs index f636118..8f057ff 100644 --- a/programs/settlement/tests/create_buffer.rs +++ b/programs/settlement/tests/create_buffer.rs @@ -5,9 +5,9 @@ use litesvm_token::{ state::{Account as TokenAccount, AccountState}, }, }; -use settlement_client::instructions::create_buffers; +use settlement_client::instructions::CreateBuffers; use settlement_client::settlement_interface::{ - instruction::create_buffer::{create_buffers as create_buffers_ix, SPL_TOKEN_PROGRAM_ID}, + instruction::create_buffer::{CreateBuffers as CreateBuffersIx, SPL_TOKEN_PROGRAM_ID}, pda::{ buffer::{buffer_pda_seeds, find_buffer_pda}, state::find_state_pda, @@ -31,7 +31,12 @@ fn happy_path_creates_initialized_buffer_token_account() { let (buffer_pda, _bump) = find_buffer_pda(&program_id, &mint); let (state_pda, _) = find_state_pda(&program_id); - let ix = create_buffers(&program_id, &payer.pubkey(), &[mint]); + let ix = CreateBuffers { + program_id, + payer: payer.pubkey(), + mints: &[mint], + } + .instruction(); let tx = common::signed_tx(&svm, &payer, &payer, ix); svm.send_transaction(tx) .expect("create_buffer should succeed"); @@ -94,7 +99,12 @@ fn buffer_can_receive_tokens() { let mint = common::token::create_mint(&mut svm, &payer); let (buffer_pda, _bump) = find_buffer_pda(&program_id, &mint); - let ix = create_buffers(&program_id, &payer.pubkey(), &[mint]); + let ix = CreateBuffers { + program_id, + payer: payer.pubkey(), + mints: &[mint], + } + .instruction(); let tx = common::signed_tx(&svm, &payer, &payer, ix); svm.send_transaction(tx) .expect("create_buffer should succeed"); @@ -128,7 +138,12 @@ fn happy_path_creates_native_token_buffer() { let (mut svm, program_id, payer) = common::setup(); let (buffer_pda, _bump) = find_buffer_pda(&program_id, &native_mint::ID); - let ix = create_buffers(&program_id, &payer.pubkey(), &[native_mint::ID]); + let ix = CreateBuffers { + program_id, + payer: payer.pubkey(), + mints: &[native_mint::ID], + } + .instruction(); let tx = common::signed_tx(&svm, &payer, &payer, ix); svm.send_transaction(tx) .expect("create_buffer for the native mint should succeed"); @@ -159,7 +174,12 @@ fn happy_path_creates_multiple_buffers_in_one_instruction() { .map(|_| common::token::create_mint(&mut svm, &payer)) .collect(); - let ix = create_buffers(&program_id, &payer.pubkey(), &mints); + let ix = CreateBuffers { + program_id, + payer: payer.pubkey(), + mints: &mints, + } + .instruction(); let tx = common::signed_tx(&svm, &payer, &payer, ix); svm.send_transaction(tx) .expect("create_buffers should create every buffer at once"); @@ -199,7 +219,12 @@ fn happy_path_creates_multiple_buffers_in_one_instruction() { fn rejects_no_buffers() { let (mut svm, program_id, payer) = common::setup(); - let ix = create_buffers(&program_id, &payer.pubkey(), &[]); + let ix = CreateBuffers { + program_id, + payer: payer.pubkey(), + mints: &[], + } + .instruction(); let tx = common::signed_tx(&svm, &payer, &payer, ix); let err = svm @@ -223,7 +248,12 @@ fn rejects_arbitrary_wrong_buffer_pda() { let mint = common::token::create_mint(&mut svm, &payer); let wrong_pda = Pubkey::new_unique(); - let ix = create_buffers_ix(&program_id, &payer.pubkey(), &[(wrong_pda, mint)]); + let ix = CreateBuffersIx { + program_id, + payer: payer.pubkey(), + buffers: &[(wrong_pda, mint)], + } + .instruction(); let tx = common::signed_tx(&svm, &payer, &payer, ix); common::pda::assert_rejected_as_noncanonical(&mut svm, tx, &wrong_pda); @@ -239,7 +269,12 @@ fn rejects_non_canonical_bump_pda() { let (_bump, non_canonical_pda) = common::pda::find_noncanonical_pda(&program_id, buffer_pda_seeds(mint.as_array())); - let ix = create_buffers_ix(&program_id, &payer.pubkey(), &[(non_canonical_pda, mint)]); + let ix = CreateBuffersIx { + program_id, + payer: payer.pubkey(), + buffers: &[(non_canonical_pda, mint)], + } + .instruction(); let tx = common::signed_tx(&svm, &payer, &payer, ix); common::pda::assert_rejected_as_noncanonical(&mut svm, tx, &non_canonical_pda); } @@ -251,7 +286,12 @@ fn rejects_non_spl_token_program() { let (buffer_pda, _bump) = find_buffer_pda(&program_id, &mint); // Swap the token-program account for an arbitrary key. - let mut ix = create_buffers(&program_id, &payer.pubkey(), &[mint]); + let mut ix = CreateBuffers { + program_id, + payer: payer.pubkey(), + mints: &[mint], + } + .instruction(); let token_program_index = 2; assert_eq!( ix.accounts[token_program_index].pubkey, SPL_TOKEN_PROGRAM_ID, @@ -289,7 +329,12 @@ fn rejects_invalid_mint() { let not_a_mint = Pubkey::new_unique(); let (buffer_pda, _bump) = find_buffer_pda(&program_id, ¬_a_mint); - let ix = create_buffers(&program_id, &payer.pubkey(), &[not_a_mint]); + let ix = CreateBuffers { + program_id, + payer: payer.pubkey(), + mints: &[not_a_mint], + } + .instruction(); let tx = common::signed_tx(&svm, &payer, &payer, ix); let err = svm @@ -316,14 +361,24 @@ 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_buffers(&program_id, &payer.pubkey(), &[mint]); + let ix = CreateBuffers { + program_id, + payer: payer.pubkey(), + mints: &[mint], + } + .instruction(); 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_buffers(&program_id, &payer.pubkey(), &[mint]); + let ix = CreateBuffers { + program_id, + payer: payer.pubkey(), + mints: &[mint], + } + .instruction(); let tx = common::signed_tx(&svm, &payer, &payer, ix); common::pda::assert_rejected_as_existing(&mut svm, tx); } @@ -336,7 +391,12 @@ fn one_failing_buffer_reverts_the_whole_batch() { let existing = common::token::create_mint(&mut svm, &payer); let fresh = common::token::create_mint(&mut svm, &payer); - let ix = create_buffers(&program_id, &payer.pubkey(), &[existing]); + let ix = CreateBuffers { + program_id, + payer: payer.pubkey(), + mints: &[existing], + } + .instruction(); let tx = common::signed_tx(&svm, &payer, &payer, ix); svm.send_transaction(tx) .expect("creating the first buffer should succeed"); @@ -345,7 +405,12 @@ fn one_failing_buffer_reverts_the_whole_batch() { // would be allocated first, then the existing one fails. Because the // instruction is atomic, the whole batch reverts and the fresh buffer must // not survive. - let ix = create_buffers(&program_id, &payer.pubkey(), &[fresh, existing]); + let ix = CreateBuffers { + program_id, + payer: payer.pubkey(), + mints: &[fresh, existing], + } + .instruction(); let tx = common::signed_tx(&svm, &payer, &payer, ix); common::pda::assert_rejected_as_existing(&mut svm, tx); @@ -363,7 +428,12 @@ fn rejects_same_mint_twice_in_one_instruction() { // Both pairs derive the same buffer PDA: the first creates it, the second // tries to recreate the now-existing account and fails, reverting the batch. - let ix = create_buffers(&program_id, &payer.pubkey(), &[mint, mint]); + let ix = CreateBuffers { + program_id, + payer: payer.pubkey(), + mints: &[mint, mint], + } + .instruction(); let tx = common::signed_tx(&svm, &payer, &payer, ix); common::pda::assert_rejected_as_existing(&mut svm, tx); diff --git a/programs/settlement/tests/create_order.rs b/programs/settlement/tests/create_order.rs index b5d400d..c4d5262 100644 --- a/programs/settlement/tests/create_order.rs +++ b/programs/settlement/tests/create_order.rs @@ -3,7 +3,7 @@ use settlement_client::settlement_interface::{ intent::{fixtures, EncodedOrderIntent, OrderIntent, OrderKind}, order::{EncodedOrderAccount, OrderAccount}, }, - instruction::create_order::create_order, + instruction::create_order::CreateOrder, pda::order::{find_order_pda, order_pda_seeds}, SettlementError, }; @@ -43,13 +43,14 @@ fn happy_path_creates_order_pda_with_expected_body() { // `owner` doubles as `created_by` here: the same address may fill both // slots, which is the common case. It also pays the tx fee. - let ix = create_order( - &program_id, - &owner.pubkey(), - &owner.pubkey(), - &pda, - &encoded, - ); + let ix = CreateOrder { + program_id, + owner: owner.pubkey(), + created_by: owner.pubkey(), + order_pda: pda, + intent_bytes: encoded, + } + .instruction(); let tx = signed_tx(&svm, &owner, &owner, ix); svm.send_transaction(tx) .expect("create_order should succeed"); @@ -109,13 +110,14 @@ fn creates_order_with_separate_fee_payers() { let owner_before = common::lamports(&svm, &owner.pubkey()); let created_by_before = common::lamports(&svm, &created_by.pubkey()); - let ix = create_order( - &program_id, - &owner.pubkey(), - &created_by.pubkey(), - &pda, - &encoded, - ); + let ix = CreateOrder { + program_id, + owner: owner.pubkey(), + created_by: created_by.pubkey(), + order_pda: pda, + intent_bytes: encoded, + } + .instruction(); let tx = Transaction::new_signed_with_payer( &[ix], Some(&fee_payer.pubkey()), @@ -169,13 +171,14 @@ fn rejects_arbitrary_wrong_pda() { // Hand the client helper a deliberately wrong address; it forwards the // PDA we give it rather than deriving the canonical one. let wrong_pda = Pubkey::new_unique(); - let ix = create_order( - &program_id, - &owner.pubkey(), - &owner.pubkey(), - &wrong_pda, - &encoded, - ); + let ix = CreateOrder { + program_id, + owner: owner.pubkey(), + created_by: owner.pubkey(), + order_pda: wrong_pda, + intent_bytes: encoded, + } + .instruction(); let tx = signed_tx(&svm, &owner, &owner, ix); common::pda::assert_rejected_as_noncanonical(&mut svm, tx, &wrong_pda); @@ -193,13 +196,14 @@ fn rejects_non_canonical_bump_pda() { let (_bump, non_canonical_pda) = common::pda::find_noncanonical_pda(&program_id, order_pda_seeds(&uid)); - let ix = create_order( - &program_id, - &fee_payer.pubkey(), - &fee_payer.pubkey(), - &non_canonical_pda, - &bytes, - ); + let ix = CreateOrder { + program_id, + owner: fee_payer.pubkey(), + created_by: fee_payer.pubkey(), + order_pda: non_canonical_pda, + intent_bytes: bytes, + } + .instruction(); let tx = signed_tx(&svm, &fee_payer, &fee_payer, ix); common::pda::assert_rejected_as_noncanonical(&mut svm, tx, &non_canonical_pda); } @@ -215,13 +219,14 @@ fn rejects_creating_same_pda_twice() { let (encoded, pda) = encode_and_derive(&intent, &program_id); // First creation populates the PDA. - let ix = create_order( - &program_id, - &fee_payer.pubkey(), - &fee_payer.pubkey(), - &pda, - &encoded, - ); + let ix = CreateOrder { + program_id, + owner: fee_payer.pubkey(), + created_by: fee_payer.pubkey(), + order_pda: pda, + intent_bytes: encoded, + } + .instruction(); let tx = signed_tx(&svm, &fee_payer, &fee_payer, ix); svm.send_transaction(tx) .expect("first create_order should succeed"); @@ -230,13 +235,14 @@ fn rejects_creating_same_pda_twice() { // For good measure, we change `created_by` to stress that the input // account doesn't matter here. - let ix = create_order( - &program_id, - &fee_payer.pubkey(), - &another_fee_payer.pubkey(), - &pda, - &encoded, - ); + let ix = CreateOrder { + program_id, + owner: fee_payer.pubkey(), + created_by: another_fee_payer.pubkey(), + order_pda: pda, + intent_bytes: encoded, + } + .instruction(); let tx = signed_tx(&svm, &another_fee_payer, &fee_payer, ix); common::pda::assert_rejected_as_existing(&mut svm, tx); } @@ -251,13 +257,14 @@ fn rejects_when_intent_owner_differs_from_signer() { let intent = sample_intent(intent_owner); let (encoded, pda) = encode_and_derive(&intent, &program_id); - let ix = create_order( - &program_id, - &fee_payer.pubkey(), - &fee_payer.pubkey(), - &pda, - &encoded, - ); + let ix = CreateOrder { + program_id, + owner: fee_payer.pubkey(), + created_by: fee_payer.pubkey(), + order_pda: pda, + intent_bytes: encoded, + } + .instruction(); let tx = signed_tx(&svm, &fee_payer, &fee_payer, ix); let err = svm .send_transaction(tx) diff --git a/programs/settlement/tests/initialize.rs b/programs/settlement/tests/initialize.rs index 8686647..4f4f799 100644 --- a/programs/settlement/tests/initialize.rs +++ b/programs/settlement/tests/initialize.rs @@ -1,6 +1,6 @@ -use settlement_client::instructions::initialize; +use settlement_client::instructions::Initialize; use settlement_client::settlement_interface::{ - instruction::initialize::initialize as initialize_ix, pda::state::find_state_pda, + instruction::initialize::Initialize as InitializeIx, pda::state::find_state_pda, }; use solana_sdk::{ pubkey::Pubkey, @@ -16,7 +16,11 @@ fn happy_path_initializes_empty_state_pda() { // `payer` is both the transaction fee payer and the account funding the // state PDA's rent. - let ix = initialize(&program_id, &payer.pubkey()); + let ix = Initialize { + program_id, + payer: payer.pubkey(), + } + .instruction(); let tx = common::signed_tx(&svm, &payer, &payer, ix); svm.send_transaction(tx).expect("initialize should succeed"); @@ -47,7 +51,11 @@ fn funding_payer_can_differ_from_fee_payer() { svm.airdrop(&funder.pubkey(), funder_airdrop) .expect("airdrop to funder should succeed"); - let ix = initialize(&program_id, &funder.pubkey()); + let ix = Initialize { + program_id, + payer: funder.pubkey(), + } + .instruction(); let tx = common::signed_tx(&svm, &fee_payer, &funder, ix); svm.send_transaction(tx).expect("initialize should succeed"); @@ -68,7 +76,12 @@ fn rejects_arbitrary_wrong_state_pda() { // 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 ix = InitializeIx { + program_id, + payer: payer.pubkey(), + state_pda: wrong_pda, + } + .instruction(); let tx = common::signed_tx(&svm, &payer, &payer, ix); common::pda::assert_rejected_as_noncanonical(&mut svm, tx, &wrong_pda); @@ -78,14 +91,22 @@ fn rejects_arbitrary_wrong_state_pda() { fn rejects_initializing_twice() { let (mut svm, program_id, payer) = common::setup(); - let ix = initialize(&program_id, &payer.pubkey()); + let ix = Initialize { + program_id, + payer: payer.pubkey(), + } + .instruction(); 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 ix = Initialize { + program_id, + payer: payer.pubkey(), + } + .instruction(); let tx = common::signed_tx(&svm, &payer, &payer, ix); common::pda::assert_rejected_as_existing(&mut svm, tx); } diff --git a/programs/settlement/tests/matching_begin_finalize.rs b/programs/settlement/tests/matching_begin_finalize.rs index e0121d1..5b77173 100644 --- a/programs/settlement/tests/matching_begin_finalize.rs +++ b/programs/settlement/tests/matching_begin_finalize.rs @@ -1,5 +1,5 @@ use litesvm::{types::FailedTransactionMetadata, LiteSVM}; -use settlement_client::instructions::{begin_settle, finalize_settle}; +use settlement_client::instructions::{BeginSettle, FinalizeSettle}; use settlement_client::settlement_interface::SettlementError; use solana_sdk::{ instruction::{AccountMeta, Instruction, InstructionError}, @@ -35,8 +35,17 @@ fn run_sequence( let instructions: Vec = sequence .iter() .map(|spec| match spec { - AbstractInstruction::Init(idx) => begin_settle(program_id, *idx, &[]), - AbstractInstruction::Fin(idx) => finalize_settle(program_id, *idx), + AbstractInstruction::Init(idx) => BeginSettle { + program_id: *program_id, + finalize_ix_index: *idx, + orders: &[], + } + .instruction(), + AbstractInstruction::Fin(idx) => FinalizeSettle { + program_id: *program_id, + begin_ix_index: *idx, + } + .instruction(), // 0-lamport self-transfer: a side-effect-free instruction that // (unlike Compute Budget) Solana allows to appear multiple times // in the same transaction. @@ -129,9 +138,18 @@ 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 = BeginSettle { + program_id, + finalize_ix_index: 1, + orders: &[], + } + .instruction(); begin.accounts[0] = AccountMeta::new_readonly(payer.pubkey(), false); - let finalize = finalize_settle(&program_id, 0); + let finalize = FinalizeSettle { + program_id, + begin_ix_index: 0, + } + .instruction(); let tx = Transaction::new_signed_with_payer( &[begin, finalize], @@ -156,11 +174,20 @@ 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 = BeginSettle { + program_id, + finalize_ix_index: 1, + orders: &[], + } + .instruction(); // 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. - let stranger = finalize_settle(&solana_system_interface::program::ID, 0); + let stranger = FinalizeSettle { + program_id: solana_system_interface::program::ID, + begin_ix_index: 0, + } + .instruction(); let instructions = [begin, stranger]; let expected_failing_instruction_index = 0; @@ -205,7 +232,15 @@ fn rejects_cpi_call_to_begin_settle() { let (mut svm, settlement_id, payer) = common::setup(); let cpi_caller_id = common::setup_cpi_caller(&mut svm); - let cpi_caller_ix = as_cpi_call(cpi_caller_id, begin_settle(&settlement_id, 1, &[])); + let cpi_caller_ix = as_cpi_call( + cpi_caller_id, + BeginSettle { + program_id: settlement_id, + finalize_ix_index: 1, + orders: &[], + } + .instruction(), + ); let tx = Transaction::new_signed_with_payer( &[cpi_caller_ix], @@ -229,7 +264,14 @@ fn rejects_cpi_call_to_finalize_settle() { let (mut svm, settlement_id, payer) = common::setup(); let cpi_caller_id = common::setup_cpi_caller(&mut svm); - let cpi_caller_ix = as_cpi_call(cpi_caller_id, finalize_settle(&settlement_id, 0)); + let cpi_caller_ix = as_cpi_call( + cpi_caller_id, + FinalizeSettle { + program_id: settlement_id, + begin_ix_index: 0, + } + .instruction(), + ); let tx = Transaction::new_signed_with_payer( &[cpi_caller_ix], diff --git a/programs/settlement/tests/program_deployment.rs b/programs/settlement/tests/program_deployment.rs index 03bb735..8496ae0 100644 --- a/programs/settlement/tests/program_deployment.rs +++ b/programs/settlement/tests/program_deployment.rs @@ -1,4 +1,4 @@ -use settlement_client::instructions::{begin_settle, finalize_settle}; +use settlement_client::instructions::{BeginSettle, FinalizeSettle}; use solana_sdk::{ instruction::{Instruction, InstructionError}, signature::Signer, @@ -29,8 +29,17 @@ 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, &[]), - finalize_settle(&program_id, 0), + BeginSettle { + program_id, + finalize_ix_index: 1, + orders: &[], + } + .instruction(), + FinalizeSettle { + program_id, + begin_ix_index: 0, + } + .instruction(), ], Some(&payer.pubkey()), &[&payer], From d70c2c15761111119041fa5de650583039d20a38 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:10:15 +0200 Subject: [PATCH 4/6] Internal notational consistency --- interface/src/instruction/create_buffer.rs | 2 +- interface/src/instruction/initialize.rs | 2 +- interface/src/instruction/settle/begin.rs | 6 +++--- interface/src/instruction/settle/finalize.rs | 2 +- programs/settlement/tests/begin_settle_orders.rs | 6 +++--- programs/settlement/tests/create_buffer.rs | 6 +++--- programs/settlement/tests/initialize.rs | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/interface/src/instruction/create_buffer.rs b/interface/src/instruction/create_buffer.rs index e3ebf03..f3ebdc8 100644 --- a/interface/src/instruction/create_buffer.rs +++ b/interface/src/instruction/create_buffer.rs @@ -27,7 +27,7 @@ pub use spl_token_interface::ID as SPL_TOKEN_PROGRAM_ID; /// the bump itself and rejects any other address. `payer` funds every new token /// account's rent. /// -/// Wire format: `[discriminator]`, 1 byte. The tokens are implied by the +/// Wire format: `[discriminator=4]`, 1 byte. The tokens are implied by the /// `mint` accounts, so no further data is needed. /// Required accounts: /// `[payer (W,S), system_program (R), token_program (R), (buffer_pda (W), mint (R))...]`. diff --git a/interface/src/instruction/initialize.rs b/interface/src/instruction/initialize.rs index d397ba4..6f0c944 100644 --- a/interface/src/instruction/initialize.rs +++ b/interface/src/instruction/initialize.rs @@ -27,7 +27,7 @@ use crate::SettlementInstruction; /// no parameters and succeeds only once: a second call fails because the /// account already exists. /// -/// Wire format: just `[discriminator]`, 1 byte. +/// Wire format: just `[discriminator=3]`, 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. diff --git a/interface/src/instruction/settle/begin.rs b/interface/src/instruction/settle/begin.rs index fe27de6..071827b 100644 --- a/interface/src/instruction/settle/begin.rs +++ b/interface/src/instruction/settle/begin.rs @@ -35,9 +35,9 @@ pub struct Pull { /// Wire format (grouped, with `n` orders and `T` total transfers): /// `[discriminator=0][finalize_ix_index: u16 BE][n: u8][bump×n][transfer_count×n] /// [amount: u64 BE ×T]`. -/// Accounts: -/// `[instructions_sysvar (R), state_pda (R), token_program (R)]` followed, per -/// order, by `[order_pda (R), sell_token_account (W), destination (W)...]`. +/// Required accounts: `[instructions_sysvar (R), state_pda (R), token_program +/// (R)]` followed, per order, by `[order_pda (R), sell_token_account (W), +/// destination (W)...]`. /// /// The program requires the order PDAs to be strictly increasing by address. /// This builder establishes that ordering for the caller: it sorts the orders by diff --git a/interface/src/instruction/settle/finalize.rs b/interface/src/instruction/settle/finalize.rs index 4db6b85..dbcaca0 100644 --- a/interface/src/instruction/settle/finalize.rs +++ b/interface/src/instruction/settle/finalize.rs @@ -17,7 +17,7 @@ use super::{recover_counterpart, INSTRUCTIONS_SYSVAR_ID}; /// `begin_ix_index` is the index of the paired `BeginSettle` instruction in the /// same transaction. /// -/// Wire format: `[discriminator, begin_ix_index: u16 BE]`, 3 bytes. +/// Wire format: `[discriminator=1, begin_ix_index: u16 BE]`, 3 bytes. /// Required accounts: `[instructions_sysvar (R)]`. pub struct FinalizeSettle { pub program_id: Pubkey, diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index fc237b0..b5f31f5 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -19,7 +19,7 @@ use settlement_client::settlement_interface::{ order::{EncodedOrderAccount, OrderAccount}, }, instruction::settle::{ - BeginSettle as RawBeginSettle, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, + BeginSettle as BeginSettleRaw, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, }, pda::{order::find_order_pda, state::find_state_pda}, Instruction, SettlementError, SettlementInstruction, @@ -170,7 +170,7 @@ fn settle_raw( sell_token_accounts: &[Pubkey], bumps: &[u8], ) -> Result { - let begin = RawBeginSettle { + let begin = BeginSettleRaw { program_id: *program_id, state_pda: find_state_pda(program_id).0, finalize_ix_index: 1, @@ -734,7 +734,7 @@ fn rejects_wrong_state_pda() { &mut svm, &program_id, &payer, - RawBeginSettle { + BeginSettleRaw { program_id, state_pda: not_the_state_pda, finalize_ix_index: 1, diff --git a/programs/settlement/tests/create_buffer.rs b/programs/settlement/tests/create_buffer.rs index 8f057ff..02119ce 100644 --- a/programs/settlement/tests/create_buffer.rs +++ b/programs/settlement/tests/create_buffer.rs @@ -7,7 +7,7 @@ use litesvm_token::{ }; use settlement_client::instructions::CreateBuffers; use settlement_client::settlement_interface::{ - instruction::create_buffer::{CreateBuffers as CreateBuffersIx, SPL_TOKEN_PROGRAM_ID}, + instruction::create_buffer::{CreateBuffers as CreateBuffersRaw, SPL_TOKEN_PROGRAM_ID}, pda::{ buffer::{buffer_pda_seeds, find_buffer_pda}, state::find_state_pda, @@ -248,7 +248,7 @@ fn rejects_arbitrary_wrong_buffer_pda() { let mint = common::token::create_mint(&mut svm, &payer); let wrong_pda = Pubkey::new_unique(); - let ix = CreateBuffersIx { + let ix = CreateBuffersRaw { program_id, payer: payer.pubkey(), buffers: &[(wrong_pda, mint)], @@ -269,7 +269,7 @@ fn rejects_non_canonical_bump_pda() { let (_bump, non_canonical_pda) = common::pda::find_noncanonical_pda(&program_id, buffer_pda_seeds(mint.as_array())); - let ix = CreateBuffersIx { + let ix = CreateBuffersRaw { program_id, payer: payer.pubkey(), buffers: &[(non_canonical_pda, mint)], diff --git a/programs/settlement/tests/initialize.rs b/programs/settlement/tests/initialize.rs index 4f4f799..c658df4 100644 --- a/programs/settlement/tests/initialize.rs +++ b/programs/settlement/tests/initialize.rs @@ -1,6 +1,6 @@ use settlement_client::instructions::Initialize; use settlement_client::settlement_interface::{ - instruction::initialize::Initialize as InitializeIx, pda::state::find_state_pda, + instruction::initialize::Initialize as InitializeRaw, pda::state::find_state_pda, }; use solana_sdk::{ pubkey::Pubkey, @@ -76,7 +76,7 @@ fn rejects_arbitrary_wrong_state_pda() { // 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 = InitializeIx { + let ix = InitializeRaw { program_id, payer: payer.pubkey(), state_pda: wrong_pda, From c3138171985934e78f6668e638d5e710fdd79e06 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:48:25 +0200 Subject: [PATCH 5/6] Parse pushes in `FinalizeSettle` --- DESIGN.md | 3 +- client/src/instructions.rs | 150 +++++- interface/src/instruction/settle/finalize.rs | 445 ++++++++++++++++-- interface/src/instruction/settle/mod.rs | 2 +- interface/src/lib.rs | 4 + .../settlement/tests/begin_settle_orders.rs | 168 ++----- programs/settlement/tests/common/mod.rs | 1 + programs/settlement/tests/common/order.rs | 92 ++++ .../tests/finalize_settle_pushes.rs | 249 ++++++++++ .../tests/matching_begin_finalize.rs | 4 + .../settlement/tests/program_deployment.rs | 1 + 11 files changed, 939 insertions(+), 180 deletions(-) create mode 100644 programs/settlement/tests/common/order.rs create mode 100644 programs/settlement/tests/finalize_settle_pushes.rs diff --git a/DESIGN.md b/DESIGN.md index ec39931..7363c3d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -256,9 +256,8 @@ Differences with Ethereum: A settlement transaction is split into multiple instructions. All settlement operations occur between a `BeginSettle` and a `FinalizeSettle` instruction with the exception of arbitrary interactions, which can take place at any point of a transaction. Except for that, the order of instructions in the transaction is arbitrary. - `BeginSettle`: Snapshots each order's receiver token account, spender token account, and withdrawal balances. Pulls funds from each order’s sell token account to the solver-specified destination accounts, using the settlement state PDA’s token delegation. Carries an explicit `finalize_ix_index` pointing to its paired `FinalizeSettle`. -- `Push`: It references a unique SPL transfer token instruction between `BeginSettle` and `FinalizeSettle` that sends the proceeds of an order to its buy token account. - (arbitrary interactions): Any instruction from the solver. This could be a token transfer, an AMM swap, or anything else. -- `FinalizeSettle`: Reads balances again, computes deltas against the snapshots, validates clearing/limit prices, updates `amount_received` and order status, revokes solver approvals. Carries an explicit `begin_ix_index` pointing to its paired `BeginSettle`. +- `FinalizeSettle`: Pushes the proceeds of each order from the settlement’s buffer accounts to the order’s buy token account, using the settlement state PDA’s authority over the buffers. Reads balances again, computes deltas against the snapshots, validates clearing/limit prices, updates `amount_received` and order status, revokes solver approvals. Carries an explicit `begin_ix_index` pointing to its paired `BeginSettle`. Additionally, a settlement transaction will include the batch number as part of the instruction bytes of `BeginSettle`. diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 1d81a4a..0244348 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -13,12 +13,12 @@ use settlement_interface::{ // Reexport the instruction builders that don't change from the interface. // We want the client to provide all instruction builders. -pub use settlement_interface::instruction::settle::{FinalizeSettle, Pull}; +pub use settlement_interface::instruction::settle::Pull; -/// An order to settle together with the funds to pull from it: `intent` -/// identifies the order and `pulls` lists the [`Pull`]s to make from its sell -/// token account. -pub struct SettledOrder<'a> { +/// An order ready to be settled, together with the funds to pull from it: +/// `intent` identifies the order and `pulls` lists the [`Pull`]s to make from +/// its sell token account. +pub struct SettleableOrder<'a> { pub intent: &'a OrderIntent, pub pulls: &'a [Pull], } @@ -27,7 +27,7 @@ pub struct SettledOrder<'a> { pub struct BeginSettle<'a> { pub program_id: Pubkey, pub finalize_ix_index: u16, - pub orders: &'a [SettledOrder<'a>], + pub orders: &'a [SettleableOrder<'a>], } impl BeginSettle<'_> { @@ -57,6 +57,61 @@ impl BeginSettle<'_> { } } +/// A settled order whose proceeds are pushed to it: `intent` identifies the +/// order (its `buy_token_account` is the push destination), `mint` selects the +/// canonical source buffer, and `amount` is the quantity to push. +pub struct SettledOrder<'a> { + pub intent: &'a OrderIntent, + pub mint: Pubkey, + pub amount: u64, +} + +/// Builder for a `FinalizeSettle` instruction pushing each order's proceeds to +/// its buy token account. +/// +/// The destination is the order intent's `buy_token_account` and the source is +/// the canonical buffer PDA for `mint` (see [`find_buffer_pda`]). The orders are +/// sorted by their canonical order PDA (the same key [`BeginSettle`] orders its +/// settled-order list by) so the two instructions present the orders in the +/// same order and their lists line up. +pub struct FinalizeSettle<'a> { + pub program_id: Pubkey, + pub begin_ix_index: u16, + pub orders: &'a [SettledOrder<'a>], +} + +impl FinalizeSettle<'_> { + pub fn instruction(self) -> Instruction { + // Sort the orders by their canonical order PDA, the key `BeginSettle` + // lays its settled orders out by, so the two instructions' lists align. + let mut order: Vec = (0..self.orders.len()).collect(); + order.sort_by_key(|&i| find_order_pda(&self.program_id, &self.orders[i].intent.uid()).0); + + let mut source_buffers = Vec::with_capacity(self.orders.len()); + let mut destinations = Vec::with_capacity(self.orders.len()); + let mut bumps = Vec::with_capacity(self.orders.len()); + let mut amounts = Vec::with_capacity(self.orders.len()); + for &i in &order { + let (buffer_pda, bump) = find_buffer_pda(&self.program_id, &self.orders[i].mint); + source_buffers.push(buffer_pda); + destinations.push(self.orders[i].intent.buy_token_account); + bumps.push(bump); + amounts.push(self.orders[i].amount); + } + let (state_pda, _bump) = find_state_pda(&self.program_id); + settlement_interface::instruction::settle::FinalizeSettle { + program_id: self.program_id, + state_pda, + begin_ix_index: self.begin_ix_index, + source_buffers: &source_buffers, + destinations: &destinations, + bumps: &bumps, + amounts: &amounts, + } + .instruction() + } +} + pub struct CreateOrder<'a> { pub program_id: Pubkey, pub owner: Pubkey, @@ -127,14 +182,16 @@ mod tests { data::intent::fixtures::arb_order_intent, instruction::{ fixtures::fake_account_from_array, - settle::{BeginSettleInput, INSTRUCTIONS_SYSVAR_ID}, + settle::{ + BeginSettleInput, FinalizeSettleInput, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, + }, InstructionInputParsing, }, pda::order::find_order_pda, }; proptest! { - // `begin_settle` derives each order's PDA from its intent and forwards to + // `BeginSettle` derives each order's PDA from its intent and forwards to // the interface builder so that the on-chain parser recovers exactly // those orders. #[test] @@ -145,9 +202,9 @@ mod tests { let program_id = Pubkey::new_unique(); // No pulls here: this test only checks that orders are derived and // laid out correctly. - let orders: Vec = intents + let orders: Vec = intents .iter() - .map(|intent| SettledOrder { intent, pulls: &[] }) + .map(|intent| SettleableOrder { intent, pulls: &[] }) .collect(); let ix = BeginSettle { program_id, @@ -189,5 +246,78 @@ mod tests { prop_assert_eq!(order.bump, *bump); } } + + // `FinalizeSettle` derives each order's source buffer from its mint and + // destination from the intent, sorting by canonical order PDA like + // `BeginSettle` so the on-chain parser recovers exactly those pushes in + // that order. + #[test] + fn finalize_settle_derives_buffers_from_mints( + begin_ix_index in any::(), + cases in prop::collection::vec( + (arb_order_intent(), any::<[u8; 32]>(), any::()), + 1..=5, + ), + ) { + let program_id = Pubkey::new_unique(); + let orders: Vec = cases + .iter() + .map(|(intent, mint, amount)| SettledOrder { + intent, + mint: Pubkey::new_from_array(*mint), + amount: *amount, + }) + .collect(); + let ix = FinalizeSettle { + program_id, + begin_ix_index, + orders: &orders, + } + .instruction(); + + // Expected pushes: each order's buffer PDA (and its canonical bump), + // buy token account, and amount, sorted by the order's canonical PDA + // (the builder's order). + let mut expected: Vec<(Pubkey, Pubkey, u8, Pubkey, u64)> = orders + .iter() + .map(|order| { + let (order_pda, _bump) = find_order_pda(&program_id, &order.intent.uid()); + let (buffer_pda, buffer_bump) = find_buffer_pda(&program_id, &order.mint); + (order_pda, buffer_pda, buffer_bump, order.intent.buy_token_account, order.amount) + }) + .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 = FinalizeSettleInput::parse(&ix.data, &mut accounts) + .map_err(|e| TestCaseError::fail(format!("parse failed: {e:?}")))?; + + prop_assert_eq!(parsed.begin_ix_index, begin_ix_index); + prop_assert_eq!( + parsed.instructions_sysvar_account.address(), + &INSTRUCTIONS_SYSVAR_ID, + ); + let (state_pda, _bump) = find_state_pda(&program_id); + prop_assert_eq!(parsed.state_pda_account.address(), &state_pda); + prop_assert_eq!( + parsed.token_program_account.address(), + &SPL_TOKEN_PROGRAM_ID, + ); + + let parsed_pushes: Vec<_> = parsed.pushes.iter().collect(); + prop_assert_eq!(parsed_pushes.len(), expected.len()); + for (push, (_order_pda, buffer, buffer_bump, destination, amount)) in + parsed_pushes.iter().zip(&expected) + { + prop_assert_eq!(push.source_buffer.address(), buffer); + prop_assert_eq!(push.destination.address(), destination); + prop_assert_eq!(push.bump, *buffer_bump); + prop_assert_eq!(u64::from_be_bytes(*push.amount), *amount); + } + } } } diff --git a/interface/src/instruction/settle/finalize.rs b/interface/src/instruction/settle/finalize.rs index dbcaca0..0e8bef5 100644 --- a/interface/src/instruction/settle/finalize.rs +++ b/interface/src/instruction/settle/finalize.rs @@ -8,60 +8,204 @@ use solana_program_error::ProgramError; use solana_pubkey::Pubkey; use crate::instruction::InstructionInputParsing; -use crate::SettlementInstruction; +use crate::{SettlementError, SettlementInstruction}; -use super::{recover_counterpart, INSTRUCTIONS_SYSVAR_ID}; +use super::{recover_counterpart, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID}; -/// Builder for a `FinalizeSettle` instruction. +/// Builder for a `FinalizeSettle` instruction pushing the funds described by the +/// parallel lists: +/// - `source_buffers[i]` the buffer token account the funds come from, +/// - `destinations[i]` the account the funds go to (an order's buy token +/// account), +/// - `bumps[i]` the canonical bump of `source_buffers[i]`, so the program +/// re-derives the buffer PDA with one hash instead of searching, +/// - `amounts[i]` the amount to push. /// -/// `begin_ix_index` is the index of the paired `BeginSettle` instruction in the -/// same transaction. +/// The slices are assumed to have the same length but this is not enforced in +/// the builder. /// -/// Wire format: `[discriminator=1, begin_ix_index: u16 BE]`, 3 bytes. -/// Required accounts: `[instructions_sysvar (R)]`. -pub struct FinalizeSettle { +/// Wire format (with `T` total pushes): +/// `[discriminator=1][begin_ix_index: u16 BE][bump: u8 ×T][amount: u64 BE ×T]`. +/// Accounts: +/// `[instructions_sysvar (R), state_pda (R), token_program (R)]` followed, per +/// push, by `[source_buffer (W), destination (W)]`. +/// +/// `FinalizeSettle` validates that each source is the canonical buffer for its +/// destination's mint and executes the transfers; the order correspondence and +/// that each destination is an order's buy token account are `BeginSettle`'s +/// checks. So a push isn't aware of what orders are being paid, just the accounts +/// to move funds between, the source's bump, and the amount. The same buffer may +/// legitimately fund several pushes. +pub struct FinalizeSettle<'a> { pub program_id: Pubkey, + pub state_pda: Pubkey, pub begin_ix_index: u16, + pub source_buffers: &'a [Pubkey], + pub destinations: &'a [Pubkey], + pub bumps: &'a [u8], + pub amounts: &'a [u64], } -impl FinalizeSettle { +impl FinalizeSettle<'_> { pub fn instruction(self) -> Instruction { + let FinalizeSettle { + program_id, + state_pda, + begin_ix_index, + source_buffers, + destinations, + bumps, + amounts, + } = self; + + let data: Vec = core::iter::once(SettlementInstruction::FinalizeSettle.discriminator()) + .chain(begin_ix_index.to_be_bytes()) + .chain(bumps.iter().copied()) + .chain(amounts.iter().flat_map(|amount| amount.to_be_bytes())) + .collect(); + + let mut accounts = vec![ + AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false), + AccountMeta::new_readonly(state_pda, false), + AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), + ]; + for (source, destination) in source_buffers.iter().zip(destinations) { + accounts.push(AccountMeta::new(*source, false)); + accounts.push(AccountMeta::new(*destination, false)); + } + Instruction { - program_id: self.program_id, - accounts: vec![AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false)], - data: [ - &[SettlementInstruction::FinalizeSettle.discriminator()], - &self.begin_ix_index.to_be_bytes()[..], - ] - .concat(), + program_id, + accounts, + data, } } } +/// A single fund push parsed from `FinalizeSettle`: move `amount` (big-endian +/// `u64`) from `source_buffer` to `destination`. `bump` is `source_buffer`'s +/// claimed canonical buffer bump, which the program re-derives against. +pub struct Push<'a> { + pub source_buffer: &'a AccountView, + pub destination: &'a AccountView, + pub bump: u8, + pub amount: &'a [u8; 8], +} + +/// Struct storing accounts, bumps, and amounts from parsing the input of +/// `FinalizeSettle`, laid out as a flat list of `[source_buffer, destination]` +/// account pairs parallel to `bumps` and `amounts`. The parsing step that created +/// this struct guarantees `push_accounts.len() == 2 * amounts.len()` and +/// `bumps.len() == amounts.len()`, so the offsets below never run short. +pub struct Pushes<'a> { + /// `[source_buffer, destination]` per push, flattened. + push_accounts: &'a [AccountView], + bumps: &'a [u8], + /// One push amount (big-endian `u64`) per push, parallel to `bumps`. + amounts: &'a [[u8; 8]], +} + +impl<'a> Pushes<'a> { + /// Returns an iterator yielding one [`Push`] per step. + #[allow( + clippy::arithmetic_side_effects, + reason = "offsets are bounded by tx limits" + )] + pub fn iter(&self) -> impl Iterator> + '_ { + let push_count = self.bumps.len(); + let mut i = 0usize; + let mut account_offset = 0usize; + std::iter::from_fn(move || { + if i >= push_count { + return None; + } + let bump = self.bumps[i]; + let amount = &self.amounts[i]; + i += 1; + + let source_buffer = &self.push_accounts[account_offset]; + let destination = &self.push_accounts[account_offset + 1]; + account_offset += 2; + + Some(Push { + source_buffer, + destination, + bump, + amount, + }) + }) + } +} + /// Parsed inputs (instruction-data fields + relevant accounts) of a /// `FinalizeSettle` instruction. /// /// Strictly the raw extracted form. Fields are read from `instruction_data` and /// `accounts` but **not validated** against runtime context except confirming -/// that the discriminator matches the desired input. +/// that the discriminator matches the desired input and that the number of +/// accounts and amounts is consistent. pub struct FinalizeSettleInput<'a> { pub begin_ix_index: u16, pub instructions_sysvar_account: &'a AccountView, + pub state_pda_account: &'a AccountView, + pub token_program_account: &'a AccountView, + pub pushes: Pushes<'a>, } +/// This implementation defines how instruction bytes and accounts are laid out +/// in the transaction. It's the source of truth for deciding where the data +/// is stored. impl<'a> InstructionInputParsing<'a> for FinalizeSettleInput<'a> { const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::FinalizeSettle; fn parse_body( - instruction_data: &[u8], + instruction_data: &'a [u8], accounts: &'a mut [AccountView], ) -> Result { - let (begin_ix_index, _) = recover_counterpart(instruction_data)?; - let instructions_sysvar_account = - accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + let (begin_ix_index, body) = recover_counterpart(instruction_data)?; + + let [instructions_sysvar_account, state_pda_account, token_program_account, push_accounts @ ..] = + accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + // The body after the begin index is, per push, a bump byte (all `T` + // first) then a big-endian `u64` amount: `9 * T` bytes. Unlike + // `BeginSettle`, there's no explicit count byte, so `T` is recovered as + // `body.len() / 9`; a body that isn't a whole number of these 9-byte + // pushes can't be parsed into the push layout at all (and would otherwise + // leave `bumps` and `amounts` with mismatched lengths). + if body.len() % 9 != 0 { + return Err(ProgramError::InvalidInstructionData); + } + let push_count = body.len() / 9; + let (bumps, amount_bytes) = body + .split_at_checked(push_count) + .ok_or(ProgramError::InvalidInstructionData)?; + // `amount_bytes` is `8 * push_count` long, so this splits cleanly into + // `push_count` whole `u64`s with no remainder. + let (amounts, _remainder) = amount_bytes.as_chunks::<8>(); + + // Each push contributes a source buffer and a destination account, so + // the push-account count is `2 * T`. + let expected_accounts = push_count + .checked_mul(2) + .ok_or(ProgramError::InvalidInstructionData)?; + if push_accounts.len() != expected_accounts { + return Err(SettlementError::AccountCountNotMatchingPushCount.into()); + } + Ok(Self { begin_ix_index, instructions_sysvar_account, + state_pda_account, + token_program_account, + pushes: Pushes { + push_accounts, + bumps, + amounts, + }, }) } } @@ -69,39 +213,124 @@ impl<'a> InstructionInputParsing<'a> for FinalizeSettleInput<'a> { #[cfg(test)] mod tests { use super::*; - use crate::instruction::fixtures::fake_account; + use crate::instruction::fixtures::{ + fake_account, fake_account_from_array, fake_sequential_accounts, + }; use crate::instruction::settle::tests::ix_data; use hex_literal::hex; use solana_address::Address; + /// The fixed accounts every `FinalizeSettle` carries before its push + /// accounts: the instructions sysvar, the settlement state PDA, and the + /// token program. + const FIXED_ACCOUNTS: usize = 3; + + #[test] + fn expected_encoding_finalize_settle_no_pushes() { + let program_id = Pubkey::new_unique(); + let state_pda = Pubkey::new_unique(); + let Instruction { + program_id: ix_program_id, + accounts, + data, + } = FinalizeSettle { + program_id, + state_pda, + begin_ix_index: 0x1337, + source_buffers: &[], + destinations: &[], + bumps: &[], + amounts: &[], + } + .instruction(); + assert_eq!(ix_program_id, program_id); + assert_eq!( + data, + [ + &[SettlementInstruction::FinalizeSettle.discriminator()][..], + &hex!("1337")[..], // counterpart index + ] + .concat(), + ); + // No pushes: the three fixed accounts (sysvar, state PDA, token program). + assert_eq!(accounts.len(), 3); + assert_eq!(accounts[0].pubkey, INSTRUCTIONS_SYSVAR_ID); + assert_eq!(accounts[1].pubkey, state_pda); + assert_eq!(accounts[2].pubkey, SPL_TOKEN_PROGRAM_ID); + assert!(accounts + .iter() + .all(|meta| !meta.is_writable && !meta.is_signer)); + } + #[test] - fn expected_encoding_finalize_settle() { + fn finalize_settle_encodes_pushes() { let program_id = Pubkey::new_unique(); + let state_pda = Pubkey::new_unique(); + let source_a = Pubkey::new_from_array([0x01; 32]); + let dest_a = Pubkey::new_from_array([0x02; 32]); + let source_b = Pubkey::new_from_array([0x03; 32]); + let dest_b = Pubkey::new_from_array([0x04; 32]); + let ix = FinalizeSettle { program_id, + state_pda, begin_ix_index: 0x1337, + source_buffers: &[source_a, source_b], + destinations: &[dest_a, dest_b], + bumps: &[0xa1, 0xb1], + amounts: &[0x0102, 0x0506], } .instruction(); + assert_eq!( ix.data, [ &[SettlementInstruction::FinalizeSettle.discriminator()][..], &hex!("1337")[..], // counterpart index + &[0xa1, 0xb1][..], // bumps + // amounts + &hex!("0000000000000102")[..], + &hex!("0000000000000506")[..], ] .concat(), ); - // 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); + let actual: Vec = ix.accounts.iter().map(|meta| meta.pubkey).collect(); + assert_eq!( + actual, + vec![ + INSTRUCTIONS_SYSVAR_ID, + state_pda, + SPL_TOKEN_PROGRAM_ID, + source_a, + dest_a, + source_b, + dest_b, + ], + ); + // The fixed accounts are read-only; the source buffers and destinations + // are writable for the transfers. + let writable: Vec = ix + .accounts + .iter() + .filter(|meta| meta.is_writable) + .map(|meta| meta.pubkey) + .collect(); + assert_eq!(writable, vec![source_a, dest_a, source_b, dest_b]); + assert!(ix.accounts.iter().all(|meta| !meta.is_signer)); } #[test] - fn finalize_settle_input_parses_valid_input() { - let address = Address::new_from_array([0x42u8; 32]); - let mut accounts = [fake_account(address)]; + fn finalize_settle_input_parses_no_pushes() { + let sysvar = Address::new_from_array([0x42u8; 32]); + // The state-PDA and token-program slots are reserved but not surfaced. + let state = Address::new_from_array([0x43u8; 32]); + let token_program = Address::new_from_array([0x44u8; 32]); + let mut accounts = [ + fake_account(sysvar), + fake_account(state), + fake_account(token_program), + ]; let data = ix_data![ [SettlementInstruction::FinalizeSettle.discriminator()], [0x13, 0x37], // begin index @@ -109,9 +338,113 @@ mod tests { let FinalizeSettleInput { begin_ix_index, instructions_sysvar_account, + state_pda_account, + token_program_account, + pushes, } = FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); assert_eq!(begin_ix_index, 0x1337); - assert_eq!(instructions_sysvar_account.address(), &address); + assert_eq!(instructions_sysvar_account.address(), &sysvar); + assert_eq!(state_pda_account.address(), &state); + assert_eq!(token_program_account.address(), &token_program); + assert_eq!(pushes.iter().count(), 0); + } + + #[test] + fn finalize_settle_input_parses_pushes() { + let sysvar = Address::new_from_array([1u8; 32]); + let state = Address::new_from_array([0xa1u8; 32]); + let token_program = Address::new_from_array([0xa2u8; 32]); + // The same source buffer funds both pushes: parsing makes no uniqueness + // assumption about source buffers. + let source = Address::new_from_array([3u8; 32]); + let dest0 = Address::new_from_array([4u8; 32]); + let dest1 = Address::new_from_array([5u8; 32]); + let mut accounts = [ + fake_account(sysvar), + fake_account(state), + fake_account(token_program), + fake_account(source), + fake_account(dest0), + fake_account(source), + fake_account(dest1), + ]; + let data = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [0x13, 0x37], // begin index + [0xfe, 0xfd], // bumps + 0x1122u64.to_be_bytes(), + 0x3344u64.to_be_bytes(), + ]; + + let FinalizeSettleInput { pushes, .. } = + FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); + + let parsed: Vec<(&Address, &Address, u8, u64)> = pushes + .iter() + .map(|push| { + ( + push.source_buffer.address(), + push.destination.address(), + push.bump, + u64::from_be_bytes(*push.amount), + ) + }) + .collect(); + assert_eq!( + parsed, + vec![ + (&source, &dest0, 0xfe, 0x1122), + (&source, &dest1, 0xfd, 0x3344), + ], + ); + } + + #[test] + fn finalize_settle_input_parses_many_pushes() { + const PUSH_COUNT: usize = 16; + + let mut expected: Vec<(Address, Address, u8, u64)> = Vec::new(); + for i in 0..PUSH_COUNT { + let source = Address::new_from_array([i as u8; 32]); + let dest = Address::new_from_array([(i + PUSH_COUNT) as u8; 32]); + let bump = (i + 2 * PUSH_COUNT) as u8; + let amount = (i as u64) << 8 | 0x07; + expected.push((source, dest, bump, amount)); + } + + // The three fixed accounts (`[0xff..]`, `[0xfe..]`, `[0xfd..]`) differ + // from every source/destination address above. + let mut accounts = vec![ + fake_account_from_array([0xff; 32]), + fake_account_from_array([0xfe; 32]), + fake_account_from_array([0xfd; 32]), + ]; + let mut bump_bytes = Vec::new(); + let mut amount_bytes = Vec::new(); + for &(source, dest, bump, amount) in &expected { + accounts.push(fake_account(source)); + accounts.push(fake_account(dest)); + bump_bytes.push(bump); + amount_bytes.extend_from_slice(&amount.to_be_bytes()); + } + let data = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [0x13, 0x37], // begin index + bump_bytes, + amount_bytes, + ]; + + let parsed = + FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); + let pushes: Vec<_> = parsed.pushes.iter().collect(); + + assert_eq!(pushes.len(), PUSH_COUNT); + for (push, (source, dest, bump, amount)) in pushes.iter().zip(&expected) { + assert_eq!(push.source_buffer.address(), source); + assert_eq!(push.destination.address(), dest); + assert_eq!(push.bump, *bump); + assert_eq!(u64::from_be_bytes(*push.amount), *amount); + } } #[test] @@ -141,20 +474,44 @@ mod tests { } #[test] - fn finalize_settle_input_ignores_extra_parameters() { - let first_address = Address::new_from_array([1u8; 32]); - let second_address = Address::new_from_array([2u8; 32]); - let mut accounts = [fake_account(first_address), fake_account(second_address)]; + fn finalize_settle_input_rejects_account_count_mismatch() { + // One push (a bump byte then a `u64` amount) needs exactly two push + // accounts: its source buffer and destination. let data = ix_data![ [SettlementInstruction::FinalizeSettle.discriminator()], - [0x13, 0x37], // begin index - [42], // extra + [0, 0], // begin index + [0xff], // the push's bump + 0u64.to_be_bytes(), ]; - let FinalizeSettleInput { - begin_ix_index, - instructions_sysvar_account, - } = FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); - assert_eq!(begin_ix_index, 0x1337); - assert_eq!(instructions_sysvar_account.address(), &first_address); + + // Too few: only one push account follows the fixed accounts. + let mut too_few = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 1 }>(); + assert_eq!( + FinalizeSettleInput::parse(&data, &mut too_few).err(), + Some(SettlementError::AccountCountNotMatchingPushCount.into()), + ); + + // Too many: three push accounts follow the fixed accounts. + let mut too_many = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 3 }>(); + assert_eq!( + FinalizeSettleInput::parse(&data, &mut too_many).err(), + Some(SettlementError::AccountCountNotMatchingPushCount.into()), + ); + } + + #[test] + fn finalize_settle_input_rejects_partial_push() { + // Four trailing bytes: not a whole number of 9-byte pushes (a bump plus a + // `u64` amount), so the body can't be parsed into the push layout. + let mut accounts = fake_sequential_accounts::(); + let data = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [0, 0], // begin index + [0x11, 0x22, 0x33, 0x44], // a partial push (4 bytes) + ]; + assert_eq!( + FinalizeSettleInput::parse(&data, &mut accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); } } diff --git a/interface/src/instruction/settle/mod.rs b/interface/src/instruction/settle/mod.rs index 6fe0184..6282f46 100644 --- a/interface/src/instruction/settle/mod.rs +++ b/interface/src/instruction/settle/mod.rs @@ -13,7 +13,7 @@ mod begin; mod finalize; pub use begin::{BeginSettle, BeginSettleInput, Pull, SettledOrder, SettledOrders}; -pub use finalize::{FinalizeSettle, FinalizeSettleInput}; +pub use finalize::{FinalizeSettle, FinalizeSettleInput, Push, Pushes}; /// Reads the first two bytes of a byte slice (instruction data) and /// interprets them as a big-endian u16, returning it together with the diff --git a/interface/src/lib.rs b/interface/src/lib.rs index dc42b96..06e4e71 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -102,6 +102,10 @@ pub enum SettlementError { /// `BeginSettle`'s state account isn't the canonical settlement state PDA, /// which must sign the pulls as the user's token delegate. StateAccountMismatch = 18, + /// `FinalizeSettle`'s push-account count doesn't match its instruction + /// data: each push contributes a source buffer and a destination account, + /// so the count must be twice the number of push amounts. + AccountCountNotMatchingPushCount = 19, } impl From for u32 { diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index b5f31f5..a05cdc0 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -6,18 +6,14 @@ //! order-list checks, which is what these tests exercise. use crate::common::{ - assert_instruction_error, assert_settlement_error, create_account, set_unix_timestamp, setup, - signed_tx, token, + assert_instruction_error, assert_settlement_error, create_account, + order::{create_order_pda, sample_intent, OrderBuilder}, + set_unix_timestamp, setup, token, }; use litesvm::{types::TransactionMetadata, LiteSVM}; -use settlement_client::instructions::{ - BeginSettle, CreateOrder, FinalizeSettle, Pull, SettledOrder, -}; +use settlement_client::instructions::{BeginSettle, FinalizeSettle, Pull, SettleableOrder}; use settlement_client::settlement_interface::{ - data::{ - intent::{OrderIntent, OrderKind}, - order::{EncodedOrderAccount, OrderAccount}, - }, + data::order::{EncodedOrderAccount, OrderAccount}, instruction::settle::{ BeginSettle as BeginSettleRaw, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, }, @@ -40,81 +36,6 @@ fn no_pulls(n: usize) -> Vec<&'static [Pull]> { vec![&[]; n] } -fn sample_intent(owner: Pubkey, sell_token_account: Pubkey, salt: u8) -> OrderIntent { - OrderIntent { - owner, - buy_token_account: Pubkey::new_from_array([0x22; 32]), - sell_token_account, - sell_amount: 1_000_000, - buy_amount: 2_000_000, - valid_to: 0xdead_beef, - kind: OrderKind::Sell, - partially_fillable: true, - // `salt` is folded into `app_data` so callers can mint several orders that - // hash to different UIDs (and therefore different order PDAs). - app_data: [salt; 32], - } -} - -/// Create `intent`'s order PDA on-chain, signed and paid for by `owner`. -fn create_order_pda(svm: &mut LiteSVM, program_id: &Pubkey, owner: &Keypair, intent: &OrderIntent) { - let ix = CreateOrder { - program_id: *program_id, - owner: owner.pubkey(), - created_by: owner.pubkey(), - intent, - } - .instruction(); - let tx = signed_tx(svm, owner, owner, ix); - svm.send_transaction(tx) - .expect("create_order should succeed"); -} - -/// Builder that mints a valid settleable order on-chain and returns its intent. -/// If nothing else is specified, It uses default parameters to build the order. -/// Individula parameters can be changed before building the order. -struct SettleableOrder<'a> { - svm: &'a mut LiteSVM, - program_id: &'a Pubkey, - payer: &'a Keypair, - intent: OrderIntent, -} - -impl<'a> SettleableOrder<'a> { - fn new( - svm: &'a mut LiteSVM, - program_id: &'a Pubkey, - payer: &'a Keypair, - mint: &'a Pubkey, - ) -> Self { - let sell_token = token::create_token_account(svm, payer, mint, &payer.pubkey()); - let intent = sample_intent(payer.pubkey(), sell_token, 0); - Self { - svm, - program_id, - payer, - intent, - } - } - - /// Make this order distinct from its siblings: `salt` is folded into - /// `app_data` so each value hashes to a different UID (and order PDA). - fn salt(mut self, salt: u8) -> Self { - self.intent.app_data = [salt; 32]; - self - } - - fn valid_to(mut self, valid_to: u32) -> Self { - self.intent.valid_to = valid_to; - self - } - - fn build(self) -> OrderIntent { - create_order_pda(self.svm, self.program_id, self.payer, &self.intent); - self.intent - } -} - /// Send `[begin, finalize_settle(..)]` signed by `payer`, where `begin` is a /// pre-built `BeginSettle` instruction. fn send_settlement( @@ -126,6 +47,7 @@ fn send_settlement( let finalize = FinalizeSettle { program_id: *program_id, begin_ix_index: 0, + orders: &[], } .instruction(); let tx = Transaction::new_signed_with_payer( @@ -143,7 +65,7 @@ fn settle( svm: &mut LiteSVM, program_id: &Pubkey, payer: &Keypair, - orders: &[SettledOrder], + orders: &[SettleableOrder], ) -> Result { send_settlement( svm, @@ -188,12 +110,12 @@ fn settles_a_single_order() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); settle( &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[], }], @@ -209,15 +131,15 @@ fn settles_multiple_orders() { let mut intents = Vec::new(); for salt in 0..3u8 { intents.push( - SettleableOrder::new(&mut svm, &program_id, &payer, &mint) + OrderBuilder::new(&mut svm, &program_id, &payer, &mint) .salt(salt) .build(), ); } - let orders: Vec = intents + let orders: Vec = intents .iter() - .map(|intent| SettledOrder { intent, pulls: &[] }) + .map(|intent| SettleableOrder { intent, pulls: &[] }) .collect(); settle(&mut svm, &program_id, &payer, &orders).expect("multi-order settlement should succeed"); } @@ -227,7 +149,7 @@ fn rejects_wrong_bump() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); assert_settlement_error( settle_raw( @@ -301,7 +223,7 @@ fn rejects_sell_token_account_mismatch() { let mint = token::create_mint(&mut svm, &payer); // Supply a different token account than the one the order's intent names. - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); let wrong_sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); assert_settlement_error( @@ -332,7 +254,7 @@ fn rejects_sell_token_owner_mismatch() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[], }], @@ -354,7 +276,7 @@ fn rejects_non_token_sell_account() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[], }], @@ -368,18 +290,18 @@ fn rejects_duplicate_orders() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); assert_settlement_error( settle( &mut svm, &program_id, &payer, &[ - SettledOrder { + SettleableOrder { intent: &intent, pulls: &[], }, - SettledOrder { + SettleableOrder { intent: &intent, pulls: &[], }, @@ -394,10 +316,10 @@ fn rejects_orders_in_wrong_address_order() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let first = SettleableOrder::new(&mut svm, &program_id, &payer, &mint) + let first = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) .salt(0) .build(); - let second = SettleableOrder::new(&mut svm, &program_id, &payer, &mint) + let second = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) .salt(1) .build(); @@ -487,7 +409,7 @@ fn rejects_cancelled_order() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[], }], @@ -502,7 +424,7 @@ fn rejects_expired_order() { let mint = token::create_mint(&mut svm, &payer); let valid_to = 1_000_000; - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint) + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) .valid_to(valid_to) .build(); let after_expiration = i64::from(valid_to) + 1; @@ -513,7 +435,7 @@ fn rejects_expired_order() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[], }], @@ -528,7 +450,7 @@ fn settles_order_at_exact_valid_to() { let mint = token::create_mint(&mut svm, &payer); let valid_to = 1_000_000; - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint) + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) .valid_to(valid_to) .build(); set_unix_timestamp(&mut svm, i64::from(valid_to)); @@ -537,7 +459,7 @@ fn settles_order_at_exact_valid_to() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[], }], @@ -550,7 +472,7 @@ fn pulls_funds_to_destination() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let sell_token = intent.sell_token_account; let initial_amount = 42_000_000; token::fund_and_delegate(&mut svm, &program_id, &payer, &sell_token, initial_amount); @@ -561,7 +483,7 @@ fn pulls_funds_to_destination() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[Pull { destination, @@ -584,7 +506,7 @@ fn pulls_to_multiple_destinations() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let sell_token = intent.sell_token_account; let initial_amount: u64 = 1_000_000; token::fund_and_delegate(&mut svm, &program_id, &payer, &sell_token, initial_amount); @@ -597,7 +519,7 @@ fn pulls_to_multiple_destinations() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[ Pull { @@ -631,10 +553,10 @@ fn pulls_from_multiple_orders() { let mint = token::create_mint(&mut svm, &payer); // Two distinct orders, each selling from its own token account. - let first = SettleableOrder::new(&mut svm, &program_id, &payer, &mint) + let first = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) .salt(0) .build(); - let second = SettleableOrder::new(&mut svm, &program_id, &payer, &mint) + let second = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) .salt(1) .build(); let initial_amount_first = 1_337_000; @@ -663,14 +585,14 @@ fn pulls_from_multiple_orders() { &program_id, &payer, &[ - SettledOrder { + SettleableOrder { intent: &first, pulls: &[Pull { destination: dest_first, amount: pulled_first, }], }, - SettledOrder { + SettleableOrder { intent: &second, pulls: &[Pull { destination: dest_second, @@ -698,7 +620,7 @@ fn zero_pulls_moves_nothing() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let sell_token = intent.sell_token_account; let initial_amount = 42_000_000; token::mint_to(&mut svm, &payer, &mint, &sell_token, initial_amount); @@ -707,7 +629,7 @@ fn zero_pulls_moves_nothing() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[], }], @@ -725,7 +647,7 @@ fn rejects_wrong_state_pda() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); let not_the_state_pda = Pubkey::new_unique(); @@ -754,14 +676,14 @@ fn rejects_wrong_token_program() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); // The builder always fills in the SPL Token program, so we swap the // token-program account out afterwards. let mut begin = BeginSettle { program_id, finalize_ix_index: 1, - orders: &[SettledOrder { + orders: &[SettleableOrder { intent: &intent, pulls: &[], }], @@ -781,7 +703,7 @@ fn rejects_pull_delegated_to_incorrect_address() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let amount = 100_000; let sell_token = intent.sell_token_account; // Funds are present but some account other than the state PDA was @@ -794,7 +716,7 @@ fn rejects_pull_delegated_to_incorrect_address() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[Pull { destination, @@ -813,7 +735,7 @@ fn rejects_pull_exceeding_delegation() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let sell_token = intent.sell_token_account; // Funded generously, but the state PDA is delegated only 100_000. let initial_amount = 42_000_000; @@ -832,7 +754,7 @@ fn rejects_pull_exceeding_delegation() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[Pull { destination, @@ -855,12 +777,12 @@ fn rejects_extra_account() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); - let intent = SettleableOrder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); // A well-formed single-order, no-transfer settlement... let mut begin = BeginSettle { program_id, finalize_ix_index: 1, - orders: &[SettledOrder { + orders: &[SettleableOrder { intent: &intent, pulls: &[], }], diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index cdfbd1d..840b787 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -5,6 +5,7 @@ reason = "integration tests compile as separate crates, so items only used by a subset of the test binaries look dead to the others" )] +pub mod order; pub mod pda; pub mod token; diff --git a/programs/settlement/tests/common/order.rs b/programs/settlement/tests/common/order.rs new file mode 100644 index 0000000..bf63b34 --- /dev/null +++ b/programs/settlement/tests/common/order.rs @@ -0,0 +1,92 @@ +//! On-chain order construction shared by the settlement integration tests. + +use litesvm::LiteSVM; +use settlement_client::instructions::CreateOrder; +use settlement_client::settlement_interface::data::intent::{OrderIntent, OrderKind}; +use solana_sdk::{ + pubkey::Pubkey, + signature::{Keypair, Signer}, +}; + +use super::{signed_tx, token}; + +/// A default valid sell order owned by `owner`, selling from `sell_token_account`. +/// `salt` is folded into `app_data` so callers can mint several orders that hash +/// to different UIDs (and therefore different order PDAs). +pub fn sample_intent(owner: Pubkey, sell_token_account: Pubkey, salt: u8) -> OrderIntent { + OrderIntent { + owner, + buy_token_account: Pubkey::new_from_array([0x22; 32]), + sell_token_account, + sell_amount: 1_000_000, + buy_amount: 2_000_000, + valid_to: 0xdead_beef, + kind: OrderKind::Sell, + partially_fillable: true, + app_data: [salt; 32], + } +} + +/// Create `intent`'s order PDA on-chain, signed and paid for by `owner`. +pub fn create_order_pda( + svm: &mut LiteSVM, + program_id: &Pubkey, + owner: &Keypair, + intent: &OrderIntent, +) { + let ix = CreateOrder { + program_id: *program_id, + owner: owner.pubkey(), + created_by: owner.pubkey(), + intent, + } + .instruction(); + let tx = signed_tx(svm, owner, owner, ix); + svm.send_transaction(tx) + .expect("create_order should succeed"); +} + +/// Builder that mints a valid settleable order on-chain and returns its intent. +/// If nothing else is specified, it uses default parameters to build the order. +/// Individual parameters can be changed before building the order. +pub struct OrderBuilder<'a> { + svm: &'a mut LiteSVM, + program_id: &'a Pubkey, + payer: &'a Keypair, + intent: OrderIntent, +} + +impl<'a> OrderBuilder<'a> { + pub fn new( + svm: &'a mut LiteSVM, + program_id: &'a Pubkey, + payer: &'a Keypair, + mint: &'a Pubkey, + ) -> Self { + let sell_token = token::create_token_account(svm, payer, mint, &payer.pubkey()); + let intent = sample_intent(payer.pubkey(), sell_token, 0); + Self { + svm, + program_id, + payer, + intent, + } + } + + /// Make this order distinct from its siblings: `salt` is folded into + /// `app_data` so each value hashes to a different UID (and order PDA). + pub fn salt(mut self, salt: u8) -> Self { + self.intent.app_data = [salt; 32]; + self + } + + pub fn valid_to(mut self, valid_to: u32) -> Self { + self.intent.valid_to = valid_to; + self + } + + pub fn build(self) -> OrderIntent { + create_order_pda(self.svm, self.program_id, self.payer, &self.intent); + self.intent + } +} diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs new file mode 100644 index 0000000..7e20c9d --- /dev/null +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -0,0 +1,249 @@ +//! Integration tests for the fund-push list carried by `FinalizeSettle`. +//! +//! Each settlement transaction here is a `[BeginSettle, FinalizeSettle]` pair +//! (begin at index 0 pointing to finalize at index 1, and vice versa). Begin +//! settles the same orders the finalize pushes to — created on-chain via +//! `OrderBuilder` but with no pulls, so no funds move — and the tests assert +//! that the finalize push layout parses (or is rejected). The buffers and +//! destinations need not exist: this step's finalize handler neither reads nor +//! writes them. + +use crate::common::{order::OrderBuilder, setup, to_instruction_error, token}; +use litesvm::LiteSVM; +use settlement_client::instructions::{BeginSettle, FinalizeSettle, SettleableOrder, SettledOrder}; +use settlement_client::settlement_interface::{Instruction, SettlementError}; +use solana_sdk::{ + instruction::{AccountMeta, InstructionError}, + program_error::ProgramError, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, +}; + +mod common; + +/// Assert that the transaction failed in the `FinalizeSettle` instruction, which +/// always sits at index 1 (begin must precede finalize). +fn assert_finalize_error(result: Result<(), TransactionError>, expected: InstructionError) { + assert_eq!(result, Err(TransactionError::InstructionError(1, expected))); +} + +/// Like [`assert_finalize_error`], but compares the failure on the program-error +/// side. Converting the recorded `InstructionError` to a `ProgramError` lets us +/// name `ProgramError::NotEnoughAccountKeys` rather than its deprecated +/// `InstructionError` mirror. +fn assert_finalize_program_error(result: Result<(), TransactionError>, expected: ProgramError) { + let Err(TransactionError::InstructionError(index, ix_error)) = result else { + panic!("expected a finalize instruction error, got {result:?}"); + }; + assert_eq!(index, 1); + assert_eq!(ProgramError::try_from(ix_error), Ok(expected)); +} + +/// Send `[begin, finalize]` signed by `payer`, where `finalize` is a pre-built +/// `FinalizeSettle` at index 1 and `begin` settles `orders` (with no pulls) at +/// index 0 — the same orders the finalize is expected to push to. +fn send_settlement( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + orders: &[SettledOrder], + finalize: Instruction, +) -> Result<(), TransactionError> { + let begin_orders: Vec = orders + .iter() + .map(|order| SettleableOrder { + intent: order.intent, + pulls: &[], + }) + .collect(); + let begin = BeginSettle { + program_id: *program_id, + finalize_ix_index: 1, + orders: &begin_orders, + } + .instruction(); + let tx = Transaction::new_signed_with_payer( + &[begin, finalize], + Some(&payer.pubkey()), + &[payer], + svm.latest_blockhash(), + ); + // Drop the success metadata, not needed in these tests. + svm.send_transaction(tx).map(|_| ()).map_err(|e| e.err) +} + +/// Settle `orders` (begin) and push their proceeds (finalize) in a minimal +/// `[BeginSettle, FinalizeSettle]` transaction signed by `payer`. +fn finalize( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + orders: &[SettledOrder], +) -> Result<(), TransactionError> { + let finalize = FinalizeSettle { + program_id: *program_id, + begin_ix_index: 0, + orders, + } + .instruction(); + send_settlement(svm, program_id, payer, orders, finalize) +} + +#[test] +fn finalizes_with_no_pushes() { + let (mut svm, program_id, payer) = setup(); + + finalize(&mut svm, &program_id, &payer, &[]).expect("a finalize with no pushes should succeed"); +} + +#[test] +fn finalizes_with_single_push() { + let (mut svm, program_id, payer) = setup(); + let sell_mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &sell_mint).build(); + + finalize( + &mut svm, + &program_id, + &payer, + &[SettledOrder { + intent: &intent, + mint: Pubkey::new_unique(), + amount: 1_000, + }], + ) + .expect("a single push should parse and be accepted"); +} + +#[test] +fn finalizes_with_several_pushes_same_mint() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + + finalize( + &mut svm, + &program_id, + &payer, + &[ + SettledOrder { + intent: &intent0, + mint, + amount: 1_000, + }, + SettledOrder { + intent: &intent1, + mint, + amount: 2_000, + }, + ], + ) + .expect("several pushes should parse and be accepted"); +} + +#[test] +fn finalizes_with_several_pushes_different_mint() { + let (mut svm, program_id, payer) = setup(); + let mint_1 = token::create_mint(&mut svm, &payer); + let mint_2 = token::create_mint(&mut svm, &payer); + let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint_1).build(); + let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint_2).build(); + + finalize( + &mut svm, + &program_id, + &payer, + &[ + SettledOrder { + intent: &intent0, + mint: mint_1, + amount: 1_000, + }, + SettledOrder { + intent: &intent1, + mint: mint_2, + amount: 2_000, + }, + ], + ) + .expect("several pushes should parse and be accepted"); +} + +#[test] +fn rejects_push_account_count_mismatch() { + let (mut svm, program_id, payer) = setup(); + let sell_mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &sell_mint).build(); + let orders = [SettledOrder { + intent: &intent, + mint: Pubkey::new_unique(), + amount: 1_000, + }]; + + // A well-formed single-push finalize... + let mut finalize = FinalizeSettle { + program_id, + begin_ix_index: 0, + orders: &orders, + } + .instruction(); + // ...with one extra account appended, so the account count no longer matches + // the `2T` the instruction data implies. + finalize + .accounts + .push(AccountMeta::new_readonly(Pubkey::new_unique(), false)); + + assert_finalize_error( + send_settlement(&mut svm, &program_id, &payer, &orders, finalize), + to_instruction_error(SettlementError::AccountCountNotMatchingPushCount), + ); +} + +#[test] +fn rejects_too_few_accounts() { + let (mut svm, program_id, payer) = setup(); + + // A well-formed single-push finalize... + let mut finalize = FinalizeSettle { + program_id, + begin_ix_index: 0, + orders: &[], + } + .instruction(); + // ...with one account popped, so the accounts don't match with the amounts anymore. + finalize.accounts.pop(); + + assert_finalize_program_error( + send_settlement(&mut svm, &program_id, &payer, &[], finalize), + ProgramError::NotEnoughAccountKeys, + ); +} + +#[test] +fn rejects_partial_push_amount() { + let (mut svm, program_id, payer) = setup(); + let sell_mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &sell_mint).build(); + let orders = [SettledOrder { + intent: &intent, + mint: Pubkey::new_unique(), + amount: 1_000, + }]; + + // A well-formed single-push finalize... + let mut finalize = FinalizeSettle { + program_id, + begin_ix_index: 0, + orders: &orders, + } + .instruction(); + // ...with one byte popped, so the trailing amount is no longer a whole `u64`. + finalize.data.pop(); + + assert_finalize_error( + send_settlement(&mut svm, &program_id, &payer, &orders, finalize), + InstructionError::InvalidInstructionData, + ); +} diff --git a/programs/settlement/tests/matching_begin_finalize.rs b/programs/settlement/tests/matching_begin_finalize.rs index 5b77173..607aeee 100644 --- a/programs/settlement/tests/matching_begin_finalize.rs +++ b/programs/settlement/tests/matching_begin_finalize.rs @@ -44,6 +44,7 @@ fn run_sequence( AbstractInstruction::Fin(idx) => FinalizeSettle { program_id: *program_id, begin_ix_index: *idx, + orders: &[], } .instruction(), // 0-lamport self-transfer: a side-effect-free instruction that @@ -148,6 +149,7 @@ fn rejects_non_instructions_sysvar_account_at_position_zero() { let finalize = FinalizeSettle { program_id, begin_ix_index: 0, + orders: &[], } .instruction(); @@ -186,6 +188,7 @@ fn rejects_counterpart_instruction_in_different_program() { let stranger = FinalizeSettle { program_id: solana_system_interface::program::ID, begin_ix_index: 0, + orders: &[], } .instruction(); @@ -269,6 +272,7 @@ fn rejects_cpi_call_to_finalize_settle() { FinalizeSettle { program_id: settlement_id, begin_ix_index: 0, + orders: &[], } .instruction(), ); diff --git a/programs/settlement/tests/program_deployment.rs b/programs/settlement/tests/program_deployment.rs index 8496ae0..376ded0 100644 --- a/programs/settlement/tests/program_deployment.rs +++ b/programs/settlement/tests/program_deployment.rs @@ -38,6 +38,7 @@ fn program_can_be_invoked() { FinalizeSettle { program_id, begin_ix_index: 0, + orders: &[], } .instruction(), ], From 1e6bd0f55f372dfd1347b6045d6e3fd0d11ffe23 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:36:19 +0200 Subject: [PATCH 6/6] Push funds to users --- interface/src/instruction/settle/finalize.rs | 9 +- interface/src/lib.rs | 16 + interface/src/pda/buffer.rs | 11 + programs/settlement/src/settle/begin.rs | 120 ++++++- programs/settlement/src/settle/finalize.rs | 92 +++++- .../settlement/tests/begin_settle_orders.rs | 292 ++++++++++++++---- programs/settlement/tests/common/buffer.rs | 58 ++++ programs/settlement/tests/common/mod.rs | 1 + programs/settlement/tests/common/order.rs | 7 +- programs/settlement/tests/common/token.rs | 33 ++ .../tests/finalize_settle_pushes.rs | 233 ++++++++++---- 11 files changed, 712 insertions(+), 160 deletions(-) create mode 100644 programs/settlement/tests/common/buffer.rs diff --git a/interface/src/instruction/settle/finalize.rs b/interface/src/instruction/settle/finalize.rs index 0e8bef5..4ffdebf 100644 --- a/interface/src/instruction/settle/finalize.rs +++ b/interface/src/instruction/settle/finalize.rs @@ -183,9 +183,12 @@ impl<'a> InstructionInputParsing<'a> for FinalizeSettleInput<'a> { let (bumps, amount_bytes) = body .split_at_checked(push_count) .ok_or(ProgramError::InvalidInstructionData)?; - // `amount_bytes` is `8 * push_count` long, so this splits cleanly into - // `push_count` whole `u64`s with no remainder. - let (amounts, _remainder) = amount_bytes.as_chunks::<8>(); + // `amount_bytes` is `8 * push_count` long, so it splits cleanly into + // `push_count` whole `u64`s; matching the remainder to `[]` rejects + // anything else, mirroring `BeginSettle`'s amount parsing. + let (amounts, []) = amount_bytes.as_chunks::<8>() else { + return Err(ProgramError::InvalidInstructionData); + }; // Each push contributes a source buffer and a destination account, so // the push-account count is `2 * T`. diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 06e4e71..1c84d74 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -106,6 +106,22 @@ pub enum SettlementError { /// data: each push contributes a source buffer and a destination account, /// so the count must be twice the number of push amounts. AccountCountNotMatchingPushCount = 19, + /// `BeginSettle`: the number of pushes carried by the paired `FinalizeSettle` + /// doesn't equal the number of settled orders. Each order must be paid by + /// exactly one push. + SettledOrderPushCountMismatch = 20, + /// `BeginSettle`: a paired `FinalizeSettle` push doesn't pay the order's buy + /// token account — its destination differs from the `buy_token_account` in + /// the order's intent. + PushDestinationMismatch = 21, + /// `FinalizeSettle`: a push doesn't draw from the canonical buffer for its + /// destination's mint — its source isn't the buffer PDA derived from that + /// mint. + PushSourceNotBuffer = 22, + /// `FinalizeSettle`: a push's destination isn't a valid SPL token account + /// (wrong data length or not owned by the token program), so its mint can't + /// be read to derive the buffer. + BuyTokenAccountInvalid = 23, } impl From for u32 { diff --git a/interface/src/pda/buffer.rs b/interface/src/pda/buffer.rs index cfc505c..824ab47 100644 --- a/interface/src/pda/buffer.rs +++ b/interface/src/pda/buffer.rs @@ -26,6 +26,17 @@ pub fn buffer_pda_seeds(mint: &[u8; 32]) -> [&[u8]; 3] { [SETTLEMENT_SEED, mint, BUFFER_SEED] } +/// Canonical seeds for re-deriving the buffer PDA for `mint` with `bump`. A +/// caller that already knows the canonical bump (e.g. a solver building a +/// settlement) passes it so the program re-derives the address with a single +/// hash rather than searching for the canonical bump. By design, a buffer can +/// only be created at its canonical bump, so a non-canonical bump derives an +/// address no buffer lives at. +pub fn buffer_pda_signer_seeds<'a>(mint: &'a [u8; 32], bump: &'a [u8; 1]) -> [&'a [u8]; 4] { + let [s0, s1, s2] = buffer_pda_seeds(mint); + [s0, s1, s2, bump] +} + /// Derive the canonical buffer PDA address (and bump) for the token `mint`. pub fn find_buffer_pda(program_id: &Pubkey, mint: &Pubkey) -> (Pubkey, u8) { Pubkey::find_program_address(&buffer_pda_seeds(mint.as_array()), program_id) diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 3fc2e8e..11bdb7f 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -5,7 +5,11 @@ use std::ops::Deref; use pinocchio::{ cpi::{Seed, Signer}, error::ProgramError, - sysvars::{clock::Clock, instructions::Instructions, Sysvar}, + sysvars::{ + clock::Clock, + instructions::{Instructions, IntrospectedInstruction}, + Sysvar, + }, AccountView, Address, ProgramResult, }; use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; @@ -13,7 +17,7 @@ use settlement_interface::{ data::order::EncodedOrderAccount, instruction::{ create_buffer::SPL_TOKEN_PROGRAM_ID, - settle::{BeginSettleInput, SettledOrder}, + settle::{BeginSettleInput, SettledOrder, SettledOrders}, InstructionInputParsing, }, pda::{order::order_pda_signer_seeds, state::state_pda_seeds}, @@ -59,16 +63,77 @@ pub fn process_begin_settle( input.finalize_ix_index, )?; - pull_funds( + // The pushes this settlement performs live in the paired `FinalizeSettle`, + // which only executes them; validating them is our job. We read its accounts + // by introspecting it through the instructions sysvar. The counterpart check + // above already confirmed the instruction at `finalize_ix_index` is our + // `FinalizeSettle` pointing back at us. + let finalize_ix = instructions.load_instruction_at(usize::from(input.finalize_ix_index))?; + let pushes = FinalizePushes::new(finalize_ix)?; + + settle_orders( program_id, input.token_program_account, input.state_pda_account, - input.orders.iter(), + input.orders, + &pushes, )?; Ok(()) } +/// The number of fixed accounts every `FinalizeSettle` carries before its push +/// accounts: the instructions sysvar, the settlement state PDA, and the token +/// program. Each push then contributes a `[source_buffer, destination]` pair. +const FINALIZE_FIXED_ACCOUNTS: usize = 3; + +/// The paired `FinalizeSettle`'s pushes, seen from `BeginSettle` through +/// instruction introspection. `BeginSettle` validates only that each push pays +/// the right order's buy token account, so it reads only the destination of push +/// `index`, at account meta `FINALIZE_FIXED_ACCOUNTS + 2*index + 1` (the source +/// buffer at the preceding meta is `FinalizeSettle`'s concern). +struct FinalizePushes<'a> { + instruction: IntrospectedInstruction<'a>, + /// Number of pushes the finalize carries, recovered from its account count. + count: usize, +} + +impl<'a> FinalizePushes<'a> { + /// Recover the push count from the finalize's account metas. A finalize with + /// fewer than the fixed accounts or an odd number of push accounts can't + /// present one `[source_buffer, destination]` pair per push, so its pushes + /// can't correspond to the settled orders. + fn new(instruction: IntrospectedInstruction<'a>) -> Result { + let count = instruction + .num_account_metas() + .checked_sub(FINALIZE_FIXED_ACCOUNTS) + .filter(|push_accounts| push_accounts % 2 == 0) + .map(|push_accounts| push_accounts / 2) + .ok_or(SettlementError::SettledOrderPushCountMismatch)?; + Ok(Self { instruction, count }) + } + + /// The destination address of each push, in order. The caller pairs these + /// with the settled orders, having checked that the counts match. + fn destinations(&self) -> impl Iterator { + (0..self.count).map(|index| { + // `index < self.count`, and the count derives from a `u16`-bounded + // account-meta count, so this offset never overflows `usize` and the + // meta is in bounds. + let destination_index = index + .checked_mul(2) + .and_then(|offset| offset.checked_add(FINALIZE_FIXED_ACCOUNTS)) + .and_then(|source_index| source_index.checked_add(1)) + .expect("push offset fits in usize for a u16-bounded account count"); + &self + .instruction + .get_instruction_account_at(destination_index) + .expect("index < count, so the destination meta is in bounds") + .key + }) + } +} + /// Reject a `BeginSettle` whose pair encloses another settlement: no /// `BeginSettle`/`FinalizeSettle` of this program may appear strictly between /// `current_index` and `finalize_ix_index`. The bounds themselves are excluded. @@ -111,19 +176,28 @@ fn validate_no_nested_settlement>( Ok(()) } -/// Validate and pull funds for each order, requiring: +/// Validate each order against its push, and pull user funds. This requires: /// - the legacy SPL Token program; /// - the canonical state PDA, which signs each transfer as the user's delegate; /// - orders strictly increasing by address, rejecting duplicates. /// -/// Further validation and the actual transfers are processed through +/// Each order is paid by exactly one push, so the order and push counts must +/// match — checked once up front. The orders and the finalize's pushes are both +/// laid out sorted by order PDA, so order `i` is paid by push `i`, and that +/// push's destination must be order `i`'s buy token account. That the push draws +/// from the canonical buffer for the destination's mint is `FinalizeSettle`'s +/// check (it holds the destination account and can read its mint); here we only +/// have the orders. +/// +/// Further validation and the actual pulls are processed through /// [`process_order`]. #[must_use = "ignoring the output may lead to an unintended on-chain state"] -fn pull_funds<'a>( +fn settle_orders<'a>( program_id: &Address, token_program_account: &AccountView, state_pda_account: &AccountView, - orders: impl IntoIterator>, + orders: SettledOrders<'a>, + pushes: &FinalizePushes, ) -> ProgramResult { if token_program_account.address() != &SPL_TOKEN_PROGRAM_ID { return Err(ProgramError::IncorrectProgramId); @@ -136,6 +210,12 @@ fn pull_funds<'a>( return Err(SettlementError::StateAccountMismatch.into()); } + // One push per order. With the counts equal, every order's push index is in + // range below and no push is left unaccounted for. + if orders.iter().count() != pushes.count { + return Err(SettlementError::SettledOrderPushCountMismatch.into()); + } + let [seed] = seeds; let state_bump = [state_bump]; let signer_seeds = [seed, &state_bump].map(Seed::from); @@ -147,30 +227,40 @@ fn pull_funds<'a>( let now = Clock::get()?.unix_timestamp; - for order in orders { + // Counts match (checked above), so zipping pairs every order with its push. + for (order, push_destination) in orders.iter().zip(pushes.destinations()) { let order_pda = order.order_pda; if previous.is_some_and(|previous| order_pda.address() <= previous) { return Err(SettlementError::OrdersNotStrictlyIncreasing.into()); } previous = Some(order_pda.address()); - process_order(program_id, order, now, state_pda_account, &state_pda_signer)?; + // Validate the order and pull its funds first, so an invalid order is + // rejected with its own error before its push is examined. + let intent_buy_token = + process_order(program_id, order, now, state_pda_account, &state_pda_signer)?; + + // The push paying this order must send to the order's buy token account. + if !address_matches_pubkey(push_destination, &intent_buy_token) { + return Err(SettlementError::PushDestinationMismatch.into()); + } } Ok(()) } -/// Validate a single order and process its pulls. +/// Validate a single order, then process its pulls. /// This checks that the order is valid and settleable. Once the order passes -/// those checks, its pulls are executed. -#[must_use = "ignoring the output may lead to an unintended on-chain state"] +/// those checks, its pulls are executed. Returns the buy token account named in +/// the order's intent, which the caller checks the order's push pays. +#[must_use = "skipping the return value may lead to funds not being pulled but accounted for in the order"] fn process_order( program_id: &Address, order: SettledOrder<'_>, now: i64, state_account: &AccountView, state_pda_signer: &Signer, -) -> ProgramResult { +) -> Result { let SettledOrder { order_pda, sell_token_account, @@ -239,7 +329,7 @@ fn process_order( .invoke_signed(core::slice::from_ref(state_pda_signer))?; } - Ok(()) + Ok(intent.buy_token_account) } fn address_matches_pubkey(address: &Address, pubkey: &Pubkey) -> bool { diff --git a/programs/settlement/src/settle/finalize.rs b/programs/settlement/src/settle/finalize.rs index 403e2db..dcdffec 100644 --- a/programs/settlement/src/settle/finalize.rs +++ b/programs/settlement/src/settle/finalize.rs @@ -1,8 +1,19 @@ //! `FinalizeSettle` instruction handler. -use pinocchio::{sysvars::instructions::Instructions, AccountView, Address, ProgramResult}; +use pinocchio::{ + cpi::{Seed, Signer}, + error::ProgramError, + sysvars::instructions::Instructions, + AccountView, Address, ProgramResult, +}; +use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; use settlement_interface::{ - instruction::{settle::FinalizeSettleInput, InstructionInputParsing}, + instruction::{ + create_buffer::SPL_TOKEN_PROGRAM_ID, + settle::{FinalizeSettleInput, Pushes}, + InstructionInputParsing, + }, + pda::{buffer::buffer_pda_signer_seeds, state::state_pda_seeds}, SettlementError, SettlementInstruction, }; @@ -31,9 +42,80 @@ pub fn process_finalize_settle( current_index, input.begin_ix_index, SettlementInstruction::BeginSettle, + )?; + + // Order correspondence and destinations were validated by the paired + // `BeginSettle` (which the counterpart check above guarantees ran); the one + // push check left to us is that each push draws from the canonical buffer for + // its destination's mint — we hold the destination account, so we can read + // its mint. Then we execute the transfers. + push_funds( + program_id, + input.token_program_account, + input.state_pda_account, + input.pushes, ) +} + +/// Validate and push each order's proceeds out of the settlement's buffers. +/// Only the legacy SPL Token program is accepted and the supplied state account +/// must be the canonical state PDA, since it's the buffers' SPL authority and so +/// must sign each transfer. For every push, the source must be the canonical +/// buffer for the destination's mint; the destination's correspondence to an +/// order is the paired `BeginSettle`'s responsibility. +#[must_use = "ignoring the output may lead to an unintended on-chain state"] +fn push_funds<'a>( + program_id: &Address, + token_program_account: &AccountView, + state_pda_account: &AccountView, + pushes: Pushes<'a>, +) -> ProgramResult { + if token_program_account.address() != &SPL_TOKEN_PROGRAM_ID { + return Err(ProgramError::IncorrectProgramId); + } + + // The buffers' SPL authority is the state PDA, so it must sign each transfer. + let seeds = state_pda_seeds(); + let (state_pda, state_bump) = Address::find_program_address(&seeds, program_id); + if state_pda_account.address() != &state_pda { + return Err(SettlementError::StateAccountMismatch.into()); + } + + let [seed] = seeds; + let state_bump = [state_bump]; + let signer_seeds = [seed, &state_bump].map(Seed::from); + let state_pda_signer = Signer::from(&signer_seeds); + + for push in pushes.iter() { + // The source must be the canonical buffer for the destination's mint. + // The mint is read from the destination account; the borrow is released + // at the end of this block, before the transfer touches it again. + let mint = { + let destination = TokenAccount::from_account_view(push.destination) + .map_err(|_| SettlementError::BuyTokenAccountInvalid)?; + *destination.mint().as_array() + }; + // Re-derive the buffer with the bump the push carries (one hash) rather + // than searching for the canonical bump. A buffer exists only at its + // canonical address, so a wrong bump derives an address the transfer + // below can't draw from. + let derived = Address::create_program_address( + &buffer_pda_signer_seeds(&mint, &[push.bump]), + program_id, + ) + .map_err(|_| SettlementError::PushSourceNotBuffer)?; + if push.source_buffer.address() != &derived { + return Err(SettlementError::PushSourceNotBuffer.into()); + } + + Transfer::new( + push.source_buffer, + push.destination, + state_pda_account, + u64::from_be_bytes(*push.amount), + ) + .invoke_signed(core::slice::from_ref(&state_pda_signer))?; + } - // Some checks are carried out by `BeginSettle` and we don't repeat them - // under the assumption that the counterpart exists and, since it's a - // `BeginSettle`, it performs the checks. + Ok(()) } diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index a05cdc0..179793b 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -4,18 +4,29 @@ //! 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. +//! +//! `BeginSettle` checks one push per order up front (after the token-program and +//! state-PDA checks), so even a settlement expected to be rejected during order +//! validation must pair with a finalize whose push count matches the order count +//! — [`settle`] and [`settle_raw`] attach placeholder pushes for that, while +//! [`settle_and_pay`] attaches real ones for settlements expected to succeed. +//! Tests rejected before that count check (wrong token program or state PDA) +//! pair with an empty finalize ([`send_settlement`]). use crate::common::{ - assert_instruction_error, assert_settlement_error, create_account, + assert_instruction_error, assert_settlement_error, buffer, create_account, order::{create_order_pda, sample_intent, OrderBuilder}, set_unix_timestamp, setup, token, }; -use litesvm::{types::TransactionMetadata, LiteSVM}; -use settlement_client::instructions::{BeginSettle, FinalizeSettle, Pull, SettleableOrder}; +use litesvm::LiteSVM; +use settlement_client::instructions::{ + BeginSettle, FinalizeSettle, Pull, SettleableOrder, SettledOrder, +}; use settlement_client::settlement_interface::{ data::order::{EncodedOrderAccount, OrderAccount}, instruction::settle::{ - BeginSettle as BeginSettleRaw, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, + BeginSettle as BeginSettleRaw, FinalizeSettle as FinalizeSettleRaw, INSTRUCTIONS_SYSVAR_ID, + SPL_TOKEN_PROGRAM_ID, }, pda::{order::find_order_pda, state::find_state_pda}, Instruction, SettlementError, SettlementInstruction, @@ -36,14 +47,16 @@ fn no_pulls(n: usize) -> Vec<&'static [Pull]> { vec![&[]; n] } -/// Send `[begin, finalize_settle(..)]` signed by `payer`, where `begin` is a -/// pre-built `BeginSettle` instruction. +/// Send `[begin, finalize]` signed by `payer`, where `begin` is a pre-built +/// `BeginSettle` instruction and `finalize` settles no pushes. Use it only for +/// cases rejected before `BeginSettle`'s one-push-per-order count check (wrong +/// token program or state PDA); otherwise the empty finalize trips that check. fn send_settlement( svm: &mut LiteSVM, program_id: &Pubkey, payer: &Keypair, begin: Instruction, -) -> Result { +) -> Result<(), TransactionError> { let finalize = FinalizeSettle { program_id: *program_id, begin_ix_index: 0, @@ -56,34 +69,113 @@ fn send_settlement( &[payer], svm.latest_blockhash(), ); - svm.send_transaction(tx).map_err(|e| e.err) + svm.send_transaction(tx).map(|_| ()).map_err(|e| e.err) +} + +/// Send `[begin, finalize]` where `finalize` carries `push_count` placeholder +/// pushes — enough to satisfy `BeginSettle`'s one-push-per-order count check, but +/// never executed because these settlements are expected to be rejected during +/// order validation. +fn send_settlement_with_placeholder_pushes( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + begin: Instruction, + push_count: usize, +) -> Result<(), TransactionError> { + let placeholders: Vec = (0..push_count).map(|_| Pubkey::new_unique()).collect(); + let bumps = vec![0u8; push_count]; + let amounts = vec![0u64; push_count]; + let finalize = FinalizeSettleRaw { + program_id: *program_id, + state_pda: find_state_pda(program_id).0, + begin_ix_index: 0, + source_buffers: &placeholders, + destinations: &placeholders, + bumps: &bumps, + amounts: &amounts, + } + .instruction(); + 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) } /// Settle `orders` in a minimal `[BeginSettle, FinalizeSettle]` transaction -/// (begin at index 0, finalize at index 1) signed by `payer`. +/// (begin at index 0, finalize at index 1) signed by `payer`. The finalize +/// carries placeholder pushes matching the order count, so this reaches +/// `BeginSettle`'s order validation: use it for cases expected to be rejected +/// there. fn settle( svm: &mut LiteSVM, program_id: &Pubkey, payer: &Keypair, orders: &[SettleableOrder], -) -> Result { - send_settlement( - svm, - program_id, - payer, - BeginSettle { - program_id: *program_id, - finalize_ix_index: 1, - orders, - } - .instruction(), - ) +) -> Result<(), TransactionError> { + let begin = BeginSettle { + program_id: *program_id, + finalize_ix_index: 1, + orders, + } + .instruction(); + send_settlement_with_placeholder_pushes(svm, program_id, payer, begin, orders.len()) +} + +/// Settle `orders` and pay each one: the finalize pushes a zero amount from each +/// order's canonical buy-token buffer to its buy token account, lining up +/// one-to-one with the orders so `BeginSettle`'s push pass passes. The buffer for +/// each order's buy mint is created on demand. Use it for settlements expected to +/// succeed. (Real push amounts are exercised in `finalize_settle_pushes.rs`.) +fn settle_and_pay( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + orders: &[SettleableOrder], +) -> Result<(), TransactionError> { + let settled: Vec = orders + .iter() + .map(|order| { + let buy_mint = token::mint_of(svm, &order.intent.buy_token_account); + buffer::ensure(svm, program_id, payer, &buy_mint); + SettledOrder { + intent: order.intent, + mint: buy_mint, + amount: 0, + } + }) + .collect(); + + let begin = BeginSettle { + program_id: *program_id, + finalize_ix_index: 1, + orders, + } + .instruction(); + let finalize = FinalizeSettle { + program_id: *program_id, + begin_ix_index: 0, + orders: &settled, + } + .instruction(); + 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) } /// Settle orders described by raw, parallel `(order_pda, sell_token, bump)` /// lists, pulling nothing. Uses the canonical state PDA and SPL Token program so /// execution reaches the order-validation checks; tests that need a -/// non-canonical state PDA or token program build the instruction directly. +/// non-canonical state PDA or token program build the instruction directly. The +/// finalize carries placeholder pushes matching the order count to clear the +/// count check; every caller expects rejection during order validation. fn settle_raw( svm: &mut LiteSVM, program_id: &Pubkey, @@ -91,7 +183,7 @@ fn settle_raw( order_pdas: &[Pubkey], sell_token_accounts: &[Pubkey], bumps: &[u8], -) -> Result { +) -> Result<(), TransactionError> { let begin = BeginSettleRaw { program_id: *program_id, state_pda: find_state_pda(program_id).0, @@ -102,7 +194,7 @@ fn settle_raw( pulls: &no_pulls(bumps.len()), } .instruction(); - send_settlement(svm, program_id, payer, begin) + send_settlement_with_placeholder_pushes(svm, program_id, payer, begin, bumps.len()) } #[test] @@ -111,7 +203,7 @@ fn settles_a_single_order() { let mint = token::create_mint(&mut svm, &payer); let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); - settle( + settle_and_pay( &mut svm, &program_id, &payer, @@ -141,7 +233,8 @@ fn settles_multiple_orders() { .iter() .map(|intent| SettleableOrder { intent, pulls: &[] }) .collect(); - settle(&mut svm, &program_id, &payer, &orders).expect("multi-order settlement should succeed"); + settle_and_pay(&mut svm, &program_id, &payer, &orders) + .expect("multi-order settlement should succeed"); } #[test] @@ -291,8 +384,10 @@ fn rejects_duplicate_orders() { let mint = token::create_mint(&mut svm, &payer); let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + // A matching push per order, so the first order's push validates and the + // second order trips the strictly-increasing check. assert_settlement_error( - settle( + settle_and_pay( &mut svm, &program_id, &payer, @@ -327,21 +422,33 @@ fn rejects_orders_in_wrong_address_order() { let (second_pda, second_bump) = find_order_pda(&program_id, &second.uid()); // Lay out the two distinct orders strictly decreasing by PDA address, which - // the program rejects. The interface builder would sort them, so build the - // instruction by hand in the current wire format: data is + // the program rejects. The interface builders would sort them, so build both + // instructions by hand in the current wire format. Begin data is // `[discriminator, finalize_ix_index (BE), order_count, bump×n, transfer_count×n]` - // (no transfers here) and accounts are `[instructions_sysvar, state_pda, - // token_program, (order_pda, sell_token_account)...]`. + // (no transfers here) and begin accounts are `[instructions_sysvar, state_pda, + // token_program, (order_pda, sell_token_account)...]`. The finalize's push + // destinations are laid out in the same decreasing order, so the first order's + // destination check passes and the second order trips the ordering check. let mut orders = [ - (first_pda, first.sell_token_account, first_bump), - (second_pda, second.sell_token_account, second_bump), + ( + first_pda, + first.sell_token_account, + first.buy_token_account, + first_bump, + ), + ( + second_pda, + second.sell_token_account, + second.buy_token_account, + second_bump, + ), ]; - orders.sort_by_key(|&(pda, _, _)| std::cmp::Reverse(pda)); + orders.sort_by_key(|&(pda, ..)| std::cmp::Reverse(pda)); let mut data = vec![SettlementInstruction::BeginSettle.discriminator()]; data.extend_from_slice(&1u16.to_be_bytes()); data.push(orders.len() as u8); - data.extend(orders.iter().map(|&(_, _, bump)| bump)); + data.extend(orders.iter().map(|&(_, _, _, bump)| bump)); // No transfers: one zero transfer-count byte per order. data.extend(orders.iter().map(|_| 0u8)); @@ -350,24 +457,40 @@ fn rejects_orders_in_wrong_address_order() { AccountMeta::new_readonly(find_state_pda(&program_id).0, false), AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), ]; - for (order_pda, sell_token_account, _) in orders { + for (order_pda, sell_token_account, _, _) in orders { accounts.push(AccountMeta::new_readonly(order_pda, false)); accounts.push(AccountMeta::new(sell_token_account, false)); } + let begin = Instruction { + program_id, + accounts, + data, + }; + + // One zero-amount push per order, paying each order's buy token account, + // aligned with begin's decreasing order. `BeginSettle` checks only the + // destinations, so the sources are placeholders (and the finalize never runs, + // as begin rejects the ordering first). + let placeholder_source = Pubkey::new_unique(); + let finalize = FinalizeSettleRaw { + program_id, + state_pda: find_state_pda(&program_id).0, + begin_ix_index: 0, + source_buffers: &[placeholder_source, placeholder_source], + destinations: &[orders[0].2, orders[1].2], + bumps: &[0, 0], + amounts: &[0, 0], + } + .instruction(); - assert_settlement_error( - send_settlement( - &mut svm, - &program_id, - &payer, - Instruction { - program_id, - accounts, - data, - }, - ), - SettlementError::OrdersNotStrictlyIncreasing, + let tx = Transaction::new_signed_with_payer( + &[begin, finalize], + Some(&payer.pubkey()), + &[&payer], + svm.latest_blockhash(), ); + let result = svm.send_transaction(tx).map(|_| ()).map_err(|e| e.err); + assert_settlement_error(result, SettlementError::OrdersNotStrictlyIncreasing); } #[test] @@ -455,7 +578,7 @@ fn settles_order_at_exact_valid_to() { .build(); set_unix_timestamp(&mut svm, i64::from(valid_to)); - settle( + settle_and_pay( &mut svm, &program_id, &payer, @@ -479,7 +602,7 @@ fn pulls_funds_to_destination() { let destination = token::create_token_account(&mut svm, &payer, &mint, &Pubkey::new_unique()); let amount = 2_000_000; - settle( + settle_and_pay( &mut svm, &program_id, &payer, @@ -515,7 +638,7 @@ fn pulls_to_multiple_destinations() { let pulled0 = 300_000; let pulled1 = 100_000; - settle( + settle_and_pay( &mut svm, &program_id, &payer, @@ -580,7 +703,7 @@ fn pulls_from_multiple_orders() { let pulled_first = 42_000; let pulled_second = 67_000; - settle( + settle_and_pay( &mut svm, &program_id, &payer, @@ -618,28 +741,61 @@ fn pulls_from_multiple_orders() { #[test] fn zero_pulls_moves_nothing() { let (mut svm, program_id, payer) = setup(); - let mint = token::create_mint(&mut svm, &payer); + // The order sells `sell_mint` and is paid in a distinct `buy_mint`, so the + // buy-side push touches only `buy_mint` accounts. That isolates the sell + // mint: with no pulls, no token instruction should reference its account. + let sell_mint = token::create_mint(&mut svm, &payer); + let buy_mint = token::create_mint(&mut svm, &payer); + + let sell_token = token::create_token_account(&mut svm, &payer, &sell_mint, &payer.pubkey()); + let buy_token = token::create_token_account(&mut svm, &payer, &buy_mint, &payer.pubkey()); + let mut intent = sample_intent(payer.pubkey(), sell_token, 0); + intent.buy_token_account = buy_token; + create_order_pda(&mut svm, &program_id, &payer, &intent); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); - let sell_token = intent.sell_token_account; let initial_amount = 42_000_000; - token::mint_to(&mut svm, &payer, &mint, &sell_token, initial_amount); - - let transaction = settle( - &mut svm, - &program_id, - &payer, - &[SettleableOrder { + token::mint_to(&mut svm, &payer, &sell_mint, &sell_token, initial_amount); + // The buy-side buffer must exist for the (zero-amount) push to draw from. + buffer::ensure(&mut svm, &program_id, &payer, &buy_mint); + + // Build the `[begin, finalize]` settlement by hand so the issued token + // instructions can be inspected. Begin settles the order with no pulls; + // finalize pushes a zero amount from the buy buffer to the buy token account. + let begin = BeginSettle { + program_id, + finalize_ix_index: 1, + orders: &[SettleableOrder { intent: &intent, pulls: &[], }], - ) - .expect("settling without pulling should succeed"); - + } + .instruction(); + let finalize = FinalizeSettle { + program_id, + begin_ix_index: 0, + orders: &[SettledOrder { + intent: &intent, + mint: buy_mint, + amount: 0, + }], + } + .instruction(); + let tx = Transaction::new_signed_with_payer( + &[begin, finalize], + Some(&payer.pubkey()), + &[&payer], + svm.latest_blockhash(), + ); + let account_keys = tx.message.account_keys.clone(); + let transaction = svm + .send_transaction(tx) + .expect("settling without pulling should succeed"); + + // No token instruction references the sell token account (the sell mint's + // only account here): the lone token transfer is the buy-side push, which + // draws from `buy_mint`'s buffer. Its balance is also left untouched. + token::assert_no_token_instruction_touching(&transaction, &account_keys, &sell_token); assert_eq!(token::balance(&svm, &sell_token), initial_amount); - // Confirm that there are no transfers because there are no token - // invocations in general. - token::assert_no_spl_token_invocation(&transaction); } #[test] diff --git a/programs/settlement/tests/common/buffer.rs b/programs/settlement/tests/common/buffer.rs new file mode 100644 index 0000000..ad14946 --- /dev/null +++ b/programs/settlement/tests/common/buffer.rs @@ -0,0 +1,58 @@ +//! Buffer-account helpers for the settlement integration tests. + +use litesvm::LiteSVM; +use settlement_client::instructions::CreateBuffers; +use settlement_client::settlement_interface::pda::buffer::find_buffer_pda; +use solana_sdk::{ + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::Transaction, +}; + +use super::token; + +/// The canonical buffer PDA for `mint`. +pub fn buffer_pda(program_id: &Pubkey, mint: &Pubkey) -> Pubkey { + find_buffer_pda(program_id, mint).0 +} + +/// Create the canonical buffer for `mint`, paid for by `payer`, unless it +/// already exists, and return its address. Idempotent so several orders can +/// share one buy mint. +pub fn ensure(svm: &mut LiteSVM, program_id: &Pubkey, payer: &Keypair, mint: &Pubkey) -> Pubkey { + let pda = buffer_pda(program_id, mint); + if svm.get_account(&pda).is_some() { + return pda; + } + let ix = CreateBuffers { + program_id: *program_id, + payer: payer.pubkey(), + mints: &[*mint], + } + .instruction(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[payer], + svm.latest_blockhash(), + ); + svm.send_transaction(tx) + .expect("create_buffer should succeed"); + pda +} + +/// Ensure the buffer for `mint` exists and mint `amount` of `mint` into it, so a +/// push can draw from it. Returns the buffer address. +pub fn ensure_funded( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + mint: &Pubkey, + amount: u64, +) -> Pubkey { + let pda = ensure(svm, program_id, payer, mint); + if amount > 0 { + token::mint_to(svm, payer, mint, &pda, amount); + } + pda +} diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index 840b787..713c1a1 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -5,6 +5,7 @@ reason = "integration tests compile as separate crates, so items only used by a subset of the test binaries look dead to the others" )] +pub mod buffer; pub mod order; pub mod pda; pub mod token; diff --git a/programs/settlement/tests/common/order.rs b/programs/settlement/tests/common/order.rs index bf63b34..2687b6e 100644 --- a/programs/settlement/tests/common/order.rs +++ b/programs/settlement/tests/common/order.rs @@ -64,7 +64,12 @@ impl<'a> OrderBuilder<'a> { mint: &'a Pubkey, ) -> Self { let sell_token = token::create_token_account(svm, payer, mint, &payer.pubkey()); - let intent = sample_intent(payer.pubkey(), sell_token, 0); + // `BeginSettle` reads the buy token account's mint to validate the push + // that pays the order, so it must be a real token account. The same mint + // works for both sides; the buy side just needs a distinct account. + let buy_token = token::create_token_account(svm, payer, mint, &payer.pubkey()); + let mut intent = sample_intent(payer.pubkey(), sell_token, 0); + intent.buy_token_account = buy_token; Self { svm, program_id, diff --git a/programs/settlement/tests/common/token.rs b/programs/settlement/tests/common/token.rs index 4e6ac87..e1db9fe 100644 --- a/programs/settlement/tests/common/token.rs +++ b/programs/settlement/tests/common/token.rs @@ -133,6 +133,39 @@ pub fn assert_no_spl_token_invocation(transaction: &TransactionMetadata) { ); } +/// Assert that no SPL Token instruction issued by the transaction references +/// `account`. Each token transfer the program performs is a CPI recorded in +/// `transaction.inner_instructions` as a compiled instruction whose program and +/// account slots are indices into the transaction's `account_keys`; this resolves +/// those indices and checks the token-program instructions, so a settlement that +/// must leave one side untouched (e.g. a zero-pull order's sell token account) +/// can prove no token instruction so much as named it. +pub fn assert_no_token_instruction_touching( + transaction: &TransactionMetadata, + account_keys: &[Pubkey], + account: &Pubkey, +) { + let token_program = Pubkey::new_from_array(litesvm_token::spl_token::ID.to_bytes()); + for instruction in transaction + .inner_instructions + .iter() + .flatten() + .map(|inner| &inner.instruction) + { + if account_keys[usize::from(instruction.program_id_index)] != token_program { + continue; + } + let touches_account = instruction + .accounts + .iter() + .any(|&index| account_keys[usize::from(index)] == *account); + assert!( + !touches_account, + "expected no SPL Token instruction touching {account}, but one did", + ); + } +} + /// Read the mint that `account` holds tokens of. pub fn mint_of(svm: &LiteSVM, account: &Pubkey) -> Pubkey { litesvm_token::get_spl_account::(svm, account) diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs index 7e20c9d..1444648 100644 --- a/programs/settlement/tests/finalize_settle_pushes.rs +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -1,20 +1,24 @@ -//! Integration tests for the fund-push list carried by `FinalizeSettle`. +//! Integration tests for the fund pushes carried by `FinalizeSettle` and +//! validated by `BeginSettle`. //! -//! Each settlement transaction here is a `[BeginSettle, FinalizeSettle]` pair -//! (begin at index 0 pointing to finalize at index 1, and vice versa). Begin -//! settles the same orders the finalize pushes to — created on-chain via -//! `OrderBuilder` but with no pulls, so no funds move — and the tests assert -//! that the finalize push layout parses (or is rejected). The buffers and -//! destinations need not exist: this step's finalize handler neither reads nor -//! writes them. - -use crate::common::{order::OrderBuilder, setup, to_instruction_error, token}; +//! Each settlement transaction is a `[BeginSettle, FinalizeSettle]` pair (begin +//! at index 0 pointing to finalize at index 1, and vice versa). `BeginSettle` +//! settles the orders the finalize pays — created on-chain via `OrderBuilder` +//! with no pulls, so only the push side moves funds — and validates that each +//! order is paid by exactly one push to its buy token account from the canonical +//! buffer for that token's mint. `FinalizeSettle` then executes the transfers, +//! signed by the settlement state PDA that owns the buffers. + +use crate::common::{ + buffer, + order::{create_order_pda, sample_intent, OrderBuilder}, + setup, to_instruction_error, token, +}; use litesvm::LiteSVM; use settlement_client::instructions::{BeginSettle, FinalizeSettle, SettleableOrder, SettledOrder}; use settlement_client::settlement_interface::{Instruction, SettlementError}; use solana_sdk::{ - instruction::{AccountMeta, InstructionError}, - program_error::ProgramError, + instruction::InstructionError, pubkey::Pubkey, signature::{Keypair, Signer}, transaction::{Transaction, TransactionError}, @@ -22,22 +26,20 @@ use solana_sdk::{ mod common; -/// Assert that the transaction failed in the `FinalizeSettle` instruction, which -/// always sits at index 1 (begin must precede finalize). -fn assert_finalize_error(result: Result<(), TransactionError>, expected: InstructionError) { - assert_eq!(result, Err(TransactionError::InstructionError(1, expected))); +/// Assert the transaction failed in `BeginSettle` (index 0) with `expected`. +fn assert_begin_error(result: Result<(), TransactionError>, expected: SettlementError) { + assert_eq!( + result, + Err(TransactionError::InstructionError( + 0, + to_instruction_error(expected) + )), + ); } -/// Like [`assert_finalize_error`], but compares the failure on the program-error -/// side. Converting the recorded `InstructionError` to a `ProgramError` lets us -/// name `ProgramError::NotEnoughAccountKeys` rather than its deprecated -/// `InstructionError` mirror. -fn assert_finalize_program_error(result: Result<(), TransactionError>, expected: ProgramError) { - let Err(TransactionError::InstructionError(index, ix_error)) = result else { - panic!("expected a finalize instruction error, got {result:?}"); - }; - assert_eq!(index, 1); - assert_eq!(ProgramError::try_from(ix_error), Ok(expected)); +/// Assert the transaction failed in `FinalizeSettle` (index 1) with `expected`. +fn assert_finalize_error(result: Result<(), TransactionError>, expected: InstructionError) { + assert_eq!(result, Err(TransactionError::InstructionError(1, expected))); } /// Send `[begin, finalize]` signed by `payer`, where `finalize` is a pre-built @@ -98,30 +100,40 @@ fn finalizes_with_no_pushes() { } #[test] -fn finalizes_with_single_push() { +fn pushes_a_single_order() { let (mut svm, program_id, payer) = setup(); - let sell_mint = token::create_mint(&mut svm, &payer); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &sell_mint).build(); + let mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + let funding = 1_000; + let buffer_pda = buffer::ensure_funded(&mut svm, &program_id, &payer, &mint, funding); + let amount = 400; finalize( &mut svm, &program_id, &payer, &[SettledOrder { intent: &intent, - mint: Pubkey::new_unique(), - amount: 1_000, + mint, + amount, }], ) - .expect("a single push should parse and be accepted"); + .expect("a single push should be paid"); + + assert_eq!(token::balance(&svm, &intent.buy_token_account), amount); + assert_eq!(token::balance(&svm, &buffer_pda), funding - amount); } #[test] -fn finalizes_with_several_pushes_same_mint() { +fn pushes_several_orders_from_one_buffer() { let (mut svm, program_id, payer) = setup(); let mint = token::create_mint(&mut svm, &payer); + // Distinct orders (each `OrderBuilder` makes fresh sell and buy token + // accounts) sharing one buy mint, so both pushes draw from one buffer. let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + let funding = 10_000; + let buffer_pda = buffer::ensure_funded(&mut svm, &program_id, &payer, &mint, funding); finalize( &mut svm, @@ -140,16 +152,22 @@ fn finalizes_with_several_pushes_same_mint() { }, ], ) - .expect("several pushes should parse and be accepted"); + .expect("several pushes from one buffer should be paid"); + + assert_eq!(token::balance(&svm, &intent0.buy_token_account), 1_000); + assert_eq!(token::balance(&svm, &intent1.buy_token_account), 2_000); + assert_eq!(token::balance(&svm, &buffer_pda), funding - 3_000); } #[test] -fn finalizes_with_several_pushes_different_mint() { +fn pushes_several_orders_from_different_buffers() { let (mut svm, program_id, payer) = setup(); - let mint_1 = token::create_mint(&mut svm, &payer); - let mint_2 = token::create_mint(&mut svm, &payer); - let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint_1).build(); - let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint_2).build(); + let mint0 = token::create_mint(&mut svm, &payer); + let mint1 = token::create_mint(&mut svm, &payer); + let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint0).build(); + let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint1).build(); + let buffer0 = buffer::ensure_funded(&mut svm, &program_id, &payer, &mint0, 5_000); + let buffer1 = buffer::ensure_funded(&mut svm, &program_id, &payer, &mint1, 5_000); finalize( &mut svm, @@ -158,88 +176,167 @@ fn finalizes_with_several_pushes_different_mint() { &[ SettledOrder { intent: &intent0, - mint: mint_1, + mint: mint0, amount: 1_000, }, SettledOrder { intent: &intent1, - mint: mint_2, + mint: mint1, amount: 2_000, }, ], ) - .expect("several pushes should parse and be accepted"); + .expect("pushes from different buffers should be paid"); + + assert_eq!(token::balance(&svm, &intent0.buy_token_account), 1_000); + assert_eq!(token::balance(&svm, &intent1.buy_token_account), 2_000); + assert_eq!(token::balance(&svm, &buffer0), 5_000 - 1_000); + assert_eq!(token::balance(&svm, &buffer1), 5_000 - 2_000); } #[test] -fn rejects_push_account_count_mismatch() { +fn rejects_push_to_wrong_destination() { let (mut svm, program_id, payer) = setup(); - let sell_mint = token::create_mint(&mut svm, &payer); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &sell_mint).build(); + let mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let orders = [SettledOrder { intent: &intent, - mint: Pubkey::new_unique(), - amount: 1_000, + mint, + amount: 100, }]; - // A well-formed single-push finalize... let mut finalize = FinalizeSettle { program_id, begin_ix_index: 0, orders: &orders, } .instruction(); - // ...with one extra account appended, so the account count no longer matches - // the `2T` the instruction data implies. - finalize - .accounts - .push(AccountMeta::new_readonly(Pubkey::new_unique(), false)); + // Redirect the push to an account that isn't the order's buy token account. + // Accounts: `[sysvar, state, token_program, source, destination]`. + let destination_index = 4; + finalize.accounts[destination_index].pubkey = Pubkey::new_unique(); - assert_finalize_error( + assert_begin_error( send_settlement(&mut svm, &program_id, &payer, &orders, finalize), - to_instruction_error(SettlementError::AccountCountNotMatchingPushCount), + SettlementError::PushDestinationMismatch, ); } #[test] -fn rejects_too_few_accounts() { +fn rejects_push_from_non_buffer_source() { let (mut svm, program_id, payer) = setup(); + let buy_mint = token::create_mint(&mut svm, &payer); + let other_mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &buy_mint).build(); + // The push draws from the buffer for `other_mint`, not the buy token's mint, + // which `FinalizeSettle` rejects when it reads the destination's mint. + let orders = [SettledOrder { + intent: &intent, + mint: other_mint, + amount: 100, + }]; - // A well-formed single-push finalize... - let mut finalize = FinalizeSettle { + assert_finalize_error( + finalize(&mut svm, &program_id, &payer, &orders), + to_instruction_error(SettlementError::PushSourceNotBuffer), + ); +} + +#[test] +fn rejects_fewer_pushes_than_orders() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + let orders = [SettledOrder { + intent: &intent, + mint, + amount: 100, + }]; + + // A finalize carrying no pushes, paired with a begin settling one order. + let finalize = FinalizeSettle { program_id, begin_ix_index: 0, orders: &[], } .instruction(); - // ...with one account popped, so the accounts don't match with the amounts anymore. - finalize.accounts.pop(); - assert_finalize_program_error( + assert_begin_error( + send_settlement(&mut svm, &program_id, &payer, &orders, finalize), + SettlementError::SettledOrderPushCountMismatch, + ); +} + +#[test] +fn rejects_more_pushes_than_orders() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + + // A finalize that pushes to one order, paired with a begin that settles none, + // so the extra push has no order to account for it. + let finalize = FinalizeSettle { + program_id, + begin_ix_index: 0, + orders: &[SettledOrder { + intent: &intent, + mint, + amount: 0, + }], + } + .instruction(); + + assert_begin_error( send_settlement(&mut svm, &program_id, &payer, &[], finalize), - ProgramError::NotEnoughAccountKeys, + SettlementError::SettledOrderPushCountMismatch, + ); +} + +#[test] +fn rejects_invalid_buy_token_account() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); + + // The order's buy token account (the push destination) isn't a token account, + // so `FinalizeSettle` can't read its mint to derive the buffer. `BeginSettle` + // accepts it: the push destination still matches the intent's buy token. + let not_a_token_account = Pubkey::new_unique(); + let mut intent = sample_intent(payer.pubkey(), sell_token, 0); + intent.buy_token_account = not_a_token_account; + create_order_pda(&mut svm, &program_id, &payer, &intent); + let orders = [SettledOrder { + intent: &intent, + mint, + amount: 0, + }]; + + assert_finalize_error( + finalize(&mut svm, &program_id, &payer, &orders), + to_instruction_error(SettlementError::BuyTokenAccountInvalid), ); } #[test] fn rejects_partial_push_amount() { let (mut svm, program_id, payer) = setup(); - let sell_mint = token::create_mint(&mut svm, &payer); - let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &sell_mint).build(); + let mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); let orders = [SettledOrder { intent: &intent, - mint: Pubkey::new_unique(), - amount: 1_000, + mint, + amount: 100, }]; - // A well-formed single-push finalize... let mut finalize = FinalizeSettle { program_id, begin_ix_index: 0, orders: &orders, } .instruction(); - // ...with one byte popped, so the trailing amount is no longer a whole `u64`. + // Drop one byte so the trailing amount is no longer a whole `u64`. Begin + // validates the push from the (unchanged) account metas and passes; finalize + // then rejects the malformed data. finalize.data.pop(); assert_finalize_error(