Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9ce4ad9
setup reclaim order
kaze-cow Jul 1, 2026
555239a
fix lints and errors
kaze-cow Jul 1, 2026
013557a
simplify reclaim order test even more
kaze-cow Jul 1, 2026
b892ae7
Merge branch 'main' into kaze/sc-150-close-order-account
kaze-cow Jul 3, 2026
9199880
stub timestamp function only runs when explicit feature is enabled
kaze-cow Jul 3, 2026
83e3556
Merge branch 'kaze/sc-150-close-order-account' of https://github.com/…
kaze-cow Jul 3, 2026
ce3280c
Merge branch 'main' into kaze/sc-150-close-order-account
kaze-cow Jul 8, 2026
68a3bea
Update interface/src/instruction/reclaim_order.rs
kaze-cow Jul 8, 2026
8919f68
Update programs/settlement/src/reclaim_order.rs
kaze-cow Jul 8, 2026
76e8434
Update programs/settlement/src/reclaim_order.rs
kaze-cow Jul 8, 2026
af95669
Update programs/settlement/tests/reclaim_order.rs
kaze-cow Jul 8, 2026
3d25f1f
remove settlement-test-clock and related unit test, will move to inte…
kaze-cow Jul 8, 2026
908681b
fix test error
kaze-cow Jul 8, 2026
c6627d7
fix lint failures
kaze-cow Jul 8, 2026
8a4a989
use signed_tx helper
kaze-cow Jul 8, 2026
9ed99a6
remove unnecessary test
kaze-cow Jul 8, 2026
ffdefe4
add a helper function to load an order account with canonical safety …
kaze-cow Jul 8, 2026
a7afebd
use suggested code from pinnochio to construct an account with fake data
kaze-cow Jul 8, 2026
4c045d4
just fmt
kaze-cow Jul 8, 2026
fee918a
Merge branch 'main' into kaze/sc-150-close-order-account
kaze-cow Jul 16, 2026
e0c8467
Update interface/src/instruction/mod.rs
kaze-cow Jul 17, 2026
ba0fd79
Update interface/src/instruction/reclaim_order.rs
kaze-cow Jul 17, 2026
bfb85ae
Update programs/settlement/src/reclaim_order.rs
kaze-cow Jul 17, 2026
09b2d87
Update programs/settlement/tests/reclaim_order.rs
kaze-cow Jul 17, 2026
ed4b7e1
Update interface/src/data/order.rs
kaze-cow Jul 17, 2026
a2d2d99
Merge branch 'main' into kaze/sc-150-close-order-account
kaze-cow Jul 17, 2026
a227027
fix lints and remove AFTER_EXPIRY
kaze-cow Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions interface/src/data/intent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion interface/src/data/order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 33 additions & 4 deletions interface/src/instruction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -85,11 +86,39 @@ 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::<RuntimeAccount>();

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());
}

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, &[])
}

Comment thread
kaze-cow marked this conversation as resolved.
pub fn fake_account_from_array(address_array: [u8; 32]) -> AccountView {
Expand Down
192 changes: 192 additions & 0 deletions interface/src/instruction/reclaim_order.rs
Original file line number Diff line number Diff line change
@@ -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<Self, ProgramError> {
// 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<u8> {
Comment thread
fedgiac marked this conversation as resolved.
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() {
Comment thread
kaze-cow marked this conversation as resolved.
Outdated
let mut data = default_reclaim_data();
data.push(0);
let mut accounts = fake_sequential_accounts::<NUM_ACCOUNTS>();
assert_eq!(
ReclaimOrderInput::parse(&data, &mut accounts).err(),
Some(ProgramError::InvalidInstructionData),
);
}

#[test]
fn reclaim_order_input_rejects_missing_accounts() {
Comment thread
kaze-cow marked this conversation as resolved.
let data = default_reclaim_data();
let mut accounts: Vec<AccountView> = fake_sequential_accounts::<NUM_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()
);
Comment thread
kaze-cow marked this conversation as resolved.
Outdated
}

#[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);
}
}
6 changes: 6 additions & 0 deletions interface/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub enum SettlementInstruction {
CreateOrder = 2,
Initialize = 3,
CreateBuffer = 4,
ReclaimOrder = 5,
}

impl SettlementInstruction {
Expand Down Expand Up @@ -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<SettlementError> for u32 {
Expand Down
3 changes: 3 additions & 0 deletions programs/settlement/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions programs/settlement/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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)
}
}
}
19 changes: 19 additions & 0 deletions programs/settlement/src/processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use pinocchio::{
address::MAX_SEEDS,
cpi::{Seed, Signer},
error::ProgramError,
AccountView, Address, ProgramResult,
};

Expand Down Expand Up @@ -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 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<i64, ProgramError> {
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<i64, ProgramError> {
Ok(1000) // arbitrary fixed timestamp for unit testing purposes
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading