diff --git a/DESIGN.md b/DESIGN.md index ec39931..7363c3d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -256,9 +256,8 @@ Differences with Ethereum: A settlement transaction is split into multiple instructions. All settlement operations occur between a `BeginSettle` and a `FinalizeSettle` instruction with the exception of arbitrary interactions, which can take place at any point of a transaction. Except for that, the order of instructions in the transaction is arbitrary. - `BeginSettle`: Snapshots each order's receiver token account, spender token account, and withdrawal balances. Pulls funds from each order’s sell token account to the solver-specified destination accounts, using the settlement state PDA’s token delegation. Carries an explicit `finalize_ix_index` pointing to its paired `FinalizeSettle`. -- `Push`: It references a unique SPL transfer token instruction between `BeginSettle` and `FinalizeSettle` that sends the proceeds of an order to its buy token account. - (arbitrary interactions): Any instruction from the solver. This could be a token transfer, an AMM swap, or anything else. -- `FinalizeSettle`: Reads balances again, computes deltas against the snapshots, validates clearing/limit prices, updates `amount_received` and order status, revokes solver approvals. Carries an explicit `begin_ix_index` pointing to its paired `BeginSettle`. +- `FinalizeSettle`: Pushes the proceeds of each order from the settlement’s buffer accounts to the order’s buy token account, using the settlement state PDA’s authority over the buffers. Reads balances again, computes deltas against the snapshots, validates clearing/limit prices, updates `amount_received` and order status, revokes solver approvals. Carries an explicit `begin_ix_index` pointing to its paired `BeginSettle`. Additionally, a settlement transaction will include the batch number as part of the instruction bytes of `BeginSettle`. diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 0a91a34..84403d5 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -13,7 +13,7 @@ use settlement_interface::{ // Reexport the instruction builders that don't change from the interface. // We want the client to provide all instruction builders. -pub use settlement_interface::instruction::settle::{FinalizeSettle, Pull}; +pub use settlement_interface::instruction::settle::Pull; /// 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 @@ -57,6 +57,66 @@ impl From> for 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 FinalizedIntent<'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 [FinalizedIntent<'a>], +} + +impl From> for Instruction { + fn from(builder: FinalizeSettle<'_>) -> Self { + // Sort the orders by their canonical order PDA, the key `BeginSettle` + // lays its settled orders out by, so the two instruction lists align. + // For BeginSettle, sorting can take place in the interface. But the + // order PDAs don't appear in the actual FinalizeSettle instruction, so + // the sorting can only happen here. + let mut order: Vec = (0..builder.orders.len()).collect(); + order.sort_by_key(|&i| { + find_order_pda(&builder.program_id, &builder.orders[i].intent.uid()).0 + }); + + let mut source_buffers: Vec = Vec::with_capacity(builder.orders.len()); + let mut destinations = Vec::with_capacity(builder.orders.len()); + let mut bumps = Vec::with_capacity(builder.orders.len()); + let mut amounts = Vec::with_capacity(builder.orders.len()); + for &i in &order { + let (buffer_pda, bump) = find_buffer_pda(&builder.program_id, &builder.orders[i].mint); + source_buffers.push(buffer_pda); + destinations.push(builder.orders[i].intent.buy_token_account); + bumps.push(bump); + amounts.push(builder.orders[i].amount); + } + let (state_pda, _bump) = find_state_pda(&builder.program_id); + settlement_interface::instruction::settle::FinalizeSettle { + program_id: builder.program_id, + state_pda, + begin_ix_index: builder.begin_ix_index, + source_buffers: &source_buffers, + destinations: &destinations, + bumps: &bumps, + amounts: &amounts, + } + .into() + } +} + pub struct CreateOrder<'a> { pub program_id: Pubkey, pub owner: Pubkey, @@ -127,14 +187,16 @@ mod tests { data::intent::fixtures::arb_order_intent, instruction::{ fixtures::fake_account_from_array, - settle::{BeginSettleInput, INSTRUCTIONS_SYSVAR_ID}, + settle::{ + BeginSettleInput, FinalizeSettleInput, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, + }, InstructionInputParsing, }, pda::order::find_order_pda, }; proptest! { - // `begin_settle` derives each order's PDA from its intent and forwards to + // `BeginSettle` derives each order's PDA from its intent and forwards to // the interface builder so that the on-chain parser recovers exactly // those orders. #[test] @@ -188,5 +250,88 @@ mod tests { prop_assert_eq!(order.bump, *bump); } } + + // `FinalizeSettle` derives each order's source buffer from its mint and + // destination from the intent, sorting by canonical order PDA like + // `BeginSettle` so the on-chain parser recovers exactly those pushes in + // that order. + #[test] + fn finalize_settle_derives_buffers_from_mints( + begin_ix_index in any::(), + cases in prop::collection::vec( + (arb_order_intent(), any::<[u8; 32]>(), any::()), + 1..=5, + ), + ) { + let program_id = Pubkey::new_unique(); + let orders: Vec = cases + .iter() + .map(|(intent, mint, amount)| FinalizedIntent { + intent, + mint: Pubkey::new_from_array(*mint), + amount: *amount, + }) + .collect(); + let ix = Instruction::from(FinalizeSettle { + program_id, + begin_ix_index, + orders: &orders, + }); + + // 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). + struct ExpectedPush { + order_pda: Pubkey, + buffer: Pubkey, + bump: u8, + destination: Pubkey, + amount: u64, + } + let mut expected: Vec = orders + .iter() + .map(|order| { + let (order_pda, _bump) = find_order_pda(&program_id, &order.intent.uid()); + let (buffer, bump) = find_buffer_pda(&program_id, &order.mint); + ExpectedPush { + order_pda, + buffer, + bump, + destination: order.intent.buy_token_account, + amount: order.amount, + } + }) + .collect(); + expected.sort_by_key(|push| push.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, expected) in parsed_pushes.iter().zip(&expected) { + prop_assert_eq!(push.source_buffer.address(), &expected.buffer); + prop_assert_eq!(push.destination.address(), &expected.destination); + prop_assert_eq!(push.bump, expected.bump); + prop_assert_eq!(u64::from_be_bytes(*push.amount), expected.amount); + } + } } } diff --git a/interface/src/instruction/settle/finalize.rs b/interface/src/instruction/settle/finalize.rs index e6dc737..8684333 100644 --- a/interface/src/instruction/settle/finalize.rs +++ b/interface/src/instruction/settle/finalize.rs @@ -8,60 +8,207 @@ use solana_program_error::ProgramError; use solana_pubkey::Pubkey; use crate::instruction::InstructionInputParsing; -use crate::SettlementInstruction; +use crate::{SettlementError, SettlementInstruction}; -use super::{recover_counterpart, INSTRUCTIONS_SYSVAR_ID}; +use super::{recover_counterpart, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID}; -/// Builder for a `FinalizeSettle` instruction. +/// Builder for a `FinalizeSettle` instruction pushing the funds described by the +/// parallel lists: +/// - `source_buffers[i]` is the buffer token account the funds come from, +/// - `destinations[i]` is the account the funds go to (an order's buy token +/// account), +/// - `bumps[i]` is the canonical bump of `source_buffers[i]`, so the program +/// re-derives the buffer PDA with one hash instead of searching, +/// - `amounts[i]` is the amount to push. /// -/// `begin_ix_index` is the index of the paired `BeginSettle` instruction in the -/// same transaction. +/// This instruction comes in pair with a `BeginSettle` instruction. The slices +/// are assumed to be built in parallel with the order information specified in +/// `BeginSettle`. Notably, (1) the source buffer is the one corresponding to +/// the order's buy token, and (2) the destinations must be the buy token +/// accounts specified in the corresponding order. +/// The slices are assumed to have the same length but this is not enforced by +/// the builder. /// -/// Wire format: `[discriminator=1, begin_ix_index: u16 BE]`, 3 bytes. -/// Required accounts: `[instructions_sysvar (R)]`. -pub struct FinalizeSettle { +/// Wire format (with `n` total pushes): +/// `[discriminator=1][begin_ix_index: u16 BE][bump: u8 ×n][amount: u64 BE ×n]`. +/// 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 From for Instruction { - fn from(builder: FinalizeSettle) -> Self { +impl From> for Instruction { + fn from(builder: FinalizeSettle<'_>) -> Self { + let FinalizeSettle { + program_id, + state_pda, + begin_ix_index, + source_buffers, + destinations, + bumps, + amounts, + } = builder; + + 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: builder.program_id, - accounts: vec![AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false)], - data: [ - &[SettlementInstruction::FinalizeSettle.discriminator()], - &builder.begin_ix_index.to_be_bytes()[..], - ] - .concat(), + program_id, + accounts, + data, } } } +/// A single fund push parsed from `FinalizeSettle`: move `amount` (big-endian +/// `u64`) from `source_buffer` to `destination`. `bump` is `source_buffer`'s +/// claimed canonical buffer bump, which the program re-derives against. +pub struct Push<'a> { + pub source_buffer: &'a AccountView, + pub destination: &'a AccountView, + pub bump: u8, + pub amount: &'a [u8; 8], +} + +/// Struct storing accounts, bumps, and amounts from parsing the input of +/// `FinalizeSettle`, laid out as a flat list of `[source_buffer, destination]` +/// account pairs parallel to `bumps` and `amounts`. The parsing step that created +/// this struct guarantees `push_accounts.len() == 2 * amounts.len()` and +/// `bumps.len() == amounts.len()`, so the offsets below never run short. +pub struct Pushes<'a> { + /// `[source_buffer, destination]` per push, flattened. + push_accounts: &'a [AccountView], + bumps: &'a [u8], + /// One push amount (big-endian `u64`) per push, parallel to `bumps`. + amounts: &'a [[u8; 8]], +} + +impl<'a> Pushes<'a> { + /// Returns an iterator yielding one [`Push`] per step. + #[allow( + clippy::arithmetic_side_effects, + reason = "offsets are bounded by tx limits" + )] + pub fn iter(&self) -> impl Iterator> + '_ { + let push_count = self.bumps.len(); + let mut i = 0usize; + let mut account_offset = 0usize; + std::iter::from_fn(move || { + if i >= push_count { + return None; + } + let bump = self.bumps[i]; + let amount = &self.amounts[i]; + i += 1; + + let source_buffer = &self.push_accounts[account_offset]; + let destination = &self.push_accounts[account_offset + 1]; + account_offset += 2; + + Some(Push { + source_buffer, + destination, + bump, + amount, + }) + }) + } +} + /// Parsed inputs (instruction-data fields + relevant accounts) of a /// `FinalizeSettle` instruction. /// /// Strictly the raw extracted form. Fields are read from `instruction_data` and /// `accounts` but **not validated** against runtime context except confirming -/// that the discriminator matches the desired input. +/// that the discriminator matches the desired input and that the number of +/// accounts and amounts is consistent. pub struct FinalizeSettleInput<'a> { pub begin_ix_index: u16, pub instructions_sysvar_account: &'a AccountView, + pub state_pda_account: &'a AccountView, + pub token_program_account: &'a AccountView, + pub pushes: Pushes<'a>, } +/// This implementation defines how instruction bytes and accounts are laid out +/// in the transaction. It's the source of truth for deciding where the data +/// is stored. impl<'a> InstructionInputParsing<'a> for FinalizeSettleInput<'a> { const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::FinalizeSettle; fn parse_body( - instruction_data: &[u8], + instruction_data: &'a [u8], accounts: &'a mut [AccountView], ) -> Result { - let (begin_ix_index, _) = recover_counterpart(instruction_data)?; - let instructions_sysvar_account = - accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + let (begin_ix_index, body) = recover_counterpart(instruction_data)?; + + let [instructions_sysvar_account, state_pda_account, token_program_account, push_accounts @ ..] = + accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + // The body after the begin index is, per push, a bump byte (all `n` + // first) then a big-endian `u64` amount: `9 * n` bytes. Unlike + // `BeginSettle`, there's no explicit count byte, so `n` 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(push_count); + 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 * n`. + let expected_accounts = push_count + .checked_mul(2) + .ok_or(ProgramError::InvalidInstructionData)?; + if push_accounts.len() != expected_accounts { + return Err(SettlementError::AccountCountNotMatchingPushCount.into()); + } + Ok(Self { begin_ix_index, instructions_sysvar_account, + state_pda_account, + token_program_account, + pushes: Pushes { + push_accounts, + bumps, + amounts, + }, }) } } @@ -69,39 +216,121 @@ impl<'a> InstructionInputParsing<'a> for FinalizeSettleInput<'a> { #[cfg(test)] mod tests { use super::*; - use crate::instruction::fixtures::fake_account; + use crate::instruction::fixtures::{ + fake_account, fake_account_from_array, fake_sequential_accounts, + }; use crate::instruction::settle::tests::ix_data; use hex_literal::hex; use solana_address::Address; + /// The fixed accounts every `FinalizeSettle` carries before its push + /// accounts: the instructions sysvar, the settlement state PDA, and the + /// token program. + const FIXED_ACCOUNTS: usize = 3; + #[test] - fn expected_encoding_finalize_settle() { + fn expected_encoding_finalize_settle_no_pushes() { let program_id = Pubkey::new_unique(); - let Instruction { data, accounts, .. } = FinalizeSettle { + 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: &[], } .into(); + assert_eq!(ix_program_id, program_id); assert_eq!( data, - [ - &[SettlementInstruction::FinalizeSettle.discriminator()][..], - &hex!("1337")[..], // counterpart index - ] - .concat(), + ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + hex!("1337"), // counterpart index + ], ); - - // Only the instructions sysvar is referenced. - assert_eq!(accounts.len(), 1); + // 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!(!accounts[0].is_writable); - assert!(!accounts[0].is_signer); + 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_input_parses_valid_input() { - let address = Address::new_from_array([0x42u8; 32]); - let mut accounts = [fake_account(address)]; + 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 = Instruction::from(FinalizeSettle { + program_id, + state_pda, + begin_ix_index: 0x1337, + source_buffers: &[source_a, source_b], + destinations: &[dest_a, dest_b], + bumps: &[0xa1, 0xb1], + amounts: &[0x01020304, 0x05060708], + }); + + assert_eq!( + ix.data, + ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + hex!("1337"), // counterpart index + [0xa1, 0xb1], // bumps + // amounts + hex!("0000000001020304"), + hex!("0000000005060708"), + ], + ); + + 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 @@ -109,9 +338,124 @@ mod tests { let FinalizeSettleInput { begin_ix_index, instructions_sysvar_account, + state_pda_account, + token_program_account, + pushes, } = FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); assert_eq!(begin_ix_index, 0x1337); - assert_eq!(instructions_sysvar_account.address(), &address); + assert_eq!(instructions_sysvar_account.address(), &sysvar); + assert_eq!(state_pda_account.address(), &state); + assert_eq!(token_program_account.address(), &token_program); + assert_eq!(pushes.iter().count(), 0); + } + + #[test] + fn finalize_settle_input_parses_pushes() { + let sysvar = Address::new_from_array([1u8; 32]); + let state = Address::new_from_array([0xa1u8; 32]); + let token_program = Address::new_from_array([0xa2u8; 32]); + // The same source buffer funds both pushes: parsing makes no uniqueness + // assumption about source buffers. + let source = Address::new_from_array([3u8; 32]); + let dest0 = Address::new_from_array([4u8; 32]); + let dest1 = Address::new_from_array([5u8; 32]); + let mut accounts = [ + fake_account(sysvar), + fake_account(state), + fake_account(token_program), + fake_account(source), + fake_account(dest0), + fake_account(source), + fake_account(dest1), + ]; + let data = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [0x13, 0x37], // begin index + [0xfe, 0xfd], // bumps + 0x1122u64.to_be_bytes(), + 0x3344u64.to_be_bytes(), + ]; + + let FinalizeSettleInput { pushes, .. } = + FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); + + let parsed: Vec<(&Address, &Address, u8, u64)> = pushes + .iter() + .map(|push| { + ( + push.source_buffer.address(), + push.destination.address(), + push.bump, + u64::from_be_bytes(*push.amount), + ) + }) + .collect(); + assert_eq!( + parsed, + vec![ + (&source, &dest0, 0xfe, 0x1122), + (&source, &dest1, 0xfd, 0x3344), + ], + ); + } + + #[test] + fn finalize_settle_input_parses_many_pushes() { + const PUSH_COUNT: usize = 16; + + struct ExpectedPush { + source: Address, + dest: Address, + bump: u8, + amount: u64, + } + let mut expected: Vec = 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 = u64::from_be_bytes([(i + 3 * PUSH_COUNT) as u8; 8]); + expected.push(ExpectedPush { + 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 push in &expected { + accounts.push(fake_account(push.source)); + accounts.push(fake_account(push.dest)); + bump_bytes.push(push.bump); + amount_bytes.extend_from_slice(&push.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, expected) in pushes.iter().zip(&expected) { + assert_eq!(push.source_buffer.address(), &expected.source); + assert_eq!(push.destination.address(), &expected.dest); + assert_eq!(push.bump, expected.bump); + assert_eq!(u64::from_be_bytes(*push.amount), expected.amount); + } } #[test] @@ -141,20 +485,45 @@ mod tests { } #[test] - fn finalize_settle_input_ignores_extra_parameters() { - let first_address = Address::new_from_array([1u8; 32]); - let second_address = Address::new_from_array([2u8; 32]); - let mut accounts = [fake_account(first_address), fake_account(second_address)]; + fn finalize_settle_input_rejects_account_count_mismatch() { + // One push (a bump byte then a `u64` amount) needs exactly two push + // accounts: its source buffer and destination. + let data: Vec = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [13, 37], // begin index + [0xff], // the push's bump + 31337u64.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()], - [0x13, 0x37], // begin index - [42], // extra + [13, 37], // begin index + [0xff], // the push's bump + [0x11, 0x22, 0x33, 0x44], // a partial push (4 bytes) ]; - let FinalizeSettleInput { - begin_ix_index, - instructions_sysvar_account, - } = FinalizeSettleInput::parse(&data, &mut accounts).expect("parse should succeed"); - assert_eq!(begin_ix_index, 0x1337); - assert_eq!(instructions_sysvar_account.address(), &first_address); + assert_eq!( + FinalizeSettleInput::parse(&data, &mut accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); } } diff --git a/interface/src/instruction/settle/mod.rs b/interface/src/instruction/settle/mod.rs index b2c553f..358035a 100644 --- a/interface/src/instruction/settle/mod.rs +++ b/interface/src/instruction/settle/mod.rs @@ -10,7 +10,7 @@ mod begin; mod finalize; pub use begin::{BeginSettle, BeginSettleInput, Pull, SettledOrder}; -pub use finalize::{FinalizeSettle, FinalizeSettleInput}; +pub use finalize::{FinalizeSettle, FinalizeSettleInput, Push, Pushes}; /// Reads the first two bytes of a byte slice (instruction data) and /// interprets them as a big-endian u16, returning it together with the diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 732831d..0c7eb86 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -112,6 +112,10 @@ pub enum SettlementError { /// `BeginSettle`'s state account isn't the canonical settlement state PDA, /// which must sign the pulls as the user's token delegate. StateAccountMismatch = 18, + /// `FinalizeSettle`'s push-account count doesn't match its instruction + /// data: each push contributes a source buffer and a destination account, + /// so the count must be twice the number of push amounts. + AccountCountNotMatchingPushCount = 19, } impl From for u32 { diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index 4191a78..650af0b 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -47,6 +47,7 @@ fn send_settlement( let finalize = FinalizeSettle { program_id: *program_id, begin_ix_index: 0, + orders: &[], }; let tx = Transaction::new_signed_with_payer( &[begin.into(), finalize.into()], diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs new file mode 100644 index 0000000..dc981a0 --- /dev/null +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -0,0 +1,251 @@ +//! Integration tests for the fund-push list carried by `FinalizeSettle`. + +use crate::common::{order::OrderBuilder, setup, to_instruction_error, token}; +use litesvm::LiteSVM; +use settlement_client::instructions::{ + BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, +}; +use settlement_client::settlement_interface::{Instruction, SettlementError}; +use solana_sdk::{ + instruction::{AccountMeta, InstructionError}, + program_error::ProgramError, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, +}; + +mod common; + +/// The following [`send_settlement`] function simulates a settlement and for +/// that hardcodes some instruction indices that will be referenced in the +/// tests. We make those indices more explicit with a constant. +const BEGIN_INDEX: u8 = 0; +const FINALIZE_INDEX: u8 = 1; + +/// Send `[begin, finalize]` signed by `payer`, where `finalize` is a pre-built +/// `FinalizeSettle` at [`FINALIZE_INDEX`] and `begin` settles `orders` (with no +/// pulls) at [`BEGIN_INDEX`], the same orders the finalize is expected to push +/// to. +fn send_settlement( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + orders: &[FinalizedIntent], + finalize: impl Into, +) -> Result<(), TransactionError> { + let begin_orders: Vec = orders + .iter() + .map(|order| InitializedIntent { + intent: order.intent, + pulls: &[], + }) + .collect(); + let begin = Instruction::from(BeginSettle { + program_id: *program_id, + finalize_ix_index: FINALIZE_INDEX.into(), + orders: &begin_orders, + }); + // Assemble the transaction, confirming each instruction lands at its named + // index to make sure the constants are meaningfully defined. + let mut instructions = Vec::new(); + assert_eq!(instructions.len(), usize::from(BEGIN_INDEX)); + instructions.push(begin); + assert_eq!(instructions.len(), usize::from(FINALIZE_INDEX)); + instructions.push(finalize.into()); + let tx = Transaction::new_signed_with_payer( + &instructions, + 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: &[FinalizedIntent], +) -> Result<(), TransactionError> { + let finalize = FinalizeSettle { + program_id: *program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders, + }; + send_settlement(svm, program_id, payer, orders, finalize) +} + +#[test] +fn finalizes_with_no_pushes() { + let (mut svm, program_id, payer) = setup(); + + finalize(&mut svm, &program_id, &payer, &[]).expect("a finalize with no pushes should succeed"); +} + +#[test] +fn finalizes_with_single_push() { + let (mut svm, program_id, payer) = setup(); + let sell_mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &sell_mint).build(); + + finalize( + &mut svm, + &program_id, + &payer, + &[FinalizedIntent { + intent: &intent, + mint: Pubkey::new_unique(), + amount: 1_000, + }], + ) + .expect("a single push should parse and be accepted"); +} + +#[test] +fn finalizes_with_several_pushes_same_mint() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) + .salt(1) + .build(); + let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint) + .salt(2) + .build(); + + finalize( + &mut svm, + &program_id, + &payer, + &[ + FinalizedIntent { + intent: &intent0, + mint, + amount: 1_000, + }, + FinalizedIntent { + intent: &intent1, + mint, + amount: 2_000, + }, + ], + ) + .expect("several pushes should parse and be accepted"); +} + +#[test] +fn finalizes_with_several_pushes_different_mint() { + let (mut svm, program_id, payer) = setup(); + let mint_1 = token::create_mint(&mut svm, &payer); + let mint_2 = token::create_mint(&mut svm, &payer); + let intent0 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint_1).build(); + let intent1 = OrderBuilder::new(&mut svm, &program_id, &payer, &mint_2).build(); + + finalize( + &mut svm, + &program_id, + &payer, + &[ + FinalizedIntent { + intent: &intent0, + mint: mint_1, + amount: 1_000, + }, + FinalizedIntent { + intent: &intent1, + mint: mint_2, + amount: 2_000, + }, + ], + ) + .expect("several pushes should parse and be accepted"); +} + +#[test] +fn rejects_push_account_count_mismatch() { + let (mut svm, program_id, payer) = setup(); + let sell_mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &sell_mint).build(); + let orders = [FinalizedIntent { + intent: &intent, + mint: Pubkey::new_unique(), + amount: 1_000, + }]; + + // A well-formed single-push finalize... + let mut finalize = Instruction::from(FinalizeSettle { + program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders: &orders, + }); + // ...with one extra account appended. + finalize + .accounts + .push(AccountMeta::new_readonly(Pubkey::new_unique(), false)); + + assert_eq!( + send_settlement(&mut svm, &program_id, &payer, &orders, finalize), + Err(TransactionError::InstructionError( + FINALIZE_INDEX, + to_instruction_error(SettlementError::AccountCountNotMatchingPushCount), + )), + ); +} + +#[test] +fn rejects_too_few_accounts() { + let (mut svm, program_id, payer) = setup(); + + // A well-formed single-push finalize... + let mut finalize = Instruction::from(FinalizeSettle { + program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders: &[], + }); + // ...with one account popped. + finalize.accounts.pop(); + + let result = send_settlement(&mut svm, &program_id, &payer, &[], finalize); + let Err(TransactionError::InstructionError(index, ix_error)) = result else { + panic!("expected an instruction error, got {result:?}"); + }; + assert_eq!(index, FINALIZE_INDEX); + assert_eq!( + // This unusual way to test is because `InstructionError::NotEnoughAccountKeys` + // is deprecated, while `ProgramError::NotEnoughAccountKeys` is not. + // Rather than silencing a linting, let's use the program error. + ProgramError::try_from(ix_error), + Ok(ProgramError::NotEnoughAccountKeys), + ); +} + +#[test] +fn rejects_partial_push_amount() { + let (mut svm, program_id, payer) = setup(); + let sell_mint = token::create_mint(&mut svm, &payer); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer, &sell_mint).build(); + let orders = [FinalizedIntent { + intent: &intent, + mint: Pubkey::new_unique(), + amount: 1_000, + }]; + + // A well-formed single-push finalize... + let mut finalize = Instruction::from(FinalizeSettle { + program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders: &orders, + }); + // ...with one byte popped, so the trailing amount is no longer a whole `u64`. + finalize.data.pop(); + + assert_eq!( + send_settlement(&mut svm, &program_id, &payer, &orders, finalize), + Err(TransactionError::InstructionError( + FINALIZE_INDEX, + InstructionError::InvalidInstructionData, + )), + ); +} diff --git a/programs/settlement/tests/matching_begin_finalize.rs b/programs/settlement/tests/matching_begin_finalize.rs index 0484e8d..6048b33 100644 --- a/programs/settlement/tests/matching_begin_finalize.rs +++ b/programs/settlement/tests/matching_begin_finalize.rs @@ -44,6 +44,7 @@ fn run_sequence( AbstractInstruction::Fin(idx) => FinalizeSettle { program_id: *program_id, begin_ix_index: *idx, + orders: &[], } .into(), // 0-lamport self-transfer: a side-effect-free instruction that @@ -193,6 +194,7 @@ fn rejects_non_instructions_sysvar_account_at_position_zero() { let finalize = FinalizeSettle { program_id, begin_ix_index: 0, + orders: &[], } .into(); @@ -231,6 +233,7 @@ fn rejects_counterpart_instruction_in_different_program() { let stranger = FinalizeSettle { program_id: solana_system_interface::program::ID, begin_ix_index: 0, + orders: &[], } .into(); @@ -314,6 +317,7 @@ fn rejects_cpi_call_to_finalize_settle() { FinalizeSettle { program_id: settlement_id, begin_ix_index: 0, + orders: &[], }, ); diff --git a/programs/settlement/tests/program_deployment.rs b/programs/settlement/tests/program_deployment.rs index 526778f..373f7a3 100644 --- a/programs/settlement/tests/program_deployment.rs +++ b/programs/settlement/tests/program_deployment.rs @@ -38,6 +38,7 @@ fn program_can_be_invoked() { FinalizeSettle { program_id, begin_ix_index: 0, + orders: &[], } .into(), ],