From 9ce4ad9851b0110a48f8ace7a555c11ef3d59420 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:41:59 +0900 Subject: [PATCH 01/16] setup reclaim order --- interface/src/data/intent.rs | 6 +- interface/src/data/order.rs | 2 +- interface/src/instruction/mod.rs | 31 ++- interface/src/instruction/reclaim_order.rs | 192 +++++++++++++++++ interface/src/lib.rs | 6 + programs/settlement/src/lib.rs | 5 + programs/settlement/src/processor.rs | 19 ++ programs/settlement/src/reclaim_order.rs | 116 +++++++++++ programs/settlement/src/settle/begin.rs | 6 +- programs/settlement/tests/reclaim_order.rs | 227 +++++++++++++++++++++ 10 files changed, 599 insertions(+), 11 deletions(-) create mode 100644 interface/src/instruction/reclaim_order.rs create mode 100644 programs/settlement/src/reclaim_order.rs create mode 100644 programs/settlement/tests/reclaim_order.rs diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index 3686f6d..2072f55 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -24,14 +24,14 @@ use solana_program_error::ProgramError; use solana_pubkey::Pubkey; /// Direction of the trade. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] #[repr(u8)] pub enum OrderKind { - Sell = 0, + #[default] Sell = 0, Buy = 1, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Default)] pub struct OrderIntent { /// Account authorized to create and invalidate this order and whose /// signature authenticates it. For off-chain orders this is the Ed25519 diff --git a/interface/src/data/order.rs b/interface/src/data/order.rs index 2130523..3efc08c 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -29,7 +29,7 @@ use solana_pubkey::Pubkey; use crate::data::intent::{self, EncodedOrderIntent, OrderIntent}; /// Idiomatic representation of an order PDA's body. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Default)] pub struct OrderAccount { /// `false` = the order is still active and can be filled; `true` = the /// order has been cancelled by the owner and must not be filled. diff --git a/interface/src/instruction/mod.rs b/interface/src/instruction/mod.rs index 3c53184..d528fef 100644 --- a/interface/src/instruction/mod.rs +++ b/interface/src/instruction/mod.rs @@ -12,6 +12,7 @@ use crate::{recover_discriminator, SettlementInstruction}; pub mod create_buffer; pub mod create_order; pub mod initialize; +pub mod reclaim_order; pub mod settle; /// Shared components for parsing generic instruction input. @@ -85,11 +86,33 @@ pub mod fixtures { unsafe { AccountView::new_unchecked(backing as *mut RuntimeAccount) } } + pub fn fake_account_with_data(address: Address, data: &[u8]) -> AccountView { + + // The RuntimeAccount struct actually functions as a header. If any data is included in the account, it should be placed in the bytes following the header. + // For this, we need to allocate some data on the heap to hold both the account and the data we want to store. + // We use Box::leak to prevent the memory from being deallocated after this function, which is fine for tests. + const HEADER: usize = core::mem::size_of::(); + + let buf = Box::leak(Box::<[u8]>::new_uninit_slice(HEADER + data.len())); + let base = buf.as_mut_ptr() as *mut u8; + + unsafe { + std::ptr::write(base as *mut RuntimeAccount, RuntimeAccount { + address, + borrow_state: solana_account_view::NOT_BORROWED, // allows for code to borrow this account to read its data + data_len: data.len() as u64, + ..Default::default() + }); + + // mostly equivalent to C's `memcpy` + std::ptr::copy_nonoverlapping(data.as_ptr(), base.add(HEADER), data.len()); + } + + unsafe { AccountView::new_unchecked(buf.as_mut_ptr() as *mut RuntimeAccount) } + } + pub fn fake_account(address: Address) -> AccountView { - fake_account_from(RuntimeAccount { - address, - ..Default::default() - }) + fake_account_with_data(address, &[]) } pub fn fake_account_from_array(address_array: [u8; 32]) -> AccountView { diff --git a/interface/src/instruction/reclaim_order.rs b/interface/src/instruction/reclaim_order.rs new file mode 100644 index 0000000..0467863 --- /dev/null +++ b/interface/src/instruction/reclaim_order.rs @@ -0,0 +1,192 @@ +//! `ReclaimOrder` instruction builder. +//! +//! Closes an expired order PDA and returns its rent lamports to the +//! `created_by` account recorded in the order body. The instruction may only be +//! executed after the order's `valid_to` timestamp has elapsed. +//! +//! Wire format: `[discriminator=5]`, 1 byte. +//! Required accounts: +//! `[order_pda (W), reclaim_recipient (W)]`. + +use solana_account_view::AccountView; +use solana_instruction::{AccountMeta, Instruction}; +use solana_program_error::ProgramError; +use solana_pubkey::Pubkey; + +use super::InstructionInputParsing; +use crate::SettlementInstruction; + +/// Builder for a `ReclaimOrder` instruction. +/// +/// `order_pda` is the canonical order PDA to close. `reclaim_recipient` must +/// be the account recorded as `created_by` in the order PDA; it receives the +/// recovered rent lamports. The instruction enforces no signature requirement: +/// anyone may reclaim an expired order on behalf of its reclaim_recipient. +pub struct ReclaimOrder { + pub program_id: Pubkey, + pub order_pda: Pubkey, + pub reclaim_recipient: Pubkey, +} + +impl ReclaimOrder { + pub fn instruction(self) -> Instruction { + Instruction { + program_id: self.program_id, + accounts: vec![ + AccountMeta::new(self.order_pda, false), + AccountMeta::new(self.reclaim_recipient, false), + ], + data: vec![SettlementInstruction::ReclaimOrder.discriminator()], + } + } +} + +/// Parsed inputs of a `ReclaimOrder` instruction. +pub struct ReclaimOrderInput<'a> { + pub order_pda: &'a mut AccountView, + pub reclaim_recipient: &'a mut AccountView, +} + +impl<'a> InstructionInputParsing<'a> for ReclaimOrderInput<'a> { + const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::ReclaimOrder; + + fn parse_body( + instruction_data: &'a [u8], + accounts: &'a mut [AccountView], + ) -> Result { + // No instruction body: only the discriminator, already stripped. + if !instruction_data.is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + let [order_pda, reclaim_recipient, ..] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + Ok(Self { + order_pda, + reclaim_recipient, + }) + } +} + +/// Test scaffolding for `ReclaimOrder` parsing, 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::ReclaimOrder; + + /// Number of accounts `ReclaimOrder` expects: order PDA and reclaim + /// recipient. + pub const NUM_ACCOUNTS: usize = 2; + + /// `ReclaimOrder` instruction data with placeholder addresses. + pub fn default_reclaim_data() -> Vec { + let zero = Address::new_from_array([0; 32]); + ReclaimOrder { + program_id: zero, + order_pda: zero, + reclaim_recipient: zero, + } + .instruction() + .data + } +} + +#[cfg(test)] +mod tests { + use super::fixtures::{default_reclaim_data, NUM_ACCOUNTS}; + use super::*; + use crate::instruction::fixtures::{fake_account, fake_sequential_accounts}; + use solana_address::Address; + + #[test] + fn reclaim_order_input_parses_valid_input() { + let program_id = Address::new_from_array([1; 32]); + let order_pda = Address::new_from_array([2; 32]); + let reclaim_recipient = Address::new_from_array([3; 32]); + + let data = ReclaimOrder { + program_id, + order_pda, + reclaim_recipient, + } + .instruction() + .data; + let mut accounts = [fake_account(order_pda), fake_account(reclaim_recipient)]; + + let ReclaimOrderInput { + order_pda: derived_order_pda, + reclaim_recipient: derived_reclaim_recipient, + } = ReclaimOrderInput::parse(&data, &mut accounts).expect("parse should succeed"); + + assert_eq!(*derived_order_pda.address(), order_pda); + assert_eq!(*derived_reclaim_recipient.address(), reclaim_recipient); + } + + #[test] + fn reclaim_order_input_rejects_nonempty_body() { + let mut data = default_reclaim_data(); + data.push(0); + let mut accounts = fake_sequential_accounts::(); + assert_eq!( + ReclaimOrderInput::parse(&data, &mut accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn reclaim_order_input_rejects_missing_accounts() { + let data = default_reclaim_data(); + let mut accounts: Vec = fake_sequential_accounts::().into(); + accounts.pop(); + assert_eq!( + ReclaimOrderInput::parse(&data, &mut accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + ); + } + + #[test] + fn instruction_data_has_expected_layout() { + let program_id = Address::new_from_array([1; 32]); + let order_pda = Address::new_from_array([2; 32]); + let reclaim_recipient = Address::new_from_array([3; 32]); + + let ix = ReclaimOrder { + program_id, + order_pda, + reclaim_recipient, + } + .instruction(); + + assert_eq!(ix.data.len(), 1); + assert_eq!( + ix.data[0], + SettlementInstruction::ReclaimOrder.discriminator() + ); + } + + #[test] + fn instruction_data_has_expected_accounts() { + let program_id = Address::new_from_array([1; 32]); + let order_pda = Address::new_from_array([2; 32]); + let reclaim_recipient = Address::new_from_array([3; 32]); + + let ix = ReclaimOrder { + program_id, + order_pda, + reclaim_recipient, + } + .instruction(); + + assert_eq!(ix.accounts.len(), 2); + // order_pda: writable, not signer (the PDA being closed) + assert_eq!(ix.accounts[0].pubkey, order_pda); + assert!(ix.accounts[0].is_writable); + assert!(!ix.accounts[0].is_signer); + // reclaim_recipient: writable, not signer (receives the recovered rent) + assert_eq!(ix.accounts[1].pubkey, reclaim_recipient); + assert!(ix.accounts[1].is_writable); + assert!(!ix.accounts[1].is_signer); + } +} diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 732831d..15fd3f2 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -22,6 +22,7 @@ pub enum SettlementInstruction { CreateOrder = 2, Initialize = 3, CreateBuffer = 4, + ReclaimOrder = 5, } impl SettlementInstruction { @@ -112,6 +113,11 @@ 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, + /// `ReclaimOrder` was called before the order's `valid_to` has elapsed. + OrderNotExpired = 19, + /// `ReclaimOrder`'s `reclaim_recipient` account doesn't match the + /// `created_by` address recorded in the order. + ReclaimRecipientMismatch = 20, } impl From for u32 { diff --git a/programs/settlement/src/lib.rs b/programs/settlement/src/lib.rs index f5558e9..5de01b5 100644 --- a/programs/settlement/src/lib.rs +++ b/programs/settlement/src/lib.rs @@ -4,12 +4,14 @@ mod create_buffer; mod create_order; mod initialize; mod processor; +mod reclaim_order; mod settle; use create_buffer::process_create_buffer; use create_order::process_create_order; use initialize::process_initialize; use pinocchio::{entrypoint, AccountView, Address, ProgramResult}; +use reclaim_order::process_reclaim_order; use settle::{process_begin_settle, process_finalize_settle}; use settlement_interface::{recover_discriminator, SettlementInstruction}; @@ -37,5 +39,8 @@ pub fn process_instruction( SettlementInstruction::CreateBuffer => { process_create_buffer(program_id, accounts, instruction_data) } + SettlementInstruction::ReclaimOrder => { + process_reclaim_order(program_id, accounts, instruction_data) + } } } diff --git a/programs/settlement/src/processor.rs b/programs/settlement/src/processor.rs index 8b2f9d3..ccaf43d 100644 --- a/programs/settlement/src/processor.rs +++ b/programs/settlement/src/processor.rs @@ -3,6 +3,7 @@ use pinocchio::{ address::MAX_SEEDS, cpi::{Seed, Signer}, + error::ProgramError, AccountView, Address, ProgramResult, }; @@ -64,6 +65,24 @@ pub fn is_cpi_call() -> bool { get_stack_height() > TRANSACTION_LEVEL_STACK_HEIGHT } +/// Current on-chain unix timestamp. +/// +/// Off the Solana target (e.g. host-run unit tests), the `Clock` sysvar isn't +/// available, so this returns a fixed 2026-01-01T00:00:00Z timestamp instead. +#[cfg(target_os = "solana")] +#[inline(always)] +pub fn get_timestamp() -> Result { + use pinocchio::sysvars::{clock::Clock, Sysvar}; + + Ok(Clock::get()?.unix_timestamp) +} + +#[cfg(not(target_os = "solana"))] +#[inline(always)] +pub fn get_timestamp() -> Result { + Ok(1_767_225_600) +} + #[cfg(test)] mod tests { use super::*; diff --git a/programs/settlement/src/reclaim_order.rs b/programs/settlement/src/reclaim_order.rs new file mode 100644 index 0000000..f470574 --- /dev/null +++ b/programs/settlement/src/reclaim_order.rs @@ -0,0 +1,116 @@ +//! `ReclaimOrder` instruction handler. + +use pinocchio::{error::ProgramError, AccountView, ProgramResult}; +use settlement_interface::{ + data::order::{EncodedOrderAccount, OrderAccount}, + instruction::{reclaim_order::ReclaimOrderInput, InstructionInputParsing}, + SettlementError, +}; + +use crate::processor::get_timestamp; + +pub fn process_reclaim_order( + _program_id: &pinocchio::Address, + accounts: &mut [AccountView], + instruction_data: &[u8], +) -> ProgramResult { + let ReclaimOrderInput { + order_pda, + reclaim_recipient, + } = ReclaimOrderInput::parse(instruction_data, accounts)?; + + let account = { + let data = order_pda.try_borrow()?; + let bytes: &[u8; EncodedOrderAccount::SIZE] = (&*data) + .try_into() + .map_err(|_| ProgramError::InvalidAccountData)?; + OrderAccount::try_from(*bytes)? + }; + + // Verify the reclaim_recipient account matches the one recorded in the order. + if reclaim_recipient.address().as_array() != &account.created_by.to_bytes() { + return Err(SettlementError::ReclaimRecipientMismatch.into()); + } + + let now = get_timestamp()?; + if now <= i64::from(account.intent.valid_to) { + return Err(SettlementError::OrderNotExpired.into()); + } + + // Transfer the rent lamports to the reclaim_recipient account, then close the PDA. + let order_lamports = order_pda.lamports(); + reclaim_recipient.set_lamports( + reclaim_recipient + .lamports() + .checked_add(order_lamports) + .ok_or(ProgramError::ArithmeticOverflow)?, + ); + order_pda.close()?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use pinocchio::Address; +use settlement_interface::{ + data::intent::{OrderKind, fixtures::sample_intent}, instruction::{fixtures::{fake_account, fake_account_with_data, fake_sequential_accounts}, reclaim_order::fixtures::{NUM_ACCOUNTS, default_reclaim_data}}, + }; + + use super::*; + + const PROGRAM_ID: pinocchio::Address = pinocchio::Address::new_from_array([1; 32]); + + #[test] + fn process_reclaim_order_propagates_parse_error() { + let mut data = default_reclaim_data(); + data.push(0); // trailing byte triggers parse error + let mut accounts = fake_sequential_accounts::(); + + assert_eq!( + process_reclaim_order(&PROGRAM_ID, &mut accounts, &data), + Err(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn process_reclaim_order_rejects_mismatched_reclaim_recipient() { + let data = default_reclaim_data(); + + let reclaim_recipient = fake_account(Address::new_unique()); + + let order_data = OrderAccount { + created_by: Address::new_unique(), + ..Default::default() + }; + + let order_pda = fake_account_with_data(Address::new_unique(), &EncodedOrderAccount::from(order_data)[..]); + + assert_eq!( + process_reclaim_order(&PROGRAM_ID, &mut [order_pda, reclaim_recipient], &data), + Err(SettlementError::ReclaimRecipientMismatch.into()), + ); + } + + #[test] + fn process_reclaim_order_rejects_not_yet_expired() { + let data = default_reclaim_data(); + + let reclaim_recipient = fake_account(Address::new_unique()); + + let mut intent = sample_intent(OrderKind::Sell, false); + intent.valid_to = 4_000_000_000; // far in the future + let order_data = OrderAccount { + intent: intent, + created_by: *reclaim_recipient.address(), + ..Default::default() + }; + + let order_pda = fake_account_with_data(Address::new_unique(), &EncodedOrderAccount::from(order_data)[..]); + + assert_eq!( + process_reclaim_order(&PROGRAM_ID, &mut [order_pda, reclaim_recipient], &data), + Err(SettlementError::OrderNotExpired.into()), + ); + } +} diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 3fc2e8e..aa61e3f 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -5,7 +5,7 @@ use std::ops::Deref; use pinocchio::{ cpi::{Seed, Signer}, error::ProgramError, - sysvars::{clock::Clock, instructions::Instructions, Sysvar}, + sysvars::instructions::Instructions, AccountView, Address, ProgramResult, }; use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; @@ -20,7 +20,7 @@ use settlement_interface::{ recover_discriminator, Pubkey, SettlementError, SettlementInstruction, }; -use crate::processor::is_cpi_call; +use crate::processor::{get_timestamp, is_cpi_call}; use super::validate_counterpart; @@ -145,7 +145,7 @@ fn pull_funds<'a>( // duplicates (settling the same order twice) without a separate scan. let mut previous: Option<&Address> = None; - let now = Clock::get()?.unix_timestamp; + let now = get_timestamp()?; for order in orders { let order_pda = order.order_pda; diff --git a/programs/settlement/tests/reclaim_order.rs b/programs/settlement/tests/reclaim_order.rs new file mode 100644 index 0000000..0f4ea11 --- /dev/null +++ b/programs/settlement/tests/reclaim_order.rs @@ -0,0 +1,227 @@ +use settlement_client::settlement_interface::{ + data::intent::{EncodedOrderIntent, OrderIntent, OrderKind}, + instruction::{create_order::CreateOrder, reclaim_order::ReclaimOrder}, + pda::order::find_order_pda, + SettlementError, +}; +use solana_sdk::{ + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::Transaction, +}; + +mod common; + +const VALID_TO: u32 = 1_000; +const AFTER_EXPIRY: i64 = 1_001; + +fn sample_intent(owner: Pubkey) -> OrderIntent { + OrderIntent { + owner, + buy_token_account: Pubkey::new_from_array([0x22; 32]), + sell_token_account: Pubkey::new_from_array([0x33; 32]), + sell_amount: 1_000_000, + buy_amount: 2_000_000, + valid_to: VALID_TO, + kind: OrderKind::Sell, + partially_fillable: true, + app_data: [0; 32], + } +} + +fn encode_and_derive( + intent: &OrderIntent, + program_id: &Pubkey, +) -> ([u8; EncodedOrderIntent::SIZE], Pubkey) { + let encoded = EncodedOrderIntent::from(intent); + let bytes: [u8; EncodedOrderIntent::SIZE] = (&encoded).into(); + let (pda, _) = find_order_pda(program_id, &encoded.hash()); + (bytes, pda) +} + +/// Create an order PDA owned by `owner` (who also pays rent), return the PDA. +fn create_order( + svm: &mut litesvm::LiteSVM, + program_id: &Pubkey, + owner: &Keypair, + intent: &OrderIntent, +) -> Pubkey { + let (encoded, pda) = encode_and_derive(intent, program_id); + let ix = CreateOrder { + program_id: *program_id, + owner: owner.pubkey(), + created_by: owner.pubkey(), + order_pda: pda, + intent_bytes: encoded, + } + .instruction(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&owner.pubkey()), + &[owner], + svm.latest_blockhash(), + ); + svm.send_transaction(tx) + .expect("create_order should succeed"); + pda +} + +#[test] +fn happy_path_returns_lamports_and_closes_pda() { + let (mut svm, program_id, fee_payer) = common::setup(); + + // `reclaim_recipient` is the `created_by` funder; it's separate from the fee + // payer so its balance change reflects only the returned rent, not tx fees. + let reclaim_recipient = Keypair::new(); + svm.airdrop(&reclaim_recipient.pubkey(), 1_000_000_000) + .expect("airdrop should succeed"); + + let intent = OrderIntent { + owner: fee_payer.pubkey(), + ..sample_intent(fee_payer.pubkey()) + }; + let encoded = EncodedOrderIntent::from(&intent); + let encoded_bytes: [u8; EncodedOrderIntent::SIZE] = (&encoded).into(); + let (pda, _) = find_order_pda(&program_id, &encoded.hash()); + + let pda_rent = svm.minimum_balance_for_rent_exemption( + settlement_client::settlement_interface::data::order::EncodedOrderAccount::SIZE, + ); + + // Create the order; `reclaim_recipient` funds the rent (`created_by`). + let ix = CreateOrder { + program_id, + owner: fee_payer.pubkey(), + created_by: reclaim_recipient.pubkey(), + order_pda: pda, + intent_bytes: encoded_bytes, + } + .instruction(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&fee_payer.pubkey()), + &[&fee_payer, &reclaim_recipient], + svm.latest_blockhash(), + ); + svm.send_transaction(tx) + .expect("create_order should succeed"); + + // Since ReclaimOrder should return any funds in the order pda (even if beyond the rent limit), we airdrop some extra lamports + let extra_lamports = 10; + svm.airdrop(&pda, extra_lamports) + .expect("airdrop should succeed"); + + assert!(svm.get_account(&pda).is_some(), "order PDA must exist"); + + let reclaim_recipient_before = common::lamports(&svm, &reclaim_recipient.pubkey()); + + common::set_unix_timestamp(&mut svm, AFTER_EXPIRY); + + let ix = ReclaimOrder { + program_id, + order_pda: pda, + reclaim_recipient: reclaim_recipient.pubkey(), + } + .instruction(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&fee_payer.pubkey()), + &[&fee_payer], + svm.latest_blockhash(), + ); + svm.send_transaction(tx) + .expect("reclaim_order should succeed after expiry"); + + // PDA is gone. + assert!( + svm.get_account(&pda).is_none(), + "order PDA must be closed after reclaim" + ); + + // Reclaim recipient account received all lamports that were in the order pda; it paid no tx fees. + let reclaim_recipient_after = common::lamports(&svm, &reclaim_recipient.pubkey()); + assert_eq!( + reclaim_recipient_after - reclaim_recipient_before, + pda_rent + extra_lamports, + "reclaim recipient account must receive exactly the order PDA's rent lamports" + ); +} + +#[test] +fn rejects_when_order_not_yet_expired() { + let (mut svm, program_id, owner) = common::setup(); + + let intent = sample_intent(owner.pubkey()); + let pda = create_order(&mut svm, &program_id, &owner, &intent); + + common::set_unix_timestamp(&mut svm, VALID_TO as i64); // technically this is the last valid timestamp + + let ix = ReclaimOrder { + program_id, + order_pda: pda, + reclaim_recipient: owner.pubkey(), + } + .instruction(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&owner.pubkey()), + &[&owner], + svm.latest_blockhash(), + ); + common::assert_settlement_error( + svm.send_transaction(tx).map_err(|e| e.err), + SettlementError::OrderNotExpired, + ); +} + +#[test] +fn rejects_when_reclaim_recipient_mismatch() { + let (mut svm, program_id, owner) = common::setup(); + + let intent = sample_intent(owner.pubkey()); + let pda = create_order(&mut svm, &program_id, &owner, &intent); + + common::set_unix_timestamp(&mut svm, AFTER_EXPIRY); + + let wrong_authority = Pubkey::new_unique(); + let ix = ReclaimOrder { + program_id, + order_pda: pda, + reclaim_recipient: wrong_authority, + } + .instruction(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&owner.pubkey()), + &[&owner], + svm.latest_blockhash(), + ); + common::assert_settlement_error( + svm.send_transaction(tx).map_err(|e| e.err), + SettlementError::ReclaimRecipientMismatch, + ); +} + +#[test] +fn rejects_missing_accounts() { + let (mut svm, program_id, owner) = common::setup(); + + common::set_unix_timestamp(&mut svm, AFTER_EXPIRY); + + // Build a minimal instruction with only the discriminator, no accounts. + let ix = solana_sdk::instruction::Instruction { + program_id, + accounts: vec![], + data: vec![ + settlement_client::settlement_interface::SettlementInstruction::ReclaimOrder + .discriminator(), + ], + }; + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&owner.pubkey()), + &[&owner], + svm.latest_blockhash(), + ); + assert!(svm.send_transaction(tx).is_err()); +} From 555239addcbc9a4232738607dc5951147fb96a72 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:48:39 +0900 Subject: [PATCH 02/16] fix lints and errors --- interface/src/data/intent.rs | 3 ++- interface/src/instruction/mod.rs | 22 ++++++++++++++-------- programs/settlement/src/reclaim_order.rs | 20 +++++++++++++++----- programs/settlement/tests/reclaim_order.rs | 10 ++++------ 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index 2072f55..1452fa5 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -27,7 +27,8 @@ use solana_pubkey::Pubkey; #[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] #[repr(u8)] pub enum OrderKind { - #[default] Sell = 0, + #[default] + Sell = 0, Buy = 1, } diff --git a/interface/src/instruction/mod.rs b/interface/src/instruction/mod.rs index d528fef..ab9616b 100644 --- a/interface/src/instruction/mod.rs +++ b/interface/src/instruction/mod.rs @@ -87,22 +87,28 @@ pub mod fixtures { } pub fn fake_account_with_data(address: Address, data: &[u8]) -> AccountView { - // The RuntimeAccount struct actually functions as a header. If any data is included in the account, it should be placed in the bytes following the header. // For this, we need to allocate some data on the heap to hold both the account and the data we want to store. // We use Box::leak to prevent the memory from being deallocated after this function, which is fine for tests. const HEADER: usize = core::mem::size_of::(); - let buf = Box::leak(Box::<[u8]>::new_uninit_slice(HEADER + data.len())); + let buf = Box::leak(Box::<[u8]>::new_uninit_slice( + HEADER + .checked_add(data.len()) + .expect("overflow when allocating account data"), + )); let base = buf.as_mut_ptr() as *mut u8; unsafe { - std::ptr::write(base as *mut RuntimeAccount, RuntimeAccount { - address, - borrow_state: solana_account_view::NOT_BORROWED, // allows for code to borrow this account to read its data - data_len: data.len() as u64, - ..Default::default() - }); + std::ptr::write( + base as *mut RuntimeAccount, + RuntimeAccount { + address, + borrow_state: solana_account_view::NOT_BORROWED, // allows for code to borrow this account to read its data + data_len: data.len() as u64, + ..Default::default() + }, + ); // mostly equivalent to C's `memcpy` std::ptr::copy_nonoverlapping(data.as_ptr(), base.add(HEADER), data.len()); diff --git a/programs/settlement/src/reclaim_order.rs b/programs/settlement/src/reclaim_order.rs index f470574..dcc3be7 100644 --- a/programs/settlement/src/reclaim_order.rs +++ b/programs/settlement/src/reclaim_order.rs @@ -53,8 +53,12 @@ pub fn process_reclaim_order( #[cfg(test)] mod tests { use pinocchio::Address; -use settlement_interface::{ - data::intent::{OrderKind, fixtures::sample_intent}, instruction::{fixtures::{fake_account, fake_account_with_data, fake_sequential_accounts}, reclaim_order::fixtures::{NUM_ACCOUNTS, default_reclaim_data}}, + use settlement_interface::{ + data::intent::{fixtures::sample_intent, OrderKind}, + instruction::{ + fixtures::{fake_account, fake_account_with_data, fake_sequential_accounts}, + reclaim_order::fixtures::{default_reclaim_data, NUM_ACCOUNTS}, + }, }; use super::*; @@ -84,7 +88,10 @@ use settlement_interface::{ ..Default::default() }; - let order_pda = fake_account_with_data(Address::new_unique(), &EncodedOrderAccount::from(order_data)[..]); + let order_pda = fake_account_with_data( + Address::new_unique(), + &EncodedOrderAccount::from(order_data)[..], + ); assert_eq!( process_reclaim_order(&PROGRAM_ID, &mut [order_pda, reclaim_recipient], &data), @@ -101,12 +108,15 @@ use settlement_interface::{ let mut intent = sample_intent(OrderKind::Sell, false); intent.valid_to = 4_000_000_000; // far in the future let order_data = OrderAccount { - intent: intent, + intent, created_by: *reclaim_recipient.address(), ..Default::default() }; - let order_pda = fake_account_with_data(Address::new_unique(), &EncodedOrderAccount::from(order_data)[..]); + let order_pda = fake_account_with_data( + Address::new_unique(), + &EncodedOrderAccount::from(order_data)[..], + ); assert_eq!( process_reclaim_order(&PROGRAM_ID, &mut [order_pda, reclaim_recipient], &data), diff --git a/programs/settlement/tests/reclaim_order.rs b/programs/settlement/tests/reclaim_order.rs index 0f4ea11..26e6efe 100644 --- a/programs/settlement/tests/reclaim_order.rs +++ b/programs/settlement/tests/reclaim_order.rs @@ -53,10 +53,9 @@ fn create_order( created_by: owner.pubkey(), order_pda: pda, intent_bytes: encoded, - } - .instruction(); + }; let tx = Transaction::new_signed_with_payer( - &[ix], + &[ix.into()], Some(&owner.pubkey()), &[owner], svm.latest_blockhash(), @@ -95,10 +94,9 @@ fn happy_path_returns_lamports_and_closes_pda() { created_by: reclaim_recipient.pubkey(), order_pda: pda, intent_bytes: encoded_bytes, - } - .instruction(); + }; let tx = Transaction::new_signed_with_payer( - &[ix], + &[ix.into()], Some(&fee_payer.pubkey()), &[&fee_payer, &reclaim_recipient], svm.latest_blockhash(), From 013557a400f639bb0ab07dbd96032901187d0955 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:02:37 +0900 Subject: [PATCH 03/16] simplify reclaim order test even more --- programs/settlement/src/reclaim_order.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/programs/settlement/src/reclaim_order.rs b/programs/settlement/src/reclaim_order.rs index dcc3be7..5da25d0 100644 --- a/programs/settlement/src/reclaim_order.rs +++ b/programs/settlement/src/reclaim_order.rs @@ -54,7 +54,7 @@ pub fn process_reclaim_order( mod tests { use pinocchio::Address; use settlement_interface::{ - data::intent::{fixtures::sample_intent, OrderKind}, + data::intent::OrderIntent, instruction::{ fixtures::{fake_account, fake_account_with_data, fake_sequential_accounts}, reclaim_order::fixtures::{default_reclaim_data, NUM_ACCOUNTS}, @@ -105,10 +105,11 @@ mod tests { let reclaim_recipient = fake_account(Address::new_unique()); - let mut intent = sample_intent(OrderKind::Sell, false); - intent.valid_to = 4_000_000_000; // far in the future let order_data = OrderAccount { - intent, + intent: OrderIntent { + valid_to: 4_000_000_000, + ..Default::default() + }, created_by: *reclaim_recipient.address(), ..Default::default() }; From 91998802a9d183d861d9fb2a82c0dc934eb47a62 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:07:40 +0900 Subject: [PATCH 04/16] stub timestamp function only runs when explicit feature is enabled prevents potential unexpected behavior anywhere else --- Justfile | 2 +- programs/settlement/Cargo.toml | 3 +++ programs/settlement/src/processor.rs | 8 ++++---- programs/settlement/src/reclaim_order.rs | 1 + 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Justfile b/Justfile index dc4b13c..31ad823 100644 --- a/Justfile +++ b/Justfile @@ -16,7 +16,7 @@ build: build-program # Run the test suite (builds the program first so the .so exists). test: build-program build-test-programs - cargo test + cargo test --features settlement-test-clock # Format the source code. fmt: diff --git a/programs/settlement/Cargo.toml b/programs/settlement/Cargo.toml index 0f5f03a..266cc09 100644 --- a/programs/settlement/Cargo.toml +++ b/programs/settlement/Cargo.toml @@ -29,5 +29,8 @@ solana-address-lookup-table-interface = { workspace = true, features = ["bincode solana-sdk.workspace = true solana-system-interface.workspace = true +[features] +settlement-test-clock = [] + [lints] workspace = true diff --git a/programs/settlement/src/processor.rs b/programs/settlement/src/processor.rs index ccaf43d..282b54a 100644 --- a/programs/settlement/src/processor.rs +++ b/programs/settlement/src/processor.rs @@ -68,8 +68,8 @@ pub fn is_cpi_call() -> bool { /// Current on-chain unix timestamp. /// /// Off the Solana target (e.g. host-run unit tests), the `Clock` sysvar isn't -/// available, so this returns a fixed 2026-01-01T00:00:00Z timestamp instead. -#[cfg(target_os = "solana")] +/// available, so to allow for unit tests coverage,we return fixed timestamp instead. +#[cfg(any(target_os = "solana", not(feature = "settlement-test-clock")))] #[inline(always)] pub fn get_timestamp() -> Result { use pinocchio::sysvars::{clock::Clock, Sysvar}; @@ -77,10 +77,10 @@ pub fn get_timestamp() -> Result { Ok(Clock::get()?.unix_timestamp) } -#[cfg(not(target_os = "solana"))] +#[cfg(all(not(target_os = "solana"), feature = "settlement-test-clock"))] #[inline(always)] pub fn get_timestamp() -> Result { - Ok(1_767_225_600) + Ok(1000) // arbitrary fixed timestamp for unit testing purposes } #[cfg(test)] diff --git a/programs/settlement/src/reclaim_order.rs b/programs/settlement/src/reclaim_order.rs index 5da25d0..d12c393 100644 --- a/programs/settlement/src/reclaim_order.rs +++ b/programs/settlement/src/reclaim_order.rs @@ -99,6 +99,7 @@ mod tests { ); } + #[cfg(feature = "settlement-test-clock")] #[test] fn process_reclaim_order_rejects_not_yet_expired() { let data = default_reclaim_data(); From 68a3bea2085dc7f629313ed806aafe6a20879829 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:33:51 +0900 Subject: [PATCH 05/16] Update interface/src/instruction/reclaim_order.rs Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com> --- interface/src/instruction/reclaim_order.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/interface/src/instruction/reclaim_order.rs b/interface/src/instruction/reclaim_order.rs index 0467863..5f6ecd0 100644 --- a/interface/src/instruction/reclaim_order.rs +++ b/interface/src/instruction/reclaim_order.rs @@ -159,10 +159,9 @@ mod tests { } .instruction(); - assert_eq!(ix.data.len(), 1); assert_eq!( - ix.data[0], - SettlementInstruction::ReclaimOrder.discriminator() + ix.data, + vec![SettlementInstruction::ReclaimOrder.discriminator()] ); } From 8919f6854122823d34708c5d847345c71feb6b38 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:34:12 +0900 Subject: [PATCH 06/16] Update programs/settlement/src/reclaim_order.rs Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com> --- programs/settlement/src/reclaim_order.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/programs/settlement/src/reclaim_order.rs b/programs/settlement/src/reclaim_order.rs index d12c393..c4e90bd 100644 --- a/programs/settlement/src/reclaim_order.rs +++ b/programs/settlement/src/reclaim_order.rs @@ -27,7 +27,6 @@ pub fn process_reclaim_order( OrderAccount::try_from(*bytes)? }; - // Verify the reclaim_recipient account matches the one recorded in the order. if reclaim_recipient.address().as_array() != &account.created_by.to_bytes() { return Err(SettlementError::ReclaimRecipientMismatch.into()); } From 76e843455286523b4b382c73a6ad8430dd5e8f04 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:34:25 +0900 Subject: [PATCH 07/16] Update programs/settlement/src/reclaim_order.rs Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com> --- programs/settlement/src/reclaim_order.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programs/settlement/src/reclaim_order.rs b/programs/settlement/src/reclaim_order.rs index c4e90bd..3b59403 100644 --- a/programs/settlement/src/reclaim_order.rs +++ b/programs/settlement/src/reclaim_order.rs @@ -27,7 +27,7 @@ pub fn process_reclaim_order( OrderAccount::try_from(*bytes)? }; - if reclaim_recipient.address().as_array() != &account.created_by.to_bytes() { + if reclaim_recipient.address() != &account.created_by { return Err(SettlementError::ReclaimRecipientMismatch.into()); } From af956692f5b4f4d041b6e1e227391cdd71867dec Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:47:31 +0900 Subject: [PATCH 08/16] Update programs/settlement/tests/reclaim_order.rs Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com> --- programs/settlement/tests/reclaim_order.rs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/programs/settlement/tests/reclaim_order.rs b/programs/settlement/tests/reclaim_order.rs index 26e6efe..ab376af 100644 --- a/programs/settlement/tests/reclaim_order.rs +++ b/programs/settlement/tests/reclaim_order.rs @@ -18,14 +18,8 @@ const AFTER_EXPIRY: i64 = 1_001; fn sample_intent(owner: Pubkey) -> OrderIntent { OrderIntent { owner, - buy_token_account: Pubkey::new_from_array([0x22; 32]), - sell_token_account: Pubkey::new_from_array([0x33; 32]), - sell_amount: 1_000_000, - buy_amount: 2_000_000, valid_to: VALID_TO, - kind: OrderKind::Sell, - partially_fillable: true, - app_data: [0; 32], + .. ..fixtures::sample_intent(OrderKind::Sell, true) } } From 3d25f1fef3c34b462be9ee51694f8db8bc470321 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:56:09 +0900 Subject: [PATCH 09/16] remove settlement-test-clock and related unit test, will move to integraiton test --- Justfile | 2 +- interface/src/instruction/reclaim_order.rs | 2 +- programs/settlement/Cargo.toml | 3 -- programs/settlement/src/processor.rs | 19 ------------- programs/settlement/src/reclaim_order.rs | 33 ++-------------------- programs/settlement/src/settle/begin.rs | 9 ++---- programs/settlement/tests/reclaim_order.rs | 2 +- 7 files changed, 8 insertions(+), 62 deletions(-) diff --git a/Justfile b/Justfile index 31ad823..dc4b13c 100644 --- a/Justfile +++ b/Justfile @@ -16,7 +16,7 @@ build: build-program # Run the test suite (builds the program first so the .so exists). test: build-program build-test-programs - cargo test --features settlement-test-clock + cargo test # Format the source code. fmt: diff --git a/interface/src/instruction/reclaim_order.rs b/interface/src/instruction/reclaim_order.rs index 5f6ecd0..4495a50 100644 --- a/interface/src/instruction/reclaim_order.rs +++ b/interface/src/instruction/reclaim_order.rs @@ -81,7 +81,7 @@ pub mod fixtures { pub const NUM_ACCOUNTS: usize = 2; /// `ReclaimOrder` instruction data with placeholder addresses. - pub fn default_reclaim_data() -> Vec { + fn default_reclaim_data() -> Vec { let zero = Address::new_from_array([0; 32]); ReclaimOrder { program_id: zero, diff --git a/programs/settlement/Cargo.toml b/programs/settlement/Cargo.toml index 7d542b4..cd70c15 100644 --- a/programs/settlement/Cargo.toml +++ b/programs/settlement/Cargo.toml @@ -30,8 +30,5 @@ solana-address-lookup-table-interface = { workspace = true, features = ["bincode solana-sdk.workspace = true solana-system-interface.workspace = true -[features] -settlement-test-clock = [] - [lints] workspace = true diff --git a/programs/settlement/src/processor.rs b/programs/settlement/src/processor.rs index 282b54a..8b2f9d3 100644 --- a/programs/settlement/src/processor.rs +++ b/programs/settlement/src/processor.rs @@ -3,7 +3,6 @@ use pinocchio::{ address::MAX_SEEDS, cpi::{Seed, Signer}, - error::ProgramError, AccountView, Address, ProgramResult, }; @@ -65,24 +64,6 @@ pub fn is_cpi_call() -> bool { get_stack_height() > TRANSACTION_LEVEL_STACK_HEIGHT } -/// Current on-chain unix timestamp. -/// -/// Off the Solana target (e.g. host-run unit tests), the `Clock` sysvar isn't -/// available, so to allow for unit tests coverage,we return fixed timestamp instead. -#[cfg(any(target_os = "solana", not(feature = "settlement-test-clock")))] -#[inline(always)] -pub fn get_timestamp() -> Result { - use pinocchio::sysvars::{clock::Clock, Sysvar}; - - Ok(Clock::get()?.unix_timestamp) -} - -#[cfg(all(not(target_os = "solana"), feature = "settlement-test-clock"))] -#[inline(always)] -pub fn get_timestamp() -> Result { - Ok(1000) // arbitrary fixed timestamp for unit testing purposes -} - #[cfg(test)] mod tests { use super::*; diff --git a/programs/settlement/src/reclaim_order.rs b/programs/settlement/src/reclaim_order.rs index 3b59403..845fbef 100644 --- a/programs/settlement/src/reclaim_order.rs +++ b/programs/settlement/src/reclaim_order.rs @@ -1,14 +1,12 @@ //! `ReclaimOrder` instruction handler. -use pinocchio::{error::ProgramError, AccountView, ProgramResult}; +use pinocchio::{AccountView, ProgramResult, error::ProgramError, sysvars::{Sysvar, clock::Clock}}; use settlement_interface::{ data::order::{EncodedOrderAccount, OrderAccount}, instruction::{reclaim_order::ReclaimOrderInput, InstructionInputParsing}, SettlementError, }; -use crate::processor::get_timestamp; - pub fn process_reclaim_order( _program_id: &pinocchio::Address, accounts: &mut [AccountView], @@ -31,7 +29,7 @@ pub fn process_reclaim_order( return Err(SettlementError::ReclaimRecipientMismatch.into()); } - let now = get_timestamp()?; + let now = Clock::get()?.unix_timestamp; if now <= i64::from(account.intent.valid_to) { return Err(SettlementError::OrderNotExpired.into()); } @@ -97,31 +95,4 @@ mod tests { Err(SettlementError::ReclaimRecipientMismatch.into()), ); } - - #[cfg(feature = "settlement-test-clock")] - #[test] - fn process_reclaim_order_rejects_not_yet_expired() { - let data = default_reclaim_data(); - - let reclaim_recipient = fake_account(Address::new_unique()); - - let order_data = OrderAccount { - intent: OrderIntent { - valid_to: 4_000_000_000, - ..Default::default() - }, - created_by: *reclaim_recipient.address(), - ..Default::default() - }; - - let order_pda = fake_account_with_data( - Address::new_unique(), - &EncodedOrderAccount::from(order_data)[..], - ); - - assert_eq!( - process_reclaim_order(&PROGRAM_ID, &mut [order_pda, reclaim_recipient], &data), - Err(SettlementError::OrderNotExpired.into()), - ); - } } diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index aa61e3f..9921ca1 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -3,10 +3,7 @@ use std::ops::Deref; use pinocchio::{ - cpi::{Seed, Signer}, - error::ProgramError, - sysvars::instructions::Instructions, - AccountView, Address, ProgramResult, + AccountView, Address, ProgramResult, cpi::{Seed, Signer}, error::ProgramError, sysvars::{Sysvar, clock::Clock, instructions::Instructions}, }; use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; use settlement_interface::{ @@ -20,7 +17,7 @@ use settlement_interface::{ recover_discriminator, Pubkey, SettlementError, SettlementInstruction, }; -use crate::processor::{get_timestamp, is_cpi_call}; +use crate::processor::is_cpi_call; use super::validate_counterpart; @@ -145,7 +142,7 @@ fn pull_funds<'a>( // duplicates (settling the same order twice) without a separate scan. let mut previous: Option<&Address> = None; - let now = get_timestamp()?; + let now = Clock::get()?.unix_timestamp; for order in orders { let order_pda = order.order_pda; diff --git a/programs/settlement/tests/reclaim_order.rs b/programs/settlement/tests/reclaim_order.rs index ab376af..6d1e278 100644 --- a/programs/settlement/tests/reclaim_order.rs +++ b/programs/settlement/tests/reclaim_order.rs @@ -19,7 +19,7 @@ fn sample_intent(owner: Pubkey) -> OrderIntent { OrderIntent { owner, valid_to: VALID_TO, - .. ..fixtures::sample_intent(OrderKind::Sell, true) + ....fixtures::sample_intent(OrderKind::Sell, true) } } From 908681b475c1a129dfde0303635dec9f6173bda9 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:00:40 +0900 Subject: [PATCH 10/16] fix test error --- programs/settlement/tests/reclaim_order.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/programs/settlement/tests/reclaim_order.rs b/programs/settlement/tests/reclaim_order.rs index 6d1e278..9e9e92a 100644 --- a/programs/settlement/tests/reclaim_order.rs +++ b/programs/settlement/tests/reclaim_order.rs @@ -1,5 +1,5 @@ use settlement_client::settlement_interface::{ - data::intent::{EncodedOrderIntent, OrderIntent, OrderKind}, + data::intent::{EncodedOrderIntent, OrderIntent, OrderKind, fixtures::sample_intent}, instruction::{create_order::CreateOrder, reclaim_order::ReclaimOrder}, pda::order::find_order_pda, SettlementError, @@ -15,11 +15,11 @@ mod common; const VALID_TO: u32 = 1_000; const AFTER_EXPIRY: i64 = 1_001; -fn sample_intent(owner: Pubkey) -> OrderIntent { +fn reclaim_sample_intent(owner: Pubkey) -> OrderIntent { OrderIntent { owner, valid_to: VALID_TO, - ....fixtures::sample_intent(OrderKind::Sell, true) + ..sample_intent(OrderKind::Sell, true) } } @@ -71,7 +71,7 @@ fn happy_path_returns_lamports_and_closes_pda() { let intent = OrderIntent { owner: fee_payer.pubkey(), - ..sample_intent(fee_payer.pubkey()) + ..reclaim_sample_intent(fee_payer.pubkey()) }; let encoded = EncodedOrderIntent::from(&intent); let encoded_bytes: [u8; EncodedOrderIntent::SIZE] = (&encoded).into(); @@ -143,7 +143,7 @@ fn happy_path_returns_lamports_and_closes_pda() { fn rejects_when_order_not_yet_expired() { let (mut svm, program_id, owner) = common::setup(); - let intent = sample_intent(owner.pubkey()); + let intent = reclaim_sample_intent(owner.pubkey()); let pda = create_order(&mut svm, &program_id, &owner, &intent); common::set_unix_timestamp(&mut svm, VALID_TO as i64); // technically this is the last valid timestamp @@ -170,7 +170,7 @@ fn rejects_when_order_not_yet_expired() { fn rejects_when_reclaim_recipient_mismatch() { let (mut svm, program_id, owner) = common::setup(); - let intent = sample_intent(owner.pubkey()); + let intent = reclaim_sample_intent(owner.pubkey()); let pda = create_order(&mut svm, &program_id, &owner, &intent); common::set_unix_timestamp(&mut svm, AFTER_EXPIRY); From c6627d7d8000ec4f39278e1a02b6401174a5bae1 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:05:03 +0900 Subject: [PATCH 11/16] fix lint failures --- interface/src/instruction/reclaim_order.rs | 2 +- programs/settlement/src/reclaim_order.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/interface/src/instruction/reclaim_order.rs b/interface/src/instruction/reclaim_order.rs index 4495a50..5f6ecd0 100644 --- a/interface/src/instruction/reclaim_order.rs +++ b/interface/src/instruction/reclaim_order.rs @@ -81,7 +81,7 @@ pub mod fixtures { pub const NUM_ACCOUNTS: usize = 2; /// `ReclaimOrder` instruction data with placeholder addresses. - fn default_reclaim_data() -> Vec { + pub fn default_reclaim_data() -> Vec { let zero = Address::new_from_array([0; 32]); ReclaimOrder { program_id: zero, diff --git a/programs/settlement/src/reclaim_order.rs b/programs/settlement/src/reclaim_order.rs index 845fbef..03bda3c 100644 --- a/programs/settlement/src/reclaim_order.rs +++ b/programs/settlement/src/reclaim_order.rs @@ -51,7 +51,6 @@ pub fn process_reclaim_order( mod tests { use pinocchio::Address; use settlement_interface::{ - data::intent::OrderIntent, instruction::{ fixtures::{fake_account, fake_account_with_data, fake_sequential_accounts}, reclaim_order::fixtures::{default_reclaim_data, NUM_ACCOUNTS}, From 8a4a98973b244cbeb6dc65fe5dd485e69c89a85d Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:15:29 +0900 Subject: [PATCH 12/16] use signed_tx helper --- programs/settlement/src/reclaim_order.rs | 14 ++++--- programs/settlement/src/settle/begin.rs | 5 ++- programs/settlement/tests/reclaim_order.rs | 47 +++++----------------- 3 files changed, 21 insertions(+), 45 deletions(-) diff --git a/programs/settlement/src/reclaim_order.rs b/programs/settlement/src/reclaim_order.rs index 03bda3c..be42d1a 100644 --- a/programs/settlement/src/reclaim_order.rs +++ b/programs/settlement/src/reclaim_order.rs @@ -1,6 +1,10 @@ //! `ReclaimOrder` instruction handler. -use pinocchio::{AccountView, ProgramResult, error::ProgramError, sysvars::{Sysvar, clock::Clock}}; +use pinocchio::{ + error::ProgramError, + sysvars::{clock::Clock, Sysvar}, + AccountView, ProgramResult, +}; use settlement_interface::{ data::order::{EncodedOrderAccount, OrderAccount}, instruction::{reclaim_order::ReclaimOrderInput, InstructionInputParsing}, @@ -50,11 +54,9 @@ pub fn process_reclaim_order( #[cfg(test)] mod tests { use pinocchio::Address; - use settlement_interface::{ - instruction::{ - fixtures::{fake_account, fake_account_with_data, fake_sequential_accounts}, - reclaim_order::fixtures::{default_reclaim_data, NUM_ACCOUNTS}, - }, + use settlement_interface::instruction::{ + fixtures::{fake_account, fake_account_with_data, fake_sequential_accounts}, + reclaim_order::fixtures::{default_reclaim_data, NUM_ACCOUNTS}, }; use super::*; diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 9921ca1..3fc2e8e 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -3,7 +3,10 @@ use std::ops::Deref; use pinocchio::{ - AccountView, Address, ProgramResult, cpi::{Seed, Signer}, error::ProgramError, sysvars::{Sysvar, clock::Clock, instructions::Instructions}, + 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::{ diff --git a/programs/settlement/tests/reclaim_order.rs b/programs/settlement/tests/reclaim_order.rs index 9e9e92a..c7c96f1 100644 --- a/programs/settlement/tests/reclaim_order.rs +++ b/programs/settlement/tests/reclaim_order.rs @@ -1,5 +1,5 @@ use settlement_client::settlement_interface::{ - data::intent::{EncodedOrderIntent, OrderIntent, OrderKind, fixtures::sample_intent}, + data::intent::{fixtures::sample_intent, EncodedOrderIntent, OrderIntent, OrderKind}, instruction::{create_order::CreateOrder, reclaim_order::ReclaimOrder}, pda::order::find_order_pda, SettlementError, @@ -7,9 +7,10 @@ use settlement_client::settlement_interface::{ use solana_sdk::{ pubkey::Pubkey, signature::{Keypair, Signer}, - transaction::Transaction, }; +use crate::common::signed_tx; + mod common; const VALID_TO: u32 = 1_000; @@ -48,12 +49,7 @@ fn create_order( order_pda: pda, intent_bytes: encoded, }; - let tx = Transaction::new_signed_with_payer( - &[ix.into()], - Some(&owner.pubkey()), - &[owner], - svm.latest_blockhash(), - ); + let tx = signed_tx(svm, owner, owner, ix); svm.send_transaction(tx) .expect("create_order should succeed"); pda @@ -89,12 +85,7 @@ fn happy_path_returns_lamports_and_closes_pda() { order_pda: pda, intent_bytes: encoded_bytes, }; - let tx = Transaction::new_signed_with_payer( - &[ix.into()], - Some(&fee_payer.pubkey()), - &[&fee_payer, &reclaim_recipient], - svm.latest_blockhash(), - ); + let tx = signed_tx(&svm, &fee_payer, &reclaim_recipient, ix); svm.send_transaction(tx) .expect("create_order should succeed"); @@ -115,12 +106,7 @@ fn happy_path_returns_lamports_and_closes_pda() { reclaim_recipient: reclaim_recipient.pubkey(), } .instruction(); - let tx = Transaction::new_signed_with_payer( - &[ix], - Some(&fee_payer.pubkey()), - &[&fee_payer], - svm.latest_blockhash(), - ); + let tx = signed_tx(&svm, &fee_payer, &fee_payer, ix); svm.send_transaction(tx) .expect("reclaim_order should succeed after expiry"); @@ -154,12 +140,7 @@ fn rejects_when_order_not_yet_expired() { reclaim_recipient: owner.pubkey(), } .instruction(); - let tx = Transaction::new_signed_with_payer( - &[ix], - Some(&owner.pubkey()), - &[&owner], - svm.latest_blockhash(), - ); + let tx = signed_tx(&svm, &owner, &owner, ix); common::assert_settlement_error( svm.send_transaction(tx).map_err(|e| e.err), SettlementError::OrderNotExpired, @@ -182,12 +163,7 @@ fn rejects_when_reclaim_recipient_mismatch() { reclaim_recipient: wrong_authority, } .instruction(); - let tx = Transaction::new_signed_with_payer( - &[ix], - Some(&owner.pubkey()), - &[&owner], - svm.latest_blockhash(), - ); + let tx = signed_tx(&svm, &owner, &owner, ix); common::assert_settlement_error( svm.send_transaction(tx).map_err(|e| e.err), SettlementError::ReclaimRecipientMismatch, @@ -209,11 +185,6 @@ fn rejects_missing_accounts() { .discriminator(), ], }; - let tx = Transaction::new_signed_with_payer( - &[ix], - Some(&owner.pubkey()), - &[&owner], - svm.latest_blockhash(), - ); + let tx = signed_tx(&svm, &owner, &owner, ix); assert!(svm.send_transaction(tx).is_err()); } From 9ed99a65bc71390c2da6204d3c12323d52360552 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:16:18 +0900 Subject: [PATCH 13/16] remove unnecessary test --- programs/settlement/tests/reclaim_order.rs | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/programs/settlement/tests/reclaim_order.rs b/programs/settlement/tests/reclaim_order.rs index c7c96f1..204fd1a 100644 --- a/programs/settlement/tests/reclaim_order.rs +++ b/programs/settlement/tests/reclaim_order.rs @@ -168,23 +168,4 @@ fn rejects_when_reclaim_recipient_mismatch() { svm.send_transaction(tx).map_err(|e| e.err), SettlementError::ReclaimRecipientMismatch, ); -} - -#[test] -fn rejects_missing_accounts() { - let (mut svm, program_id, owner) = common::setup(); - - common::set_unix_timestamp(&mut svm, AFTER_EXPIRY); - - // Build a minimal instruction with only the discriminator, no accounts. - let ix = solana_sdk::instruction::Instruction { - program_id, - accounts: vec![], - data: vec![ - settlement_client::settlement_interface::SettlementInstruction::ReclaimOrder - .discriminator(), - ], - }; - let tx = signed_tx(&svm, &owner, &owner, ix); - assert!(svm.send_transaction(tx).is_err()); -} +} \ No newline at end of file From ffdefe45cb7bc8783efd8c8c3727cb8a953c975c Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:23:53 +0900 Subject: [PATCH 14/16] add a helper function to load an order account with canonical safety checks from pda this requires adding bumps to the input data of the reclaimorder function --- interface/src/data/order.rs | 95 ++++++++++++++++++++++ interface/src/instruction/reclaim_order.rs | 47 ++++++++--- programs/settlement/src/reclaim_order.rs | 25 +++--- programs/settlement/src/settle/begin.rs | 30 ++----- programs/settlement/tests/reclaim_order.rs | 9 +- 5 files changed, 159 insertions(+), 47 deletions(-) diff --git a/interface/src/data/order.rs b/interface/src/data/order.rs index 3efc08c..3b3f0ad 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -22,11 +22,15 @@ use core::mem::size_of; use arrayref::{array_refs, mut_array_refs}; use derive_more::Deref; +use solana_account_view::AccountView; +use solana_address::Address; use solana_hash::Hash; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; use crate::data::intent::{self, EncodedOrderIntent, OrderIntent}; +use crate::pda::order::order_pda_signer_seeds; +use crate::SettlementError; /// Idiomatic representation of an order PDA's body. #[derive(Clone, Debug, Eq, PartialEq, Default)] @@ -51,6 +55,31 @@ pub struct OrderAccount { pub intent: OrderIntent, } +impl OrderAccount { + /// Load and decode the order at the given PDA, and confirm its canonical + pub fn load_from_pda( + order_pda: &AccountView, + program_id: &Address, + bump: u8, + ) -> Result { + 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)?; + drop(data); + + 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()); + } + + Ok(account) + } +} + /// Canonical 199-byte representation of an [`OrderAccount`]. The bytes /// written to/read from the order PDA's data area. /// @@ -359,6 +388,72 @@ mod tests { assert_eq!(err, ProgramError::InvalidAccountData); } + mod load_from_pda { + use super::*; + use crate::instruction::fixtures::fake_account_with_data; + use crate::pda::order::find_order_pda; + + const PROGRAM_ID: Address = Address::new_from_array([9; 32]); + + #[test] + fn accepts_the_canonical_pda() { + let account = sample_account(false); + let (pda_address, bump) = find_order_pda(&PROGRAM_ID, &account.intent.uid()); + let order_pda = fake_account_with_data( + pda_address, + &EncodedOrderAccount::from(account.clone())[..], + ); + + let loaded = OrderAccount::load_from_pda(&order_pda, &PROGRAM_ID, bump) + .expect("canonical PDA must load"); + assert_eq!(loaded, account); + } + + #[test] + fn rejects_a_non_canonical_address() { + let account = sample_account(false); + let (_, bump) = find_order_pda(&PROGRAM_ID, &account.intent.uid()); + // An address unrelated to the intent's canonical seeds. + let wrong_address = Pubkey::new_from_array([0x42; 32]); + let order_pda = + fake_account_with_data(wrong_address, &EncodedOrderAccount::from(account)[..]); + + let err = OrderAccount::load_from_pda(&order_pda, &PROGRAM_ID, bump) + .expect_err("a non-canonical address must be rejected"); + assert_eq!(err, SettlementError::OrderNotCanonical.into()); + } + + #[test] + fn rejects_a_wrong_bump() { + let account = sample_account(false); + let (pda_address, bump) = find_order_pda(&PROGRAM_ID, &account.intent.uid()); + let order_pda = + fake_account_with_data(pda_address, &EncodedOrderAccount::from(account)[..]); + + // Any bump other than the canonical one either derives a + // different address or fails to derive one at all (falling on + // curve); either way, the PDA can no longer be proven canonical. + let wrong_bump = bump.wrapping_sub(1); + let err = OrderAccount::load_from_pda(&order_pda, &PROGRAM_ID, wrong_bump) + .expect_err("a non-canonical bump must be rejected"); + assert_eq!(err, SettlementError::OrderNotCanonical.into()); + } + + #[test] + fn propagates_decode_errors() { + let account = sample_account(false); + let (pda_address, bump) = find_order_pda(&PROGRAM_ID, &account.intent.uid()); + let mut bytes: [u8; EncodedOrderAccount::SIZE] = + EncodedOrderAccount::from(account).into(); + bytes[CANCELLED_OFFSET] = 0xff; + let order_pda = fake_account_with_data(pda_address, &bytes); + + let err = OrderAccount::load_from_pda(&order_pda, &PROGRAM_ID, bump) + .expect_err("a corrupt account must fail to decode"); + assert_eq!(err, ProgramError::InvalidAccountData); + } + } + #[test] fn direct_write_account_matches_order_account_decoding() { let cancelled = true; diff --git a/interface/src/instruction/reclaim_order.rs b/interface/src/instruction/reclaim_order.rs index 5f6ecd0..b73faba 100644 --- a/interface/src/instruction/reclaim_order.rs +++ b/interface/src/instruction/reclaim_order.rs @@ -4,7 +4,9 @@ //! `created_by` account recorded in the order body. The instruction may only be //! executed after the order's `valid_to` timestamp has elapsed. //! -//! Wire format: `[discriminator=5]`, 1 byte. +//! Wire format: `[discriminator=5][bump: u8]`, 2 bytes. `bump` is the order +//! PDA's canonical bump, used to prove `order_pda` is the canonical order PDA +//! for the intent it stores. //! Required accounts: //! `[order_pda (W), reclaim_recipient (W)]`. @@ -18,13 +20,15 @@ use crate::SettlementInstruction; /// Builder for a `ReclaimOrder` instruction. /// -/// `order_pda` is the canonical order PDA to close. `reclaim_recipient` must -/// be the account recorded as `created_by` in the order PDA; it receives the -/// recovered rent lamports. The instruction enforces no signature requirement: -/// anyone may reclaim an expired order on behalf of its reclaim_recipient. +/// `order_pda` is the canonical order PDA to close, and `bump` is its +/// canonical bump. `reclaim_recipient` must be the account recorded as +/// `created_by` in the order PDA; it receives the recovered rent lamports. +/// The instruction enforces no signature requirement: anyone may reclaim an +/// expired order on behalf of its reclaim_recipient. pub struct ReclaimOrder { pub program_id: Pubkey, pub order_pda: Pubkey, + pub bump: u8, pub reclaim_recipient: Pubkey, } @@ -36,7 +40,10 @@ impl ReclaimOrder { AccountMeta::new(self.order_pda, false), AccountMeta::new(self.reclaim_recipient, false), ], - data: vec![SettlementInstruction::ReclaimOrder.discriminator()], + data: vec![ + SettlementInstruction::ReclaimOrder.discriminator(), + self.bump, + ], } } } @@ -44,6 +51,7 @@ impl ReclaimOrder { /// Parsed inputs of a `ReclaimOrder` instruction. pub struct ReclaimOrderInput<'a> { pub order_pda: &'a mut AccountView, + pub bump: u8, pub reclaim_recipient: &'a mut AccountView, } @@ -54,15 +62,16 @@ impl<'a> InstructionInputParsing<'a> for ReclaimOrderInput<'a> { instruction_data: &'a [u8], accounts: &'a mut [AccountView], ) -> Result { - // No instruction body: only the discriminator, already stripped. - if !instruction_data.is_empty() { + // Body is a single bump byte, already stripped of the discriminator. + let &[bump] = instruction_data else { return Err(ProgramError::InvalidInstructionData); - } + }; let [order_pda, reclaim_recipient, ..] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); }; Ok(Self { order_pda, + bump, reclaim_recipient, }) } @@ -86,6 +95,7 @@ pub mod fixtures { ReclaimOrder { program_id: zero, order_pda: zero, + bump: 0, reclaim_recipient: zero, } .instruction() @@ -104,11 +114,13 @@ mod tests { fn reclaim_order_input_parses_valid_input() { let program_id = Address::new_from_array([1; 32]); let order_pda = Address::new_from_array([2; 32]); + let bump = 42; let reclaim_recipient = Address::new_from_array([3; 32]); let data = ReclaimOrder { program_id, order_pda, + bump, reclaim_recipient, } .instruction() @@ -117,10 +129,12 @@ mod tests { let ReclaimOrderInput { order_pda: derived_order_pda, + bump: derived_bump, reclaim_recipient: derived_reclaim_recipient, } = ReclaimOrderInput::parse(&data, &mut accounts).expect("parse should succeed"); assert_eq!(*derived_order_pda.address(), order_pda); + assert_eq!(derived_bump, bump); assert_eq!(*derived_reclaim_recipient.address(), reclaim_recipient); } @@ -135,6 +149,16 @@ mod tests { ); } + #[test] + fn reclaim_order_input_rejects_missing_body() { + let data = vec![SettlementInstruction::ReclaimOrder.discriminator()]; + let mut accounts = fake_sequential_accounts::(); + assert_eq!( + ReclaimOrderInput::parse(&data, &mut accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + #[test] fn reclaim_order_input_rejects_missing_accounts() { let data = default_reclaim_data(); @@ -150,18 +174,20 @@ mod tests { fn instruction_data_has_expected_layout() { let program_id = Address::new_from_array([1; 32]); let order_pda = Address::new_from_array([2; 32]); + let bump = 42; let reclaim_recipient = Address::new_from_array([3; 32]); let ix = ReclaimOrder { program_id, order_pda, + bump, reclaim_recipient, } .instruction(); assert_eq!( ix.data, - vec![SettlementInstruction::ReclaimOrder.discriminator()] + vec![SettlementInstruction::ReclaimOrder.discriminator(), bump] ); } @@ -174,6 +200,7 @@ mod tests { let ix = ReclaimOrder { program_id, order_pda, + bump: 42, reclaim_recipient, } .instruction(); diff --git a/programs/settlement/src/reclaim_order.rs b/programs/settlement/src/reclaim_order.rs index be42d1a..c6d0d9f 100644 --- a/programs/settlement/src/reclaim_order.rs +++ b/programs/settlement/src/reclaim_order.rs @@ -6,28 +6,23 @@ use pinocchio::{ AccountView, ProgramResult, }; use settlement_interface::{ - data::order::{EncodedOrderAccount, OrderAccount}, + data::order::OrderAccount, instruction::{reclaim_order::ReclaimOrderInput, InstructionInputParsing}, SettlementError, }; pub fn process_reclaim_order( - _program_id: &pinocchio::Address, + program_id: &pinocchio::Address, accounts: &mut [AccountView], instruction_data: &[u8], ) -> ProgramResult { let ReclaimOrderInput { order_pda, + bump, reclaim_recipient, } = ReclaimOrderInput::parse(instruction_data, accounts)?; - let account = { - let data = order_pda.try_borrow()?; - let bytes: &[u8; EncodedOrderAccount::SIZE] = (&*data) - .try_into() - .map_err(|_| ProgramError::InvalidAccountData)?; - OrderAccount::try_from(*bytes)? - }; + let account = OrderAccount::load_from_pda(order_pda, program_id, bump)?; if reclaim_recipient.address() != &account.created_by { return Err(SettlementError::ReclaimRecipientMismatch.into()); @@ -54,10 +49,13 @@ pub fn process_reclaim_order( #[cfg(test)] mod tests { use pinocchio::Address; + use settlement_interface::data::order::EncodedOrderAccount; use settlement_interface::instruction::{ fixtures::{fake_account, fake_account_with_data, fake_sequential_accounts}, reclaim_order::fixtures::{default_reclaim_data, NUM_ACCOUNTS}, }; + use settlement_interface::pda::order::find_order_pda; + use settlement_interface::SettlementInstruction; use super::*; @@ -77,8 +75,6 @@ mod tests { #[test] fn process_reclaim_order_rejects_mismatched_reclaim_recipient() { - let data = default_reclaim_data(); - let reclaim_recipient = fake_account(Address::new_unique()); let order_data = OrderAccount { @@ -86,8 +82,13 @@ mod tests { ..Default::default() }; + // The order PDA must be canonical for `order_data.intent`, otherwise + // `load_from_pda` rejects it before the recipient check ever runs. + let (order_pda_address, bump) = find_order_pda(&PROGRAM_ID, &order_data.intent.uid()); + let data = vec![SettlementInstruction::ReclaimOrder.discriminator(), bump]; + let order_pda = fake_account_with_data( - Address::new_unique(), + order_pda_address, &EncodedOrderAccount::from(order_data)[..], ); diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 3fc2e8e..9fa802a 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -10,13 +10,13 @@ use pinocchio::{ }; use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; use settlement_interface::{ - data::order::EncodedOrderAccount, + data::order::OrderAccount, instruction::{ create_buffer::SPL_TOKEN_PROGRAM_ID, settle::{BeginSettleInput, SettledOrder}, InstructionInputParsing, }, - pda::{order::order_pda_signer_seeds, state::state_pda_seeds}, + pda::state::state_pda_seeds, recover_discriminator, Pubkey, SettlementError, SettlementInstruction, }; @@ -179,27 +179,11 @@ fn process_order( 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()); - } + // Decode the order body and prove its provenance: `load_from_pda` checks + // that `order_pda` is the canonical order PDA for the intent it stores. + let OrderAccount { + cancelled, intent, .. + } = OrderAccount::load_from_pda(order_pda, program_id, bump)?; if cancelled { return Err(SettlementError::OrderCancelled.into()); diff --git a/programs/settlement/tests/reclaim_order.rs b/programs/settlement/tests/reclaim_order.rs index 204fd1a..c1c0555 100644 --- a/programs/settlement/tests/reclaim_order.rs +++ b/programs/settlement/tests/reclaim_order.rs @@ -71,7 +71,7 @@ fn happy_path_returns_lamports_and_closes_pda() { }; let encoded = EncodedOrderIntent::from(&intent); let encoded_bytes: [u8; EncodedOrderIntent::SIZE] = (&encoded).into(); - let (pda, _) = find_order_pda(&program_id, &encoded.hash()); + let (pda, bump) = find_order_pda(&program_id, &encoded.hash()); let pda_rent = svm.minimum_balance_for_rent_exemption( settlement_client::settlement_interface::data::order::EncodedOrderAccount::SIZE, @@ -103,6 +103,7 @@ fn happy_path_returns_lamports_and_closes_pda() { let ix = ReclaimOrder { program_id, order_pda: pda, + bump, reclaim_recipient: reclaim_recipient.pubkey(), } .instruction(); @@ -131,12 +132,14 @@ fn rejects_when_order_not_yet_expired() { let intent = reclaim_sample_intent(owner.pubkey()); let pda = create_order(&mut svm, &program_id, &owner, &intent); + let (_, bump) = find_order_pda(&program_id, &EncodedOrderIntent::from(&intent).hash()); common::set_unix_timestamp(&mut svm, VALID_TO as i64); // technically this is the last valid timestamp let ix = ReclaimOrder { program_id, order_pda: pda, + bump, reclaim_recipient: owner.pubkey(), } .instruction(); @@ -153,6 +156,7 @@ fn rejects_when_reclaim_recipient_mismatch() { let intent = reclaim_sample_intent(owner.pubkey()); let pda = create_order(&mut svm, &program_id, &owner, &intent); + let (_, bump) = find_order_pda(&program_id, &EncodedOrderIntent::from(&intent).hash()); common::set_unix_timestamp(&mut svm, AFTER_EXPIRY); @@ -160,6 +164,7 @@ fn rejects_when_reclaim_recipient_mismatch() { let ix = ReclaimOrder { program_id, order_pda: pda, + bump, reclaim_recipient: wrong_authority, } .instruction(); @@ -168,4 +173,4 @@ fn rejects_when_reclaim_recipient_mismatch() { svm.send_transaction(tx).map_err(|e| e.err), SettlementError::ReclaimRecipientMismatch, ); -} \ No newline at end of file +} From a7afebd9f07e109ca8df92b4c34cd74a62de99d0 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:48:22 +0900 Subject: [PATCH 15/16] use suggested code from pinnochio to construct an account with fake data --- interface/src/instruction/mod.rs | 97 ++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 29 deletions(-) diff --git a/interface/src/instruction/mod.rs b/interface/src/instruction/mod.rs index ab9616b..9fa09cd 100644 --- a/interface/src/instruction/mod.rs +++ b/interface/src/instruction/mod.rs @@ -50,7 +50,8 @@ pub trait InstructionInputParsing<'a>: Sized { /// duplicating the unsafe initializer below. #[cfg(any(test, feature = "test-fixtures"))] pub mod fixtures { - use solana_account_view::{AccountView, RuntimeAccount}; + use std::{ptr, mem}; + use solana_account_view::{AccountView, MAX_PERMITTED_DATA_INCREASE, RuntimeAccount}; use solana_address::Address; /// Build an `AccountView` based on the input `RuntimeAccount` and whose @@ -86,35 +87,73 @@ pub mod fixtures { unsafe { AccountView::new_unchecked(backing as *mut RuntimeAccount) } } - pub fn fake_account_with_data(address: Address, data: &[u8]) -> AccountView { - // The RuntimeAccount struct actually functions as a header. If any data is included in the account, it should be placed in the bytes following the header. - // For this, we need to allocate some data on the heap to hold both the account and the data we want to store. - // We use Box::leak to prevent the memory from being deallocated after this function, which is fine for tests. - const HEADER: usize = core::mem::size_of::(); - - let buf = Box::leak(Box::<[u8]>::new_uninit_slice( - HEADER - .checked_add(data.len()) - .expect("overflow when allocating account data"), - )); - let base = buf.as_mut_ptr() as *mut u8; - - unsafe { - std::ptr::write( - base as *mut RuntimeAccount, - RuntimeAccount { - address, - borrow_state: solana_account_view::NOT_BORROWED, // allows for code to borrow this account to read its data - data_len: data.len() as u64, - ..Default::default() - }, - ); - - // mostly equivalent to C's `memcpy` - std::ptr::copy_nonoverlapping(data.as_ptr(), base.add(HEADER), data.len()); - } + /// Adapted from pinocchio's crate-private test helper; kept structurally + /// close for comparison: + /// https://docs.rs/crate/pinocchio/0.11.1/source/src/sysvars/slot_hashes/test_utils.rs#120-160 + /// + /// Allocate a heap-backed `AccountView` whose data region is initialized with + /// `data`, whose address is `address`, and whose borrow flag is `borrow_state`. + /// + /// The function also returns the backing `Vec` so the caller can keep it + /// alive for the duration of the test (otherwise the memory would be freed and + /// the raw pointer inside `AccountView` would dangle). + /// + /// # Safety + /// The caller must ensure the returned `AccountView` is used only for reading + /// or according to borrow rules because the Solana runtime invariants are not + /// fully enforced in this hand-rolled representation. + #[allow( + clippy::arithmetic_side_effects, + reason = "the function is mostly vendored and don't want to introduce unnecessary changes" + )] + pub unsafe fn make_account_view( + address: Address, + data: &[u8], + borrow_state: u8, + ) -> (AccountView, Vec) { + // pinocchio writes a hand-rolled `AccountLayout` mirror and casts the + // pointer to `*mut RuntimeAccount`, because inside that crate the + // account struct's fields are private and its tests cannot set them by + // name. Here we instead write a real `RuntimeAccount`, since + // `solana_account_view::RuntimeAccount` has public fields and a + // `Default` impl. + let hdr_size = mem::size_of::(); + // we over-allocate by MAX_PERMITTED_DATA_INCREASE to prevent memory + //corruption in the unlikely case a runtime increases the account data size + let total = hdr_size + data.len() + MAX_PERMITTED_DATA_INCREASE; + let words = total.div_ceil(mem::size_of::()); + let mut backing: Vec = vec![0u64; words]; + assert!( + mem::align_of::() >= mem::align_of::(), + "`backing` should be properly aligned to store a `RuntimeAccount` instance" + ); - unsafe { AccountView::new_unchecked(buf.as_mut_ptr() as *mut RuntimeAccount) } + let hdr_ptr = backing.as_mut_ptr() as *mut RuntimeAccount; + ptr::write( + hdr_ptr, + RuntimeAccount { + address, + borrow_state, + data_len: data.len() as u64, + ..Default::default() + }, + ); + + ptr::copy_nonoverlapping( + data.as_ptr(), + (hdr_ptr as *mut u8).add(hdr_size), + data.len(), + ); + + (AccountView::new_unchecked(hdr_ptr), backing) + } + + /// Create an account view storing the input data. + pub fn fake_account_with_data(address: Address, data: &[u8]) -> AccountView { + let (account, backing) = + unsafe { make_account_view(address, data, solana_account_view::NOT_BORROWED) }; + core::mem::forget(backing); + account } pub fn fake_account(address: Address) -> AccountView { From 4c045d4a869485357202aa835b86ebcdb15788a5 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:53:22 +0900 Subject: [PATCH 16/16] just fmt --- interface/src/instruction/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/interface/src/instruction/mod.rs b/interface/src/instruction/mod.rs index 9fa09cd..9398860 100644 --- a/interface/src/instruction/mod.rs +++ b/interface/src/instruction/mod.rs @@ -50,9 +50,9 @@ pub trait InstructionInputParsing<'a>: Sized { /// duplicating the unsafe initializer below. #[cfg(any(test, feature = "test-fixtures"))] pub mod fixtures { - use std::{ptr, mem}; - use solana_account_view::{AccountView, MAX_PERMITTED_DATA_INCREASE, RuntimeAccount}; + use solana_account_view::{AccountView, RuntimeAccount, MAX_PERMITTED_DATA_INCREASE}; use solana_address::Address; + use std::{mem, ptr}; /// Build an `AccountView` based on the input `RuntimeAccount` and whose /// data region is empty. @@ -118,7 +118,7 @@ pub mod fixtures { // `solana_account_view::RuntimeAccount` has public fields and a // `Default` impl. let hdr_size = mem::size_of::(); - // we over-allocate by MAX_PERMITTED_DATA_INCREASE to prevent memory + // we over-allocate by MAX_PERMITTED_DATA_INCREASE to prevent memory //corruption in the unlikely case a runtime increases the account data size let total = hdr_size + data.len() + MAX_PERMITTED_DATA_INCREASE; let words = total.div_ceil(mem::size_of::());