From dd787a39f4f40adb3974ab5ae42b2147a7f4d91f Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:04:29 +0900 Subject: [PATCH 01/22] Pack `OrderIntent` flags into a single byte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `kind` and `partially_fillable` each occupied a whole byte of the encoded intent, and each needed its own range check on decode. Fold both into one flags byte instead: `partially_fillable` takes the most significant defined bit, `kind` the next one down, and every remaining bit is reserved and must be zero. `OrderIntent` keeps the idiomatic representation — an `OrderKind` enum and a `bool` — so callers are unaffected. Only the wire format and the validation change: a single mask check now rejects any byte carrying an undefined bit, which keeps the encoding injective and so keeps order UIDs unique. `EncodedOrderIntent::SIZE` goes 150 -> 149 and `EncodedOrderAccount::SIZE` 201 -> 200. This is a BREAKING CHANGE to the `OrderIntent` encoding: it repurposes existing bytes, so it moves every order PDA and changes every order UID. Co-Authored-By: Claude Opus 5 (1M context) --- DESIGN.md | 4 + bench-report.json | 22 +- interface/src/data/intent.rs | 338 ++++++++++++---------- interface/src/data/order.rs | 33 +-- interface/src/instruction/create_order.rs | 6 +- 5 files changed, 219 insertions(+), 184 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index d999f7b..4468c67 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -132,6 +132,10 @@ struct OrderIntent { } ``` +The intent's `kind` and `partially_fillable` share a single flags byte in the +encoded form, one bit each, with the remaining bits reserved and required to be +zero. + Differences with Ethereum: - In Solana, the spender token account (and the owner) is part of the intent, while in Ethereum it is implied in the signature. diff --git a/bench-report.json b/bench-report.json index 2b9053c..36e8bc4 100644 --- a/bench-report.json +++ b/bench-report.json @@ -27,22 +27,22 @@ "create_buffers/happy_path_creates_initialized_buffer_token_account": 10340, "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21731, "create_buffers/max_buffers_in_one_instruction": 176947, - "create_order/happy_path_creates_order_pda_with_expected_body": 7921, + "create_order/happy_path_creates_order_pda_with_expected_body": 3416, "initialize/happy_path_initializes_state_pda_with_expected_data": 4526, "reclaim_buffer/funded_buffer_is_skipped": 6299, "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7447, "reclaim_buffer/max_buffers_in_one_instruction": 136501, "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18043, - "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2133, + "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2125, "settle/finalizes_with_no_pushes": 7043, - "settle/pulls_from_multiple_orders": 19750, - "settle/pulls_funds_to_destination": 13417, - "settle/pulls_to_multiple_destinations": 14564, - "settle/pushes_a_single_order": 12267, - "settle/pushes_several_orders_from_different_buffers": 17451, - "settle/pushes_several_orders_from_one_buffer": 17452, - "settle/settles_a_single_order": 12285, - "settle/settles_multiple_orders": 22679, + "settle/pulls_from_multiple_orders": 19738, + "settle/pulls_funds_to_destination": 13411, + "settle/pulls_to_multiple_destinations": 14558, + "settle/pushes_a_single_order": 12261, + "settle/pushes_several_orders_from_different_buffers": 17439, + "settle/pushes_several_orders_from_one_buffer": 17440, + "settle/settles_a_single_order": 12279, + "settle/settles_multiple_orders": 22661, "transfer_authority/manager_can_transfer_manager": 3170, "transfer_authority/manager_can_transfer_reclaim_authority": 3172, "transfer_authority/reclaim_authority_can_transfer_itself": 3175 @@ -51,7 +51,7 @@ "create_buffers/happy_path_creates_initialized_buffer_token_account": 303, "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 435, "create_buffers/max_buffers_in_one_instruction": 331, - "create_order/happy_path_creates_order_pda_with_expected_body": 389, + "create_order/happy_path_creates_order_pda_with_expected_body": 388, "initialize/happy_path_initializes_state_pda_with_expected_data": 301, "reclaim_buffer/funded_buffer_is_skipped": 400, "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 400, diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index 491f531..84ec0c7 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -2,18 +2,16 @@ //! //! Two types live here: //! -//! - [`OrderIntent`] is the idiomatic Rust representation. Every value is valid -//! by construction: `kind` is an [`OrderKind`] enum, `partially_fillable` is -//! a `bool`. Callers pattern-match on it directly. +//! - [`OrderIntent`] is the idiomatic Rust representation. //! - [`EncodedOrderIntent`] is its canonical byte representation: the only //! thing sent on the wire and also the data encoding used to generate the -//! order UID. +//! order UID. There, `kind` and `partially_fillable` share a single flags +//! byte. //! //! Conversion is asymmetric: [`EncodedOrderIntent`]`::from(OrderIntent)` is -//! infallible; decoding raw bytes via [`OrderIntent`]`::try_from` returns -//! `Result` and rejects out-of-range `kind` or `partially_fillable` bytes up -//! front. There is no path that produces an `OrderIntent` whose `kind` byte or -//! `partially_fillable` byte was not validated. +//! infallible, but decoding raw bytes via [`OrderIntent`]`::try_from` returns +//! `Result` and rejects a flags byte carrying a bit the encoding doesn't +//! define. use core::mem::size_of; @@ -23,7 +21,8 @@ use solana_hash::Hash; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; -/// Direction of the trade. +/// Direction of the trade. The discriminants are the values the `kind` bit of +/// the encoded flags byte takes. #[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] #[repr(u8)] pub enum OrderKind { @@ -83,7 +82,7 @@ pub struct OrderIntent { pub app_data: [u8; 32], } -/// Canonical 150-byte representation of an [`OrderIntent`]. The wire format and +/// Canonical 149-byte representation of an [`OrderIntent`]. The wire format and /// the order UID preimage. /// /// Layout: one character per byte, cell widths proportional to field size, @@ -91,16 +90,20 @@ pub struct OrderIntent { /// annotated below. Amounts and `valid_to` are little-endian encoded. /// /// ```text -/// partially_fillable ─────┐ -/// kind ────┐│ -/// ┌───────────────────────────────┬───────────────────────────────┬───────────────────────────────┬───────┬───────┬───┬┬┬───────────────────────────────┐ -/// │ │ │ │sell_ │buy_ │val│││ │ -/// │ owner │ buy_token_account │ sell_token_account │ │ │id_│││ app_data │ -/// │ │ │ │amount │amount │to │││ │ -/// └───────────────────────────────┴───────────────────────────────┴───────────────────────────────┴───────┴───────┴───┴┴┴───────────────────────────────┘ -/// 0 32 64 96 104 112 116 118 150 -/// 117 +/// flags ───┐ +/// ┌───────────────────────────────┬───────────────────────────────┬───────────────────────────────┬───────┬───────┬───┬┬───────────────────────────────┐ +/// │ │ │ │sell_ │buy_ │val││ │ +/// │ owner │ buy_token_account │ sell_token_account │ │ │id_││ app_data │ +/// │ │ │ │amount │amount │to ││ │ +/// └───────────────────────────────┴───────────────────────────────┴───────────────────────────────┴───────┴───────┴───┴┴───────────────────────────────┘ +/// 0 32 64 96 104 112 116 149 +/// 117 /// ``` +/// +/// The flags byte packs `kind` and the intent's booleans, one bit each, from +/// the most significant defined bit down: `partially_fillable`, then `kind` +/// ([`OrderKind::Sell`] = 0, [`OrderKind::Buy`] = 1). Every other bit is +/// reserved and must be zero. #[derive(Clone, Debug, Deref, Eq, PartialEq)] pub struct EncodedOrderIntent([u8; Self::SIZE]); @@ -112,11 +115,17 @@ impl EncodedOrderIntent { const WIDTH_SELL_AMOUNT: usize = size_of::(); const WIDTH_BUY_AMOUNT: usize = size_of::(); const WIDTH_VALID_TO: usize = size_of::(); - const WIDTH_KIND: usize = size_of::(); - const WIDTH_PARTIALLY_FILLABLE: usize = size_of::(); + const WIDTH_FLAGS: usize = size_of::(); const WIDTH_APP_DATA: usize = size_of::<[u8; 32]>(); - pub const SIZE: usize = 150; + // The bits of the flags byte, from the most significant defined bit down: + // one per boolean field of `OrderIntent`, plus `kind`. + const FLAG_PARTIALLY_FILLABLE: u8 = 1 << 1; + const FLAG_KIND: u8 = 1 << 0; + + const FLAGS_MASK: u8 = Self::FLAG_PARTIALLY_FILLABLE | Self::FLAG_KIND; + + pub const SIZE: usize = 149; /// Canonical hash of the bytes. pub fn hash(&self) -> Hash { @@ -124,9 +133,8 @@ impl EncodedOrderIntent { } /// 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. + /// Returns [`ProgramError::InvalidInstructionData`] for a flags byte that + /// doesn't encode correctly; every other byte combination decodes. 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 @@ -143,6 +151,15 @@ pub fn hash_bytes(bytes: &[u8; EncodedOrderIntent::SIZE]) -> Hash { solana_sha256_hasher::hashv(&[bytes.as_slice()]) } +/// The intent's `kind` and booleans packed into their canonical flags byte. +/// Reserved bits are left clear. +fn flags_byte(intent: &OrderIntent) -> u8 { + // `wrapping_neg` transforms a true `bool` (effectively 1) into 0xffffffff + let mask = |value: u8| value.wrapping_neg(); + EncodedOrderIntent::FLAG_PARTIALLY_FILLABLE & mask(intent.partially_fillable as u8) + | EncodedOrderIntent::FLAG_KIND & mask(intent.kind as u8) +} + impl From<&EncodedOrderIntent> for [u8; EncodedOrderIntent::SIZE] { fn from(encoded: &EncodedOrderIntent) -> Self { encoded.0 @@ -154,17 +171,7 @@ impl From<&OrderIntent> for EncodedOrderIntent { // `mut_array_refs` checks that `SIZE` is consistent with the sum of // the widths. let mut out = [0u8; Self::SIZE]; - let ( - owner, - buy_token, - sell_token, - sell_amount, - buy_amount, - valid_to, - kind, - partially_fillable, - app_data, - ) = mut_array_refs![ + let (owner, buy_token, sell_token, sell_amount, buy_amount, valid_to, flags, app_data) = mut_array_refs![ &mut out, EncodedOrderIntent::WIDTH_OWNER, EncodedOrderIntent::WIDTH_BUY_TOKEN, @@ -172,8 +179,7 @@ impl From<&OrderIntent> for EncodedOrderIntent { EncodedOrderIntent::WIDTH_SELL_AMOUNT, EncodedOrderIntent::WIDTH_BUY_AMOUNT, EncodedOrderIntent::WIDTH_VALID_TO, - EncodedOrderIntent::WIDTH_KIND, - EncodedOrderIntent::WIDTH_PARTIALLY_FILLABLE, + EncodedOrderIntent::WIDTH_FLAGS, EncodedOrderIntent::WIDTH_APP_DATA ]; *owner = intent.owner.to_bytes(); @@ -182,8 +188,7 @@ impl From<&OrderIntent> for EncodedOrderIntent { *sell_amount = intent.sell_amount.to_le_bytes(); *buy_amount = intent.buy_amount.to_le_bytes(); *valid_to = intent.valid_to.to_le_bytes(); - *kind = [intent.kind as u8]; - *partially_fillable = [intent.partially_fillable as u8]; + *flags = [flags_byte(intent)]; *app_data = intent.app_data; Self(out) } @@ -200,17 +205,7 @@ impl TryFrom<&[u8; EncodedOrderIntent::SIZE]> for OrderIntent { // as valid or it might be possible to replay the same order more // than once. - let ( - owner, - buy_token, - sell_token, - sell_amount, - buy_amount, - valid_to, - kind, - partially_fillable, - app_data, - ) = array_refs![ + let (owner, buy_token, sell_token, sell_amount, buy_amount, valid_to, flags, app_data) = array_refs![ bytes, EncodedOrderIntent::WIDTH_OWNER, EncodedOrderIntent::WIDTH_BUY_TOKEN, @@ -218,11 +213,18 @@ impl TryFrom<&[u8; EncodedOrderIntent::SIZE]> for OrderIntent { EncodedOrderIntent::WIDTH_SELL_AMOUNT, EncodedOrderIntent::WIDTH_BUY_AMOUNT, EncodedOrderIntent::WIDTH_VALID_TO, - EncodedOrderIntent::WIDTH_KIND, - EncodedOrderIntent::WIDTH_PARTIALLY_FILLABLE, + EncodedOrderIntent::WIDTH_FLAGS, EncodedOrderIntent::WIDTH_APP_DATA ]; + let [flags] = *flags; + // A reserved bit carries no meaning to this version of the program, so + // accepting it would give the same intent several encodings, and with + // them several UIDs. + if flags & !EncodedOrderIntent::FLAGS_MASK != 0 { + return Err(ProgramError::InvalidInstructionData); + } + Ok(OrderIntent { owner: Pubkey::new_from_array(*owner), buy_token_account: Pubkey::new_from_array(*buy_token), @@ -230,16 +232,12 @@ impl TryFrom<&[u8; EncodedOrderIntent::SIZE]> for OrderIntent { sell_amount: u64::from_le_bytes(*sell_amount), buy_amount: u64::from_le_bytes(*buy_amount), valid_to: u32::from_le_bytes(*valid_to), - kind: match kind { - [0] => OrderKind::Sell, - [1] => OrderKind::Buy, - _ => return Err(ProgramError::InvalidInstructionData), - }, - partially_fillable: match partially_fillable { - [0] => false, - [1] => true, - _ => return Err(ProgramError::InvalidInstructionData), + kind: if flags & EncodedOrderIntent::FLAG_KIND == 0 { + OrderKind::Sell + } else { + OrderKind::Buy }, + partially_fillable: flags & EncodedOrderIntent::FLAG_PARTIALLY_FILLABLE != 0, app_data: *app_data, }) } @@ -266,14 +264,13 @@ impl OrderIntent { pub mod fixtures { use proptest::{prelude::*, strategy::Union}; - use super::{EncodedOrderIntent, OrderIntent, OrderKind, Pubkey}; + use super::{flags_byte, EncodedOrderIntent, OrderIntent, OrderKind, Pubkey}; /// Every valid [`OrderKind`]. pub const ALL_ORDER_KINDS: [OrderKind; 2] = [OrderKind::Sell, OrderKind::Buy]; // Hardcoded but verified in a sanity-check test. - pub const KIND_OFFSET: usize = 116; - pub const PARTIALLY_FILLABLE_OFFSET: usize = KIND_OFFSET + EncodedOrderIntent::WIDTH_KIND; + pub const FLAGS_OFFSET: usize = 116; pub fn sample_intent(kind: OrderKind, partially_fillable: bool) -> OrderIntent { OrderIntent { @@ -294,6 +291,35 @@ pub mod fixtures { Union::new(ALL_ORDER_KINDS.map(Just)) } + /// The canonical flags byte for the given `kind` and booleans, as the + /// encoder writes it. Lets a test pin the flags slot of otherwise arbitrary + /// bytes. + pub fn intent_flags_byte(partially_fillable: bool, kind: OrderKind) -> u8 { + flags_byte(&OrderIntent { + partially_fillable, + kind, + ..Default::default() + }) + } + + /// Any flags byte the decoder accepts. + pub fn arb_flags_byte() -> impl Strategy { + (any::(), arb_order_kind()) + .prop_map(|(partially_fillable, kind)| intent_flags_byte(partially_fillable, kind)) + } + + /// Any flags byte the decoder rejects: one carrying at least one bit + /// outside those the encoding defines. + pub fn arb_invalid_flags_byte() -> impl Strategy { + ( + any::().prop_filter("at least one reserved bit must be set", |reserved| { + reserved & !EncodedOrderIntent::FLAGS_MASK != 0 + }), + arb_flags_byte(), + ) + .prop_map(|(reserved, defined)| (reserved & !EncodedOrderIntent::FLAGS_MASK) | defined) + } + /// Any valid [`OrderIntent`]. pub fn arb_order_intent() -> impl Strategy { ( @@ -329,15 +355,17 @@ pub mod fixtures { mod tests { use hex_literal::hex; - use super::fixtures::{sample_intent, KIND_OFFSET, PARTIALLY_FILLABLE_OFFSET}; + use super::fixtures::{sample_intent, FLAGS_OFFSET}; use super::*; - // Full Cartesian product of `OrderKind × bool` for tests that need to - // exercise every shape an `OrderIntent` can take on these axes. - fn all_kind_and_fillable() -> impl Iterator { - fixtures::ALL_ORDER_KINDS - .into_iter() - .flat_map(|kind| core::iter::repeat(kind).zip([false, true])) + // Every shape an `OrderIntent` can take on its validated axes: the `kind` + // enum and the `partially_fillable` flag bit. + fn all_intent_shapes() -> impl Iterator { + fixtures::ALL_ORDER_KINDS.into_iter().flat_map(|kind| { + [false, true] + .into_iter() + .map(move |partially_fillable| sample_intent(kind, partially_fillable)) + }) } // Pin each width to the size of the `OrderIntent` field it encodes. The @@ -372,11 +400,6 @@ mod tests { EncodedOrderIntent::WIDTH_VALID_TO, size_of_val(&intent.valid_to) ); - assert_eq!(EncodedOrderIntent::WIDTH_KIND, size_of_val(&intent.kind)); - assert_eq!( - EncodedOrderIntent::WIDTH_PARTIALLY_FILLABLE, - size_of_val(&intent.partially_fillable) - ); assert_eq!( EncodedOrderIntent::WIDTH_APP_DATA, size_of_val(&intent.app_data) @@ -386,9 +409,48 @@ mod tests { } #[test] - fn roundtrip_all_kind_and_bool_combinations() { - for (kind, partially_fillable) in all_kind_and_fillable() { - let intent = sample_intent(kind, partially_fillable); + fn every_flag_owns_a_distinct_bit() { + let flags_of = |intent: &OrderIntent| EncodedOrderIntent::from(intent)[FLAGS_OFFSET]; + let cleared = OrderIntent { + partially_fillable: false, + kind: OrderKind::Sell, + ..sample_intent(OrderKind::Sell, false) + }; + assert_eq!(flags_of(&cleared), 0); + + let set_one_by_one = [ + ( + EncodedOrderIntent::FLAG_PARTIALLY_FILLABLE, + OrderIntent { + partially_fillable: true, + ..cleared.clone() + }, + ), + ( + EncodedOrderIntent::FLAG_KIND, + OrderIntent { + kind: OrderKind::Buy, + ..cleared.clone() + }, + ), + ]; + let mut seen = 0u8; + for (bit, intent) in set_one_by_one { + assert_eq!(bit.count_ones(), 1, "a flag must occupy a single bit"); + assert_eq!(seen & bit, 0, "two flags must not share a bit"); + assert!( + seen == 0 || bit < seen, + "each flag must be less significant than the ones before it" + ); + seen |= bit; + assert_eq!(flags_of(&intent), bit); + } + assert_eq!(seen, EncodedOrderIntent::FLAGS_MASK); + } + + #[test] + fn roundtrip_all_kind_and_flag_combinations() { + for intent in all_intent_shapes() { let encoded = EncodedOrderIntent::from(&intent); let (decoded, _uid) = EncodedOrderIntent::decode_and_hash(&encoded).expect("example must decode"); @@ -402,8 +464,8 @@ mod tests { // encode/decode, this test fails. #[test] fn decode_and_hash_uid_matches_encoded_hash() { - for (kind, partially_fillable) in all_kind_and_fillable() { - let encoded = EncodedOrderIntent::from(&sample_intent(kind, partially_fillable)); + for intent in all_intent_shapes() { + let encoded = EncodedOrderIntent::from(&intent); let (_intent, uid) = EncodedOrderIntent::decode_and_hash(&encoded).expect("example must decode"); assert_eq!(uid, encoded.hash()); @@ -421,44 +483,51 @@ mod tests { assert_eq!( first_differing_byte(sell_false.as_slice(), sell_true.as_slice()) - .expect("should have different partially fillable byte"), - PARTIALLY_FILLABLE_OFFSET + .expect("should have different flags byte"), + FLAGS_OFFSET ); assert_eq!( first_differing_byte(buy_true.as_slice(), sell_true.as_slice()) - .expect("should have different kind byte"), - KIND_OFFSET + .expect("should have different flags byte"), + FLAGS_OFFSET ); } #[test] - fn decode_rejects_out_of_range_kind() { + fn decode_accepts_defined_flag_bits_only() { let encoded = EncodedOrderIntent::from(&sample_intent(OrderKind::Sell, false)); let mut bytes: [u8; EncodedOrderIntent::SIZE] = *encoded; - for bad in 0x02u8..=0xff { - bytes[KIND_OFFSET] = bad; - let err = EncodedOrderIntent::decode_and_hash(&bytes) - .expect_err("should reject out of range kind"); - assert_eq!(err, ProgramError::InvalidInstructionData); - } - } - - #[test] - fn decode_rejects_non_boolean_partially_fillable() { - let encoded = EncodedOrderIntent::from(&sample_intent(OrderKind::Sell, false)); - let mut bytes: [u8; EncodedOrderIntent::SIZE] = *encoded; - for bad in 0x02u8..=0xff { - bytes[PARTIALLY_FILLABLE_OFFSET] = bad; - let err = EncodedOrderIntent::decode_and_hash(&bytes) - .expect_err("should reject out of range partially fillable"); - assert_eq!(err, ProgramError::InvalidInstructionData); + for flags in u8::MIN..=u8::MAX { + bytes[FLAGS_OFFSET] = flags; + let decoded = EncodedOrderIntent::decode_and_hash(&bytes); + if flags & !EncodedOrderIntent::FLAGS_MASK != 0 { + assert_eq!( + decoded.err(), + Some(ProgramError::InvalidInstructionData), + "flags {flags:#04x} sets a reserved bit and must be rejected", + ); + } else { + let (intent, _uid) = decoded.expect("defined flag bits must decode"); + assert_eq!( + intent.partially_fillable, + flags & EncodedOrderIntent::FLAG_PARTIALLY_FILLABLE != 0, + ); + assert_eq!( + intent.kind, + if flags & EncodedOrderIntent::FLAG_KIND == 0 { + OrderKind::Sell + } else { + OrderKind::Buy + }, + ); + } } } #[test] fn uid_digest_regression() { let intent = sample_intent(OrderKind::Buy, true); - let expected = hex!("7ce7c6a74671090771fa33851387444064aca759ce55b80708723076722f5e00"); + let expected = hex!("d2a82e919ec3d5e8b21c512cf14251e98bf79cdf01f0a2bdd0ecbed3007a9761"); assert_eq!(intent.uid(), Hash::from(expected)); } @@ -489,10 +558,8 @@ mod tests { 0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe, // valid_to (0xdead_beef, LE u32) 0xef, 0xbe, 0xad, 0xde, - // kind (Buy = 1) - 0x01, - // partially_fillable (true = 1) - 0x01, + // flags (partially_fillable | kind (Buy = 1)) + 0b00000011, // app_data ([0x44; 32]) 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, @@ -508,19 +575,9 @@ mod tests { use super::*; use crate::data::intent::fixtures::{ - arb_order_intent, arb_order_kind, KIND_OFFSET, PARTIALLY_FILLABLE_OFFSET, + arb_flags_byte, arb_invalid_flags_byte, arb_order_intent, FLAGS_OFFSET, }; - // Any byte not decoding to a valid order type. - fn arb_bad_order_kind_byte() -> impl Strategy { - 2u8..=255 - } - - // Any byte not decoding to a valid bool. - fn arb_bad_bool_byte() -> impl Strategy { - 2u8..=255 - } - proptest! { // For any `OrderIntent`, encoding an intent into an encoded // intent and then decoding it with `decode_and_hash()` returns @@ -535,50 +592,27 @@ mod tests { prop_assert_eq!(uid, encoded.hash()); } - // For any bytes whose `kind` and `partially_fillable` slots - // are valid, `decode_and_hash` and then re-encoding produces - // back the original bytes. + // For any bytes whose flags slot is valid, `decode_and_hash` and + // then re-encoding produces back the original bytes. #[test] fn bytes_roundtrip( mut bytes in any::<[u8; EncodedOrderIntent::SIZE]>(), - kind in arb_order_kind(), - partially_fillable in any::(), + flags in arb_flags_byte(), ) { - bytes[KIND_OFFSET] = kind as u8; - bytes[PARTIALLY_FILLABLE_OFFSET] = partially_fillable as u8; + bytes[FLAGS_OFFSET] = flags; let (intent, _uid) = EncodedOrderIntent::decode_and_hash(&bytes) .map_err(|e| TestCaseError::fail(format!("decode failed: {e:?}")))?; prop_assert_eq!(*EncodedOrderIntent::from(&intent), bytes); } - // For any bytes with an invalid `kind` byte (and a valid - // `partially_fillable`), `decode_and_hash` returns - // `InvalidInstructionData`. - #[test] - fn rejects_invalid_kind_byte( - mut bytes in any::<[u8; EncodedOrderIntent::SIZE]>(), - bad_kind in arb_bad_order_kind_byte(), - partially_fillable in any::(), - ) { - bytes[KIND_OFFSET] = bad_kind; - bytes[PARTIALLY_FILLABLE_OFFSET] = partially_fillable as u8; - prop_assert_eq!( - EncodedOrderIntent::decode_and_hash(&bytes), - Err(ProgramError::InvalidInstructionData), - ); - } - - // Symmetric: any bytes with an out-of-range - // `partially_fillable` byte (and a valid `kind`) return - // `InvalidInstructionData`. + // Symmetric: any bytes whose flags byte carries a reserved bit + // return `InvalidInstructionData`. #[test] - fn rejects_invalid_partially_fillable_byte( + fn rejects_reserved_flag_bits( mut bytes in any::<[u8; EncodedOrderIntent::SIZE]>(), - kind in arb_order_kind(), - bad_pf in arb_bad_bool_byte(), + bad_flags in arb_invalid_flags_byte(), ) { - bytes[KIND_OFFSET] = kind as u8; - bytes[PARTIALLY_FILLABLE_OFFSET] = bad_pf; + bytes[FLAGS_OFFSET] = bad_flags; prop_assert_eq!( EncodedOrderIntent::decode_and_hash(&bytes), Err(ProgramError::InvalidInstructionData), diff --git a/interface/src/data/order.rs b/interface/src/data/order.rs index d362e7d..20d5306 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -88,7 +88,7 @@ impl OrderAccount { } } -/// Canonical 201-byte representation of an [`OrderAccount`]. The bytes +/// Canonical 200-byte representation of an [`OrderAccount`]. The bytes /// written to/read from the order PDA's data area. /// /// Layout: one character per byte, cell widths proportional to field size, @@ -105,7 +105,7 @@ impl OrderAccount { /// ││││with- │re- │ created_by │ intent (EncodedOrderIntent) │ /// ││││drawn │ceived │ │ │ /// └┴┴┴───────┴───────┴───────────────────────────────┴─────────────────...─────────────────┘ -/// 0 1 2 3 11 19 51 ... 201 +/// 0 1 2 3 11 19 51 ... 200 /// ``` #[derive(Clone, Debug, Deref, Eq, PartialEq)] pub struct EncodedOrderAccount([u8; Self::SIZE]); @@ -120,7 +120,7 @@ impl EncodedOrderAccount { const W_CREATED_BY: usize = size_of::(); const W_INTENT: usize = EncodedOrderIntent::SIZE; - pub const SIZE: usize = 201; + pub const SIZE: usize = 200; /// Single-byte account discriminator. See [`SettlementAccount`]. pub const DISCRIMINATOR: u8 = SettlementAccount::OrderAccount.discriminator(); @@ -317,7 +317,7 @@ mod tests { use super::fixtures::{sample_account, CANCELLED_OFFSET, DISCRIMINATOR_OFFSET, INTENT_OFFSET}; use super::*; use crate::data::intent::{ - fixtures::{sample_intent, KIND_OFFSET, PARTIALLY_FILLABLE_OFFSET}, + fixtures::{sample_intent, FLAGS_OFFSET}, OrderKind, }; @@ -390,9 +390,9 @@ mod tests { (&EncodedOrderIntent::from(&sample_account_base.intent)).into(); // Hack: xoring each byte makes sure all bytes are different. // In general, it isn't guaranteed that the result encodes to a - // valid intent, but in this case we know it because the only bytes - // that may fail decoding are `kind` and `partially_fillable`, both - // of which stay valid if flipped with `^0x01`. + // valid intent, but in this case we know it because the only byte + // that may fail decoding is the flags byte, and `^0x01` only flips + // its `kind` bit, never a reserved one. let bitwise_different_encoded_intent: [u8; EncodedOrderIntent::SIZE] = encoded_intent.map(|b| b ^ 0x01); sample_account_base.intent = @@ -430,13 +430,12 @@ mod tests { fn decode_propagates_invalid_intent() { let mut bytes: [u8; EncodedOrderAccount::SIZE] = EncodedOrderAccount::from(sample_account(false)).into(); - // Corrupt the `kind` byte inside the intent slot: the intent - // decoder rejects it and the order-account decode surfaces that - // failure as `InvalidAccountData`. - let kind_offset = INTENT_OFFSET + KIND_OFFSET; - bytes[kind_offset] = 0x02; + // Set a reserved bit of the flags byte inside the intent slot: the + // intent decoder rejects it and the order-account decode surfaces + // that failure as `InvalidAccountData`. + bytes[INTENT_OFFSET + FLAGS_OFFSET] = 0xff; let err = OrderAccount::try_from(bytes) - .expect_err("an invalid intent kind byte must propagate as a decode failure"); + .expect_err("an invalid intent flags byte must propagate as a decode failure"); assert_eq!(err, ProgramError::InvalidAccountData); } @@ -598,7 +597,7 @@ mod tests { use ::proptest::{prelude::*, test_runner::TestCaseError}; use super::*; - use crate::data::{intent::fixtures::arb_order_kind, order::fixtures::arb_order_account}; + use crate::data::{intent::fixtures::arb_flags_byte, order::fixtures::arb_order_account}; proptest! { // For any `OrderAccount`, encode then decode returns the same @@ -618,13 +617,11 @@ mod tests { fn bytes_roundtrip( mut bytes in any::<[u8; EncodedOrderAccount::SIZE]>(), cancelled in any::(), - kind in arb_order_kind(), - partially_fillable in any::(), + flags in arb_flags_byte(), ) { bytes[DISCRIMINATOR_OFFSET] = EncodedOrderAccount::DISCRIMINATOR; bytes[CANCELLED_OFFSET] = cancelled as u8; - bytes[INTENT_OFFSET + KIND_OFFSET] = kind as u8; - bytes[INTENT_OFFSET + PARTIALLY_FILLABLE_OFFSET] = partially_fillable as u8; + bytes[INTENT_OFFSET + FLAGS_OFFSET] = flags; let account = OrderAccount::try_from(bytes) .map_err(|e| TestCaseError::fail(format!("decode failed: {e:?}")))?; prop_assert_eq!(*EncodedOrderAccount::from(account), bytes); diff --git a/interface/src/instruction/create_order.rs b/interface/src/instruction/create_order.rs index c317256..1708ecc 100644 --- a/interface/src/instruction/create_order.rs +++ b/interface/src/instruction/create_order.rs @@ -40,7 +40,7 @@ use crate::{data::intent::EncodedOrderIntent, SettlementInstruction}; /// instruction reverts with `AccountAlreadyInitialized`. Recreating the same /// order is only possible after its PDA has been closed. /// -/// Wire format: `[discriminator=2, ..150 intent bytes]`, 151 bytes. +/// Wire format: `[discriminator=2, ..149 intent bytes]`, 150 bytes. /// Required accounts: /// `[owner (S), created_by (W,S), order_pda (W), system_program (R)]`. /// The system program needs to be available but doesn't need to be at that @@ -84,7 +84,7 @@ impl<'a, A> InstructionInputParsing<'a, A> for CreateOrderInput<'a, A> { const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::CreateOrder; fn parse_body(instruction_data: &'a [u8], accounts: &'a [A]) -> Result { - // Body (discriminator already stripped): exactly the 150 intent bytes. + // Body (discriminator already stripped): exactly the 149 intent bytes. if instruction_data.len() != EncodedOrderIntent::SIZE { return Err(ProgramError::InvalidInstructionData); } @@ -126,7 +126,7 @@ pub mod fixtures { /// and the system program. pub const NUM_ACCOUNTS: usize = 4; - /// Canonical 150-byte intent payload for a valid sell order owned by + /// Canonical 149-byte intent payload for a valid sell order owned by /// [`DEFAULT_OWNER`]. pub fn valid_intent_bytes() -> [u8; EncodedOrderIntent::SIZE] { (&EncodedOrderIntent::from(&OrderIntent { From e1cee83718fb9c5d993b2ba176377298b912f0f4 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:08:01 +0900 Subject: [PATCH 02/22] Allow `ReclaimOrder` of completed orders before expiry An order PDA could only be reclaimed once its `valid_to` had elapsed, because a reclaimed order can be recreated and reclaiming early would let a completed order be replayed. That reasoning only holds for orders authenticated by an off-chain signature, which anyone holding the signature can recreate. An order created on-chain needs its owner's signature to come back, so once it has finished its lifecycle its account is safe to close. Add a `created_on_chain` flag to the intent, taking the least significant bit of the flags byte, and let `ReclaimOrder` accept an order that carries it and is either cancelled or fully filled. `CreateOrder` rejects an intent that doesn't declare the flag, so it always records the flow that actually created the order. `OrderNotExpired` becomes `OrderNotReclaimable`, since expiry is no longer the only route. Deciding "fully filled" needs the exact side of the order, which `BeginSettle` already computes: that logic moves to `order::fill_progress` and backs the new `OrderAccount::is_fully_filled`. This is a BREAKING CHANGE to the `OrderIntent` encoding: `created_on_chain` shifts the other flag bits up, so it moves every order PDA and changes every order UID. Co-Authored-By: Claude Opus 5 (1M context) --- DESIGN.md | 14 +- bench-report.json | 20 +-- interface/src/data/intent.rs | 104 ++++++++++--- interface/src/data/order.rs | 61 +++++++- interface/src/instruction/create_order.rs | 4 +- interface/src/instruction/reclaim_order.rs | 12 +- interface/src/lib.rs | 7 +- programs/settlement/src/create_order.rs | 30 ++++ programs/settlement/src/reclaim_order.rs | 58 +++++++- programs/settlement/src/settle/begin.rs | 10 +- programs/settlement/tests/common/order.rs | 1 + programs/settlement/tests/create_order.rs | 31 ++++ programs/settlement/tests/reclaim_order.rs | 163 ++++++++++++++++++++- test-cli/src/cmd/create_order.rs | 1 + 14 files changed, 461 insertions(+), 55 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 4468c67..1f81800 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -127,14 +127,16 @@ struct OrderIntent { // Either Buy or Sell kind: OrderKind partially_fillable: bool + // Which of the two authentication schemes applies to this order: + // on-chain creation by the owner, or an off-chain signature. + created_on_chain: bool // Usual app data field, it isn't directly used in the program. app_data: [u8; 32] } ``` -The intent's `kind` and `partially_fillable` share a single flags byte in the -encoded form, one bit each, with the remaining bits reserved and required to be -zero. +The intent's `kind` and booleans share a single flags byte in the encoded form, +one bit each, with the remaining bits reserved and required to be zero. Differences with Ethereum: @@ -203,6 +205,8 @@ Allocating an order PDA requires paying rent. If the order is expired, anyone can close the order account through the `ReclaimOrder` instruction. On account closure, the rent is sent to the order's `created_by` account, i.e., the original creator of the order. +If an order created on-chain (`created_on_chain`), it can safely be closed earlier. In this case, `ReclaimOrder` will additionally allow reclaiming of orders that are cancelled or completely filled. + This is useful for solvers who need to allocate the order for executing it, but the allocation itself would be orders of magnitude more expensive than the compute cost for executing an instruction. This is particularly relevant to make small orders economically viable. ## Authenticating an order @@ -239,9 +243,9 @@ Differences with Ethereum: Orders can be created by the owner by executing an instruction on-chain. -The order owner executes the `CreateOrder` instruction. The settlement program checks that the order comes from the owner and [creates the order PDA](#orders-are-accounts). +The order owner executes the `CreateOrder` instruction. The settlement program checks that the order comes from the owner, that the intent declares this authentication scheme (`created_on_chain`), and [creates the order PDA](#orders-are-accounts). Intents that don't declare it are rejected, so the flag always states which flow actually created the order. -In this authentication flow, the user needs to pay for the rent in SOL necessary to create the PDA. Note that the rent may be significantly higher than the expected trading fee. The rent can be recovered by the user once the order has expired by [clearing the order](#order-clearing). +In this authentication flow, the user needs to pay for the rent in SOL necessary to create the PDA. Note that the rent may be significantly higher than the expected trading fee. The rent can be recovered by the user once the order has expired, or once it's cancelled or completely filled, by [clearing the order](#order-clearing). This flow supports both standard ("on-curve") accounts and PDA signatures. diff --git a/bench-report.json b/bench-report.json index 36e8bc4..b79a67c 100644 --- a/bench-report.json +++ b/bench-report.json @@ -27,22 +27,22 @@ "create_buffers/happy_path_creates_initialized_buffer_token_account": 10340, "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21731, "create_buffers/max_buffers_in_one_instruction": 176947, - "create_order/happy_path_creates_order_pda_with_expected_body": 3416, + "create_order/happy_path_creates_order_pda_with_expected_body": 7923, "initialize/happy_path_initializes_state_pda_with_expected_data": 4526, "reclaim_buffer/funded_buffer_is_skipped": 6299, "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7447, "reclaim_buffer/max_buffers_in_one_instruction": 136501, "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18043, - "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2125, + "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2143, "settle/finalizes_with_no_pushes": 7043, - "settle/pulls_from_multiple_orders": 19738, - "settle/pulls_funds_to_destination": 13411, - "settle/pulls_to_multiple_destinations": 14558, - "settle/pushes_a_single_order": 12261, - "settle/pushes_several_orders_from_different_buffers": 17439, - "settle/pushes_several_orders_from_one_buffer": 17440, - "settle/settles_a_single_order": 12279, - "settle/settles_multiple_orders": 22661, + "settle/pulls_from_multiple_orders": 19758, + "settle/pulls_funds_to_destination": 13421, + "settle/pulls_to_multiple_destinations": 14568, + "settle/pushes_a_single_order": 12271, + "settle/pushes_several_orders_from_different_buffers": 17459, + "settle/pushes_several_orders_from_one_buffer": 17460, + "settle/settles_a_single_order": 12289, + "settle/settles_multiple_orders": 22691, "transfer_authority/manager_can_transfer_manager": 3170, "transfer_authority/manager_can_transfer_reclaim_authority": 3172, "transfer_authority/reclaim_authority_can_transfer_itself": 3175 diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index 84ec0c7..1d4a9ac 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -5,7 +5,7 @@ //! - [`OrderIntent`] is the idiomatic Rust representation. //! - [`EncodedOrderIntent`] is its canonical byte representation: the only //! thing sent on the wire and also the data encoding used to generate the -//! order UID. There, `kind` and `partially_fillable` share a single flags +//! order UID. There, `kind` and the intent's booleans share a single flags //! byte. //! //! Conversion is asymmetric: [`EncodedOrderIntent`]`::from(OrderIntent)` is @@ -76,6 +76,12 @@ pub struct OrderIntent { /// must consume the full sell amount (fill-or-kill). pub partially_fillable: bool, + /// How the order is authenticated: `true` if the owner creates it + /// themselves with a `CreateOrder` instruction they sign; `false` if it's + /// authenticated off-chain by an Ed25519 signature, which lets anyone + /// holding that signature create the order. + pub created_on_chain: bool, + /// Opaque 32 bytes set by the order creator. Not interpreted by the /// settlement program; used off-chain for metadata such as the /// frontend version, slippage hints, or attribution. @@ -101,9 +107,9 @@ pub struct OrderIntent { /// ``` /// /// The flags byte packs `kind` and the intent's booleans, one bit each, from -/// the most significant defined bit down: `partially_fillable`, then `kind` -/// ([`OrderKind::Sell`] = 0, [`OrderKind::Buy`] = 1). Every other bit is -/// reserved and must be zero. +/// the most significant defined bit down: `partially_fillable`, `kind` +/// ([`OrderKind::Sell`] = 0, [`OrderKind::Buy`] = 1), then `created_on_chain`. +/// Every other bit is reserved and must be zero. #[derive(Clone, Debug, Deref, Eq, PartialEq)] pub struct EncodedOrderIntent([u8; Self::SIZE]); @@ -120,10 +126,12 @@ impl EncodedOrderIntent { // The bits of the flags byte, from the most significant defined bit down: // one per boolean field of `OrderIntent`, plus `kind`. - const FLAG_PARTIALLY_FILLABLE: u8 = 1 << 1; - const FLAG_KIND: u8 = 1 << 0; + const FLAG_PARTIALLY_FILLABLE: u8 = 1 << 2; + const FLAG_KIND: u8 = 1 << 1; + const FLAG_CREATED_ON_CHAIN: u8 = 1 << 0; - const FLAGS_MASK: u8 = Self::FLAG_PARTIALLY_FILLABLE | Self::FLAG_KIND; + const FLAGS_MASK: u8 = + Self::FLAG_PARTIALLY_FILLABLE | Self::FLAG_KIND | Self::FLAG_CREATED_ON_CHAIN; pub const SIZE: usize = 149; @@ -158,6 +166,7 @@ fn flags_byte(intent: &OrderIntent) -> u8 { let mask = |value: u8| value.wrapping_neg(); EncodedOrderIntent::FLAG_PARTIALLY_FILLABLE & mask(intent.partially_fillable as u8) | EncodedOrderIntent::FLAG_KIND & mask(intent.kind as u8) + | EncodedOrderIntent::FLAG_CREATED_ON_CHAIN & mask(intent.created_on_chain as u8) } impl From<&EncodedOrderIntent> for [u8; EncodedOrderIntent::SIZE] { @@ -238,6 +247,7 @@ impl TryFrom<&[u8; EncodedOrderIntent::SIZE]> for OrderIntent { OrderKind::Buy }, partially_fillable: flags & EncodedOrderIntent::FLAG_PARTIALLY_FILLABLE != 0, + created_on_chain: flags & EncodedOrderIntent::FLAG_CREATED_ON_CHAIN != 0, app_data: *app_data, }) } @@ -282,6 +292,7 @@ pub mod fixtures { valid_to: 0xdead_beef, kind, partially_fillable, + created_on_chain: true, app_data: [0x44; 32], } } @@ -294,18 +305,26 @@ pub mod fixtures { /// The canonical flags byte for the given `kind` and booleans, as the /// encoder writes it. Lets a test pin the flags slot of otherwise arbitrary /// bytes. - pub fn intent_flags_byte(partially_fillable: bool, kind: OrderKind) -> u8 { + pub fn intent_flags_byte( + partially_fillable: bool, + kind: OrderKind, + created_on_chain: bool, + ) -> u8 { flags_byte(&OrderIntent { partially_fillable, kind, + created_on_chain, ..Default::default() }) } /// Any flags byte the decoder accepts. pub fn arb_flags_byte() -> impl Strategy { - (any::(), arb_order_kind()) - .prop_map(|(partially_fillable, kind)| intent_flags_byte(partially_fillable, kind)) + (any::(), arb_order_kind(), any::()).prop_map( + |(partially_fillable, kind, on_chain)| { + intent_flags_byte(partially_fillable, kind, on_chain) + }, + ) } /// Any flags byte the decoder rejects: one carrying at least one bit @@ -331,10 +350,22 @@ pub mod fixtures { any::(), arb_order_kind(), any::(), + any::(), any::<[u8; 32]>(), ) .prop_map( - |(owner, buy_tok, sell_tok, sell_amount, buy_amount, valid_to, kind, pf, app)| { + |( + owner, + buy_tok, + sell_tok, + sell_amount, + buy_amount, + valid_to, + kind, + pf, + on_chain, + app, + )| { OrderIntent { owner: Pubkey::new_from_array(owner), buy_token_account: Pubkey::new_from_array(buy_tok), @@ -344,6 +375,7 @@ pub mod fixtures { valid_to, kind, partially_fillable: pf, + created_on_chain: on_chain, app_data: app, } }, @@ -358,13 +390,27 @@ mod tests { use super::fixtures::{sample_intent, FLAGS_OFFSET}; use super::*; - // Every shape an `OrderIntent` can take on its validated axes: the `kind` - // enum and the `partially_fillable` flag bit. - fn all_intent_shapes() -> impl Iterator { + // Full Cartesian product of `OrderKind × partially_fillable × + // created_on_chain` for tests that need to exercise every shape an + // `OrderIntent` can take on these axes. + fn all_flags_combinations() -> impl Iterator { fixtures::ALL_ORDER_KINDS.into_iter().flat_map(|kind| { [false, true] .into_iter() - .map(move |partially_fillable| sample_intent(kind, partially_fillable)) + .flat_map(move |partially_fillable| { + [false, true] + .into_iter() + .map(move |created_on_chain| (kind, partially_fillable, created_on_chain)) + }) + }) + } + + // Every shape an `OrderIntent` can take on its validated axes: the `kind` + // enum and both flag bits. + fn all_intent_shapes() -> impl Iterator { + all_flags_combinations().map(|(kind, partially_fillable, created_on_chain)| OrderIntent { + created_on_chain, + ..sample_intent(kind, partially_fillable) }) } @@ -414,6 +460,7 @@ mod tests { let cleared = OrderIntent { partially_fillable: false, kind: OrderKind::Sell, + created_on_chain: false, ..sample_intent(OrderKind::Sell, false) }; assert_eq!(flags_of(&cleared), 0); @@ -433,6 +480,13 @@ mod tests { ..cleared.clone() }, ), + ( + EncodedOrderIntent::FLAG_CREATED_ON_CHAIN, + OrderIntent { + created_on_chain: true, + ..cleared.clone() + }, + ), ]; let mut seen = 0u8; for (bit, intent) in set_one_by_one { @@ -480,12 +534,22 @@ mod tests { let sell_false: EncodedOrderIntent = (&sample_intent(OrderKind::Sell, false)).into(); let sell_true: EncodedOrderIntent = (&sample_intent(OrderKind::Sell, true)).into(); let buy_true: EncodedOrderIntent = (&sample_intent(OrderKind::Buy, true)).into(); + let signed_off_chain: EncodedOrderIntent = (&OrderIntent { + created_on_chain: false, + ..sample_intent(OrderKind::Sell, true) + }) + .into(); assert_eq!( first_differing_byte(sell_false.as_slice(), sell_true.as_slice()) .expect("should have different flags byte"), FLAGS_OFFSET ); + assert_eq!( + first_differing_byte(signed_off_chain.as_slice(), sell_true.as_slice()) + .expect("should have different flags byte"), + FLAGS_OFFSET + ); assert_eq!( first_differing_byte(buy_true.as_slice(), sell_true.as_slice()) .expect("should have different flags byte"), @@ -520,6 +584,10 @@ mod tests { OrderKind::Buy }, ); + assert_eq!( + intent.created_on_chain, + flags & EncodedOrderIntent::FLAG_CREATED_ON_CHAIN != 0, + ); } } } @@ -527,7 +595,7 @@ mod tests { #[test] fn uid_digest_regression() { let intent = sample_intent(OrderKind::Buy, true); - let expected = hex!("d2a82e919ec3d5e8b21c512cf14251e98bf79cdf01f0a2bdd0ecbed3007a9761"); + let expected = hex!("6353e15862df596f04869c120fe67f717fa66c8664cc652e02f6cba837dfba8e"); assert_eq!(intent.uid(), Hash::from(expected)); } @@ -558,8 +626,8 @@ mod tests { 0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe, // valid_to (0xdead_beef, LE u32) 0xef, 0xbe, 0xad, 0xde, - // flags (partially_fillable | kind (Buy = 1)) - 0b00000011, + // flags (partially_fillable | kind (Buy = 1) | created_on_chain) + 0b00000111, // app_data ([0x44; 32]) 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, diff --git a/interface/src/data/order.rs b/interface/src/data/order.rs index 20d5306..705f114 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -28,7 +28,7 @@ use solana_hash::Hash; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; -use crate::data::intent::{self, EncodedOrderIntent, OrderIntent}; +use crate::data::intent::{self, EncodedOrderIntent, OrderIntent, OrderKind}; use crate::pda::is_pda_with_signer_seeds; use crate::pda::order::order_pda_signer_seeds; use crate::{SettlementAccount, SettlementError}; @@ -86,6 +86,28 @@ impl OrderAccount { Ok(account) } + + /// Whether the order has been filled to the full amount of its exact side, + /// so no settlement can ever fill it again. + pub fn is_fully_filled(&self) -> bool { + let (filled, order_amount) = + fill_progress(&self.intent, self.amount_withdrawn, self.amount_received); + filled >= order_amount + } +} + +/// How much of the order's exact side has been filled by the given cumulative +/// totals, paired with the intent amount that side fills up to: a `Sell` order +/// fills by what's withdrawn from it, a `Buy` order by what it receives. +pub fn fill_progress( + intent: &OrderIntent, + amount_withdrawn: u64, + amount_received: u64, +) -> (u64, u64) { + match intent.kind { + OrderKind::Sell => (amount_withdrawn, intent.sell_amount), + OrderKind::Buy => (amount_received, intent.buy_amount), + } } /// Canonical 200-byte representation of an [`OrderAccount`]. The bytes @@ -369,6 +391,41 @@ mod tests { } } + #[test] + fn is_fully_filled_tracks_the_exact_side_only() { + const SELL_AMOUNT: u64 = 1_000; + const BUY_AMOUNT: u64 = 2_000; + + let account = |kind, amount_withdrawn, amount_received| OrderAccount { + amount_withdrawn, + amount_received, + intent: OrderIntent { + sell_amount: SELL_AMOUNT, + buy_amount: BUY_AMOUNT, + ..sample_intent(kind, true) + }, + ..sample_account(false) + }; + + // (kind, withdrawn, received, expected) + let cases = [ + (OrderKind::Sell, SELL_AMOUNT, 0, true), // fully filled SELL order (stolen money, generally impossible) + (OrderKind::Buy, 0, BUY_AMOUNT, true), // fully filled BUY order (free money) + (OrderKind::Sell, u64::MAX, 0, true), // sell fill past the intent amount (should be impossible) + (OrderKind::Buy, 0, u64::MAX, true), // buy fill past the intent amount (should be impossible) + (OrderKind::Sell, 0, 0, false), // unfilled order + (OrderKind::Sell, SELL_AMOUNT - 1, BUY_AMOUNT, false), // not fully filled SELL order + (OrderKind::Buy, SELL_AMOUNT, BUY_AMOUNT - 1, false), // not fully filled BUY order with fully filled sell side (generally should be impossible) + ]; + for (kind, withdrawn, received, expected) in cases { + assert_eq!( + account(kind, withdrawn, received).is_fully_filled(), + expected, + "{kind:?} order withdrawn={withdrawn} received={received}", + ); + } + } + #[test] fn sanity_check_offsets() { fn first_differing_byte(lhs: &[u8], rhs: &[u8]) -> Option { @@ -392,7 +449,7 @@ mod tests { // In general, it isn't guaranteed that the result encodes to a // valid intent, but in this case we know it because the only byte // that may fail decoding is the flags byte, and `^0x01` only flips - // its `kind` bit, never a reserved one. + // its `created_on_chain` bit, never a reserved one. let bitwise_different_encoded_intent: [u8; EncodedOrderIntent::SIZE] = encoded_intent.map(|b| b ^ 0x01); sample_account_base.intent = diff --git a/interface/src/instruction/create_order.rs b/interface/src/instruction/create_order.rs index 1708ecc..a728c8f 100644 --- a/interface/src/instruction/create_order.rs +++ b/interface/src/instruction/create_order.rs @@ -21,7 +21,9 @@ use crate::{data::intent::EncodedOrderIntent, SettlementInstruction}; /// derives the bump itself and rejects any other address. /// /// `owner` signs the instruction and must match the intent owner; this is -/// what authenticates the order. It may be a normal user account or a PDA, +/// what authenticates the order. The intent must also be flagged +/// `created_on_chain`, the authentication scheme this instruction implements, +/// or the program rejects it. It may be a normal user account or a PDA, /// the program does not check `is_on_curve`. A parent program that wants to /// create orders on behalf of its own PDA can `invoke_signed` into the /// settlement program using this instruction directly. diff --git a/interface/src/instruction/reclaim_order.rs b/interface/src/instruction/reclaim_order.rs index 26ef9a5..0824198 100644 --- a/interface/src/instruction/reclaim_order.rs +++ b/interface/src/instruction/reclaim_order.rs @@ -1,8 +1,10 @@ //! `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. +//! Closes an order PDA and returns its rent lamports to the `created_by` +//! account recorded in the order body. The instruction may only be executed +//! once the order's `valid_to` timestamp has elapsed, or, for an order created +//! on-chain (see `OrderIntent::created_on_chain`), as soon as it's cancelled or +//! completely filled. //! //! Wire format: `[discriminator=5]`, 1 byte. //! Required accounts: @@ -20,8 +22,8 @@ use crate::SettlementInstruction; /// `order_pda` is the 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. +/// The instruction enforces no signature requirement: anyone may reclaim a +/// reclaimable order on behalf of its reclaim_recipient. pub struct ReclaimOrder { pub program_id: Pubkey, pub order_pda: Pubkey, diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 0e019aa..c2dbe32 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -204,8 +204,8 @@ pub enum SettlementError { /// `BeginSettle`: the order's cumulative `amount_received` would exceed /// `u64::MAX` once this settlement's push is added. AmountReceivedOverflow = 29, - /// `ReclaimOrder` was called before the order's `valid_to` has elapsed. - OrderNotExpired = 30, + /// `ReclaimOrder` was called on an order that has is not yet eligible for reclaim. + OrderNotReclaimable = 30, /// `ReclaimOrder`'s `reclaim_recipient` account doesn't match the /// `created_by` address recorded in the order. ReclaimRecipientMismatch = 31, @@ -219,6 +219,9 @@ pub enum SettlementError { /// `TransferAuthority`'s signer is neither the manager nor the current /// holder of the role being transferred, so it may not transfer it. UnauthorizedAuthorityTransfer = 34, + /// A created order's intent isn't set with the `created_on_chain` flag corresponding + /// to the behavior of the invoked order creation instruction. + OrderCreatedOnChainMismatch = 35, } impl From for u32 { diff --git a/programs/settlement/src/create_order.rs b/programs/settlement/src/create_order.rs index 2ee64c8..d0527a7 100644 --- a/programs/settlement/src/create_order.rs +++ b/programs/settlement/src/create_order.rs @@ -34,6 +34,11 @@ pub fn process_create_order( if owner.address() != &intent.owner { return Err(SettlementError::OwnerMismatch.into()); } + // The intent commits to how it's authenticated, and this is the on-chain + // creation flow. + if !intent.created_on_chain { + return Err(SettlementError::OrderCreatedOnChainMismatch.into()); + } // We want a single order per uid; `CanonicalPda::create_new` derives the // canonical bump and, by signing the creation with the order seeds, rejects @@ -173,4 +178,29 @@ mod tests { Err(SettlementError::OwnerMismatch.into()), ); } + + #[test] + fn process_create_order_rejects_intent_not_created_on_chain() { + let intent: OrderIntent = (&valid_intent_bytes()).try_into().expect("should be valid"); + let intent_bytes: [u8; EncodedOrderIntent::SIZE] = + (&EncodedOrderIntent::from(&OrderIntent { + created_on_chain: false, + ..intent + })) + .into(); + let data = default_order_data(&intent_bytes); + let owner_runtime_account = RuntimeAccount { + address: DEFAULT_OWNER, + is_signer: 1, + ..Default::default() + }; + + let mut accounts = fake_sequential_accounts::(); + accounts[0] = fake_account_from(owner_runtime_account); + + assert_eq!( + process_create_order(&PROGRAM_ID, &mut accounts, &data), + Err(SettlementError::OrderCreatedOnChainMismatch.into()), + ); + } } diff --git a/programs/settlement/src/reclaim_order.rs b/programs/settlement/src/reclaim_order.rs index 1a9c9de..2410e2d 100644 --- a/programs/settlement/src/reclaim_order.rs +++ b/programs/settlement/src/reclaim_order.rs @@ -27,9 +27,12 @@ pub fn process_reclaim_order( return Err(SettlementError::ReclaimRecipientMismatch.into()); } - let now = Clock::get()?.unix_timestamp; - if now <= i64::from(account.intent.valid_to) { - return Err(SettlementError::OrderNotExpired.into()); + // Is this order eligible for reclaimation? + if !is_reclaimable_before_expiry(&account) { + let now = Clock::get()?.unix_timestamp; + if now <= i64::from(account.intent.valid_to) { + return Err(SettlementError::OrderNotReclaimable.into()); + } } // Transfer the rent lamports to the reclaim_recipient account, then close the PDA. @@ -49,8 +52,14 @@ pub fn process_reclaim_order( Ok(()) } +/// Determines whether the order may be reclaimed despite being unexpired +fn is_reclaimable_before_expiry(account: &OrderAccount) -> bool { + account.intent.created_on_chain && (account.cancelled || account.is_fully_filled()) +} + #[cfg(test)] mod tests { + use cow_settlement_interface::data::intent::{fixtures::sample_intent, OrderIntent, OrderKind}; use cow_settlement_interface::data::order::EncodedOrderAccount; use cow_settlement_interface::instruction::{ fixtures::{fake_account, fake_account_with_data, fake_sequential_accounts}, @@ -99,4 +108,47 @@ mod tests { Err(SettlementError::ReclaimRecipientMismatch.into()), ); } + + #[test] + fn early_reclaim_conditions() { + const SELL_AMOUNT: u64 = 1_000; + + let account = |created_on_chain, cancelled, amount_withdrawn| OrderAccount { + cancelled, + amount_withdrawn, + intent: OrderIntent { + sell_amount: SELL_AMOUNT, + created_on_chain, + ..sample_intent(OrderKind::Sell, true) + }, + ..Default::default() + }; + + // (created_on_chain, cancelled, amount_withdrawn, expected) + let cases = [ + // Created on-chain and either cancelled or fully settled. + (true, true, 0, true), + (true, false, SELL_AMOUNT, true), + (true, true, SELL_AMOUNT, true), + // Authenticated by signature: prior cancelled or fully settled cases no longer apply + (false, true, 0, false), + (false, false, SELL_AMOUNT, false), + (false, true, SELL_AMOUNT, false), + // Created on-chain and not fully filled. + (true, false, 0, false), + (true, false, SELL_AMOUNT - 1, false), + ]; + for (created_on_chain, cancelled, amount_withdrawn, expected) in cases { + assert_eq!( + is_reclaimable_before_expiry(&account( + created_on_chain, + cancelled, + amount_withdrawn + )), + expected, + "created_on_chain={created_on_chain} cancelled={cancelled} \ + amount_withdrawn={amount_withdrawn}", + ); + } + } } diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index e9aad48..a2bc558 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -4,8 +4,8 @@ use std::ops::Deref; use cow_settlement_interface::{ data::{ - intent::{OrderIntent, OrderKind}, - order::{EncodedOrderAccount, OrderAccount}, + intent::OrderIntent, + order::{fill_progress, EncodedOrderAccount, OrderAccount}, }, instruction::{ settle::{ @@ -358,10 +358,7 @@ fn validated_final_amounts( .checked_add(amount_out) .ok_or(SettlementError::AmountReceivedOverflow)?; - let (filled, order_amount) = match intent.kind { - OrderKind::Sell => (amount_withdrawn, intent.sell_amount), - OrderKind::Buy => (amount_received, intent.buy_amount), - }; + let (filled, order_amount) = fill_progress(intent, amount_withdrawn, amount_received); if filled != order_amount && !intent.partially_fillable { return Err(SettlementError::OrderNotExactlyFilled); } else if filled > order_amount { @@ -375,6 +372,7 @@ fn validated_final_amounts( mod tests { use super::*; use cow_settlement_interface::data::intent::fixtures::{arb_order_intent, sample_intent}; + use cow_settlement_interface::data::intent::OrderKind; use cow_settlement_interface::instruction::fixtures::fake_account; use cow_settlement_interface::instruction::settle::fixtures::arb_pushes; use cow_settlement_interface::instruction::settle::{FinalizeSettle, FinalizeSettleInput}; diff --git a/programs/settlement/tests/common/order.rs b/programs/settlement/tests/common/order.rs index 0b2235e..425d974 100644 --- a/programs/settlement/tests/common/order.rs +++ b/programs/settlement/tests/common/order.rs @@ -23,6 +23,7 @@ pub fn sample_intent(owner: Pubkey, sell_token_account: Pubkey, salt: u8) -> Ord valid_to: 0xdead_beef, kind: OrderKind::Sell, partially_fillable: true, + created_on_chain: true, app_data: [salt; 32], } } diff --git a/programs/settlement/tests/create_order.rs b/programs/settlement/tests/create_order.rs index 001b4b9..ce8b8ca 100644 --- a/programs/settlement/tests/create_order.rs +++ b/programs/settlement/tests/create_order.rs @@ -326,3 +326,34 @@ fn rejects_when_intent_owner_differs_from_signer() { "expected MismatchingSettlePair at instruction {expected_failing_instruction_index}" ); } + +#[test] +fn rejects_intent_authenticated_off_chain() { + let (mut svm, program_id, owner) = common::setup(); + + let intent = OrderIntent { + created_on_chain: false, + ..sample_intent(owner.pubkey()) + }; + let (encoded, pda, _bump) = encode_and_derive(&intent, &program_id); + + let ix = CreateOrder { + program_id, + owner: owner.pubkey(), + created_by: owner.pubkey(), + order_pda: pda, + intent_bytes: encoded, + }; + let tx = signed_tx(&svm, &owner, &owner, ix); + assert_eq!( + svm.send_transaction(tx).map_err(|e| e.err).err(), + Some(TransactionError::InstructionError( + 0, + to_instruction_error(SettlementError::OrderCreatedOnChainMismatch), + )), + ); + assert!( + svm.get_account(&pda).is_none(), + "no order PDA may be left behind by a rejected creation" + ); +} diff --git a/programs/settlement/tests/reclaim_order.rs b/programs/settlement/tests/reclaim_order.rs index d641ac2..71c1769 100644 --- a/programs/settlement/tests/reclaim_order.rs +++ b/programs/settlement/tests/reclaim_order.rs @@ -1,9 +1,11 @@ use cow_settlement_client::cow_settlement_interface::{ data::intent::{fixtures::sample_intent, EncodedOrderIntent, OrderIntent, OrderKind}, + data::order::{EncodedOrderAccount, OrderAccount}, instruction::{create_order::CreateOrder, reclaim_order::ReclaimOrder}, pda::order::find_order_pda, SettlementError, }; +use litesvm::LiteSVM; use solana_sdk::{ pubkey::Pubkey, signature::{Keypair, Signer}, @@ -12,7 +14,7 @@ use solana_sdk::{ use crate::common::{ assert_instruction_error, benchmark::{send_transaction_metered, BenchLabel}, - signed_tx, to_instruction_error, unique_keypair, unique_pubkey, + create_account_at, signed_tx, to_instruction_error, unique_keypair, unique_pubkey, }; mod common; @@ -37,9 +39,39 @@ fn encode_and_derive( (bytes, pda) } +/// Directly overwrite the body stored in an order PDA. +fn patch_order(svm: &mut LiteSVM, pda: &Pubkey, patch: impl FnOnce(OrderAccount) -> OrderAccount) { + let mut account = svm.get_account(pda).expect("order PDA must exist"); + let order = OrderAccount::try_from(&account.data[..]).expect("order PDA must decode"); + account.data = EncodedOrderAccount::from(patch(order)).to_vec(); + svm.set_account(*pda, account) + .expect("set_account should succeed"); +} + +/// Put an order PDA on-chain directly, bypassing `CreateOrder`, which only +/// accepts intents declaring on-chain authentication. This is how an order +/// authenticated by an off-chain signature is staged. +fn place_order_pda( + svm: &mut LiteSVM, + program_id: &Pubkey, + intent: &OrderIntent, + created_by: &Pubkey, + patch: impl FnOnce(OrderAccount) -> OrderAccount, +) -> Pubkey { + let (pda, bump) = find_order_pda(program_id, &intent.uid()); + let order = patch(OrderAccount { + bump, + created_by: *created_by, + intent: intent.clone(), + ..Default::default() + }); + create_account_at(svm, pda, program_id, &EncodedOrderAccount::from(order)[..]); + pda +} + /// Create an order PDA owned by `owner` (who also pays rent), return the PDA. fn create_order( - svm: &mut litesvm::LiteSVM, + svm: &mut LiteSVM, program_id: &Pubkey, owner: &Keypair, intent: &OrderIntent, @@ -146,7 +178,132 @@ fn rejects_when_order_not_yet_expired() { let tx = signed_tx(&svm, &owner, &owner, ix); assert_instruction_error( svm.send_transaction(tx).map_err(|e| e.err), - to_instruction_error(SettlementError::OrderNotExpired), + to_instruction_error(SettlementError::OrderNotReclaimable), + ); +} + +/// Reclaim `pda` before its `valid_to`, crediting `owner`, and return the +/// transaction result. The clock is pinned to the order's last valid second, so +/// nothing here is reclaimable by expiry. +fn reclaim_while_unexpired( + svm: &mut LiteSVM, + program_id: &Pubkey, + owner: &Keypair, + pda: &Pubkey, +) -> Result<(), solana_sdk::transaction::TransactionError> { + common::set_unix_timestamp(svm, VALID_TO as i64); + + let ix = ReclaimOrder { + program_id: *program_id, + order_pda: *pda, + reclaim_recipient: owner.pubkey(), + } + .instruction(); + let tx = signed_tx(svm, owner, owner, ix); + svm.send_transaction(tx).map(|_| ()).map_err(|e| e.err)?; + assert!( + svm.get_account(pda).is_none(), + "order PDA must be closed after reclaim" + ); + + Ok(()) +} + +#[test] +fn on_chain_order_fully_filled_is_reclaimable_before_expiry() { + 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); + // A sell order is full once its whole sell amount has been withdrawn. + patch_order(&mut svm, &pda, |order| OrderAccount { + amount_withdrawn: order.intent.sell_amount, + ..order + }); + + reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda) + .expect("a filled on-chain order should be reclaimable before it expires"); +} + +#[test] +fn on_chain_order_cancelled_is_reclaimable_before_expiry() { + 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); + patch_order(&mut svm, &pda, |order| OrderAccount { + cancelled: true, + ..order + }); + + reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda) + .expect("a cancelled on-chain order should be reclaimable before it expires"); +} + +#[test] +fn on_chain_order_partially_filled_is_not_reclaimable_before_expiry() { + 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); + // One token short of a full fill: the order can still be settled, so its + // PDA has to stay. + patch_order(&mut svm, &pda, |order| OrderAccount { + amount_withdrawn: order.intent.sell_amount - 1, + ..order + }); + + assert_instruction_error( + reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda), + to_instruction_error(SettlementError::OrderNotReclaimable), + ); +} + +/// An order authenticated by an off-chain signature can be recreated by anyone +/// holding that signature, which would reset its fills and its cancellation. +/// Being unfillable doesn't make it reclaimable, then: only expiry does, and +/// expiry does so regardless of how the order was authenticated. +#[test] +fn off_chain_order_is_reclaimable_only_once_expired() { + let (mut svm, program_id, owner) = common::setup(); + + let intent = OrderIntent { + created_on_chain: false, + ..reclaim_sample_intent(owner.pubkey()) + }; + // Cancelled *and* completely filled: the strongest case for early reclaim, + // and it still has to wait. + let pda = place_order_pda(&mut svm, &program_id, &intent, &owner.pubkey(), |order| { + OrderAccount { + cancelled: true, + amount_withdrawn: order.intent.sell_amount, + ..order + } + }); + + assert_instruction_error( + reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda), + to_instruction_error(SettlementError::OrderNotReclaimable), + ); + assert!( + svm.get_account(&pda).is_some(), + "order PDA must survive a rejected reclaim" + ); + + common::set_unix_timestamp(&mut svm, (VALID_TO + 1).into()); + svm.expire_blockhash(); + let ix = ReclaimOrder { + program_id, + order_pda: pda, + reclaim_recipient: owner.pubkey(), + } + .instruction(); + let tx = signed_tx(&svm, &owner, &owner, ix); + svm.send_transaction(tx) + .expect("an expired order should be reclaimable however it was authenticated"); + assert!( + svm.get_account(&pda).is_none(), + "order PDA must be closed after reclaim" ); } diff --git a/test-cli/src/cmd/create_order.rs b/test-cli/src/cmd/create_order.rs index 13d79bc..b5a558a 100644 --- a/test-cli/src/cmd/create_order.rs +++ b/test-cli/src/cmd/create_order.rs @@ -164,6 +164,7 @@ fn execute(ctx: Context, parsed: ParsedOrder, common: CommonArgs) -> anyhow::Res valid_to: common.valid_to, kind, partially_fillable: common.partially_fillable, + created_on_chain: true, app_data: [0u8; 32], }; From bbd883028be953b37fa58e5fad47cc67c27fbbc0 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:30:14 +0900 Subject: [PATCH 03/22] verify behavior of reclaim_order within settlement --- .../settlement/tests/common/settlement.rs | 127 +++++++++++++++++- programs/settlement/tests/reclaim_order.rs | 94 ++++++++++++- .../settlement/tests/settle_limit_prices.rs | 75 ++--------- 3 files changed, 230 insertions(+), 66 deletions(-) diff --git a/programs/settlement/tests/common/settlement.rs b/programs/settlement/tests/common/settlement.rs index 0d10eb9..f16cacf 100644 --- a/programs/settlement/tests/common/settlement.rs +++ b/programs/settlement/tests/common/settlement.rs @@ -1,8 +1,13 @@ //! Scaffolding for building `[BeginSettle, FinalizeSettle]` settlement pairs. -use cow_settlement_client::instructions::{BeginSettle, FinalizedIntent, InitializedIntent}; -use cow_settlement_interface::Instruction; -use solana_sdk::pubkey::Pubkey; +use cow_settlement_client::instructions::{ + BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, +}; +use cow_settlement_interface::{data::intent::OrderIntent, Instruction}; +use litesvm::LiteSVM; +use solana_sdk::{pubkey::Pubkey, signature::Keypair}; + +use super::{buffer, token, unique_pubkey}; /// Positions of the two instructions in the `[BeginSettle, FinalizeSettle]` pair /// the settlement tests build: begin first, finalize right after it. Each @@ -14,6 +19,9 @@ pub const FINALIZE_INDEX: u8 = 1; /// `FinalizeSettle` at [`FINALIZE_INDEX`] and `begin` settles `orders` (with no /// pulls) at [`BEGIN_INDEX`], the same orders the finalize is expected to push /// to. Submit the result with [`send`](super::send). +/// +/// Use this when the test needs to hand-build the finalize (to corrupt it, say); +/// [`build_staged_settlement`] builds both halves from staged orders instead. pub fn build_settlement( program_id: &Pubkey, solver: &Pubkey, @@ -36,3 +44,116 @@ pub fn build_settlement( }; vec![begin.into(), finalize.into()] } + +/// An order staged for settlement by [`stage_order`]: the intent, the [`Pull`]s +/// to draw from its sell token account, and the amount to push to its buy token +/// account. It owns its intent, so a helper that mints an order can stage it and +/// hand back the result in one piece. +pub struct StagedOrder { + pub intent: OrderIntent, + pub pulls: Vec, + pub amount_out: u64, +} + +/// Stage the token side of settling `intent`, so a settlement of the result can +/// actually move the funds: fund and delegate the sell token account for the +/// total of `pulls`, give each pull its own throwaway destination account of the +/// sell mint, and fund the buy mint's canonical buffer with `amount_out` for the +/// push to draw on. `payer` funds all of it. +/// +/// Buffers are shared per mint, so staging several orders that buy the same mint +/// accumulates their `amount_out` in the one buffer. +pub fn stage_order( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + intent: &OrderIntent, + pulls: &[u64], + amount_out: u64, +) -> StagedOrder { + let amount_in: u64 = pulls.iter().sum(); + token::fund_and_delegate( + svm, + program_id, + payer, + &intent.sell_token_account, + amount_in, + ); + let pulls = pulls + .iter() + .map(|&amount| Pull { + destination: token::create_token_account( + svm, + payer, + &intent.sell_mint, + &unique_pubkey(), + ), + amount, + }) + .collect(); + buffer::ensure_funded(svm, program_id, payer, &intent.buy_mint, amount_out); + + StagedOrder { + intent: intent.clone(), + pulls, + amount_out, + } +} + +/// Build the instructions settling `orders`: a `BeginSettle` at [`BEGIN_INDEX`] +/// carrying each order's pulls, `between` right after it, and the matching +/// `FinalizeSettle` last, pushing each order's `amount_out`. Submit the result +/// with [`send`](super::send). +/// +/// With `between` empty this is the plain pair at +/// [`BEGIN_INDEX`]/[`FINALIZE_INDEX`]. Anything interposed shifts the finalize, +/// and the two instructions' counterpart indices follow it. +pub fn build_staged_settlement( + program_id: &Pubkey, + solver: &Pubkey, + orders: &[StagedOrder], + between: Vec, +) -> Vec { + let begin_orders: Vec = orders + .iter() + .map(|order| InitializedIntent { + intent: &order.intent, + pulls: &order.pulls, + }) + .collect(); + let finalize_orders: Vec = orders + .iter() + .map(|order| FinalizedIntent { + intent: &order.intent, + amount: order.amount_out, + }) + .collect(); + + let begin = BeginSettle { + program_id: *program_id, + solver: *solver, + finalize_ix_index: finalize_index(between.len()), + auction_id: 0, + orders: &begin_orders, + }; + let finalize = FinalizeSettle { + program_id: *program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders: &finalize_orders, + }; + + let mut instructions = vec![begin.into()]; + instructions.extend(between); + instructions.push(finalize.into()); + instructions +} + +/// Where the `FinalizeSettle` lands in a settlement with `interposed` +/// instructions sitting between the pair: [`FINALIZE_INDEX`] shifted along by +/// them. +fn finalize_index(interposed: usize) -> u16 { + u16::try_from(interposed) + .ok() + .and_then(|shift| shift.checked_add(u16::from(FINALIZE_INDEX))) + .expect("a test transaction holds far fewer than u16::MAX instructions") +} diff --git a/programs/settlement/tests/reclaim_order.rs b/programs/settlement/tests/reclaim_order.rs index 561fb48..9b65952 100644 --- a/programs/settlement/tests/reclaim_order.rs +++ b/programs/settlement/tests/reclaim_order.rs @@ -17,7 +17,11 @@ use solana_sdk::{ use crate::common::{ assert_instruction_error, benchmark::{send_transaction_metered, BenchLabel}, - create_account_at, signed_tx, to_instruction_error, unique_keypair, unique_pubkey, + buffer, create_account_at, + order::OrderBuilder, + send, + settlement::{build_staged_settlement, stage_order, StagedOrder}, + signed_tx, to_instruction_error, token, unique_keypair, unique_pubkey, }; mod common; @@ -398,3 +402,91 @@ fn rejects_when_reclaim_recipient_mismatch() { to_instruction_error(SettlementError::ReclaimRecipientMismatch), ); } + +fn settleable_order( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, +) -> (StagedOrder, Pubkey) { + let sell_amount: u64 = 1_000_000; + let buy_amount: u64 = 2_000_000; + + let intent = OrderBuilder::new(svm, program_id, payer) + .sell_amount(sell_amount) + .buy_amount(buy_amount) + .partially_fillable(false) + .build(); + let (order_pda, _bump) = find_order_pda(program_id, &intent.uid()); + let staged = stage_order(svm, program_id, payer, &intent, &[sell_amount], buy_amount); + (staged, order_pda) +} + +#[test] +fn rejects_reclaim_of_a_partially_filled_order() { + let (mut svm, program_id, payer, _solver) = common::setup_settle_ready(); + let (_staged, order_pda) = settleable_order(&mut svm, &program_id, &payer); + + let ix = ReclaimOrder { + program_id, + order_pda, + reclaim_recipient: payer.pubkey(), + } + .instruction(); + let tx = signed_tx(&svm, &payer, &payer, ix); + assert_instruction_error( + svm.send_transaction(tx).map_err(|e| e.err), + to_instruction_error(SettlementError::OrderNotReclaimable), + ); + assert!( + svm.get_account(&order_pda).is_some(), + "order PDA must survive a rejected reclaim" + ); +} + +/// A reclaim placed between `BeginSettle` and `FinalizeSettle` closes the order +/// PDA without breaking the settlement around it. +/// +/// `BeginSettle` does all of the settlement's order validation and records the +/// fill, and it's the only instruction of the pair that takes the order PDA as +/// an account. So reclaim is free to happen after that point. +#[test] +fn reclaim_mid_settlement_succeeds() { + let (mut svm, program_id, payer, solver) = common::setup_settle_ready(); + let (staged, order_pda) = settleable_order(&mut svm, &program_id, &payer); + let pull_destination = staged.pulls[0].destination; + let buy_token_account = staged.intent.buy_token_account; + let buffer_pda = buffer::buffer_pda(&program_id, &staged.intent.buy_mint); + let pda_rent = svm.minimum_balance_for_rent_exemption(EncodedOrderAccount::SIZE); + + let reclaim = ReclaimOrder { + program_id, + order_pda, + reclaim_recipient: payer.pubkey(), + } + .instruction(); + let instructions = + build_staged_settlement(&program_id, &solver.pubkey(), &[staged], vec![reclaim]); + + // The `payer` that created the order signs nothing here and pays no fee (the + // solver does), so its balance moves by the returned rent alone. + let payer_before = common::lamports(&svm, &payer.pubkey()); + send(&mut svm, &solver, instructions) + .expect("reclaiming a just-filled order mid-settlement should succeed"); + + assert!( + svm.get_account(&order_pda).is_none(), + "order PDA must be closed by the mid-settlement reclaim" + ); + assert_eq!( + common::lamports(&svm, &payer.pubkey()) - payer_before, + pda_rent, + "the order's creator must receive the closed PDA's rent" + ); + + // Both legs of the settlement went through around the reclaim: the pull in + // `BeginSettle`, before the order PDA was closed, and the push in + // `FinalizeSettle`, after. + assert_eq!(token::balance(&svm, &pull_destination), SETTLED_SELL_AMOUNT); + assert_eq!(token::balance(&svm, &buy_token_account), SETTLED_BUY_AMOUNT); + assert_eq!(token::balance(&svm, &buffer_pda), 0); +} diff --git a/programs/settlement/tests/settle_limit_prices.rs b/programs/settlement/tests/settle_limit_prices.rs index bda5827..bd8928e 100644 --- a/programs/settlement/tests/settle_limit_prices.rs +++ b/programs/settlement/tests/settle_limit_prices.rs @@ -7,10 +7,11 @@ //! succeeds or is rejected with the expected error. use crate::common::{ - assert_instruction_error_at, buffer, + assert_instruction_error_at, order::OrderBuilder, - settlement::{BEGIN_INDEX, FINALIZE_INDEX}, - setup_settle_ready, to_instruction_error, token, unique_pubkey, + send, + settlement::{build_staged_settlement, stage_order, StagedOrder, BEGIN_INDEX}, + setup_settle_ready, to_instruction_error, token, }; use cow_settlement_client::cow_settlement_interface::{ data::intent::{OrderIntent, OrderKind}, @@ -18,14 +19,11 @@ use cow_settlement_client::cow_settlement_interface::{ pda::order::find_order_pda, SettlementError, }; -use cow_settlement_client::instructions::{ - BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, -}; use litesvm::LiteSVM; use solana_sdk::{ pubkey::Pubkey, signature::{Keypair, Signer}, - transaction::{Transaction, TransactionError}, + transaction::TransactionError, }; mod common; @@ -83,64 +81,17 @@ fn settle_all( solver: &Keypair, orders: &[(&OrderIntent, &[u64], u64)], ) -> Result<(), TransactionError> { - let mut initialized: Vec = vec![]; - let mut finalized: Vec = vec![]; - for &(intent, pulls, amount_out) in orders { - // Sell side: fund and delegate the total pulled, and give each pull its - // own throwaway destination of the sell mint to pull into. - let amount_in: u64 = pulls.iter().sum(); - token::fund_and_delegate( - svm, - program_id, - payer, - &intent.sell_token_account, - amount_in, - ); - let mut pull_list: Vec = vec![]; - for &amount in pulls { - let destination = - token::create_token_account(svm, payer, &intent.sell_mint, &unique_pubkey()); - pull_list.push(Pull { - destination, - amount, - }); - } - // Leak the pulls so the `InitializedIntent` can borrow them until the - // builder consumes every order's pulls at once, below. - let pulls: &[Pull] = Box::leak(pull_list.into_boxed_slice()); - initialized.push(InitializedIntent { intent, pulls }); - - // Buy side: fund the buffer so the push can draw `amount_out`. - buffer::ensure_funded(svm, program_id, payer, &intent.buy_mint, amount_out); - - finalized.push(FinalizedIntent { - intent, - amount: amount_out, - }); - } - - let begin = BeginSettle { - program_id: *program_id, - solver: solver.pubkey(), - finalize_ix_index: FINALIZE_INDEX.into(), - auction_id: 0, - orders: &initialized, - }; - let finalize = FinalizeSettle { - program_id: *program_id, - begin_ix_index: BEGIN_INDEX.into(), - orders: &finalized, - }; + let staged: Vec = orders + .iter() + .map(|&(intent, pulls, amount_out)| { + stage_order(svm, program_id, payer, intent, pulls, amount_out) + }) + .collect(); + let instructions = build_staged_settlement(program_id, &solver.pubkey(), &staged, vec![]); // The solver settles and pays: it's the fee payer and the only signer the // pair needs (`BeginSettle` names it as its solver-signer). `payer` above // only funds the order/buffer setup. - let tx = Transaction::new_signed_with_payer( - &[begin.into(), finalize.into()], - Some(&solver.pubkey()), - &[solver], - svm.latest_blockhash(), - ); - svm.send_transaction(tx).map(|_| ()).map_err(|e| e.err) + send(svm, solver, instructions).map(|_| ()) } // --- Limit price --------------------------------------------------------- From 56b46169437d322c7d3a7062101ac7233ff85c56 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:33:28 +0900 Subject: [PATCH 04/22] fix constant --- programs/settlement/tests/reclaim_order.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/programs/settlement/tests/reclaim_order.rs b/programs/settlement/tests/reclaim_order.rs index 9b65952..6ec0ff0 100644 --- a/programs/settlement/tests/reclaim_order.rs +++ b/programs/settlement/tests/reclaim_order.rs @@ -403,21 +403,28 @@ fn rejects_when_reclaim_recipient_mismatch() { ); } +const SETTLED_SELL_AMOUNT: u64 = 1_000_000; +const SETTLED_BUY_AMOUNT: u64 = 2_000_000; + fn settleable_order( svm: &mut LiteSVM, program_id: &Pubkey, payer: &Keypair, ) -> (StagedOrder, Pubkey) { - let sell_amount: u64 = 1_000_000; - let buy_amount: u64 = 2_000_000; - let intent = OrderBuilder::new(svm, program_id, payer) - .sell_amount(sell_amount) - .buy_amount(buy_amount) + .sell_amount(SETTLED_SELL_AMOUNT) + .buy_amount(SETTLED_BUY_AMOUNT) .partially_fillable(false) .build(); let (order_pda, _bump) = find_order_pda(program_id, &intent.uid()); - let staged = stage_order(svm, program_id, payer, &intent, &[sell_amount], buy_amount); + let staged = stage_order( + svm, + program_id, + payer, + &intent, + &[SETTLED_SELL_AMOUNT], + SETTLED_BUY_AMOUNT, + ); (staged, order_pda) } From 33b9771adcc0e6dc3cc41684f7a9924e9bceb957 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:35:11 +0900 Subject: [PATCH 05/22] fix bench --- bench-report.json | 48 +++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/bench-report.json b/bench-report.json index a1842cf..dc768ff 100644 --- a/bench-report.json +++ b/bench-report.json @@ -26,30 +26,30 @@ "transfer_authority/reclaim_authority_can_transfer_itself": 4 }, "compute_units": { - "add_solver/add_with_many_existing_solvers": 5068, - "add_solver/adds_a_solver": 4616, - "create_buffers/happy_path_creates_initialized_buffer_token_account": 10339, - "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21731, - "create_buffers/max_buffers_in_one_instruction": 176947, - "create_order/happy_path_creates_order_pda_with_expected_body": 9480, - "initialize/happy_path_initializes_state_pda_with_expected_data": 4524, - "reclaim_buffer/funded_buffer_is_skipped": 6328, - "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7479, - "reclaim_buffer/max_buffers_in_one_instruction": 136648, - "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18075, - "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2199, - "settle/finalizes_with_no_pushes": 7123, - "settle/pulls_from_multiple_orders": 19993, - "settle/pulls_funds_to_destination": 13591, - "settle/pulls_to_multiple_destinations": 14731, - "settle/pushes_a_single_order": 12447, - "settle/pushes_several_orders_from_different_buffers": 17703, - "settle/pushes_several_orders_from_one_buffer": 17702, - "settle/settles_a_single_order": 12465, - "settle/settles_multiple_orders": 23006, - "transfer_authority/manager_can_transfer_manager": 3171, - "transfer_authority/manager_can_transfer_reclaim_authority": 3173, - "transfer_authority/reclaim_authority_can_transfer_itself": 3177 + "add_solver/add_with_many_existing_solvers": 5074, + "add_solver/adds_a_solver": 4622, + "create_buffers/happy_path_creates_initialized_buffer_token_account": 10345, + "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21743, + "create_buffers/max_buffers_in_one_instruction": 177040, + "create_order/happy_path_creates_order_pda_with_expected_body": 9485, + "initialize/happy_path_initializes_state_pda_with_expected_data": 4529, + "reclaim_buffer/funded_buffer_is_skipped": 6335, + "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7483, + "reclaim_buffer/max_buffers_in_one_instruction": 136652, + "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18082, + "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2202, + "settle/finalizes_with_no_pushes": 7152, + "settle/pulls_from_multiple_orders": 20059, + "settle/pulls_funds_to_destination": 13639, + "settle/pulls_to_multiple_destinations": 14780, + "settle/pushes_a_single_order": 12494, + "settle/pushes_several_orders_from_different_buffers": 17767, + "settle/pushes_several_orders_from_one_buffer": 17766, + "settle/settles_a_single_order": 12512, + "settle/settles_multiple_orders": 23087, + "transfer_authority/manager_can_transfer_manager": 3174, + "transfer_authority/manager_can_transfer_reclaim_authority": 3176, + "transfer_authority/reclaim_authority_can_transfer_itself": 3180 }, "transaction_bytes": { "add_solver/add_with_many_existing_solvers": 366, From 3bda0dc770cf6b906d3835516377488c65783855 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:08:02 +0900 Subject: [PATCH 06/22] fix bench report again --- bench-report.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/bench-report.json b/bench-report.json index dc768ff..c64d56d 100644 --- a/bench-report.json +++ b/bench-report.json @@ -38,15 +38,15 @@ "reclaim_buffer/max_buffers_in_one_instruction": 136652, "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18082, "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2202, - "settle/finalizes_with_no_pushes": 7152, - "settle/pulls_from_multiple_orders": 20059, - "settle/pulls_funds_to_destination": 13639, - "settle/pulls_to_multiple_destinations": 14780, - "settle/pushes_a_single_order": 12494, - "settle/pushes_several_orders_from_different_buffers": 17767, - "settle/pushes_several_orders_from_one_buffer": 17766, - "settle/settles_a_single_order": 12512, - "settle/settles_multiple_orders": 23087, + "settle/finalizes_with_no_pushes": 7154, + "settle/pulls_from_multiple_orders": 20061, + "settle/pulls_funds_to_destination": 13641, + "settle/pulls_to_multiple_destinations": 14782, + "settle/pushes_a_single_order": 12496, + "settle/pushes_several_orders_from_different_buffers": 17769, + "settle/pushes_several_orders_from_one_buffer": 17768, + "settle/settles_a_single_order": 12514, + "settle/settles_multiple_orders": 23089, "transfer_authority/manager_can_transfer_manager": 3174, "transfer_authority/manager_can_transfer_reclaim_authority": 3176, "transfer_authority/reclaim_authority_can_transfer_itself": 3180 From eba176a69f952b5904ef6c92ba565c1a11d30f3e Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:55:06 +0900 Subject: [PATCH 07/22] fix random issues I noticed while re-reviewing --- bench-report.json | 48 ++++++++-------- interface/src/data/intent.rs | 10 +--- programs/settlement/tests/reclaim_order.rs | 64 +++++++++++----------- 3 files changed, 58 insertions(+), 64 deletions(-) diff --git a/bench-report.json b/bench-report.json index c64d56d..f1b910d 100644 --- a/bench-report.json +++ b/bench-report.json @@ -26,30 +26,30 @@ "transfer_authority/reclaim_authority_can_transfer_itself": 4 }, "compute_units": { - "add_solver/add_with_many_existing_solvers": 5074, - "add_solver/adds_a_solver": 4622, - "create_buffers/happy_path_creates_initialized_buffer_token_account": 10345, - "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21743, - "create_buffers/max_buffers_in_one_instruction": 177040, - "create_order/happy_path_creates_order_pda_with_expected_body": 9485, - "initialize/happy_path_initializes_state_pda_with_expected_data": 4529, - "reclaim_buffer/funded_buffer_is_skipped": 6335, - "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7483, - "reclaim_buffer/max_buffers_in_one_instruction": 136652, - "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18082, - "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2202, - "settle/finalizes_with_no_pushes": 7154, - "settle/pulls_from_multiple_orders": 20061, - "settle/pulls_funds_to_destination": 13641, - "settle/pulls_to_multiple_destinations": 14782, - "settle/pushes_a_single_order": 12496, - "settle/pushes_several_orders_from_different_buffers": 17769, - "settle/pushes_several_orders_from_one_buffer": 17768, - "settle/settles_a_single_order": 12514, - "settle/settles_multiple_orders": 23089, - "transfer_authority/manager_can_transfer_manager": 3174, - "transfer_authority/manager_can_transfer_reclaim_authority": 3176, - "transfer_authority/reclaim_authority_can_transfer_itself": 3180 + "add_solver/add_with_many_existing_solvers": 5068, + "add_solver/adds_a_solver": 4616, + "create_buffers/happy_path_creates_initialized_buffer_token_account": 10339, + "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21731, + "create_buffers/max_buffers_in_one_instruction": 176947, + "create_order/happy_path_creates_order_pda_with_expected_body": 9480, + "initialize/happy_path_initializes_state_pda_with_expected_data": 4524, + "reclaim_buffer/funded_buffer_is_skipped": 6328, + "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7479, + "reclaim_buffer/max_buffers_in_one_instruction": 136648, + "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18075, + "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2199, + "settle/finalizes_with_no_pushes": 7126, + "settle/pulls_from_multiple_orders": 19996, + "settle/pulls_funds_to_destination": 13594, + "settle/pulls_to_multiple_destinations": 14734, + "settle/pushes_a_single_order": 12450, + "settle/pushes_several_orders_from_different_buffers": 17706, + "settle/pushes_several_orders_from_one_buffer": 17705, + "settle/settles_a_single_order": 12468, + "settle/settles_multiple_orders": 23009, + "transfer_authority/manager_can_transfer_manager": 3171, + "transfer_authority/manager_can_transfer_reclaim_authority": 3173, + "transfer_authority/reclaim_authority_can_transfer_itself": 3177 }, "transaction_bytes": { "add_solver/add_with_many_existing_solvers": 366, diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index b3d71da..49767d2 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -296,14 +296,6 @@ impl TryFrom<&[u8; EncodedOrderIntent::SIZE]> for OrderIntent { EncodedOrderIntent::WIDTH_APP_DATA ]; - let flags = *flags; - // A reserved bit carries no meaning to this version of the program, so - // accepting it would give the same intent several encodings, and with - // them several UIDs. - if flags[0] & !Flags::DEFINED != 0 { - return Err(ProgramError::InvalidInstructionData); - } - Ok(OrderIntent { owner: Pubkey::new_from_array(*owner), buy_token_account: Pubkey::new_from_array(*buy_token), @@ -313,7 +305,7 @@ impl TryFrom<&[u8; EncodedOrderIntent::SIZE]> for OrderIntent { sell_amount: u64::from_le_bytes(*sell_amount), buy_amount: u64::from_le_bytes(*buy_amount), valid_to: u32::from_le_bytes(*valid_to), - flags: Flags::try_from(flags)?, + flags: Flags::try_from(*flags)?, app_data: *app_data, }) } diff --git a/programs/settlement/tests/reclaim_order.rs b/programs/settlement/tests/reclaim_order.rs index 6ec0ff0..b598c79 100644 --- a/programs/settlement/tests/reclaim_order.rs +++ b/programs/settlement/tests/reclaim_order.rs @@ -101,7 +101,7 @@ fn create_order( } #[test] -fn happy_path_returns_lamports_and_closes_pda() { +fn happy_path_expired_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 @@ -170,32 +170,10 @@ fn happy_path_returns_lamports_and_closes_pda() { ); } -#[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); - - 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 = signed_tx(&svm, &owner, &owner, ix); - assert_instruction_error( - svm.send_transaction(tx).map_err(|e| e.err), - to_instruction_error(SettlementError::OrderNotReclaimable), - ); -} - /// Reclaim `pda` before its `valid_to`, crediting `owner`, and return the /// transaction result. The clock is pinned to the order's last valid second, so /// nothing here is reclaimable by expiry. -fn reclaim_while_unexpired( +fn assert_reclaim_while_unexpired( svm: &mut LiteSVM, program_id: &Pubkey, owner: &Keypair, @@ -210,7 +188,9 @@ fn reclaim_while_unexpired( } .instruction(); let tx = signed_tx(svm, owner, owner, ix); - svm.send_transaction(tx).map(|_| ()).map_err(|e| e.err)?; + + send_transaction_metered(svm, tx, BenchLabel::ReclaimOrder).map_err(|e| e.err)?; + assert!( svm.get_account(pda).is_none(), "order PDA must be closed after reclaim" @@ -220,7 +200,7 @@ fn reclaim_while_unexpired( } #[test] -fn on_chain_order_fully_filled_is_reclaimable_before_expiry() { +fn happy_path_on_chain_order_fully_filled_is_reclaimable_before_expiry() { let (mut svm, program_id, owner) = common::setup(); let intent = reclaim_sample_intent(owner.pubkey()); @@ -231,12 +211,12 @@ fn on_chain_order_fully_filled_is_reclaimable_before_expiry() { ..order }); - reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda) + assert_reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda) .expect("a filled on-chain order should be reclaimable before it expires"); } #[test] -fn on_chain_order_cancelled_is_reclaimable_before_expiry() { +fn happy_path_on_chain_order_cancelled_is_reclaimable_before_expiry() { let (mut svm, program_id, owner) = common::setup(); let intent = reclaim_sample_intent(owner.pubkey()); @@ -246,10 +226,32 @@ fn on_chain_order_cancelled_is_reclaimable_before_expiry() { ..order }); - reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda) + assert_reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda) .expect("a cancelled on-chain order should be reclaimable before it expires"); } +#[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); + + 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 = signed_tx(&svm, &owner, &owner, ix); + assert_instruction_error( + svm.send_transaction(tx).map_err(|e| e.err), + to_instruction_error(SettlementError::OrderNotReclaimable), + ); +} + #[test] fn on_chain_order_partially_filled_is_not_reclaimable_before_expiry() { let (mut svm, program_id, owner) = common::setup(); @@ -264,7 +266,7 @@ fn on_chain_order_partially_filled_is_not_reclaimable_before_expiry() { }); assert_instruction_error( - reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda), + assert_reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda), to_instruction_error(SettlementError::OrderNotReclaimable), ); } @@ -295,7 +297,7 @@ fn off_chain_order_is_reclaimable_only_once_expired() { }); assert_instruction_error( - reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda), + assert_reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda), to_instruction_error(SettlementError::OrderNotReclaimable), ); assert!( From 81e3f2950197e4d921d30a49fc1cb6fe7125160c Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:04:41 +0900 Subject: [PATCH 08/22] fix bench --- bench-report.json | 64 ++++++++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/bench-report.json b/bench-report.json index f1b910d..57e324f 100644 --- a/bench-report.json +++ b/bench-report.json @@ -11,7 +11,11 @@ "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7, "reclaim_buffer/max_buffers_in_one_instruction": 64, "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 9, - "reclaim_order/happy_path_returns_lamports_and_closes_pda": 4, + "reclaim_order/happy_path_expired_returns_lamports_and_closes_pda": 4, + "reclaim_order/happy_path_on_chain_order_cancelled_is_reclaimable_before_expiry": 3, + "reclaim_order/happy_path_on_chain_order_fully_filled_is_reclaimable_before_expiry": 3, + "reclaim_order/off_chain_order_is_reclaimable_only_once_expired": 3, + "reclaim_order/on_chain_order_partially_filled_is_not_reclaimable_before_expiry": 3, "settle/finalizes_with_no_pushes": 5, "settle/pulls_from_multiple_orders": 15, "settle/pulls_funds_to_destination": 10, @@ -26,30 +30,34 @@ "transfer_authority/reclaim_authority_can_transfer_itself": 4 }, "compute_units": { - "add_solver/add_with_many_existing_solvers": 5068, - "add_solver/adds_a_solver": 4616, - "create_buffers/happy_path_creates_initialized_buffer_token_account": 10339, - "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21731, - "create_buffers/max_buffers_in_one_instruction": 176947, - "create_order/happy_path_creates_order_pda_with_expected_body": 9480, - "initialize/happy_path_initializes_state_pda_with_expected_data": 4524, - "reclaim_buffer/funded_buffer_is_skipped": 6328, - "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7479, - "reclaim_buffer/max_buffers_in_one_instruction": 136648, - "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18075, - "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2199, - "settle/finalizes_with_no_pushes": 7126, - "settle/pulls_from_multiple_orders": 19996, - "settle/pulls_funds_to_destination": 13594, - "settle/pulls_to_multiple_destinations": 14734, - "settle/pushes_a_single_order": 12450, - "settle/pushes_several_orders_from_different_buffers": 17706, - "settle/pushes_several_orders_from_one_buffer": 17705, - "settle/settles_a_single_order": 12468, - "settle/settles_multiple_orders": 23009, - "transfer_authority/manager_can_transfer_manager": 3171, - "transfer_authority/manager_can_transfer_reclaim_authority": 3173, - "transfer_authority/reclaim_authority_can_transfer_itself": 3177 + "add_solver/add_with_many_existing_solvers": 5074, + "add_solver/adds_a_solver": 4622, + "create_buffers/happy_path_creates_initialized_buffer_token_account": 10345, + "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21743, + "create_buffers/max_buffers_in_one_instruction": 177040, + "create_order/happy_path_creates_order_pda_with_expected_body": 9485, + "initialize/happy_path_initializes_state_pda_with_expected_data": 4529, + "reclaim_buffer/funded_buffer_is_skipped": 6335, + "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7483, + "reclaim_buffer/max_buffers_in_one_instruction": 136652, + "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18082, + "reclaim_order/happy_path_expired_returns_lamports_and_closes_pda": 2202, + "reclaim_order/happy_path_on_chain_order_cancelled_is_reclaimable_before_expiry": 2071, + "reclaim_order/happy_path_on_chain_order_fully_filled_is_reclaimable_before_expiry": 2079, + "reclaim_order/off_chain_order_is_reclaimable_only_once_expired": null, + "reclaim_order/on_chain_order_partially_filled_is_not_reclaimable_before_expiry": null, + "settle/finalizes_with_no_pushes": 7154, + "settle/pulls_from_multiple_orders": 20061, + "settle/pulls_funds_to_destination": 13641, + "settle/pulls_to_multiple_destinations": 14782, + "settle/pushes_a_single_order": 12496, + "settle/pushes_several_orders_from_different_buffers": 17769, + "settle/pushes_several_orders_from_one_buffer": 17768, + "settle/settles_a_single_order": 12514, + "settle/settles_multiple_orders": 23089, + "transfer_authority/manager_can_transfer_manager": 3174, + "transfer_authority/manager_can_transfer_reclaim_authority": 3176, + "transfer_authority/reclaim_authority_can_transfer_itself": 3180 }, "transaction_bytes": { "add_solver/add_with_many_existing_solvers": 366, @@ -63,7 +71,11 @@ "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 400, "reclaim_buffer/max_buffers_in_one_instruction": 332, "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 466, - "reclaim_order/happy_path_returns_lamports_and_closes_pda": 236, + "reclaim_order/happy_path_expired_returns_lamports_and_closes_pda": 236, + "reclaim_order/happy_path_on_chain_order_cancelled_is_reclaimable_before_expiry": 204, + "reclaim_order/happy_path_on_chain_order_fully_filled_is_reclaimable_before_expiry": 204, + "reclaim_order/off_chain_order_is_reclaimable_only_once_expired": 204, + "reclaim_order/on_chain_order_partially_filled_is_not_reclaimable_before_expiry": 204, "settle/finalizes_with_no_pushes": 290, "settle/pulls_from_multiple_orders": 656, "settle/pulls_funds_to_destination": 473, From e36f722407fe6f7f2e3989f80d3396e67ba9f936 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:07:39 +0900 Subject: [PATCH 09/22] sort ascending --- interface/src/data/intent.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index 49767d2..58a3226 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -54,9 +54,9 @@ pub struct Flags { impl Flags { // The bit each field occupies - const PARTIALLY_FILLABLE: u8 = 1 << 2; - const KIND: u8 = 1 << 1; const CREATED_ON_CHAIN: u8 = 1 << 0; + const KIND: u8 = 1 << 1; + const PARTIALLY_FILLABLE: u8 = 1 << 2; /// Every bit the encoding defines; the others are reserved. const DEFINED: u8 = Self::PARTIALLY_FILLABLE | Self::KIND | Self::CREATED_ON_CHAIN; From b2957df2268cfa594c6b5c59762416207860ef54 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:15:14 +0900 Subject: [PATCH 10/22] fix out of order flags for consistency --- interface/src/data/intent.rs | 41 ++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index 58a3226..586c973 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -59,21 +59,21 @@ impl Flags { const PARTIALLY_FILLABLE: u8 = 1 << 2; /// Every bit the encoding defines; the others are reserved. - const DEFINED: u8 = Self::PARTIALLY_FILLABLE | Self::KIND | Self::CREATED_ON_CHAIN; + const DEFINED: u8 = Self::CREATED_ON_CHAIN | Self::KIND | Self::PARTIALLY_FILLABLE; } impl From for [u8; 1] { /// The canonical flags byte. Reserved bits are left clear. fn from(flags: Flags) -> Self { let mut byte = 0; - if flags.partially_fillable { - byte |= Flags::PARTIALLY_FILLABLE; + if flags.created_on_chain { + byte |= Flags::CREATED_ON_CHAIN; } if flags.kind == OrderKind::Buy { byte |= Flags::KIND; } - if flags.created_on_chain { - byte |= Flags::CREATED_ON_CHAIN; + if flags.partially_fillable { + byte |= Flags::PARTIALLY_FILLABLE; } [byte] } @@ -434,11 +434,12 @@ mod tests { use super::fixtures::sample_intent; use super::*; - // Every shape an `OrderIntent` can take on its validated axes: the `kind` - // enum and the `partially_fillable` flag bit. + // Every shape an `OrderIntent` can take on its validated axes: the + // `created_on_chain` flag bit, the `kind` enum, and the + // `partially_fillable` flag bit. fn all_flag_shapes() -> impl Iterator { - fixtures::ALL_ORDER_KINDS.into_iter().flat_map(|kind| { - [false, true].into_iter().flat_map(move |created_on_chain| { + [false, true].into_iter().flat_map(|created_on_chain| { + fixtures::ALL_ORDER_KINDS.into_iter().flat_map(move |kind| { [false, true].into_iter().map(move |partially_fillable| { sample_intent(Flags { created_on_chain, @@ -515,9 +516,9 @@ mod tests { let set_one_by_one = [ ( - Flags::PARTIALLY_FILLABLE, + Flags::CREATED_ON_CHAIN, Flags { - partially_fillable: true, + created_on_chain: true, ..cleared }, ), @@ -529,9 +530,9 @@ mod tests { }, ), ( - Flags::CREATED_ON_CHAIN, + Flags::PARTIALLY_FILLABLE, Flags { - created_on_chain: true, + partially_fillable: true, ..cleared }, ), @@ -541,8 +542,8 @@ mod tests { assert_eq!(bit.count_ones(), 1, "a flag must occupy a single bit"); assert_eq!(seen & bit, 0, "two flags must not share a bit"); assert!( - seen == 0 || bit < seen, - "each flag must be less significant than the ones before it" + bit > seen, + "each flag must be more significant than the ones before it" ); seen |= bit; assert_eq!(byte(flags), bit); @@ -580,28 +581,28 @@ mod tests { lhs.iter().zip(rhs).position(|(l, r)| l != r) } let sell_false: EncodedOrderIntent = (&sample_intent(Flags { + created_on_chain: false, kind: OrderKind::Sell, partially_fillable: false, - created_on_chain: false, })) .into(); let sell_true: EncodedOrderIntent = (&sample_intent(Flags { + created_on_chain: false, kind: OrderKind::Sell, partially_fillable: true, - created_on_chain: false, })) .into(); let buy_true: EncodedOrderIntent = (&sample_intent(Flags { + created_on_chain: false, kind: OrderKind::Buy, partially_fillable: true, - created_on_chain: false, })) .into(); let created_on_chain: EncodedOrderIntent = (&sample_intent(Flags { + created_on_chain: true, kind: OrderKind::Sell, partially_fillable: false, - created_on_chain: true, })) .into(); @@ -703,7 +704,7 @@ mod tests { 0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe, // valid_to (0xdead_beef, LE u32) 0xef, 0xbe, 0xad, 0xde, - // flags (partially_fillable | kind (Buy = 1) | created_on_chain) + // flags (created_on_chain | kind (Buy = 1) | partially_fillable) 0b00000111, // app_data ([0x66; 32]) 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, From 6545178abcc9712cc6a9d6e8078e366fc3480750 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:19:08 +0900 Subject: [PATCH 11/22] improve comment explaining how fill_progress works --- interface/src/data/order.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/interface/src/data/order.rs b/interface/src/data/order.rs index 984dd03..7f4011f 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -96,9 +96,9 @@ impl OrderAccount { } } -/// How much of the order's exact side has been filled by the given cumulative -/// totals, paired with the intent amount that side fills up to: a `Sell` order -/// fills by what's withdrawn from it, a `Buy` order by what it receives. +/// Extract the values relevant for understanding the fill of an order. +/// Returns a tuple. First return value is the amount currently filled, and the second return +/// value is the amount that has been requested to be filled by the intent. pub fn fill_progress( intent: &OrderIntent, amount_withdrawn: u64, From 92337618ccb6068c09cb9e80d949f8cbfc411c57 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:23:18 +0900 Subject: [PATCH 12/22] remove test sanity checking byte for offset over and over again --- interface/src/data/intent.rs | 53 ------------------------------------ 1 file changed, 53 deletions(-) diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index 586c973..3967ed6 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -575,59 +575,6 @@ mod tests { } } - #[test] - fn sanity_check_offsets() { - fn first_differing_byte(lhs: &[u8], rhs: &[u8]) -> Option { - lhs.iter().zip(rhs).position(|(l, r)| l != r) - } - let sell_false: EncodedOrderIntent = (&sample_intent(Flags { - created_on_chain: false, - kind: OrderKind::Sell, - partially_fillable: false, - })) - .into(); - let sell_true: EncodedOrderIntent = (&sample_intent(Flags { - created_on_chain: false, - kind: OrderKind::Sell, - partially_fillable: true, - })) - .into(); - let buy_true: EncodedOrderIntent = (&sample_intent(Flags { - created_on_chain: false, - kind: OrderKind::Buy, - partially_fillable: true, - })) - .into(); - - let created_on_chain: EncodedOrderIntent = (&sample_intent(Flags { - created_on_chain: true, - kind: OrderKind::Sell, - partially_fillable: false, - })) - .into(); - - assert_eq!( - first_differing_byte(sell_false.as_slice(), sell_true.as_slice()) - .expect("should have different flags byte"), - FLAGS_OFFSET - ); - assert_eq!( - first_differing_byte(buy_true.as_slice(), sell_true.as_slice()) - .expect("should have different flags byte"), - FLAGS_OFFSET - ); - assert_eq!( - first_differing_byte(created_on_chain.as_slice(), sell_true.as_slice()) - .expect("should have different flags byte"), - FLAGS_OFFSET - ); - assert_eq!( - first_differing_byte(created_on_chain.as_slice(), buy_true.as_slice()) - .expect("should have different flags byte"), - FLAGS_OFFSET - ); - } - #[test] fn decode_accepts_defined_flag_bits_only() { let encoded = EncodedOrderIntent::from(&sample_intent(Default::default())); From 714437ba39427cb008126207c828c60970a1edd2 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:26:33 +0900 Subject: [PATCH 13/22] 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 afbeacf..55423c5 100644 --- a/programs/settlement/src/reclaim_order.rs +++ b/programs/settlement/src/reclaim_order.rs @@ -27,7 +27,6 @@ pub fn process_reclaim_order( return Err(SettlementError::ReclaimRecipientMismatch.into()); } - // Is this order eligible for reclaimation? if !is_reclaimable_before_expiry(&account) { let now = Clock::get()?.unix_timestamp; if now <= i64::from(account.intent.valid_to) { From 72ca50180dc25d8ed1be18178821928ea4eefa27 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:26:50 +0900 Subject: [PATCH 14/22] 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 insertion(+) diff --git a/programs/settlement/src/reclaim_order.rs b/programs/settlement/src/reclaim_order.rs index 55423c5..7a52190 100644 --- a/programs/settlement/src/reclaim_order.rs +++ b/programs/settlement/src/reclaim_order.rs @@ -137,6 +137,7 @@ mod tests { (false, true, 0, false), (false, false, SELL_AMOUNT, false), (false, true, SELL_AMOUNT, false), + (false, false, 0, false), // Created on-chain and not fully filled. (true, false, 0, false), (true, false, SELL_AMOUNT - 1, false), From f3c3b4c46be210d8d2d28011f636d31c1f93de68 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:35:47 +0900 Subject: [PATCH 15/22] update design info --- DESIGN.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DESIGN.md b/DESIGN.md index df87f69..cd123c2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -132,6 +132,8 @@ struct OrderIntent { } struct Flags { + // Indicates the path by which the order was created. Important for reclaim. + created_on_chain: bool // Either Buy or Sell kind: OrderKind partially_fillable: bool From 41f7d570039f99e6d62c0882726524c4f4f9c150 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:40:35 +0900 Subject: [PATCH 16/22] update design paragraphs to be shorter/easier to understand --- DESIGN.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index cd123c2..bd79007 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -239,6 +239,8 @@ Raw Ed25519 signatures are supported by all native Solana accounts. The data to be signed is encoded as an off-chain message and signed with raw Ed25519 signatures. +Orders that are created by this path must specify the flag `created_on_chain = false`. + Differences with Ethereum: - Unlike ECDSA signatures in Ethereum, the owner account address cannot be recovered from the Ed25519 signature. This means that the address needs to be included as part of the signed data. @@ -248,9 +250,11 @@ Differences with Ethereum: Orders can be created by the owner by executing an instruction on-chain. -The order owner executes the `CreateOrder` instruction. The settlement program checks that the order comes from the owner, that the intent declares this authentication scheme (`created_on_chain`), and [creates the order PDA](#orders-are-accounts). Intents that don't declare it are rejected, so the flag always states which flow actually created the order. +The order owner executes the `CreateOrder` instruction. The settlement program checks that the order comes from the owner and [creates the order PDA](#orders-are-accounts). + +In this authentication flow, the user needs to pay for the rent in SOL necessary to create the PDA. Note that the rent may be significantly higher than the expected trading fee. The rent can be recovered by the user once the order has expired by [clearing the order](#order-clearing). -In this authentication flow, the user needs to pay for the rent in SOL necessary to create the PDA. Note that the rent may be significantly higher than the expected trading fee. The rent can be recovered by the user once the order has expired, or once it's cancelled or completely filled, by [clearing the order](#order-clearing). +Orders that are created by this path must specify the flag `created_on_chain = true`. This flow supports both standard ("on-curve") accounts and PDA signatures. From c70614d856b27874ee58425b224492ab5cc2f47c Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:46:59 +0900 Subject: [PATCH 17/22] add a couple of additional test cases and improve the comments --- interface/src/data/order.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/interface/src/data/order.rs b/interface/src/data/order.rs index 7f4011f..2c4a995 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -410,8 +410,10 @@ mod tests { (OrderKind::Sell, SELL_AMOUNT, 0, true), // fully filled SELL order (stolen money, generally impossible) (OrderKind::Buy, 0, BUY_AMOUNT, true), // fully filled BUY order (free money) (OrderKind::Sell, u64::MAX, 0, true), // sell fill past the intent amount (should be impossible) - (OrderKind::Buy, 0, u64::MAX, true), // buy fill past the intent amount (should be impossible) - (OrderKind::Sell, 0, 0, false), // unfilled order + (OrderKind::Sell, u64::MAX, u64::MAX, true), // sell fill past the intent amount (should be impossible) + (OrderKind::Buy, 0, u64::MAX, true), // buy fill past the intent amount + (OrderKind::Buy, u64::MAX, u64::MAX, true), // buy fill past the intent amount + (OrderKind::Sell, 0, 0, false), // unfilled order (OrderKind::Sell, SELL_AMOUNT - 1, BUY_AMOUNT, false), // not fully filled SELL order (OrderKind::Buy, SELL_AMOUNT, BUY_AMOUNT - 1, false), // not fully filled BUY order with fully filled sell side (generally should be impossible) ]; From 0d9cc5b3fbb00ef2016fa14a8982ddcec988e563 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:57:54 +0900 Subject: [PATCH 18/22] assert_reclaim_while_unexpired fixes rename and explicitly confirm that valid_to is as expected --- .../settlement/tests/common/settlement.rs | 4 -- programs/settlement/tests/reclaim_order.rs | 42 +++++++++++++------ 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/programs/settlement/tests/common/settlement.rs b/programs/settlement/tests/common/settlement.rs index f16cacf..c35f9ee 100644 --- a/programs/settlement/tests/common/settlement.rs +++ b/programs/settlement/tests/common/settlement.rs @@ -104,10 +104,6 @@ pub fn stage_order( /// carrying each order's pulls, `between` right after it, and the matching /// `FinalizeSettle` last, pushing each order's `amount_out`. Submit the result /// with [`send`](super::send). -/// -/// With `between` empty this is the plain pair at -/// [`BEGIN_INDEX`]/[`FINALIZE_INDEX`]. Anything interposed shifts the finalize, -/// and the two instructions' counterpart indices follow it. pub fn build_staged_settlement( program_id: &Pubkey, solver: &Pubkey, diff --git a/programs/settlement/tests/reclaim_order.rs b/programs/settlement/tests/reclaim_order.rs index b598c79..5b4935c 100644 --- a/programs/settlement/tests/reclaim_order.rs +++ b/programs/settlement/tests/reclaim_order.rs @@ -10,6 +10,7 @@ use cow_settlement_interface::data::{ }; use litesvm::LiteSVM; use solana_sdk::{ + clock::Clock, pubkey::Pubkey, signature::{Keypair, Signer}, }; @@ -49,11 +50,16 @@ fn encode_and_derive( (bytes, pda) } +/// Decode the order stored in an order PDA. +fn read_order(svm: &LiteSVM, pda: &Pubkey) -> OrderAccount { + let account = svm.get_account(pda).expect("order PDA must exist"); + OrderAccount::try_from(&account.data[..]).expect("order PDA must decode") +} + /// Directly overwrite the body stored in an order PDA. fn patch_order(svm: &mut LiteSVM, pda: &Pubkey, patch: impl FnOnce(OrderAccount) -> OrderAccount) { let mut account = svm.get_account(pda).expect("order PDA must exist"); - let order = OrderAccount::try_from(&account.data[..]).expect("order PDA must decode"); - account.data = EncodedOrderAccount::from(patch(order)).to_vec(); + account.data = EncodedOrderAccount::from(patch(read_order(svm, pda))).to_vec(); svm.set_account(*pda, account) .expect("set_account should succeed"); } @@ -61,7 +67,7 @@ fn patch_order(svm: &mut LiteSVM, pda: &Pubkey, patch: impl FnOnce(OrderAccount) /// Put an order PDA on-chain directly, bypassing `CreateOrder`, which only /// accepts intents declaring on-chain authentication. This is how an order /// authenticated by an off-chain signature is staged. -fn place_order_pda( +fn hack_write_order( svm: &mut LiteSVM, program_id: &Pubkey, intent: &OrderIntent, @@ -171,15 +177,17 @@ fn happy_path_expired_returns_lamports_and_closes_pda() { } /// Reclaim `pda` before its `valid_to`, crediting `owner`, and return the -/// transaction result. The clock is pinned to the order's last valid second, so -/// nothing here is reclaimable by expiry. -fn assert_reclaim_while_unexpired( +/// transaction result. +fn perform_reclaim_while_unexpired( svm: &mut LiteSVM, program_id: &Pubkey, owner: &Keypair, pda: &Pubkey, ) -> Result<(), solana_sdk::transaction::TransactionError> { - common::set_unix_timestamp(svm, VALID_TO as i64); + // Taken from the order itself rather than from `VALID_TO`, so the clock the + // transaction runs at can't drift from the order it's reclaiming. + let valid_to = i64::from(read_order(svm, pda).intent.valid_to); + common::set_unix_timestamp(svm, valid_to); let ix = ReclaimOrder { program_id: *program_id, @@ -189,7 +197,15 @@ fn assert_reclaim_while_unexpired( .instruction(); let tx = signed_tx(svm, owner, owner, ix); - send_transaction_metered(svm, tx, BenchLabel::ReclaimOrder).map_err(|e| e.err)?; + let result = send_transaction_metered(svm, tx, BenchLabel::ReclaimOrder); + + let executed_at = svm.get_sysvar::().unix_timestamp; + assert!( + executed_at <= valid_to, + "reclaim must run while the order is unexpired, ran at {executed_at} with valid_to {valid_to}" + ); + + result.map_err(|e| e.err)?; assert!( svm.get_account(pda).is_none(), @@ -211,7 +227,7 @@ fn happy_path_on_chain_order_fully_filled_is_reclaimable_before_expiry() { ..order }); - assert_reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda) + perform_reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda) .expect("a filled on-chain order should be reclaimable before it expires"); } @@ -226,7 +242,7 @@ fn happy_path_on_chain_order_cancelled_is_reclaimable_before_expiry() { ..order }); - assert_reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda) + perform_reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda) .expect("a cancelled on-chain order should be reclaimable before it expires"); } @@ -266,7 +282,7 @@ fn on_chain_order_partially_filled_is_not_reclaimable_before_expiry() { }); assert_instruction_error( - assert_reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda), + perform_reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda), to_instruction_error(SettlementError::OrderNotReclaimable), ); } @@ -288,7 +304,7 @@ fn off_chain_order_is_reclaimable_only_once_expired() { }; // Cancelled *and* completely filled: the strongest case for early reclaim, // and it still has to wait. - let pda = place_order_pda(&mut svm, &program_id, &intent, &owner.pubkey(), |order| { + let pda = hack_write_order(&mut svm, &program_id, &intent, &owner.pubkey(), |order| { OrderAccount { cancelled: true, amount_withdrawn: order.intent.sell_amount, @@ -297,7 +313,7 @@ fn off_chain_order_is_reclaimable_only_once_expired() { }); assert_instruction_error( - assert_reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda), + perform_reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda), to_instruction_error(SettlementError::OrderNotReclaimable), ); assert!( From dd199cabc32daa8d31b8bdbc1bd1bdbbaad1fa3a Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:16:19 +0900 Subject: [PATCH 19/22] fix it so that the test is not vacuous --- programs/settlement/tests/reclaim_order.rs | 32 ++++++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/programs/settlement/tests/reclaim_order.rs b/programs/settlement/tests/reclaim_order.rs index 5b4935c..30ca4e1 100644 --- a/programs/settlement/tests/reclaim_order.rs +++ b/programs/settlement/tests/reclaim_order.rs @@ -424,15 +424,20 @@ fn rejects_when_reclaim_recipient_mismatch() { const SETTLED_SELL_AMOUNT: u64 = 1_000_000; const SETTLED_BUY_AMOUNT: u64 = 2_000_000; +/// Mint a partially fillable order selling [`SETTLED_SELL_AMOUNT`] for +/// [`SETTLED_BUY_AMOUNT`], and stage a settlement selling `sell_amount` of it at +/// exactly the order's limit price (so any fraction of it settles). Passing +/// [`SETTLED_SELL_AMOUNT`] stages a full fill. fn settleable_order( svm: &mut LiteSVM, program_id: &Pubkey, payer: &Keypair, + sell_amount: u64, ) -> (StagedOrder, Pubkey) { let intent = OrderBuilder::new(svm, program_id, payer) .sell_amount(SETTLED_SELL_AMOUNT) .buy_amount(SETTLED_BUY_AMOUNT) - .partially_fillable(false) + .partially_fillable(true) .build(); let (order_pda, _bump) = find_order_pda(program_id, &intent.uid()); let staged = stage_order( @@ -440,16 +445,31 @@ fn settleable_order( program_id, payer, &intent, - &[SETTLED_SELL_AMOUNT], - SETTLED_BUY_AMOUNT, + &[sell_amount], + sell_amount + .checked_mul(SETTLED_BUY_AMOUNT) + .expect("order math should work") + .checked_div(SETTLED_SELL_AMOUNT) + .expect("order math should work"), ); (staged, order_pda) } +/// A settlement that fills only part of an order leaves it fillable again, so +/// the order PDA has to stay until the order expires. #[test] fn rejects_reclaim_of_a_partially_filled_order() { - let (mut svm, program_id, payer, _solver) = common::setup_settle_ready(); - let (_staged, order_pda) = settleable_order(&mut svm, &program_id, &payer); + let (mut svm, program_id, payer, solver) = common::setup_settle_ready(); + const PARTIAL_FILL: u64 = SETTLED_SELL_AMOUNT / 3; + let (staged, order_pda) = settleable_order(&mut svm, &program_id, &payer, PARTIAL_FILL); + + let instructions = build_staged_settlement(&program_id, &solver.pubkey(), &[staged], vec![]); + send(&mut svm, &solver, instructions).expect("a partial settlement should succeed"); + assert_eq!( + read_order(&svm, &order_pda).amount_withdrawn, + PARTIAL_FILL, + "the settlement must have recorded a partial fill" + ); let ix = ReclaimOrder { program_id, @@ -477,7 +497,7 @@ fn rejects_reclaim_of_a_partially_filled_order() { #[test] fn reclaim_mid_settlement_succeeds() { let (mut svm, program_id, payer, solver) = common::setup_settle_ready(); - let (staged, order_pda) = settleable_order(&mut svm, &program_id, &payer); + let (staged, order_pda) = settleable_order(&mut svm, &program_id, &payer, SETTLED_SELL_AMOUNT); let pull_destination = staged.pulls[0].destination; let buy_token_account = staged.intent.buy_token_account; let buffer_pda = buffer::buffer_pda(&program_id, &staged.intent.buy_mint); From ea0e79dc10601b3890fec13a84cb34c163051ec0 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:26:25 +0900 Subject: [PATCH 20/22] make finalize index technically correct --- programs/settlement/tests/common/settlement.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/programs/settlement/tests/common/settlement.rs b/programs/settlement/tests/common/settlement.rs index c35f9ee..f596d2a 100644 --- a/programs/settlement/tests/common/settlement.rs +++ b/programs/settlement/tests/common/settlement.rs @@ -145,11 +145,11 @@ pub fn build_staged_settlement( } /// Where the `FinalizeSettle` lands in a settlement with `interposed` -/// instructions sitting between the pair: [`FINALIZE_INDEX`] shifted along by -/// them. +/// instructions sitting between fn finalize_index(interposed: usize) -> u16 { u16::try_from(interposed) .ok() - .and_then(|shift| shift.checked_add(u16::from(FINALIZE_INDEX))) + .and_then(|shift| shift.checked_add(BEGIN_INDEX.into())) + .and_then(|shift| shift.checked_add(1)) .expect("a test transaction holds far fewer than u16::MAX instructions") } From d363fcc544538e69e2f55e2f7e28e448d08a9479 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:28:28 +0900 Subject: [PATCH 21/22] fix bench report --- bench-report.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/bench-report.json b/bench-report.json index 57e324f..961ca5d 100644 --- a/bench-report.json +++ b/bench-report.json @@ -47,14 +47,14 @@ "reclaim_order/off_chain_order_is_reclaimable_only_once_expired": null, "reclaim_order/on_chain_order_partially_filled_is_not_reclaimable_before_expiry": null, "settle/finalizes_with_no_pushes": 7154, - "settle/pulls_from_multiple_orders": 20061, - "settle/pulls_funds_to_destination": 13641, - "settle/pulls_to_multiple_destinations": 14782, - "settle/pushes_a_single_order": 12496, - "settle/pushes_several_orders_from_different_buffers": 17769, - "settle/pushes_several_orders_from_one_buffer": 17768, - "settle/settles_a_single_order": 12514, - "settle/settles_multiple_orders": 23089, + "settle/pulls_from_multiple_orders": 20059, + "settle/pulls_funds_to_destination": 13640, + "settle/pulls_to_multiple_destinations": 14781, + "settle/pushes_a_single_order": 12495, + "settle/pushes_several_orders_from_different_buffers": 17767, + "settle/pushes_several_orders_from_one_buffer": 17766, + "settle/settles_a_single_order": 12513, + "settle/settles_multiple_orders": 23086, "transfer_authority/manager_can_transfer_manager": 3174, "transfer_authority/manager_can_transfer_reclaim_authority": 3176, "transfer_authority/reclaim_authority_can_transfer_itself": 3180 From 9897c28e2d4d8199c4332256ce91e9e29da55ef1 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:33:13 +0900 Subject: [PATCH 22/22] return to previous comment situation --- interface/src/data/order.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/interface/src/data/order.rs b/interface/src/data/order.rs index 2c4a995..5c88b43 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -448,10 +448,10 @@ mod tests { // Hack: xoring each byte makes sure all bytes are different. // In general, it isn't guaranteed that the result encodes to a // valid intent, but in this case we know it because the only byte - // that may fail decoding is the flags byte, and `^0x07` only flips - // its lowest three bits, never a reserved one. + // that may fail decoding is the flags byte, and `^0x01` only flips + // its `created_on_chain` flag bits, never a reserved one. let bitwise_different_encoded_intent: [u8; EncodedOrderIntent::SIZE] = - encoded_intent.map(|b| b ^ 0x07); + encoded_intent.map(|b| b ^ 0x01); sample_account_base.intent = OrderIntent::try_from(&bitwise_different_encoded_intent).expect("hack should work"); let changed_intent: [u8; EncodedOrderAccount::SIZE] =