Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
1b41f69
Add state PDA
fedgiac Jun 4, 2026
e9ddb24
Add buffer PDAs
fedgiac Jun 4, 2026
22eac68
Move test tools to own feature
fedgiac Jun 5, 2026
a0bf3a3
Merge branch 'main' into expose-proptest-strategies-as-feature
kaze-cow Jun 8, 2026
c557c88
Add orders to BeginSettle
fedgiac Jun 8, 2026
c4db140
Fix name in comment
fedgiac Jun 9, 2026
af2862b
space -> size
fedgiac Jun 9, 2026
f788b8a
n_accounts -> fake_sequential_accounts::<n>
fedgiac Jun 9, 2026
3a8efc4
Merge remote-tracking branch 'authed/main' into state-pda
fedgiac Jun 9, 2026
5db57d2
Merge branch 'state-pda' into token-pdas
fedgiac Jun 9, 2026
c78d594
Fix formatting
fedgiac Jun 9, 2026
5d68fb6
super::super:: simplification
fedgiac Jun 9, 2026
54c10bf
Merge branch 'expose-proptest-strategies-as-feature' into include-ord…
fedgiac Jun 9, 2026
4b4f89d
Merge remote-tracking branch 'authed/main' into include-orders-in-set…
fedgiac Jun 9, 2026
0b161b4
Merge remote-tracking branch 'authed/main' into expose-proptest-strat…
fedgiac Jun 9, 2026
eb3521f
Merge branch 'expose-proptest-strategies-as-feature' into include-ord…
fedgiac Jun 9, 2026
e86bb43
[u8; 32] -> Hash
fedgiac Jun 9, 2026
1592674
Remove mention of zipping in doc comment
fedgiac Jun 9, 2026
b18e23c
decode_and_hash error bubbles up
fedgiac Jun 9, 2026
585fd18
Don't allocate vecs in `begin_settle`
fedgiac Jun 9, 2026
6d69063
Merge branch 'main' into include-orders-in-settlement
fedgiac Jun 10, 2026
d4fc5a4
Merge branch 'main' into state-pda
fedgiac Jun 10, 2026
eff2d70
Merge branch 'state-pda' into token-pdas
fedgiac Jun 10, 2026
d09903c
Comment on `process_initialize` checks
fedgiac Jun 11, 2026
7c8f236
Test different rent payer
fedgiac Jun 11, 2026
be64fd7
Fix phrasing
fedgiac Jun 11, 2026
0a97292
Fix phrasing, again
fedgiac Jun 11, 2026
3848ab6
Merge branch 'state-pda' into token-pdas
fedgiac Jun 11, 2026
63402fa
Merge branch 'main' into state-pda
fedgiac Jun 11, 2026
c060181
Merge branch 'state-pda' into token-pdas
fedgiac Jun 11, 2026
91cec54
Merge branch 'main' into include-orders-in-settlement
fedgiac Jun 11, 2026
febfcb2
Comment clarification
fedgiac Jun 12, 2026
4a2448f
Improve readability of canonical PDA creation
fedgiac Jun 12, 2026
9984f63
Test that buffers can receive funds
fedgiac Jun 12, 2026
5a21b36
Merge branch 'main' into token-pdas
fedgiac Jun 12, 2026
858d1d9
Merge branch 'token-pdas' into include-orders-in-settlement
fedgiac Jun 12, 2026
decf380
Use IntoIter instead of Iter
fedgiac Jun 12, 2026
94f24a3
Simplify expression
fedgiac Jun 12, 2026
76f9e46
Sort orders in interface
fedgiac Jun 12, 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: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pinocchio-token = "0.6"
proptest = "1"
settlement-client = { path = "client" }
settlement-interface = { path = "interface" }
solana-hash = "3"
solana-instruction = "3"
solana-program-error = "3"
solana-pubkey = "3"
Expand Down
27 changes: 26 additions & 1 deletion client/src/instructions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,32 @@ use settlement_interface::{

// Reexport the instruction builders that don't change from the interface.
// We want the client to provide all instruction builders.
pub use settlement_interface::instruction::settle::{begin_settle, finalize_settle};
pub use settlement_interface::instruction::settle::finalize_settle;

/// Build a `BeginSettle` instruction settling the specified orders. The orders
/// may be supplied in any order; the interface builder sorts them by PDA address.
pub fn begin_settle(
program_id: &Pubkey,
finalize_ix_index: u16,
intents: &[OrderIntent],
) -> Instruction {
let mut order_pdas = Vec::with_capacity(intents.len());
let mut sell_token_accounts = Vec::with_capacity(intents.len());
let mut bumps = Vec::with_capacity(intents.len());
for intent in intents {
let (order_pda, bump) = find_order_pda(program_id, &intent.uid());
order_pdas.push(order_pda);
sell_token_accounts.push(intent.sell_token_account);
bumps.push(bump);
}
settlement_interface::instruction::settle::begin_settle(
program_id,
finalize_ix_index,
&order_pdas,
&sell_token_accounts,
&bumps,
)
}

pub fn create_order(
program_id: &Pubkey,
Expand Down
1 change: 1 addition & 0 deletions interface/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ arrayref.workspace = true
derive_more.workspace = true
num_enum.workspace = true
proptest = { workspace = true, optional = true }
solana-hash.workspace = true
solana-instruction.workspace = true
solana-program-error.workspace = true
solana-pubkey = { workspace = true, features = ["curve25519"] }
Expand Down
19 changes: 11 additions & 8 deletions interface/src/data/intent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use core::mem::size_of;

use arrayref::{array_refs, mut_array_refs};
use derive_more::Deref;
use solana_hash::Hash;
use solana_program_error::ProgramError;
use solana_pubkey::Pubkey;

Expand Down Expand Up @@ -117,28 +118,30 @@ impl EncodedOrderIntent {
pub const SIZE: usize = 150;

/// Canonical hash of the bytes.
pub fn hash(&self) -> [u8; 32] {
solana_sha256_hasher::hashv(&[self.as_slice()]).to_bytes()
pub fn hash(&self) -> Hash {
hash_bytes(&self.0)
}

/// Decode raw bytes to an [`OrderIntent`] and compute the UID in one shot.
/// Returns [`ProgramError::InvalidInstructionData`] for an out-of-range
/// `kind` or `partially_fillable` byte; every other byte combination
/// decodes.
pub fn decode_and_hash(
bytes: &[u8; Self::SIZE],
) -> Result<(OrderIntent, [u8; 32]), ProgramError> {
pub fn decode_and_hash(bytes: &[u8; Self::SIZE]) -> Result<(OrderIntent, Hash), ProgramError> {
let intent = OrderIntent::try_from(bytes)?;
// The UID is the SHA-256 of the input bytes. Hashing the input
// (no re-encode) is correct because encode/decode is a bijection on
// inputs that pass validation. Any normalization added to the `From`
// or `TryFrom` impls later would break this and the UID would silently
// diverge from `OrderIntent::uid()`.
let uid = solana_sha256_hasher::hashv(&[bytes.as_slice()]).to_bytes();
let uid = hash_bytes(bytes);
Ok((intent, uid))
}
}

pub fn hash_bytes(bytes: &[u8; EncodedOrderIntent::SIZE]) -> Hash {
solana_sha256_hasher::hashv(&[bytes.as_slice()])
}

impl From<&EncodedOrderIntent> for [u8; EncodedOrderIntent::SIZE] {
fn from(encoded: &EncodedOrderIntent) -> Self {
encoded.0
Expand Down Expand Up @@ -253,7 +256,7 @@ impl OrderIntent {
/// SHA-256 of the canonical bytes. Doubles as the order UID and the
/// middle seed of the order PDA. On SBF this compiles to a single
/// `sol_sha256` syscall; off-target it goes through the `sha2` crate.
pub fn uid(&self) -> [u8; 32] {
pub fn uid(&self) -> Hash {
EncodedOrderIntent::from(self).hash()
}
}
Expand Down Expand Up @@ -455,7 +458,7 @@ mod tests {
fn uid_digest_regression() {
let intent = sample_intent(OrderKind::Buy, true);
let expected = hex!("091d7e1959ac6f7a400a91f1dcd9ce436f8f53e2b7a1d968acb08f79d3c1231d");
assert_eq!(intent.uid(), expected);
assert_eq!(intent.uid(), Hash::from(expected));
}

#[test]
Expand Down
42 changes: 41 additions & 1 deletion interface/src/data/order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ use core::mem::size_of;

use arrayref::{array_refs, mut_array_refs};
use derive_more::Deref;
use solana_hash::Hash;
use solana_program_error::ProgramError;
use solana_pubkey::Pubkey;

use crate::data::intent::{EncodedOrderIntent, OrderIntent};
use crate::data::intent::{self, EncodedOrderIntent, OrderIntent};

/// Idiomatic representation of an order PDA's body.
#[derive(Clone, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -79,6 +80,24 @@ impl EncodedOrderAccount {
const W_INTENT: usize = EncodedOrderIntent::SIZE;

pub const SIZE: usize = 199;

/// Decode the account body and compute the embedded intent's UID in one
/// shot, mirroring [`EncodedOrderIntent::decode_and_hash`]. Decoding
/// validates the intent; returns [`ProgramError::InvalidAccountData`] on a
/// decode error.
pub fn decode_and_hash(bytes: &[u8; Self::SIZE]) -> Result<(OrderAccount, Hash), ProgramError> {
let order_account = OrderAccount::try_from(*bytes)?;
// The order UID is the hash of the intent's canonical bytes. Decoding
// succeeded, so the intent slot already holds those exact bytes: hash
// them in place rather than using `intent.uid()` to avoid re-encoding.
let (_, raw_intent) = array_refs![
bytes,
EncodedOrderAccount::SIZE - EncodedOrderAccount::W_INTENT,
EncodedOrderAccount::W_INTENT
];
let intent_uid = intent::hash_bytes(raw_intent);
Ok((order_account, intent_uid))
}
}

/// Writes the canonical [`EncodedOrderAccount`] encoding of the given fields
Expand Down Expand Up @@ -328,6 +347,18 @@ mod tests {
assert_eq!(err, ProgramError::InvalidAccountData);
}

#[test]
fn decode_and_hash_catches_errors() {
let mut bytes: [u8; EncodedOrderAccount::SIZE] =
EncodedOrderAccount::from(sample_account(false)).into();
// Corrupt the `cancelled` byte to an out-of-range value so the
// underlying `try_from` rejects it.
bytes[CANCELLED_OFFSET] = 0xff;
let err = EncodedOrderAccount::decode_and_hash(&bytes)
.expect_err("decode_and_hash must propagate the try_from error");
assert_eq!(err, ProgramError::InvalidAccountData);
}

#[test]
fn direct_write_account_matches_order_account_decoding() {
let cancelled = true;
Expand Down Expand Up @@ -392,6 +423,15 @@ mod tests {
.map_err(|e| TestCaseError::fail(format!("decode failed: {e:?}")))?;
prop_assert_eq!(*EncodedOrderAccount::from(account), bytes);
}

#[test]
fn consistent_decode_and_hash(account in arb_order_account()) {
let encoded = EncodedOrderAccount::from(account.clone());
let (decoded, hash) = EncodedOrderAccount::decode_and_hash(&encoded)
.map_err(|e| TestCaseError::fail(format!("decode failed: {e:?}")))?;
prop_assert_eq!(hash, account.intent.uid());
prop_assert_eq!(decoded, account);
}
Comment thread
fedgiac marked this conversation as resolved.
}
}
}
126 changes: 110 additions & 16 deletions interface/src/instruction/settle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,53 @@ pub use solana_sdk_ids::sysvar::instructions::ID as INSTRUCTIONS_SYSVAR_ID;

use crate::SettlementInstruction;

pub fn begin_settle(program_id: &Pubkey, finalize_ix_index: u16) -> Instruction {
/// Build a `BeginSettle` instruction settling the orders described by the three
/// parallel lists: `order_pdas[i]` is the canonical order PDA (see
/// [`crate::pda::order`]), `sell_token_accounts[i]` its sell token account, and
/// `bumps[i]` the canonical PDA bump. The three slices are assumed to have the
/// same length but this is not enforced in the builder.
///
/// Wire format:
/// `[discriminator=0][finalize_ix_index: u16 BE][bump...]`, one `bump` byte per
/// order.
/// Accounts:
/// `[instructions_sysvar (R), (order_pda (R), sell_token_account (R))...]`, one
// pair of accounts per order.
///
/// The program requires the order PDAs to be strictly increasing by address.
/// This builder establishes that ordering for the caller: it sorts the orders by
/// PDA address (carrying each order's sell token account and bump along) before
/// emitting them.
pub fn begin_settle(
program_id: &Pubkey,
finalize_ix_index: u16,
order_pdas: &[Pubkey],
sell_token_accounts: &[Pubkey],
bumps: &[u8],
) -> Instruction {
// Sort the three parallel lists together by order PDA address via a shared
// permutation, so each order keeps its own sell token account and bump.
let mut order: Vec<usize> = (0..order_pdas.len()).collect();
order.sort_by_key(|&i| order_pdas[i]);

let data = std::iter::once(SettlementInstruction::BeginSettle.discriminator())
.chain(finalize_ix_index.to_be_bytes())
.chain(order.iter().map(|&i| bumps[i]))
.collect();

let accounts = std::iter::once(INSTRUCTIONS_SYSVAR_ID)
.chain(
order
.iter()
.flat_map(|&i| [order_pdas[i], sell_token_accounts[i]]),
)
.map(|pubkey| AccountMeta::new_readonly(pubkey, false))
.collect();

Instruction {
program_id: *program_id,
accounts: vec![AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false)],
data: [
&[SettlementInstruction::BeginSettle.discriminator()],
&finalize_ix_index.to_be_bytes()[..],
]
.concat(),
accounts,
data,
}
}

Expand All @@ -36,17 +74,16 @@ pub fn finalize_settle(program_id: &Pubkey, begin_ix_index: u16) -> Instruction
}

/// Reads the first two bytes of a byte slice (instruction data) and
/// interprets them as a big-endian u16.
/// interprets them as a big-endian u16, returning it together with the
/// remaining bytes to parse.
/// It's meant to be used for BeginSettle and FinalizeSettle to extract the
/// counterpart index, that is, the index linking that instruction to the
/// opposite instruction which is encoded as the first
/// 2 bytes of the instruction data: `[0x13, 0x37]` → `0x1337`.
/// Trailing bytes are ignored, so it can be used with instruction input
/// directly.
/// Returns `InvalidInstructionData` if fewer than two bytes are provided.
pub fn recover_counterpart(instruction_data: &[u8]) -> Result<u16, ProgramError> {
pub fn recover_counterpart(instruction_data: &[u8]) -> Result<(u16, &[u8]), ProgramError> {
match instruction_data {
[b1, b2, ..] => Ok(u16::from_be_bytes([*b1, *b2])),
[b1, b2, rest @ ..] => Ok((u16::from_be_bytes([*b1, *b2]), rest)),
_ => Err(ProgramError::InvalidInstructionData),
}
}
Expand All @@ -72,21 +109,21 @@ mod tests {
}

#[test]
fn ignores_trailing_bytes() {
fn returns_trailing_bytes() {
assert_eq!(
recover_counterpart(&[
0x13, // counterpart index
0x37, // counterpart index
42, // unused
42, // trailing
]),
Ok(0x1337),
Ok((0x1337, [42].as_slice())),
);
}

#[test]
fn expected_encoding_begin_settle() {
let program_id = Pubkey::new_unique();
let ix = begin_settle(&program_id, 0x1337);
let ix = begin_settle(&program_id, 0x1337, &[], &[], &[]);
assert_eq!(
ix.data,
[
Expand All @@ -95,6 +132,57 @@ mod tests {
0x37
]
);
// No orders: only the instructions sysvar is referenced.
assert_eq!(ix.accounts.len(), 1);
assert_eq!(ix.accounts[0].pubkey, INSTRUCTIONS_SYSVAR_ID);
assert!(!ix.accounts[0].is_writable);
assert!(!ix.accounts[0].is_signer);
}

#[test]
fn begin_settle_sorts_orders_by_pda() {
let program_id = Pubkey::new_unique();
// Two orders supplied in descending PDA order. All the other parameters
// are chosen to sort in the opposite order.
let high_order_pda = Pubkey::new_from_array([0xbb; 32]);
let high_sell_token_account = Pubkey::new_from_array([0xa0; 32]);
let high_bump = 0xaa;
let low_order_pda = Pubkey::new_from_array([0xaa; 32]);
let low_sell_token_account = Pubkey::new_from_array([0xb0; 32]);
let low_bump = 0xbb;
let ix = begin_settle(
&program_id,
0x1337,
&[high_order_pda, low_order_pda],
&[high_sell_token_account, low_sell_token_account],
&[high_bump, low_bump],
);

// Bumps follow the sorted order: the low PDA's bump comes first.
assert_eq!(
ix.data,
[
SettlementInstruction::BeginSettle.discriminator(),
0x13,
0x37,
low_bump,
high_bump,
],
);

let expected: Vec<Pubkey> = vec![
INSTRUCTIONS_SYSVAR_ID,
low_order_pda,
low_sell_token_account,
high_order_pda,
high_sell_token_account,
];
let actual: Vec<Pubkey> = ix.accounts.iter().map(|meta| meta.pubkey).collect();
assert_eq!(actual, expected);
assert!(ix
.accounts
.iter()
.all(|meta| !meta.is_writable && !meta.is_signer));
}

#[test]
Expand All @@ -109,5 +197,11 @@ mod tests {
0x37
]
);

// Only the instructions sysvar is referenced.
assert_eq!(ix.accounts.len(), 1);
assert_eq!(ix.accounts[0].pubkey, INSTRUCTIONS_SYSVAR_ID);
assert!(!ix.accounts[0].is_writable);
assert!(!ix.accounts[0].is_signer);
}
}
Loading