diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index 3686f6d..1452fa5 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -24,14 +24,15 @@ 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 { + #[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..3b3f0ad 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -22,14 +22,18 @@ 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)] +#[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. @@ -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/mod.rs b/interface/src/instruction/mod.rs index 3c53184..9398860 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. @@ -49,8 +50,9 @@ 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 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. @@ -85,11 +87,77 @@ pub mod fixtures { unsafe { AccountView::new_unchecked(backing as *mut RuntimeAccount) } } + /// 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" + ); + + 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 { - 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..b73faba --- /dev/null +++ b/interface/src/instruction/reclaim_order.rs @@ -0,0 +1,218 @@ +//! `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][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)]`. + +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, 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, +} + +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(), + self.bump, + ], + } + } +} + +/// 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, +} + +impl<'a> InstructionInputParsing<'a> for ReclaimOrderInput<'a> { + const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::ReclaimOrder; + + fn parse_body( + instruction_data: &'a [u8], + accounts: &'a mut [AccountView], + ) -> Result { + // 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, + }) + } +} + +/// 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, + bump: 0, + 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 bump = 42; + let reclaim_recipient = Address::new_from_array([3; 32]); + + let data = ReclaimOrder { + program_id, + order_pda, + bump, + reclaim_recipient, + } + .instruction() + .data; + let mut accounts = [fake_account(order_pda), fake_account(reclaim_recipient)]; + + 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); + } + + #[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_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(); + 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 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(), bump] + ); + } + + #[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, + bump: 42, + 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/reclaim_order.rs b/programs/settlement/src/reclaim_order.rs new file mode 100644 index 0000000..c6d0d9f --- /dev/null +++ b/programs/settlement/src/reclaim_order.rs @@ -0,0 +1,100 @@ +//! `ReclaimOrder` instruction handler. + +use pinocchio::{ + error::ProgramError, + sysvars::{clock::Clock, Sysvar}, + AccountView, ProgramResult, +}; +use settlement_interface::{ + data::order::OrderAccount, + instruction::{reclaim_order::ReclaimOrderInput, InstructionInputParsing}, + SettlementError, +}; + +pub fn process_reclaim_order( + 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 = OrderAccount::load_from_pda(order_pda, program_id, bump)?; + + if reclaim_recipient.address() != &account.created_by { + return Err(SettlementError::ReclaimRecipientMismatch.into()); + } + + let now = Clock::get()?.unix_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::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::*; + + 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 reclaim_recipient = fake_account(Address::new_unique()); + + let order_data = OrderAccount { + created_by: Address::new_unique(), + ..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( + order_pda_address, + &EncodedOrderAccount::from(order_data)[..], + ); + + assert_eq!( + process_reclaim_order(&PROGRAM_ID, &mut [order_pda, reclaim_recipient], &data), + Err(SettlementError::ReclaimRecipientMismatch.into()), + ); + } +} 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 new file mode 100644 index 0000000..c1c0555 --- /dev/null +++ b/programs/settlement/tests/reclaim_order.rs @@ -0,0 +1,176 @@ +use settlement_client::settlement_interface::{ + data::intent::{fixtures::sample_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}, +}; + +use crate::common::signed_tx; + +mod common; + +const VALID_TO: u32 = 1_000; +const AFTER_EXPIRY: i64 = 1_001; + +fn reclaim_sample_intent(owner: Pubkey) -> OrderIntent { + OrderIntent { + owner, + valid_to: VALID_TO, + ..sample_intent(OrderKind::Sell, true) + } +} + +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, + }; + let tx = signed_tx(svm, owner, owner, ix); + 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(), + ..reclaim_sample_intent(fee_payer.pubkey()) + }; + let encoded = EncodedOrderIntent::from(&intent); + let encoded_bytes: [u8; EncodedOrderIntent::SIZE] = (&encoded).into(); + 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, + ); + + // 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, + }; + let tx = signed_tx(&svm, &fee_payer, &reclaim_recipient, ix); + 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, + bump, + reclaim_recipient: reclaim_recipient.pubkey(), + } + .instruction(); + let tx = signed_tx(&svm, &fee_payer, &fee_payer, ix); + 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 = 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(); + let tx = signed_tx(&svm, &owner, &owner, ix); + 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 = 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); + + let wrong_authority = Pubkey::new_unique(); + let ix = ReclaimOrder { + program_id, + order_pda: pda, + bump, + reclaim_recipient: wrong_authority, + } + .instruction(); + let tx = signed_tx(&svm, &owner, &owner, ix); + common::assert_settlement_error( + svm.send_transaction(tx).map_err(|e| e.err), + SettlementError::ReclaimRecipientMismatch, + ); +}