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/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/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..0244348 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -13,72 +13,311 @@ 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::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], } -/// 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 [SettleableOrder<'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() + } +} + +/// 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, + 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)] +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, FinalizeSettleInput, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, + }, + InstructionInputParsing, + }, + pda::order::find_order_pda, + }; + + proptest! { + // `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] + 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| SettleableOrder { intent, pulls: &[] }) + .collect(); + 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). + 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); + } + } + + // `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/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..f3ebdc8 100644 --- a/interface/src/instruction/create_buffer.rs +++ b/interface/src/instruction/create_buffer.rs @@ -5,18 +5,21 @@ //! 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 /// 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 @@ -24,37 +27,237 @@ 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))...]`. /// 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)); +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()], + } } - Instruction { - program_id: *program_id, - accounts, - data: vec![SettlementInstruction::CreateBuffer.discriminator()], +} + +/// 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::CreateBuffers; + + /// 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]); + CreateBuffers { + program_id: zero, + payer: zero, + buffers: &[(zero, zero)], + } + .instruction() + .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 = CreateBuffers { + program_id, + payer, + buffers: &[(buffer_pda, mint)], + } + .instruction() + .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 = CreateBuffers { + program_id, + payer, + buffers: &[(buffer_a, mint_a), (buffer_b, mint_b)], + } + .instruction() + .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() { @@ -62,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()] @@ -75,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 @@ -108,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); @@ -130,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 eb91434..4e2ec72 100644 --- a/interface/src/instruction/create_order.rs +++ b/interface/src/instruction/create_order.rs @@ -4,14 +4,17 @@ //! 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. +/// Builder for a `CreateOrder` instruction. /// /// `intent_bytes` is the canonical byte encoding (see /// [`EncodedOrderIntent`]). `order_pda` must be the canonical PDA returned @@ -39,32 +42,197 @@ 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); - - 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, +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: 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, + } + } +} + +/// 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::CreateOrder; + 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]); + CreateOrder { + program_id: zero, + owner: zero, + created_by: zero, + order_pda: zero, + intent_bytes: *intent_bytes, + } + .instruction() + .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 = CreateOrder { + program_id, + owner, + created_by, + order_pda, + intent_bytes, + } + .instruction() + .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() { @@ -74,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!( @@ -92,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 f15123e..6f0c944 100644 --- a/interface/src/instruction/initialize.rs +++ b/interface/src/instruction/initialize.rs @@ -2,14 +2,17 @@ //! //! 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. +/// 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 @@ -24,25 +27,135 @@ 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. -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()], + } + } +} + +/// 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 { + program_id: zero, + payer: zero, + state_pda: zero, + } + .instruction() + .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: *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]; + + 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() { @@ -50,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()] @@ -63,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/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 deleted file mode 100644 index 8e48711..0000000 --- a/interface/src/instruction/settle.rs +++ /dev/null @@ -1,358 +0,0 @@ -//! `BeginSettle`/`FinalizeSettle` instruction tools, the instructions-sysvar -//! account ID they all reference, and the off-chain instruction builders. - -use std::vec; - -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 crate::SettlementInstruction; - -/// 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)] -pub struct Pull { - pub destination: Pubkey, - pub amount: u64, -} - -/// Build 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 -/// - `sell_token_accounts[i]` its sell token account of the order, -/// - `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. -/// -/// The slices are assumed to have the same length but this is not enforced in -/// the builder. -/// -/// 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)...]`. -/// -/// The program requires the order PDAs to be strictly increasing by address. -/// This builder establishes that ordering for the caller: it sorts the orders by -/// PDA address, carrying each order's sell token account, bump, transfer count, -/// amounts, and destination metas before emitting them. -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 - .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, - } -} - -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), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use hex_literal::hex; - - #[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(); - let state_pda = Pubkey::new_unique(); - let Instruction { - program_id: ix_program_id, - accounts, - data, - } = begin_settle(&program_id, &state_pda, 0x1337, &[], &[], &[], &[]); - assert_eq!(ix_program_id, program_id); - assert_eq!( - data, - [ - &[SettlementInstruction::BeginSettle.discriminator()][..], - &hex!("1337")[..], // counterpart index - &[0][..], // order count - ] - .concat(), - ); - // No orders: 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); - // They are all generic accounts that don't play an active role in the - // transaction. - assert!(accounts - .iter() - .all(|account| !account.is_writable && !account.is_signer)); - } - - #[test] - fn begin_settle_sorts_orders_by_pda() { - let program_id = Pubkey::new_unique(); - let state_pda = Pubkey::new_unique(); - // Two orders supplied in descending PDA order. All the other parameters - // are chosen to sort in the opposite order. - let high_order_pda = Pubkey::new_from_array([0xbb; 32]); - let high_sell_token_account = Pubkey::new_from_array([0xa0; 32]); - let high_bump = 0xaa; - let low_order_pda = Pubkey::new_from_array([0xaa; 32]); - let low_sell_token_account = Pubkey::new_from_array([0xb0; 32]); - let low_bump = 0xbb; - let ix = begin_settle( - &program_id, - &state_pda, - 0x1337, - &[high_order_pda, low_order_pda], - &[high_bump, low_bump], - &[high_sell_token_account, low_sell_token_account], - &[&[], &[]], - ); - - // Bumps follow the sorted order: the low PDA's bump comes first. - assert_eq!( - ix.data, - [ - &[SettlementInstruction::BeginSettle.discriminator()][..], - &hex!("1337")[..], // counterpart index - &[2][..], // order count - &[low_bump, high_bump][..], // bumps - &[0, 0][..], // transfer counts (both zero) - ] - .concat(), - ); - - let expected: Vec = vec![ - INSTRUCTIONS_SYSVAR_ID, - state_pda, - SPL_TOKEN_PROGRAM_ID, - low_order_pda, - low_sell_token_account, - high_order_pda, - high_sell_token_account, - ]; - let actual: Vec = ix.accounts.iter().map(|account| account.pubkey).collect(); - assert_eq!(actual, expected); - // The fixed accounts and the order PDAs are read-only; only the sell - // token accounts are writable, following the sorted order. - let writable: Vec = ix - .accounts - .iter() - .filter(|account| account.is_writable) - .map(|account| account.pubkey) - .collect(); - assert_eq!( - writable, - vec![low_sell_token_account, high_sell_token_account], - ); - assert!(ix.accounts.iter().all(|account| !account.is_signer)); - } - - #[test] - fn begin_settle_encodes_grouped_transfers() { - let program_id = Pubkey::new_unique(); - let state_pda = Pubkey::new_unique(); - let order_a = Pubkey::new_from_array([0x01; 32]); - let sell_a = Pubkey::new_from_array([0x02; 32]); - let order_b = Pubkey::new_from_array([0x03; 32]); - let sell_b = Pubkey::new_from_array([0x04; 32]); - let dest_a0 = Pubkey::new_from_array([0x05; 32]); - let dest_a1 = Pubkey::new_from_array([0x06; 32]); - let dest_b0 = Pubkey::new_from_array([0x07; 32]); - - // 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], - &[ - &[ - Pull { - destination: dest_a0, - amount: 0x0102, - }, - Pull { - destination: dest_a1, - amount: 0x0304, - }, - ], - &[Pull { - destination: dest_b0, - amount: 0x0506, - }], - ], - ); - - assert_eq!( - ix.data, - [ - &[SettlementInstruction::BeginSettle.discriminator()][..], - &hex!("1337")[..], // counterpart index - &[2][..], // order count - &[0xa1, 0xb1][..], // bumps - &[2, 1][..], // counts - // amounts - &hex!("0000000000000102")[..], - &hex!("0000000000000304")[..], - &hex!("0000000000000506")[..], - ] - .concat(), - ); - - let expected: Vec = vec![ - INSTRUCTIONS_SYSVAR_ID, - state_pda, - SPL_TOKEN_PROGRAM_ID, - order_a, - sell_a, - dest_a0, - dest_a1, - order_b, - sell_b, - dest_b0, - ]; - let actual: Vec = ix.accounts.iter().map(|account| account.pubkey).collect(); - assert_eq!(actual, expected); - // The fixed accounts and the order PDAs are read-only; sell and - // destination accounts are writable for the transfer. - let writable: Vec = ix - .accounts - .iter() - .filter(|account| account.is_writable) - .map(|account| account.pubkey) - .collect(); - assert_eq!(writable, vec![sell_a, dest_a0, dest_a1, sell_b, dest_b0]); - assert!(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); - } -} diff --git a/programs/settlement/src/settle.rs b/interface/src/instruction/settle/begin.rs similarity index 50% rename from programs/settlement/src/settle.rs rename to interface/src/instruction/settle/begin.rs index 799f20f..071827b 100644 --- a/programs/settlement/src/settle.rs +++ b/interface/src/instruction/settle/begin.rs @@ -1,39 +1,137 @@ -//! `BeginSettle`/`FinalizeSettle` instruction handlers. - -use std::ops::Deref; - -use pinocchio::{ - cpi::{Seed, Signer}, - error::ProgramError, - sysvars::{clock::Clock, instructions::Instructions, Sysvar}, - AccountView, Address, ProgramResult, -}; -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}, - pda::{order::order_pda_signer_seeds, state::state_pda_seeds}, - recover_discriminator, Pubkey, SettlementError, SettlementInstruction, -}; - -use crate::processor::{is_cpi_call, InstructionInputParsing}; +//! Off-chain builder and input parsing for the `BeginSettle` 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::{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)] +pub struct Pull { + pub destination: Pubkey, + pub amount: u64, +} + +/// 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 +/// - `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. +/// +/// The slices are assumed to have the same length but this is not enforced in +/// the builder. +/// +/// 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]`. +/// 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 +/// PDA address, carrying each order's sell token account, bump, transfer count, +/// amounts, and destination metas before emitting them. +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() + .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, + accounts, + data, + } + } +} /// 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, +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. - destinations: &'a [AccountView], + pub destinations: &'a [AccountView], /// Transfer amounts (big-endian `u64`), one per destination. - amounts: &'a [[u8; 8]], + 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. -struct SettledOrders<'a> { +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: @@ -54,7 +152,7 @@ impl<'a> SettledOrders<'a> { clippy::arithmetic_side_effects, reason = "offsets are bounded by tx limits" )] - fn iter(&self) -> impl Iterator> + '_ { + pub fn iter(&self) -> impl Iterator> + '_ { let order_count = self.bumps.len(); let mut i = 0usize; let mut account_offset = 0usize; @@ -95,12 +193,12 @@ impl<'a> SettledOrders<'a> { /// `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>, +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 @@ -174,335 +272,201 @@ 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. -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, - }) - } -} - -pub fn process_begin_settle( - program_id: &Address, - accounts: &mut [AccountView], - instruction_data: &[u8], -) -> ProgramResult { - if is_cpi_call() { - return Err(SettlementError::CalledViaCpi.into()); - } - - let input = BeginSettleInput::parse(instruction_data, accounts)?; - - // We use `instructions_sysvar_account` from the input but this could be - // any address since parsing doesn't validate the input. We rely on the - // fact that the Pinocchio library already checks that the input account - // is the expected one. - let instructions = Instructions::try_from(input.instructions_sysvar_account)?; - let current_index = instructions.load_current_index(); - - // Reciprocity: the input index is a finalize_settle instruction and that - // instruction points to the current one. - validate_counterpart( - program_id, - &instructions, - current_index, - input.finalize_ix_index, - SettlementInstruction::FinalizeSettle, - )?; - - validate_no_nested_settlement( - program_id, - &instructions, - current_index, - input.finalize_ix_index, - )?; - - pull_funds( - program_id, - input.token_program_account, - input.state_pda_account, - input.orders.iter(), - )?; - - Ok(()) -} +#[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; -/// Reject a `BeginSettle` whose pair encloses another settlement: no -/// `BeginSettle`/`FinalizeSettle` of this program may appear strictly between -/// `current_index` and `finalize_ix_index`. The bounds themselves are excluded. -#[must_use = "ignoring the output may lead to an unintended on-chain state"] -fn validate_no_nested_settlement>( - program_id: &Address, - instructions: &Instructions, - current_index: u16, - finalize_ix_index: u16, -) -> ProgramResult { - if finalize_ix_index <= current_index { - return Err(SettlementError::FinalizeBeforeInitialize.into()); - } + /// 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; - let search_start = current_index - .checked_add(1) - .expect("the finalize index is tested to be larger, no overflow can happen"); - for i in search_start..finalize_ix_index { - let inner = instructions.load_instruction_at(usize::from(i))?; - // Skip instructions belonging to a different program. - if inner.get_program_id() != program_id { - continue; + #[test] + fn expected_encoding_begin_settle_no_orders() { + let program_id = Pubkey::new_unique(); + let state_pda = Pubkey::new_unique(); + let Instruction { + program_id: ix_program_id, + accounts, + data, + } = BeginSettle { + program_id, + state_pda, + finalize_ix_index: 0x1337, + order_pdas: &[], + order_pda_bumps: &[], + sell_token_accounts: &[], + pulls: &[], } - // If it can't recover the discriminator, it's fine: we expect that - // instruction to fail, but this isn't something that matters here. - // If the discriminator is valid, then it should not be the start - // or end of a settlement. - if let Ok((discriminator, _)) = recover_discriminator(inner.get_instruction_data()) { - if [ - SettlementInstruction::BeginSettle, - SettlementInstruction::FinalizeSettle, + .instruction(); + assert_eq!(ix_program_id, program_id); + assert_eq!( + data, + [ + &[SettlementInstruction::BeginSettle.discriminator()][..], + &hex!("1337")[..], // counterpart index + &[0][..], // order count ] - .contains(&discriminator) - { - return Err(SettlementError::BeginFinalizePairOverlap.into()); - } - } - } - - Ok(()) -} - -/// Validate and pull funds for each order, requiring: -/// - 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 -/// [`process_order`]. -#[must_use = "ignoring the output may lead to an unintended on-chain state"] -fn pull_funds<'a>( - program_id: &Address, - token_program_account: &AccountView, - state_pda_account: &AccountView, - orders: impl IntoIterator>, -) -> ProgramResult { - if token_program_account.address() != &SPL_TOKEN_PROGRAM_ID { - return Err(ProgramError::IncorrectProgramId); - } - - // Funds are pulled with the state PDA's delegation, so it must be the signer. - let 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()); + .concat(), + ); + // No orders: 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); + // They are all generic accounts that don't play an active role in the + // transaction. + assert!(accounts + .iter() + .all(|account| !account.is_writable && !account.is_signer)); } - 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); - - // Orders must be passed strictly increasing by address; this rejects - // duplicates (settling the same order twice) without a separate scan. - let mut previous: Option<&Address> = None; - - let now = Clock::get()?.unix_timestamp; - - for order in orders { - let order_pda = order.order_pda; - if previous.is_some_and(|previous| order_pda.address() <= previous) { - return Err(SettlementError::OrdersNotStrictlyIncreasing.into()); + #[test] + fn begin_settle_sorts_orders_by_pda() { + let program_id = Pubkey::new_unique(); + let state_pda = Pubkey::new_unique(); + // Two orders supplied in descending PDA order. All the other parameters + // are chosen to sort in the opposite order. + let high_order_pda = Pubkey::new_from_array([0xbb; 32]); + let high_sell_token_account = Pubkey::new_from_array([0xa0; 32]); + let high_bump = 0xaa; + let low_order_pda = Pubkey::new_from_array([0xaa; 32]); + let low_sell_token_account = Pubkey::new_from_array([0xb0; 32]); + let low_bump = 0xbb; + let ix = 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: &[&[], &[]], } - previous = Some(order_pda.address()); - - process_order(program_id, order, now, state_pda_account, &state_pda_signer)?; - } + .instruction(); - Ok(()) -} - -/// Validate a singe 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"] -fn process_order( - program_id: &Address, - order: SettledOrder<'_>, - now: i64, - state_account: &AccountView, - state_pda_signer: &Signer, -) -> ProgramResult { - let SettledOrder { - order_pda, - sell_token_account, - bump, - destinations, - amounts, - } = order; - - // Decode the order body. Reading is safe regardless of who owns the - // account; the canonical-address check below is what proves provenance. - // The borrow is released at the end of this block, before any other - // account is touched. - let (cancelled, intent, uid) = { - let data = order_pda.try_borrow()?; - let bytes: &[u8; EncodedOrderAccount::SIZE] = (&*data) - .try_into() - .map_err(|_| ProgramError::InvalidAccountData)?; - let (account, uid) = EncodedOrderAccount::decode_and_hash(bytes)?; - (account.cancelled, account.intent, uid) - }; - - // Only at this point we can validate that the PDA is indeed a valid - // order PDA by seeing its address matches the computed one. - let expected = - Address::create_program_address(&order_pda_signer_seeds(&uid, &[bump]), program_id) - .map_err(|_| SettlementError::OrderNotCanonical)?; - if &expected != order_pda.address() { - return Err(SettlementError::OrderNotCanonical.into()); - } - - if cancelled { - return Err(SettlementError::OrderCancelled.into()); - } + // Bumps follow the sorted order: the low PDA's bump comes first. + assert_eq!( + ix.data, + [ + &[SettlementInstruction::BeginSettle.discriminator()][..], + &hex!("1337")[..], // counterpart index + &[2][..], // order count + &[low_bump, high_bump][..], // bumps + &[0, 0][..], // transfer counts (both zero) + ] + .concat(), + ); - if now > i64::from(intent.valid_to) { - return Err(SettlementError::OrderExpired.into()); + let expected: Vec = vec![ + INSTRUCTIONS_SYSVAR_ID, + state_pda, + SPL_TOKEN_PROGRAM_ID, + low_order_pda, + low_sell_token_account, + high_order_pda, + high_sell_token_account, + ]; + let actual: Vec = ix.accounts.iter().map(|account| account.pubkey).collect(); + assert_eq!(actual, expected); + // The fixed accounts and the order PDAs are read-only; only the sell + // token accounts are writable, following the sorted order. + let writable: Vec = ix + .accounts + .iter() + .filter(|account| account.is_writable) + .map(|account| account.pubkey) + .collect(); + assert_eq!( + writable, + vec![low_sell_token_account, high_sell_token_account], + ); + assert!(ix.accounts.iter().all(|account| !account.is_signer)); } - // The sell token account must be the one named in the intent, owned by - // the intent owner: an order can only sell funds its own owner controls. - if !address_matches_pubkey(sell_token_account.address(), &intent.sell_token_account) { - return Err(SettlementError::SellTokenAccountMismatch.into()); - } - // Assert the order intent owner matches that of the sell token account. - { - // `from_account_view` confirms this is a real SPL token account - // (right length, owned by the token program) before we read its - // owner. The borrow it holds is released at the end of this block, - // before the transfers below touch the same account. - let token_account = TokenAccount::from_account_view(sell_token_account) - .map_err(|_| SettlementError::SellTokenAccountInvalid)?; - if !address_matches_pubkey(token_account.owner(), &intent.owner) { - return Err(SettlementError::SellTokenOwnerMismatch.into()); + #[test] + fn begin_settle_encodes_grouped_transfers() { + let program_id = Pubkey::new_unique(); + let state_pda = Pubkey::new_unique(); + let order_a = Pubkey::new_from_array([0x01; 32]); + let sell_a = Pubkey::new_from_array([0x02; 32]); + let order_b = Pubkey::new_from_array([0x03; 32]); + let sell_b = Pubkey::new_from_array([0x04; 32]); + let dest_a0 = Pubkey::new_from_array([0x05; 32]); + let dest_a1 = Pubkey::new_from_array([0x06; 32]); + let dest_b0 = Pubkey::new_from_array([0x07; 32]); + + // Order A has two transfers, order B has one. + 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, + amount: 0x0102, + }, + Pull { + destination: dest_a1, + amount: 0x0304, + }, + ], + &[Pull { + destination: dest_b0, + amount: 0x0506, + }], + ], } - } - - // Pull the configured amounts out of the sell token account. The state - // PDA is the SPL delegate, so it signs each transfer via `signer`. - for (destination, amount) in destinations.iter().zip(amounts) { - Transfer::new( - sell_token_account, - destination, - state_account, - u64::from_be_bytes(*amount), - ) - .invoke_signed(core::slice::from_ref(state_pda_signer))?; - } - - Ok(()) -} - -fn address_matches_pubkey(address: &Address, pubkey: &Pubkey) -> bool { - address.as_array() == &pubkey.to_bytes() -} - -pub fn process_finalize_settle( - program_id: &Address, - accounts: &mut [AccountView], - 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(()) -} - -#[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, - }; + .instruction(); - /// 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; + assert_eq!( + ix.data, + [ + &[SettlementInstruction::BeginSettle.discriminator()][..], + &hex!("1337")[..], // counterpart index + &[2][..], // order count + &[0xa1, 0xb1][..], // bumps + &[2, 1][..], // counts + // amounts + &hex!("0000000000000102")[..], + &hex!("0000000000000304")[..], + &hex!("0000000000000506")[..], + ] + .concat(), + ); - /// 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() - }; + let expected: Vec = vec![ + INSTRUCTIONS_SYSVAR_ID, + state_pda, + SPL_TOKEN_PROGRAM_ID, + order_a, + sell_a, + dest_a0, + dest_a1, + order_b, + sell_b, + dest_b0, + ]; + let actual: Vec = ix.accounts.iter().map(|account| account.pubkey).collect(); + assert_eq!(actual, expected); + // The fixed accounts and the order PDAs are read-only; sell and + // destination accounts are writable for the transfer. + let writable: Vec = ix + .accounts + .iter() + .filter(|account| account.is_writable) + .map(|account| account.pubkey) + .collect(); + assert_eq!(writable, vec![sell_a, dest_a0, dest_a1, sell_b, dest_b0]); + assert!(ix.accounts.iter().all(|account| !account.is_signer)); } #[test] @@ -535,22 +499,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![ @@ -564,19 +512,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![ @@ -590,19 +525,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]); @@ -846,83 +768,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); - } - - 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/interface/src/instruction/settle/finalize.rs b/interface/src/instruction/settle/finalize.rs new file mode 100644 index 0000000..4ffdebf --- /dev/null +++ b/interface/src/instruction/settle/finalize.rs @@ -0,0 +1,520 @@ +//! 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::{SettlementError, SettlementInstruction}; + +use super::{recover_counterpart, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID}; + +/// 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. +/// +/// The slices are assumed to have the same length but this is not enforced in +/// the builder. +/// +/// 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<'_> { + 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, + 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 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: &'a [u8], + accounts: &'a mut [AccountView], + ) -> Result { + 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 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`. + 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, + }, + }) + } +} + +#[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; + + /// 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 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(), + ); + + let actual: Vec = ix.accounts.iter().map(|meta| meta.pubkey).collect(); + assert_eq!( + actual, + vec![ + INSTRUCTIONS_SYSVAR_ID, + state_pda, + SPL_TOKEN_PROGRAM_ID, + source_a, + dest_a, + source_b, + dest_b, + ], + ); + // The fixed accounts are read-only; the source buffers and destinations + // are writable for the transfers. + let writable: Vec = ix + .accounts + .iter() + .filter(|meta| meta.is_writable) + .map(|meta| meta.pubkey) + .collect(); + assert_eq!(writable, vec![source_a, dest_a, source_b, dest_b]); + assert!(ix.accounts.iter().all(|meta| !meta.is_signer)); + } + + #[test] + fn finalize_settle_input_parses_no_pushes() { + let sysvar = Address::new_from_array([0x42u8; 32]); + // The state-PDA and token-program slots are reserved but not surfaced. + let state = Address::new_from_array([0x43u8; 32]); + let token_program = Address::new_from_array([0x44u8; 32]); + let mut accounts = [ + fake_account(sysvar), + fake_account(state), + fake_account(token_program), + ]; + let data = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [0x13, 0x37], // begin index + ]; + 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(), &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] + 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_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()], + [0, 0], // begin index + [0xff], // the push's bump + 0u64.to_be_bytes(), + ]; + + // Too few: only one push account follows the fixed accounts. + let mut too_few = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 1 }>(); + assert_eq!( + FinalizeSettleInput::parse(&data, &mut too_few).err(), + Some(SettlementError::AccountCountNotMatchingPushCount.into()), + ); + + // Too many: three push accounts follow the fixed accounts. + let mut too_many = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 3 }>(); + assert_eq!( + FinalizeSettleInput::parse(&data, &mut too_many).err(), + Some(SettlementError::AccountCountNotMatchingPushCount.into()), + ); + } + + #[test] + fn finalize_settle_input_rejects_partial_push() { + // Four trailing bytes: not a whole number of 9-byte pushes (a bump plus a + // `u64` amount), so the body can't be parsed into the push layout. + let mut accounts = fake_sequential_accounts::(); + let data = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [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 new file mode 100644 index 0000000..6282f46 --- /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::{BeginSettle, BeginSettleInput, Pull, SettledOrder, SettledOrders}; +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 +/// 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/interface/src/lib.rs b/interface/src/lib.rs index dc42b96..1c84d74 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -102,6 +102,26 @@ 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, + /// `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/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/begin.rs b/programs/settlement/src/settle/begin.rs new file mode 100644 index 0000000..11bdb7f --- /dev/null +++ b/programs/settlement/src/settle/begin.rs @@ -0,0 +1,337 @@ +//! `BeginSettle` instruction handler. + +use std::ops::Deref; + +use pinocchio::{ + cpi::{Seed, Signer}, + error::ProgramError, + sysvars::{ + clock::Clock, + instructions::{Instructions, IntrospectedInstruction}, + Sysvar, + }, + AccountView, Address, ProgramResult, +}; +use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; +use settlement_interface::{ + data::order::EncodedOrderAccount, + instruction::{ + create_buffer::SPL_TOKEN_PROGRAM_ID, + settle::{BeginSettleInput, SettledOrder, SettledOrders}, + InstructionInputParsing, + }, + pda::{order::order_pda_signer_seeds, state::state_pda_seeds}, + recover_discriminator, Pubkey, SettlementError, SettlementInstruction, +}; + +use crate::processor::is_cpi_call; + +use super::validate_counterpart; + +pub fn process_begin_settle( + program_id: &Address, + accounts: &mut [AccountView], + instruction_data: &[u8], +) -> ProgramResult { + if is_cpi_call() { + return Err(SettlementError::CalledViaCpi.into()); + } + + let input = BeginSettleInput::parse(instruction_data, accounts)?; + + // We use `instructions_sysvar_account` from the input but this could be + // any address since parsing doesn't validate the input. We rely on the + // fact that the Pinocchio library already checks that the input account + // is the expected one. + let instructions = Instructions::try_from(input.instructions_sysvar_account)?; + let current_index = instructions.load_current_index(); + + // Reciprocity: the input index is a finalize_settle instruction and that + // instruction points to the current one. + validate_counterpart( + program_id, + &instructions, + current_index, + input.finalize_ix_index, + SettlementInstruction::FinalizeSettle, + )?; + + validate_no_nested_settlement( + program_id, + &instructions, + current_index, + input.finalize_ix_index, + )?; + + // 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, + &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. +#[must_use = "ignoring the output may lead to an unintended on-chain state"] +fn validate_no_nested_settlement>( + program_id: &Address, + instructions: &Instructions, + current_index: u16, + finalize_ix_index: u16, +) -> ProgramResult { + if finalize_ix_index <= current_index { + return Err(SettlementError::FinalizeBeforeInitialize.into()); + } + + let search_start = current_index + .checked_add(1) + .expect("the finalize index is tested to be larger, no overflow can happen"); + for i in search_start..finalize_ix_index { + let inner = instructions.load_instruction_at(usize::from(i))?; + // Skip instructions belonging to a different program. + if inner.get_program_id() != program_id { + continue; + } + // If it can't recover the discriminator, it's fine: we expect that + // instruction to fail, but this isn't something that matters here. + // If the discriminator is valid, then it should not be the start + // or end of a settlement. + if let Ok((discriminator, _)) = recover_discriminator(inner.get_instruction_data()) { + if [ + SettlementInstruction::BeginSettle, + SettlementInstruction::FinalizeSettle, + ] + .contains(&discriminator) + { + return Err(SettlementError::BeginFinalizePairOverlap.into()); + } + } + } + + Ok(()) +} + +/// Validate each order against its push, and pull user funds. This requires: +/// - the legacy SPL Token program; +/// - the canonical state PDA, which signs each transfer as the user's delegate; +/// - orders strictly increasing by address, rejecting duplicates. +/// +/// 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 settle_orders<'a>( + program_id: &Address, + token_program_account: &AccountView, + state_pda_account: &AccountView, + orders: SettledOrders<'a>, + pushes: &FinalizePushes, +) -> ProgramResult { + if token_program_account.address() != &SPL_TOKEN_PROGRAM_ID { + return Err(ProgramError::IncorrectProgramId); + } + + // Funds are pulled with the state PDA's delegation, so it must be the signer. + let 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()); + } + + // 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); + let state_pda_signer = Signer::from(&signer_seeds); + + // Orders must be passed strictly increasing by address; this rejects + // duplicates (settling the same order twice) without a separate scan. + let mut previous: Option<&Address> = None; + + let now = Clock::get()?.unix_timestamp; + + // 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()); + + // 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, then process its pulls. +/// This checks that the order is valid and settleable. Once the order passes +/// 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, +) -> Result { + let SettledOrder { + order_pda, + sell_token_account, + bump, + destinations, + amounts, + } = order; + + // Decode the order body. Reading is safe regardless of who owns the + // account; the canonical-address check below is what proves provenance. + // The borrow is released at the end of this block, before any other + // account is touched. + let (cancelled, intent, uid) = { + let data = order_pda.try_borrow()?; + let bytes: &[u8; EncodedOrderAccount::SIZE] = (&*data) + .try_into() + .map_err(|_| ProgramError::InvalidAccountData)?; + let (account, uid) = EncodedOrderAccount::decode_and_hash(bytes)?; + (account.cancelled, account.intent, uid) + }; + + // Only at this point we can validate that the PDA is indeed a valid + // order PDA by seeing its address matches the computed one. + let expected = + Address::create_program_address(&order_pda_signer_seeds(&uid, &[bump]), program_id) + .map_err(|_| SettlementError::OrderNotCanonical)?; + if &expected != order_pda.address() { + return Err(SettlementError::OrderNotCanonical.into()); + } + + if cancelled { + return Err(SettlementError::OrderCancelled.into()); + } + + if now > i64::from(intent.valid_to) { + return Err(SettlementError::OrderExpired.into()); + } + + // The sell token account must be the one named in the intent, owned by + // the intent owner: an order can only sell funds its own owner controls. + if !address_matches_pubkey(sell_token_account.address(), &intent.sell_token_account) { + return Err(SettlementError::SellTokenAccountMismatch.into()); + } + // Assert the order intent owner matches that of the sell token account. + { + // `from_account_view` confirms this is a real SPL token account + // (right length, owned by the token program) before we read its + // owner. The borrow it holds is released at the end of this block, + // before the transfers below touch the same account. + let token_account = TokenAccount::from_account_view(sell_token_account) + .map_err(|_| SettlementError::SellTokenAccountInvalid)?; + if !address_matches_pubkey(token_account.owner(), &intent.owner) { + return Err(SettlementError::SellTokenOwnerMismatch.into()); + } + } + + // Pull the configured amounts out of the sell token account. The state + // PDA is the SPL delegate, so it signs each transfer via `signer`. + for (destination, amount) in destinations.iter().zip(amounts) { + Transfer::new( + sell_token_account, + destination, + state_account, + u64::from_be_bytes(*amount), + ) + .invoke_signed(core::slice::from_ref(state_pda_signer))?; + } + + Ok(intent.buy_token_account) +} + +fn address_matches_pubkey(address: &Address, pubkey: &Pubkey) -> bool { + address.as_array() == &pubkey.to_bytes() +} diff --git a/programs/settlement/src/settle/finalize.rs b/programs/settlement/src/settle/finalize.rs new file mode 100644 index 0000000..dcdffec --- /dev/null +++ b/programs/settlement/src/settle/finalize.rs @@ -0,0 +1,121 @@ +//! `FinalizeSettle` instruction handler. + +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::{ + create_buffer::SPL_TOKEN_PROGRAM_ID, + settle::{FinalizeSettleInput, Pushes}, + InstructionInputParsing, + }, + pda::{buffer::buffer_pda_signer_seeds, state::state_pda_seeds}, + 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, + )?; + + // 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))?; + } + + Ok(()) +} 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(()) +} 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])) -} diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index ee105c1..179793b 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -4,22 +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, set_unix_timestamp, setup, - signed_tx, token, + 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 litesvm::LiteSVM; use settlement_client::instructions::{ - begin_settle, create_order, finalize_settle, Pull, SettledOrder, + BeginSettle, FinalizeSettle, Pull, SettleableOrder, SettledOrder, }; use settlement_client::settlement_interface::{ - data::{ - intent::{OrderIntent, OrderKind}, - order::{EncodedOrderAccount, OrderAccount}, - }, + data::order::{EncodedOrderAccount, OrderAccount}, instruction::settle::{ - begin_settle as raw_begin_settle, 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, @@ -40,108 +47,135 @@ 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 = create_order(program_id, &owner.pubkey(), &owner.pubkey(), intent); - let tx = signed_tx(svm, owner, owner, ix); - svm.send_transaction(tx) - .expect("create_order should succeed"); -} - -/// 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]` 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<(), TransactionError> { + let finalize = FinalizeSettle { + program_id: *program_id, + begin_ix_index: 0, + orders: &[], } + .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) } -/// Send `[begin, finalize_settle(..)]` signed by `payer`, where `begin` is a -/// pre-built `BeginSettle` instruction. -fn send_settlement( +/// 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, -) -> Result { - let finalize = finalize_settle(program_id, 0); + 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_err(|e| e.err) + 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: &[SettledOrder], -) -> Result { - send_settlement(svm, program_id, payer, begin_settle(program_id, 1, orders)) + orders: &[SettleableOrder], +) -> 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, @@ -149,17 +183,18 @@ fn settle_raw( order_pdas: &[Pubkey], sell_token_accounts: &[Pubkey], bumps: &[u8], -) -> Result { - let begin = raw_begin_settle( - program_id, - &find_state_pda(program_id).0, - 1, +) -> Result<(), TransactionError> { + let begin = BeginSettleRaw { + 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()), - ); - send_settlement(svm, program_id, payer, begin) + pulls: &no_pulls(bumps.len()), + } + .instruction(); + send_settlement_with_placeholder_pushes(svm, program_id, payer, begin, bumps.len()) } #[test] @@ -167,12 +202,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(); - settle( + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + settle_and_pay( &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[], }], @@ -188,17 +223,18 @@ 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"); + settle_and_pay(&mut svm, &program_id, &payer, &orders) + .expect("multi-order settlement should succeed"); } #[test] @@ -206,7 +242,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( @@ -280,7 +316,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( @@ -311,7 +347,7 @@ fn rejects_sell_token_owner_mismatch() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[], }], @@ -333,7 +369,7 @@ fn rejects_non_token_sell_account() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[], }], @@ -347,18 +383,20 @@ 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(); + // 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, &[ - SettledOrder { + SettleableOrder { intent: &intent, pulls: &[], }, - SettledOrder { + SettleableOrder { intent: &intent, pulls: &[], }, @@ -373,10 +411,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(); @@ -384,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)); @@ -407,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] @@ -466,7 +532,7 @@ fn rejects_cancelled_order() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[], }], @@ -481,7 +547,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; @@ -492,7 +558,7 @@ fn rejects_expired_order() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[], }], @@ -507,16 +573,16 @@ 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)); - settle( + settle_and_pay( &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[], }], @@ -529,18 +595,18 @@ 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); 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, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[Pull { destination, @@ -563,7 +629,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); @@ -572,11 +638,11 @@ fn pulls_to_multiple_destinations() { let pulled0 = 300_000; let pulled1 = 100_000; - settle( + settle_and_pay( &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[ Pull { @@ -610,10 +676,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; @@ -637,19 +703,19 @@ fn pulls_from_multiple_orders() { let pulled_first = 42_000; let pulled_second = 67_000; - settle( + settle_and_pay( &mut svm, &program_id, &payer, &[ - SettledOrder { + SettleableOrder { intent: &first, pulls: &[Pull { destination: dest_first, amount: pulled_first, }], }, - SettledOrder { + SettleableOrder { intent: &second, pulls: &[Pull { destination: dest_second, @@ -675,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 = SettleableOrder::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, - &[SettledOrder { + 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] @@ -704,7 +803,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(); @@ -713,15 +812,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), - ), + BeginSettleRaw { + 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, ); @@ -732,18 +832,19 @@ 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 = begin_settle( - &program_id, - 1, - &[SettledOrder { + let mut begin = BeginSettle { + program_id, + finalize_ix_index: 1, + orders: &[SettleableOrder { intent: &intent, pulls: &[], }], - ); + } + .instruction(); let token_account_index = 2; begin.accounts[token_account_index] = AccountMeta::new_readonly(Pubkey::new_unique(), false); @@ -758,7 +859,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 @@ -771,7 +872,7 @@ fn rejects_pull_delegated_to_incorrect_address() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[Pull { destination, @@ -790,7 +891,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; @@ -809,7 +910,7 @@ fn rejects_pull_exceeding_delegation() { &mut svm, &program_id, &payer, - &[SettledOrder { + &[SettleableOrder { intent: &intent, pulls: &[Pull { destination, @@ -832,16 +933,17 @@ 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 = begin_settle( - &program_id, - 1, - &[SettledOrder { + let mut begin = BeginSettle { + program_id, + finalize_ix_index: 1, + orders: &[SettleableOrder { 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/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 cdfbd1d..713c1a1 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -5,6 +5,8 @@ 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 new file mode 100644 index 0000000..2687b6e --- /dev/null +++ b/programs/settlement/tests/common/order.rs @@ -0,0 +1,97 @@ +//! 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()); + // `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, + 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/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/create_buffer.rs b/programs/settlement/tests/create_buffer.rs index f636118..02119ce 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 CreateBuffersRaw, 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 = CreateBuffersRaw { + 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 = CreateBuffersRaw { + 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/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs new file mode 100644 index 0000000..1444648 --- /dev/null +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -0,0 +1,346 @@ +//! Integration tests for the fund pushes carried by `FinalizeSettle` and +//! validated by `BeginSettle`. +//! +//! 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::InstructionError, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, +}; + +mod common; + +/// 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) + )), + ); +} + +/// 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 +/// `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 pushes_a_single_order() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + 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, + amount, + }], + ) + .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 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, + &program_id, + &payer, + &[ + SettledOrder { + intent: &intent0, + mint, + amount: 1_000, + }, + SettledOrder { + intent: &intent1, + mint, + amount: 2_000, + }, + ], + ) + .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 pushes_several_orders_from_different_buffers() { + let (mut svm, program_id, payer) = setup(); + let mint0 = token::create_mint(&mut svm, &payer); + let mint1 = token::create_mint(&mut svm, &payer); + let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer, &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, + &program_id, + &payer, + &[ + SettledOrder { + intent: &intent0, + mint: mint0, + amount: 1_000, + }, + SettledOrder { + intent: &intent1, + mint: mint1, + amount: 2_000, + }, + ], + ) + .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_to_wrong_destination() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &mint).build(); + let orders = [SettledOrder { + intent: &intent, + mint, + amount: 100, + }]; + + let mut finalize = FinalizeSettle { + program_id, + begin_ix_index: 0, + orders: &orders, + } + .instruction(); + // Redirect the push to an account that isn't the order's buy token account. + // Accounts: `[sysvar, state, token_program, source, destination]`. + let destination_index = 4; + finalize.accounts[destination_index].pubkey = Pubkey::new_unique(); + + assert_begin_error( + send_settlement(&mut svm, &program_id, &payer, &orders, finalize), + SettlementError::PushDestinationMismatch, + ); +} + +#[test] +fn rejects_push_from_non_buffer_source() { + let (mut svm, program_id, payer) = setup(); + let buy_mint = token::create_mint(&mut svm, &payer); + let other_mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &buy_mint).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, + }]; + + 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(); + + 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), + 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 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, + }]; + + let mut finalize = FinalizeSettle { + program_id, + begin_ix_index: 0, + orders: &orders, + } + .instruction(); + // 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( + send_settlement(&mut svm, &program_id, &payer, &orders, finalize), + InstructionError::InvalidInstructionData, + ); +} diff --git a/programs/settlement/tests/initialize.rs b/programs/settlement/tests/initialize.rs index 8686647..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::instructions::Initialize; use settlement_client::settlement_interface::{ - instruction::initialize::initialize as initialize_ix, pda::state::find_state_pda, + instruction::initialize::Initialize as InitializeRaw, 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 = InitializeRaw { + 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..607aeee 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,18 @@ 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, + orders: &[], + } + .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 +139,19 @@ 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, + orders: &[], + } + .instruction(); let tx = Transaction::new_signed_with_payer( &[begin, finalize], @@ -156,11 +176,21 @@ 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, + orders: &[], + } + .instruction(); let instructions = [begin, stranger]; let expected_failing_instruction_index = 0; @@ -205,7 +235,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 +267,15 @@ 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, + orders: &[], + } + .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..376ded0 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,18 @@ 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, + orders: &[], + } + .instruction(), ], Some(&payer.pubkey()), &[&payer],