diff --git a/Cargo.lock b/Cargo.lock index b674f1b..681a72a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2212,6 +2212,7 @@ dependencies = [ "pinocchio", "pinocchio-system", "pinocchio-token", + "proptest", "settlement-client", "settlement-interface", "solana-sdk", @@ -2234,6 +2235,7 @@ dependencies = [ "hex-literal", "num_enum", "proptest", + "solana-hash 3.1.0", "solana-instruction", "solana-program-error", "solana-pubkey 3.0.0", diff --git a/Cargo.toml b/Cargo.toml index 7924608..82f5862 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ pinocchio-token = "0.6" proptest = "1" settlement-client = { path = "client" } settlement-interface = { path = "interface" } +solana-hash = "3" solana-instruction = "3" solana-program-error = "3" solana-pubkey = "3" diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 1c93e34..6156251 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -13,7 +13,32 @@ 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::{begin_settle, finalize_settle}; +pub use settlement_interface::instruction::settle::finalize_settle; + +/// Build a `BeginSettle` instruction settling the specified orders. The orders +/// may be supplied in any order; the interface builder sorts them by PDA address. +pub fn begin_settle( + program_id: &Pubkey, + finalize_ix_index: u16, + intents: &[OrderIntent], +) -> Instruction { + let mut order_pdas = Vec::with_capacity(intents.len()); + let mut sell_token_accounts = Vec::with_capacity(intents.len()); + let mut bumps = Vec::with_capacity(intents.len()); + for intent in intents { + let (order_pda, bump) = find_order_pda(program_id, &intent.uid()); + order_pdas.push(order_pda); + sell_token_accounts.push(intent.sell_token_account); + bumps.push(bump); + } + settlement_interface::instruction::settle::begin_settle( + program_id, + finalize_ix_index, + &order_pdas, + &sell_token_accounts, + &bumps, + ) +} pub fn create_order( program_id: &Pubkey, diff --git a/interface/Cargo.toml b/interface/Cargo.toml index c6c134d..4de3f1e 100644 --- a/interface/Cargo.toml +++ b/interface/Cargo.toml @@ -8,6 +8,7 @@ arrayref.workspace = true derive_more.workspace = true num_enum.workspace = true proptest = { workspace = true, optional = true } +solana-hash.workspace = true solana-instruction.workspace = true solana-program-error.workspace = true solana-pubkey = { workspace = true, features = ["curve25519"] } diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index 52ecea1..3686f6d 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -19,6 +19,7 @@ use core::mem::size_of; use arrayref::{array_refs, mut_array_refs}; use derive_more::Deref; +use solana_hash::Hash; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; @@ -117,28 +118,30 @@ impl EncodedOrderIntent { pub const SIZE: usize = 150; /// Canonical hash of the bytes. - pub fn hash(&self) -> [u8; 32] { - solana_sha256_hasher::hashv(&[self.as_slice()]).to_bytes() + pub fn hash(&self) -> Hash { + hash_bytes(&self.0) } /// Decode raw bytes to an [`OrderIntent`] and compute the UID in one shot. /// Returns [`ProgramError::InvalidInstructionData`] for an out-of-range /// `kind` or `partially_fillable` byte; every other byte combination /// decodes. - pub fn decode_and_hash( - bytes: &[u8; Self::SIZE], - ) -> Result<(OrderIntent, [u8; 32]), ProgramError> { + pub fn decode_and_hash(bytes: &[u8; Self::SIZE]) -> Result<(OrderIntent, Hash), ProgramError> { let intent = OrderIntent::try_from(bytes)?; // The UID is the SHA-256 of the input bytes. Hashing the input // (no re-encode) is correct because encode/decode is a bijection on // inputs that pass validation. Any normalization added to the `From` // or `TryFrom` impls later would break this and the UID would silently // diverge from `OrderIntent::uid()`. - let uid = solana_sha256_hasher::hashv(&[bytes.as_slice()]).to_bytes(); + let uid = hash_bytes(bytes); Ok((intent, uid)) } } +pub fn hash_bytes(bytes: &[u8; EncodedOrderIntent::SIZE]) -> Hash { + solana_sha256_hasher::hashv(&[bytes.as_slice()]) +} + impl From<&EncodedOrderIntent> for [u8; EncodedOrderIntent::SIZE] { fn from(encoded: &EncodedOrderIntent) -> Self { encoded.0 @@ -253,7 +256,7 @@ impl OrderIntent { /// SHA-256 of the canonical bytes. Doubles as the order UID and the /// middle seed of the order PDA. On SBF this compiles to a single /// `sol_sha256` syscall; off-target it goes through the `sha2` crate. - pub fn uid(&self) -> [u8; 32] { + pub fn uid(&self) -> Hash { EncodedOrderIntent::from(self).hash() } } @@ -455,7 +458,7 @@ mod tests { fn uid_digest_regression() { let intent = sample_intent(OrderKind::Buy, true); let expected = hex!("091d7e1959ac6f7a400a91f1dcd9ce436f8f53e2b7a1d968acb08f79d3c1231d"); - assert_eq!(intent.uid(), expected); + assert_eq!(intent.uid(), Hash::from(expected)); } #[test] diff --git a/interface/src/data/order.rs b/interface/src/data/order.rs index eacfb67..2130523 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -22,10 +22,11 @@ use core::mem::size_of; use arrayref::{array_refs, mut_array_refs}; use derive_more::Deref; +use solana_hash::Hash; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; -use crate::data::intent::{EncodedOrderIntent, OrderIntent}; +use crate::data::intent::{self, EncodedOrderIntent, OrderIntent}; /// Idiomatic representation of an order PDA's body. #[derive(Clone, Debug, Eq, PartialEq)] @@ -79,6 +80,24 @@ impl EncodedOrderAccount { const W_INTENT: usize = EncodedOrderIntent::SIZE; pub const SIZE: usize = 199; + + /// Decode the account body and compute the embedded intent's UID in one + /// shot, mirroring [`EncodedOrderIntent::decode_and_hash`]. Decoding + /// validates the intent; returns [`ProgramError::InvalidAccountData`] on a + /// decode error. + pub fn decode_and_hash(bytes: &[u8; Self::SIZE]) -> Result<(OrderAccount, Hash), ProgramError> { + let order_account = OrderAccount::try_from(*bytes)?; + // The order UID is the hash of the intent's canonical bytes. Decoding + // succeeded, so the intent slot already holds those exact bytes: hash + // them in place rather than using `intent.uid()` to avoid re-encoding. + let (_, raw_intent) = array_refs![ + bytes, + EncodedOrderAccount::SIZE - EncodedOrderAccount::W_INTENT, + EncodedOrderAccount::W_INTENT + ]; + let intent_uid = intent::hash_bytes(raw_intent); + Ok((order_account, intent_uid)) + } } /// Writes the canonical [`EncodedOrderAccount`] encoding of the given fields @@ -328,6 +347,18 @@ mod tests { assert_eq!(err, ProgramError::InvalidAccountData); } + #[test] + fn decode_and_hash_catches_errors() { + let mut bytes: [u8; EncodedOrderAccount::SIZE] = + EncodedOrderAccount::from(sample_account(false)).into(); + // Corrupt the `cancelled` byte to an out-of-range value so the + // underlying `try_from` rejects it. + bytes[CANCELLED_OFFSET] = 0xff; + let err = EncodedOrderAccount::decode_and_hash(&bytes) + .expect_err("decode_and_hash must propagate the try_from error"); + assert_eq!(err, ProgramError::InvalidAccountData); + } + #[test] fn direct_write_account_matches_order_account_decoding() { let cancelled = true; @@ -392,6 +423,15 @@ mod tests { .map_err(|e| TestCaseError::fail(format!("decode failed: {e:?}")))?; prop_assert_eq!(*EncodedOrderAccount::from(account), bytes); } + + #[test] + fn consistent_decode_and_hash(account in arb_order_account()) { + let encoded = EncodedOrderAccount::from(account.clone()); + let (decoded, hash) = EncodedOrderAccount::decode_and_hash(&encoded) + .map_err(|e| TestCaseError::fail(format!("decode failed: {e:?}")))?; + prop_assert_eq!(hash, account.intent.uid()); + prop_assert_eq!(decoded, account); + } } } } diff --git a/interface/src/instruction/settle.rs b/interface/src/instruction/settle.rs index f00257e..56c5c0a 100644 --- a/interface/src/instruction/settle.rs +++ b/interface/src/instruction/settle.rs @@ -11,15 +11,53 @@ pub use solana_sdk_ids::sysvar::instructions::ID as INSTRUCTIONS_SYSVAR_ID; use crate::SettlementInstruction; -pub fn begin_settle(program_id: &Pubkey, finalize_ix_index: u16) -> Instruction { +/// Build a `BeginSettle` instruction settling the orders described by the three +/// parallel lists: `order_pdas[i]` is the canonical order PDA (see +/// [`crate::pda::order`]), `sell_token_accounts[i]` its sell token account, and +/// `bumps[i]` the canonical PDA bump. The three slices are assumed to have the +/// same length but this is not enforced in the builder. +/// +/// Wire format: +/// `[discriminator=0][finalize_ix_index: u16 BE][bump...]`, one `bump` byte per +/// order. +/// Accounts: +/// `[instructions_sysvar (R), (order_pda (R), sell_token_account (R))...]`, one +// pair of accounts per order. +/// +/// The program requires the order PDAs to be strictly increasing by address. +/// This builder establishes that ordering for the caller: it sorts the orders by +/// PDA address (carrying each order's sell token account and bump along) before +/// emitting them. +pub fn begin_settle( + program_id: &Pubkey, + finalize_ix_index: u16, + order_pdas: &[Pubkey], + sell_token_accounts: &[Pubkey], + bumps: &[u8], +) -> Instruction { + // Sort the three parallel lists together by order PDA address via a shared + // permutation, so each order keeps its own sell token account and bump. + let mut order: Vec = (0..order_pdas.len()).collect(); + order.sort_by_key(|&i| order_pdas[i]); + + let data = std::iter::once(SettlementInstruction::BeginSettle.discriminator()) + .chain(finalize_ix_index.to_be_bytes()) + .chain(order.iter().map(|&i| bumps[i])) + .collect(); + + let accounts = std::iter::once(INSTRUCTIONS_SYSVAR_ID) + .chain( + order + .iter() + .flat_map(|&i| [order_pdas[i], sell_token_accounts[i]]), + ) + .map(|pubkey| AccountMeta::new_readonly(pubkey, false)) + .collect(); + Instruction { program_id: *program_id, - accounts: vec![AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false)], - data: [ - &[SettlementInstruction::BeginSettle.discriminator()], - &finalize_ix_index.to_be_bytes()[..], - ] - .concat(), + accounts, + data, } } @@ -36,17 +74,16 @@ pub fn finalize_settle(program_id: &Pubkey, begin_ix_index: u16) -> Instruction } /// Reads the first two bytes of a byte slice (instruction data) and -/// interprets them as a big-endian u16. +/// interprets them as a big-endian u16, returning it together with the +/// remaining bytes to parse. /// It's meant to be used for BeginSettle and FinalizeSettle to extract the /// counterpart index, that is, the index linking that instruction to the /// opposite instruction which is encoded as the first /// 2 bytes of the instruction data: `[0x13, 0x37]` → `0x1337`. -/// Trailing bytes are ignored, so it can be used with instruction input -/// directly. /// Returns `InvalidInstructionData` if fewer than two bytes are provided. -pub fn recover_counterpart(instruction_data: &[u8]) -> Result { +pub fn recover_counterpart(instruction_data: &[u8]) -> Result<(u16, &[u8]), ProgramError> { match instruction_data { - [b1, b2, ..] => Ok(u16::from_be_bytes([*b1, *b2])), + [b1, b2, rest @ ..] => Ok((u16::from_be_bytes([*b1, *b2]), rest)), _ => Err(ProgramError::InvalidInstructionData), } } @@ -72,21 +109,21 @@ mod tests { } #[test] - fn ignores_trailing_bytes() { + fn returns_trailing_bytes() { assert_eq!( recover_counterpart(&[ 0x13, // counterpart index 0x37, // counterpart index - 42, // unused + 42, // trailing ]), - Ok(0x1337), + Ok((0x1337, [42].as_slice())), ); } #[test] fn expected_encoding_begin_settle() { let program_id = Pubkey::new_unique(); - let ix = begin_settle(&program_id, 0x1337); + let ix = begin_settle(&program_id, 0x1337, &[], &[], &[]); assert_eq!( ix.data, [ @@ -95,6 +132,57 @@ mod tests { 0x37 ] ); + // No orders: only the instructions sysvar is referenced. + assert_eq!(ix.accounts.len(), 1); + assert_eq!(ix.accounts[0].pubkey, INSTRUCTIONS_SYSVAR_ID); + assert!(!ix.accounts[0].is_writable); + assert!(!ix.accounts[0].is_signer); + } + + #[test] + fn begin_settle_sorts_orders_by_pda() { + let program_id = 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, + 0x1337, + &[high_order_pda, low_order_pda], + &[high_sell_token_account, low_sell_token_account], + &[high_bump, low_bump], + ); + + // Bumps follow the sorted order: the low PDA's bump comes first. + assert_eq!( + ix.data, + [ + SettlementInstruction::BeginSettle.discriminator(), + 0x13, + 0x37, + low_bump, + high_bump, + ], + ); + + let expected: Vec = vec![ + INSTRUCTIONS_SYSVAR_ID, + low_order_pda, + low_sell_token_account, + high_order_pda, + high_sell_token_account, + ]; + let actual: Vec = ix.accounts.iter().map(|meta| meta.pubkey).collect(); + assert_eq!(actual, expected); + assert!(ix + .accounts + .iter() + .all(|meta| !meta.is_writable && !meta.is_signer)); } #[test] @@ -109,5 +197,11 @@ mod tests { 0x37 ] ); + + // Only the instructions sysvar is referenced. + assert_eq!(ix.accounts.len(), 1); + assert_eq!(ix.accounts[0].pubkey, INSTRUCTIONS_SYSVAR_ID); + assert!(!ix.accounts[0].is_writable); + assert!(!ix.accounts[0].is_signer); } } diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 7de92a5..dcfe426 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -68,6 +68,24 @@ pub enum SettlementError { /// `CreateOrder` instruction wasn't signed by the created `OrderIntent` /// owner. OwnerMismatch = 7, + /// A `BeginSettle` order account doesn't sit at the canonical order PDA + /// derived from the intent it stores and the supplied bump. + OrderNotCanonical = 8, + /// `BeginSettle`'s order accounts aren't passed strictly increasing by + /// address. + OrdersNotStrictlyIncreasing = 9, + /// A `BeginSettle` sell token account doesn't match the + /// `sell_token_account` recorded in the order's intent. + SellTokenAccountMismatch = 10, + /// A `BeginSettle` sell token account isn't a valid SPL token account + /// (wrong data length or not owned by the token program). + SellTokenAccountInvalid = 11, + /// A `BeginSettle` sell token account's SPL owner isn't the order's intent + /// owner. + SellTokenOwnerMismatch = 12, + /// `BeginSettle`'s order accounts don't form exactly one + /// `(order_pda, sell_token_account)` pair per order bump. + AccountCountNotMatchingBumps = 13, } impl From for u32 { diff --git a/interface/src/pda/order.rs b/interface/src/pda/order.rs index 23e066f..530a2ca 100644 --- a/interface/src/pda/order.rs +++ b/interface/src/pda/order.rs @@ -12,6 +12,7 @@ //! For every valid [`crate::data::intent::OrderIntent`], there exists only //! a single valid PDA representing that intent. +use solana_hash::Hash; use solana_pubkey::Pubkey; use crate::pda::SETTLEMENT_SEED; @@ -20,15 +21,25 @@ use crate::pda::SETTLEMENT_SEED; pub const ORDER_SEED: &[u8] = b"order"; /// Canonical seed components for the order PDA at `uid`. -pub fn order_pda_seeds(uid: &[u8; 32]) -> [&[u8]; 3] { - [SETTLEMENT_SEED, uid, ORDER_SEED] +pub fn order_pda_seeds(uid: &Hash) -> [&[u8]; 3] { + [SETTLEMENT_SEED, uid.as_ref(), ORDER_SEED] +} + +/// Canonical seeds for signing as the order PDA at `uid` with `bump`. The +/// on-chain `CreateOrder` handler uses this to construct the CPI signer. +/// By design, order PDAs can be created only if it uses the canonical bump. +/// Calling this function with another bump could lead to a theoretically +/// valid PDA that however cannot and should not be instantiated. +pub fn order_pda_signer_seeds<'a>(uid: &'a Hash, bump: &'a [u8; 1]) -> [&'a [u8]; 4] { + let [s0, s1, s2] = order_pda_seeds(uid); + [s0, s1, s2, bump] } /// Derive the canonical order PDA address (and bump) for `uid`. /// /// `uid` is the unique identifier of an intent. See /// [`crate::data::intent::OrderIntent::uid`]. -pub fn find_order_pda(program_id: &Pubkey, uid: &[u8; 32]) -> (Pubkey, u8) { +pub fn find_order_pda(program_id: &Pubkey, uid: &Hash) -> (Pubkey, u8) { Pubkey::find_program_address(&order_pda_seeds(uid), program_id) } @@ -38,7 +49,7 @@ mod tests { #[test] fn find_order_pda_uses_canonical_seeds() { - let uid = *Pubkey::new_unique().as_array(); + let uid = Hash::new_from_array(*Pubkey::new_unique().as_array()); crate::pda::tests::assert_canonical_bump( |program_id| find_order_pda(program_id, &uid), @@ -60,8 +71,8 @@ mod tests { ) { prop_assume!(uid1 != uid2); let program_id = Pubkey::new_from_array(program_id); - let (pda1, _) = find_order_pda(&program_id, &uid1); - let (pda2, _) = find_order_pda(&program_id, &uid2); + let (pda1, _) = find_order_pda(&program_id, &Hash::new_from_array(uid1)); + let (pda2, _) = find_order_pda(&program_id, &Hash::new_from_array(uid2)); prop_assert_ne!(pda1, pda2); } } diff --git a/programs/settlement/Cargo.toml b/programs/settlement/Cargo.toml index 2fe2a02..8bb53c2 100644 --- a/programs/settlement/Cargo.toml +++ b/programs/settlement/Cargo.toml @@ -21,6 +21,7 @@ settlement-interface.workspace = true arrayref.workspace = true litesvm.workspace = true litesvm-token.workspace = true +proptest.workspace = true settlement-client.workspace = true settlement-interface = { workspace = true, features = ["test-fixtures"] } solana-sdk.workspace = true diff --git a/programs/settlement/src/create_order.rs b/programs/settlement/src/create_order.rs index 32c2f14..530f9c7 100644 --- a/programs/settlement/src/create_order.rs +++ b/programs/settlement/src/create_order.rs @@ -24,7 +24,7 @@ impl<'a> InstructionInputParsing<'a> for CreateOrderInput<'a> { const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::CreateOrder; fn parse_body( - instruction_data: &[u8], + instruction_data: &'a [u8], accounts: &'a mut [AccountView], ) -> Result { // Body (discriminator already stripped): exactly the 150 intent bytes. diff --git a/programs/settlement/src/processor.rs b/programs/settlement/src/processor.rs index 3396b06..daea067 100644 --- a/programs/settlement/src/processor.rs +++ b/programs/settlement/src/processor.rs @@ -19,12 +19,12 @@ pub trait InstructionInputParsing<'a>: Sized { const DISCRIMINATOR: SettlementInstruction; fn parse_body( - instruction_data: &[u8], + instruction_data: &'a [u8], accounts: &'a mut [AccountView], ) -> Result; fn parse( - instruction_data: &[u8], + instruction_data: &'a [u8], accounts: &'a mut [AccountView], ) -> Result { match recover_discriminator(instruction_data)? { @@ -97,7 +97,7 @@ mod tests { const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::BeginSettle; fn parse_body( - _instruction_data: &[u8], + _instruction_data: &'a [u8], _accounts: &'a mut [AccountView], ) -> Result { Ok(Self {}) diff --git a/programs/settlement/src/settle.rs b/programs/settlement/src/settle.rs index 7f84fa3..0cbb47c 100644 --- a/programs/settlement/src/settle.rs +++ b/programs/settlement/src/settle.rs @@ -1,23 +1,68 @@ //! `BeginSettle`/`FinalizeSettle` instruction handlers. +use std::ops::Deref; + use pinocchio::{ error::ProgramError, sysvars::instructions::Instructions, AccountView, Address, ProgramResult, }; +use pinocchio_token::state::Account as TokenAccount; use settlement_interface::{ - instruction::settle::recover_counterpart, recover_discriminator, SettlementError, + data::order::EncodedOrderAccount, instruction::settle::recover_counterpart, + pda::order::order_pda_signer_seeds, recover_discriminator, Pubkey, SettlementError, SettlementInstruction, }; use crate::processor::InstructionInputParsing; +/// A single settled order, resulted from parsing `BeginSettle`. +struct SettledOrder<'a> { + order_pda: &'a AccountView, + sell_token_account: &'a AccountView, + bump: u8, +} + +/// Struct storing account and bumps from parsing the input of BeginSettle. +/// We want parsing to provide the data in an ergonomic format. This struct +/// implements `IntoIterator`, yielding all available information for each +/// order without further parsing steps. The parsing step that created this +/// struct guarantees that there aren't missing elements or that they are +/// assigned incorrectly. +struct SettledOrders<'a> { + accounts: &'a [[AccountView; 2]], + bumps: &'a [u8], +} + +impl<'a> IntoIterator for SettledOrders<'a> { + type Item = SettledOrder<'a>; + type IntoIter = std::iter::Map< + std::iter::Zip, std::slice::Iter<'a, u8>>, + fn((&'a [AccountView; 2], &'a u8)) -> SettledOrder<'a>, + >; + + fn into_iter(self) -> Self::IntoIter { + // A non-capturing closure coerced to a function pointer so the iterator + // type stays nameable in `IntoIter` above. + let pair_to_order: fn((&'a [AccountView; 2], &'a u8)) -> SettledOrder<'a> = + |([order_pda, sell_token_account], &bump)| SettledOrder { + order_pda, + sell_token_account, + bump, + }; + self.accounts.iter().zip(self.bumps).map(pair_to_order) + } +} + /// Parsed inputs of a `BeginSettle` instruction. /// /// Strictly the raw extracted form. Fields are read from `instruction_data` and /// `accounts` but **not validated** against runtime context except confirming -/// that the discriminator matches the desired input. +/// that the discriminator matches the desired input and that the number of +/// accounts and bumps is consistent. struct BeginSettleInput<'a> { finalize_ix_index: u16, instructions_sysvar_account: &'a AccountView, + /// The settled orders, each paired with its order PDA's canonical bump. + orders: SettledOrders<'a>, } /// This implementation defines how instruction bytes and accounts are laid out @@ -27,15 +72,29 @@ impl<'a> InstructionInputParsing<'a> for BeginSettleInput<'a> { const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::BeginSettle; fn parse_body( - instruction_data: &[u8], + instruction_data: &'a [u8], accounts: &'a mut [AccountView], ) -> Result { - let finalize_ix_index = recover_counterpart(instruction_data)?; - let instructions_sysvar_account = - accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + // The remaining bytes after recovering the counterpart are the bumps. + let (finalize_ix_index, bumps) = recover_counterpart(instruction_data)?; + + // Accounts: the instructions sysvar followed by one + // `(order_pda, sell_token_account)` pair per bump. + let (instructions_sysvar_account, order_accounts) = accounts + .split_first() + .ok_or(ProgramError::NotEnoughAccountKeys)?; + let (account_pairs, remainder) = order_accounts.as_chunks::<2>(); + if !remainder.is_empty() || account_pairs.len() != bumps.len() { + return Err(SettlementError::AccountCountNotMatchingBumps.into()); + } + Ok(Self { finalize_ix_index, instructions_sysvar_account, + orders: SettledOrders { + accounts: account_pairs, + bumps, + }, }) } } @@ -58,7 +117,7 @@ impl<'a> InstructionInputParsing<'a> for FinalizeSettleInput<'a> { instruction_data: &[u8], accounts: &'a mut [AccountView], ) -> Result { - let begin_ix_index = recover_counterpart(instruction_data)?; + let (begin_ix_index, _) = recover_counterpart(instruction_data)?; let instructions_sysvar_account = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; Ok(Self { @@ -82,11 +141,6 @@ pub fn process_begin_settle( let instructions = Instructions::try_from(input.instructions_sysvar_account)?; let current_index = instructions.load_current_index(); - // Ordering: the counterpart `FinalizeSettle` must sit strictly after us. - if input.finalize_ix_index <= current_index { - return Err(SettlementError::FinalizeBeforeInitialize.into()); - } - // Reciprocity: the input index is a finalize_settle instruction and that // instruction points to the current one. validate_counterpart( @@ -97,12 +151,36 @@ pub fn process_begin_settle( SettlementInstruction::FinalizeSettle, )?; - // Nesting check: no BeginSettle/FinalizeSettle of this program may appear - // strictly between `current_index` and `finalize_ix_index`. + validate_no_nested_settlement( + program_id, + &instructions, + current_index, + input.finalize_ix_index, + )?; + + validate_settled_orders(program_id, input.orders)?; + + Ok(()) +} + +/// Reject a `BeginSettle` whose pair encloses another settlement: no +/// `BeginSettle`/`FinalizeSettle` of this program may appear strictly between +/// `current_index` and `finalize_ix_index`. The bounds themselves are excluded. +#[must_use = "skipping the nesting check silently accepts overlapping settle pairs"] +fn validate_no_nested_settlement>( + program_id: &Address, + instructions: &Instructions, + current_index: u16, + finalize_ix_index: u16, +) -> ProgramResult { + if finalize_ix_index <= current_index { + return Err(SettlementError::FinalizeBeforeInitialize.into()); + } + let search_start = current_index .checked_add(1) .expect("the finalize index is tested to be larger, no overflow can happen"); - for i in search_start..input.finalize_ix_index { + for i in search_start..finalize_ix_index { let inner = instructions.load_instruction_at(usize::from(i))?; // Skip instructions belonging to a different program. if inner.get_program_id() != program_id { @@ -127,6 +205,71 @@ pub fn process_begin_settle( Ok(()) } +/// For each order, this checks that the order account was created by this +/// program and that its sell token account is the one the order's owner +/// controls. +fn validate_settled_orders<'a>( + program_id: &Address, + orders: impl IntoIterator>, +) -> ProgramResult { + // Orders must be passed strictly increasing by address; this rejects + // duplicates (settling the same order twice) without a separate scan. + let mut previous: Option<&Address> = None; + + for SettledOrder { + order_pda, + sell_token_account, + bump, + } in orders + { + // Decode the order body. Reading is safe regardless of who owns the + // account; the canonical-address check below is what proves provenance. + // The borrow is released at the end of this block, before any other + // account is touched. + let (intent, uid) = { + let data = order_pda.try_borrow()?; + let bytes: &[u8; EncodedOrderAccount::SIZE] = (&*data) + .try_into() + .map_err(|_| ProgramError::InvalidAccountData)?; + let (account, uid) = EncodedOrderAccount::decode_and_hash(bytes)?; + (account.intent, uid) + }; + + // Only at this point we can validate that the PDA is indeed a valid + // order PDA by seeing its address matches the computed one. + let derived = + Address::create_program_address(&order_pda_signer_seeds(&uid, &[bump]), program_id) + .map_err(|_| SettlementError::OrderNotCanonical)?; + if &derived != order_pda.address() { + return Err(SettlementError::OrderNotCanonical.into()); + } + + if previous.is_some_and(|previous| order_pda.address() <= previous) { + return Err(SettlementError::OrdersNotStrictlyIncreasing.into()); + } + previous = Some(order_pda.address()); + + // The sell token account must be the one named in the intent, owned by + // the intent owner: an order can only sell funds its own owner controls. + if !address_matches_pubkey(sell_token_account.address(), &intent.sell_token_account) { + return Err(SettlementError::SellTokenAccountMismatch.into()); + } + // `from_account_view` confirms this is a real SPL token account (right + // length, owned by the token program) before we read its owner. + let token_account = TokenAccount::from_account_view(sell_token_account) + .map_err(|_| SettlementError::SellTokenAccountInvalid)?; + if !address_matches_pubkey(token_account.owner(), &intent.owner) { + return Err(SettlementError::SellTokenOwnerMismatch.into()); + } + } + + Ok(()) +} + +fn address_matches_pubkey(address: &Address, pubkey: &Pubkey) -> bool { + address.as_array() == &pubkey.to_bytes() +} + pub fn process_finalize_settle( program_id: &Address, accounts: &mut [AccountView], @@ -155,7 +298,7 @@ pub fn process_finalize_settle( /// back at the current instruction. Ordering (before/after) is the caller's /// responsibility. #[must_use = "skipping the counterpart check silently accepts an invalid settle pair"] -fn validate_counterpart>( +fn validate_counterpart>( program_id: &Address, instructions: &Instructions, current_index: u16, @@ -171,7 +314,7 @@ fn validate_counterpart>( let counterpart_ix_data = counterpart_ix.get_instruction_data(); let (their_discriminator, remaining_data) = recover_discriminator(counterpart_ix_data) .map_err(|_| SettlementError::InvalidCounterpartDiscriminator)?; - let their_counterpart_ix = recover_counterpart(remaining_data) + let (their_counterpart_ix, _) = recover_counterpart(remaining_data) .map_err(|_| SettlementError::InvalidCounterpartCounterpart)?; if their_discriminator != expected_discriminator || their_counterpart_ix != current_index { return Err(SettlementError::MismatchedCounterpartDiscriminator.into()); @@ -182,7 +325,12 @@ fn validate_counterpart>( #[cfg(test)] mod tests { use super::*; - use crate::test_utils::fake_account; + use crate::test_utils::{fake_account, fake_account_from_array}; + use ::proptest::{prelude::*, test_runner::TestCaseError}; + use settlement_interface::{ + data::intent::fixtures::arb_order_intent, instruction::settle::INSTRUCTIONS_SYSVAR_ID, + pda::order::find_order_pda, + }; #[test] fn begin_settle_input_parses_valid_input() { @@ -193,9 +341,14 @@ mod tests { 0x13, 0x37, ]; - let parsed = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); - assert_eq!(parsed.finalize_ix_index, 0x1337); - assert_eq!(parsed.instructions_sysvar_account.address(), &address); + let BeginSettleInput { + finalize_ix_index, + instructions_sysvar_account, + orders, + } = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); + assert_eq!(finalize_ix_index, 0x1337); + assert_eq!(instructions_sysvar_account.address(), &address); + assert_eq!(orders.into_iter().count(), 0); } #[test] @@ -207,10 +360,12 @@ mod tests { 0x13, 0x37, ]; - let parsed = - FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); - assert_eq!(parsed.begin_ix_index, 0x1337); - assert_eq!(parsed.instructions_sysvar_account.address(), &address); + let FinalizeSettleInput { + begin_ix_index, + instructions_sysvar_account, + } = FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); + assert_eq!(begin_ix_index, 0x1337); + assert_eq!(instructions_sysvar_account.address(), &address); } #[test] @@ -254,19 +409,91 @@ mod tests { } #[test] - fn begin_settle_input_ignores_extra_parameters() { - let first_address = Address::new_from_array([1u8; 32]); - let second_address = Address::new_from_array([2u8; 32]); - let mut accounts = [fake_account(first_address), fake_account(second_address)]; + fn begin_settle_input_parses_order_bumps_and_pairs() { + let sysvar = Address::new_from_array([1u8; 32]); + let order_pda = Address::new_from_array([2u8; 32]); + let sell_token = Address::new_from_array([3u8; 32]); + let mut accounts = [ + fake_account(sysvar), + fake_account(order_pda), + fake_account(sell_token), + ]; let data = [ SettlementInstruction::BeginSettle.discriminator(), - 0x13, // used - 0x37, // used - 42, // extra + 0x13, // finalize index hi + 0x37, // finalize index lo + 0xab, // one order's bump + ]; + let BeginSettleInput { + finalize_ix_index, + instructions_sysvar_account, + orders, + } = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); + assert_eq!(finalize_ix_index, 0x1337); + assert_eq!(instructions_sysvar_account.address(), &sysvar); + + let mut orders = orders.into_iter(); + let order = orders.next().expect("one settled order"); + assert_eq!(order.order_pda.address(), &order_pda); + assert_eq!(order.sell_token_account.address(), &sell_token); + assert_eq!(order.bump, 0xab); + assert!(orders.next().is_none()); + } + + #[test] + fn begin_settle_input_pairs_every_order_with_its_bump() { + const ORDER_COUNT: usize = 16; + + let mut expected: Vec<(Address, Address, u8)> = Vec::new(); + for i in 0..ORDER_COUNT { + let order_pda = Address::new_from_array([i as u8; 32]); + let sell_token = Address::new_from_array([(i + ORDER_COUNT) as u8; 32]); + let bump: u8 = (i + 2 * ORDER_COUNT) as u8; + expected.push((order_pda, sell_token, bump)); + } + + // The sysvar (`[0xff; 32]`) differs from every order/token address above. + let mut accounts = vec![(fake_account_from_array([0xff; 32]))]; + let mut data = vec![ + SettlementInstruction::BeginSettle.discriminator(), + 0x13, + 0x37, ]; + for &(order_pda, sell_token, bump) in &expected { + accounts.push(fake_account(order_pda)); + accounts.push(fake_account(sell_token)); + data.push(bump); + } + let parsed = BeginSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); - assert_eq!(parsed.finalize_ix_index, 0x1337); - assert_eq!(parsed.instructions_sysvar_account.address(), &first_address); + let orders: Vec<_> = parsed.orders.into_iter().collect(); + + assert_eq!(orders.len(), ORDER_COUNT); + for (order, (order_pda, sell_token, bump)) in orders.iter().zip(&expected) { + assert_eq!(order.order_pda.address(), order_pda); + assert_eq!(order.sell_token_account.address(), sell_token); + assert_eq!(order.bump, *bump); + } + } + + #[test] + fn begin_settle_input_rejects_account_count_mismatch() { + // One bump expects two order accounts; only one is supplied (together + // with the sysvar account). + let mut accounts = [ + fake_account_from_array([1u8; 32]), + fake_account_from_array([2u8; 32]), + ]; + let data = [ + SettlementInstruction::BeginSettle.discriminator(), + 0, + 0, + 0xab, // one bump + ]; + assert_eq!( + BeginSettleInput::parse(&data, &mut accounts).err(), + Some(SettlementError::AccountCountNotMatchingBumps.into()), + ); } #[test] @@ -280,9 +507,66 @@ mod tests { 0x37, // used 42, // extra ]; - let parsed = - FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); - assert_eq!(parsed.begin_ix_index, 0x1337); - assert_eq!(parsed.instructions_sysvar_account.address(), &first_address); + let FinalizeSettleInput { + begin_ix_index, + instructions_sysvar_account, + } = FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); + assert_eq!(begin_ix_index, 0x1337); + assert_eq!(instructions_sysvar_account.address(), &first_address); + } + + proptest! { + // The client's `begin_settle` builder derives each order's PDA from its + // intent 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(); + let ix = settlement_client::instructions::begin_settle( + &program_id, + finalize_ix_index, + &intents, + ); + + // Expected orders: each intent's canonical PDA paired with its sell + // token account and bump, sorted by PDA address (the builder's order). + let mut expected: Vec<(Pubkey, Pubkey, u8)> = intents + .iter() + .map(|intent| { + let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); + (order_pda, intent.sell_token_account, bump) + }) + .collect(); + expected.sort_by_key(|(order_pda, _, _)| *order_pda); + + + let mut accounts: Vec = ix + .accounts + .iter() + .map(|meta| fake_account_from_array(meta.pubkey.to_bytes())) + .collect(); + let parsed = BeginSettleInput::parse(&ix.data, &mut accounts) + .map_err(|e| TestCaseError::fail(format!("parse failed: {e:?}")))?; + + prop_assert_eq!(parsed.finalize_ix_index, finalize_ix_index); + prop_assert!(address_matches_pubkey( + parsed.instructions_sysvar_account.address(), + &INSTRUCTIONS_SYSVAR_ID, + )); + + let parsed_orders: Vec<_> = parsed.orders.into_iter().collect(); + prop_assert_eq!(parsed_orders.len(), expected.len()); + for (order, (order_pda, sell_token, bump)) in parsed_orders.iter().zip(&expected) { + prop_assert!(address_matches_pubkey(order.order_pda.address(), order_pda)); + prop_assert!(address_matches_pubkey( + order.sell_token_account.address(), + sell_token, + )); + prop_assert_eq!(order.bump, *bump); + } + } } } diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs new file mode 100644 index 0000000..1c005b4 --- /dev/null +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -0,0 +1,316 @@ +//! Integration tests for the settled-orders list carried by `BeginSettle`. +//! +//! Each settlement transaction here is the minimal `[BeginSettle, FinalizeSettle]` +//! pair (begin at index 0 pointing to finalize at index 1, and vice versa) so +//! that the begin/finalize pairing always validates and execution reaches the +//! order-list checks, which is what these tests exercise. + +use crate::common::{ + assert_instruction_error, assert_settlement_error, create_account, setup, signed_tx, token, +}; +use litesvm::LiteSVM; +use settlement_client::instructions::{begin_settle, create_order, finalize_settle}; +use settlement_client::settlement_interface::{ + data::{ + intent::{OrderIntent, OrderKind}, + order::{EncodedOrderAccount, OrderAccount}, + }, + instruction::settle::{begin_settle as begin_settle_from_pdas, INSTRUCTIONS_SYSVAR_ID}, + pda::order::find_order_pda, + AccountMeta, Instruction, SettlementError, SettlementInstruction, +}; +use solana_sdk::{ + instruction::InstructionError, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, +}; + +mod common; + +fn sample_intent(owner: Pubkey, sell_token_account: Pubkey, salt: u8) -> OrderIntent { + OrderIntent { + owner, + buy_token_account: Pubkey::new_from_array([0x22; 32]), + sell_token_account, + sell_amount: 1_000_000, + buy_amount: 2_000_000, + valid_to: 0xdead_beef, + kind: OrderKind::Sell, + partially_fillable: true, + // `salt` is folded into `app_data` so callers can mint several orders that + // hash to different UIDs (and therefore different order PDAs). + app_data: [salt; 32], + } +} + +/// Create `intent`'s order PDA on-chain, signed and paid for by `owner`. +fn create_order_pda(svm: &mut LiteSVM, program_id: &Pubkey, owner: &Keypair, intent: &OrderIntent) { + let ix = create_order(program_id, &owner.pubkey(), &owner.pubkey(), intent); + let tx = signed_tx(svm, owner, owner, ix); + svm.send_transaction(tx) + .expect("create_order should succeed"); +} + +/// Mint a valid order on-chain, distinct per `salt`, selling from a fresh token +/// account owned by `payer`, and return its intent. +fn settleable_order( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + mint: &Pubkey, + salt: u8, +) -> OrderIntent { + let sell_token = token::create_token_account(svm, payer, mint, &payer.pubkey()); + let intent = sample_intent(payer.pubkey(), sell_token, salt); + create_order_pda(svm, program_id, payer, &intent); + intent +} + +/// Send `[begin, finalize_settle(..)]` signed by `payer`, where `begin` is a +/// pre-built `BeginSettle` instruction. +fn send_settlement( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + begin: Instruction, +) -> Result<(), TransactionError> { + let finalize = finalize_settle(program_id, 0); + let tx = Transaction::new_signed_with_payer( + &[begin, finalize], + Some(&payer.pubkey()), + &[payer], + svm.latest_blockhash(), + ); + svm.send_transaction(tx).map(|_| ()).map_err(|e| e.err) +} + +#[test] +fn settles_a_single_order() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + + let intent = settleable_order(&mut svm, &program_id, &payer, &mint, 0); + send_settlement( + &mut svm, + &program_id, + &payer, + begin_settle(&program_id, 1, &[intent]), + ) + .expect("settlement should succeed"); +} + +#[test] +fn settles_multiple_orders() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + + let mut intents = Vec::new(); + for salt in 0..3u8 { + intents.push(settleable_order(&mut svm, &program_id, &payer, &mint, salt)); + } + + send_settlement( + &mut svm, + &program_id, + &payer, + begin_settle(&program_id, 1, &intents), + ) + .expect("multi-order settlement should succeed"); +} + +#[test] +fn rejects_wrong_bump() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + + let intent = settleable_order(&mut svm, &program_id, &payer, &mint, 0); + let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); + assert_settlement_error( + send_settlement( + &mut svm, + &program_id, + &payer, + begin_settle_from_pdas( + &program_id, + 1, + &[order_pda], + &[intent.sell_token_account], + &[bump ^ 0x01], + ), + ), + SettlementError::OrderNotCanonical, + ); +} + +#[test] +fn rejects_fabricated_program_owned_account() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + + let sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); + let body: [u8; EncodedOrderAccount::SIZE] = EncodedOrderAccount::from(OrderAccount { + cancelled: false, + amount_withdrawn: 0, + amount_received: 0, + created_by: payer.pubkey(), + intent: sample_intent(payer.pubkey(), sell_token, 0), + }) + .into(); + // A program-owned account holding a valid order body, but sitting at an + // address that isn't the canonical order PDA. + let fake_order = create_account(&mut svm, &program_id, &body); + + assert_settlement_error( + send_settlement( + &mut svm, + &program_id, + &payer, + begin_settle_from_pdas(&program_id, 1, &[fake_order], &[sell_token], &[255]), + ), + SettlementError::OrderNotCanonical, + ); +} + +#[test] +fn rejects_non_order_account_in_order_slot() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + + let sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); + + // Put a token account in the order slot. Its 165-byte data can't decode as a + // 199-byte order body, so it's rejected before the canonical-address check. + assert_instruction_error( + send_settlement( + &mut svm, + &program_id, + &payer, + begin_settle_from_pdas(&program_id, 1, &[sell_token], &[sell_token], &[255]), + ), + InstructionError::InvalidAccountData, + ); +} + +#[test] +fn rejects_sell_token_account_mismatch() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + + // Supply a different token account than the one the order's intent names. + let intent = settleable_order(&mut svm, &program_id, &payer, &mint, 0); + let (order_pda, bump) = find_order_pda(&program_id, &intent.uid()); + let wrong_sell_token = token::create_token_account(&mut svm, &payer, &mint, &payer.pubkey()); + assert_settlement_error( + send_settlement( + &mut svm, + &program_id, + &payer, + begin_settle_from_pdas(&program_id, 1, &[order_pda], &[wrong_sell_token], &[bump]), + ), + SettlementError::SellTokenAccountMismatch, + ); +} + +#[test] +fn rejects_sell_token_owner_mismatch() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + + let other_owner = Pubkey::new_unique(); + let sell_token = token::create_token_account(&mut svm, &payer, &mint, &other_owner); + let intent = sample_intent(payer.pubkey(), sell_token, 1); + create_order_pda(&mut svm, &program_id, &payer, &intent); + + assert_settlement_error( + send_settlement( + &mut svm, + &program_id, + &payer, + begin_settle(&program_id, 1, &[intent]), + ), + SettlementError::SellTokenOwnerMismatch, + ); +} + +#[test] +fn rejects_non_token_sell_account() { + let (mut svm, program_id, payer) = setup(); + + let non_token = Pubkey::new_unique(); + let intent = sample_intent(payer.pubkey(), non_token, 1); + create_order_pda(&mut svm, &program_id, &payer, &intent); + + assert_settlement_error( + send_settlement( + &mut svm, + &program_id, + &payer, + begin_settle(&program_id, 1, &[intent]), + ), + SettlementError::SellTokenAccountInvalid, + ); +} + +#[test] +fn rejects_duplicate_orders() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + + let intent = settleable_order(&mut svm, &program_id, &payer, &mint, 0); + assert_settlement_error( + send_settlement( + &mut svm, + &program_id, + &payer, + begin_settle(&program_id, 1, &[intent.clone(), intent]), + ), + SettlementError::OrdersNotStrictlyIncreasing, + ); +} + +#[test] +fn rejects_orders_in_wrong_address_order() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + + let first = settleable_order(&mut svm, &program_id, &payer, &mint, 0); + let second = settleable_order(&mut svm, &program_id, &payer, &mint, 1); + + let (first_pda, first_bump) = find_order_pda(&program_id, &first.uid()); + let (second_pda, second_bump) = find_order_pda(&program_id, &second.uid()); + + // Lay out the two distinct orders strictly decreasing by PDA address, which + // the program rejects. The builder would sort them, so build by hand: + // data is `[discriminator, finalize_ix_index (BE), bump-per-order...]` and + // accounts are `[instructions_sysvar, (order_pda, sell_token_account)...]`. + let mut orders = [ + (first_pda, first.sell_token_account, first_bump), + (second_pda, second.sell_token_account, second_bump), + ]; + orders.sort_by_key(|&(pda, _, _)| std::cmp::Reverse(pda)); + + let mut data = vec![SettlementInstruction::BeginSettle.discriminator()]; + data.extend_from_slice(&1u16.to_be_bytes()); + data.extend(orders.iter().map(|&(_, _, bump)| bump)); + + let mut accounts = vec![AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false)]; + for (order_pda, sell_token_account, _) in orders { + accounts.push(AccountMeta::new_readonly(order_pda, false)); + accounts.push(AccountMeta::new_readonly(sell_token_account, false)); + } + + assert_settlement_error( + send_settlement( + &mut svm, + &program_id, + &payer, + Instruction { + program_id, + accounts, + data, + }, + ), + SettlementError::OrdersNotStrictlyIncreasing, + ); +} diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index ec7960a..d1440c3 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -16,7 +16,7 @@ use solana_sdk::{ instruction::InstructionError, pubkey::Pubkey, signature::{Keypair, Signer}, - transaction::Transaction, + transaction::{Transaction, TransactionError}, }; pub const PROGRAM_SO: &str = concat!( @@ -50,6 +50,34 @@ pub fn to_instruction_error(e: SettlementError) -> InstructionError { InstructionError::Custom(e.into()) } +pub fn assert_instruction_error(result: Result<(), TransactionError>, expected: InstructionError) { + assert_eq!(result, Err(TransactionError::InstructionError(0, expected))); +} +pub fn assert_settlement_error(result: Result<(), TransactionError>, expected: SettlementError) { + assert_instruction_error(result, to_instruction_error(expected)); +} + +/// Place a fresh, rent-exempt account holding `data` and owned by `owner` at a +/// new address, and return it. Lets a test populate an arbitrary account (e.g. +/// program-owned, with a crafted body or a deliberately wrong size or owner) +/// directly, bypassing the runtime. +pub fn create_account(svm: &mut LiteSVM, owner: &Pubkey, data: &[u8]) -> Pubkey { + let address = Pubkey::new_unique(); + let lamports = svm.minimum_balance_for_rent_exemption(data.len()); + svm.set_account( + address, + Account { + lamports, + data: data.to_vec(), + owner: *owner, + executable: false, + rent_epoch: 0, + }, + ) + .expect("set_account should succeed"); + address +} + /// Read the lamports balance of an account, or 0 if the account doesn't /// exist. pub fn lamports(svm: &LiteSVM, address: &Pubkey) -> u64 { diff --git a/programs/settlement/tests/common/token.rs b/programs/settlement/tests/common/token.rs index 4fd524b..f898acd 100644 --- a/programs/settlement/tests/common/token.rs +++ b/programs/settlement/tests/common/token.rs @@ -1,7 +1,7 @@ //! SPL Token helpers for the settlement integration tests. use litesvm::LiteSVM; -use litesvm_token::{CreateAssociatedTokenAccount, CreateMint, MintTo, Transfer}; +use litesvm_token::{CreateAccount, CreateAssociatedTokenAccount, CreateMint, MintTo, Transfer}; use solana_sdk::{pubkey::Pubkey, signature::Keypair}; /// Create a fresh mint owned by `payer` and return its address. @@ -11,12 +11,29 @@ pub fn create_mint(svm: &mut LiteSVM, payer: &Keypair) -> Pubkey { .expect("mint creation should succeed") } -/// Create `owner`'s associated token account for `mint` and return its address. +/// Create an initialized SPL token account for `mint` whose SPL owner is +/// `owner`, funded by `payer`, and return its address. Each call produces a +/// fresh account, so the same `owner` can hold several accounts for one `mint`. pub fn create_token_account( svm: &mut LiteSVM, payer: &Keypair, + mint: &Pubkey, owner: &Pubkey, +) -> Pubkey { + CreateAccount::new(svm, payer, mint) + .owner(owner) + .send() + .expect("token account creation should succeed") +} + +/// Create `owner`'s associated token account for `mint`, funded by `payer`, and +/// return its address. The address is the canonical ATA, so `transfer` can +/// source from it without being told where the tokens live. +pub fn create_associated_token_account( + svm: &mut LiteSVM, + payer: &Keypair, mint: &Pubkey, + owner: &Pubkey, ) -> Pubkey { CreateAssociatedTokenAccount::new(svm, payer, mint) .owner(owner) diff --git a/programs/settlement/tests/create_buffer.rs b/programs/settlement/tests/create_buffer.rs index 3614384..275ec24 100644 --- a/programs/settlement/tests/create_buffer.rs +++ b/programs/settlement/tests/create_buffer.rs @@ -104,7 +104,7 @@ fn buffer_can_receive_tokens() { svm.airdrop(&sender.pubkey(), 1_000_000_000) .expect("airdrop to sender should succeed"); let sender_account = - common::token::create_token_account(&mut svm, &sender, &sender.pubkey(), &mint); + common::token::create_associated_token_account(&mut svm, &sender, &mint, &sender.pubkey()); let amount = 1_000; common::token::mint_to(&mut svm, &payer, &mint, &sender_account, amount); diff --git a/programs/settlement/tests/matching_begin_finalize.rs b/programs/settlement/tests/matching_begin_finalize.rs index 2e10aae..3f00ac5 100644 --- a/programs/settlement/tests/matching_begin_finalize.rs +++ b/programs/settlement/tests/matching_begin_finalize.rs @@ -35,7 +35,7 @@ fn run_sequence( let instructions: Vec = sequence .iter() .map(|spec| match spec { - AbstractInstruction::Init(idx) => begin_settle(program_id, *idx), + AbstractInstruction::Init(idx) => begin_settle(program_id, *idx, &[]), AbstractInstruction::Fin(idx) => finalize_settle(program_id, *idx), // 0-lamport self-transfer: a side-effect-free instruction that // (unlike Compute Budget) Solana allows to appear multiple times @@ -129,7 +129,7 @@ fn invalid_sequences() { fn rejects_non_instructions_sysvar_account_at_position_zero() { let (mut svm, program_id, payer) = common::setup(); - let mut begin = begin_settle(&program_id, 1); + let mut begin = begin_settle(&program_id, 1, &[]); begin.accounts[0] = AccountMeta::new_readonly(payer.pubkey(), false); let finalize = finalize_settle(&program_id, 0); @@ -156,7 +156,7 @@ fn rejects_non_instructions_sysvar_account_at_position_zero() { fn rejects_counterpart_instruction_in_different_program() { let (mut svm, program_id, payer) = common::setup(); - let begin = begin_settle(&program_id, 1); + let begin = begin_settle(&program_id, 1, &[]); // We build a transaction that looks like a valid finalize_settle but // calling a different program. It doesn't really matter what program // we use here because execution isn't expected to reach this point. diff --git a/programs/settlement/tests/program_deployment.rs b/programs/settlement/tests/program_deployment.rs index 06082ca..03bb735 100644 --- a/programs/settlement/tests/program_deployment.rs +++ b/programs/settlement/tests/program_deployment.rs @@ -29,7 +29,7 @@ fn program_can_be_invoked() { // Indices encode the BeginSettle/FinalizeSettle pair // `Begin` at 0 → finalize_ix=1, `Finalize` at 1 → begin_ix=0. &[ - begin_settle(&program_id, 1), + begin_settle(&program_id, 1, &[]), finalize_settle(&program_id, 0), ], Some(&payer.pubkey()),