diff --git a/DESIGN.md b/DESIGN.md index 015eaff..b83322c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -126,14 +126,21 @@ struct OrderIntent { buy_amount: u64 // Unix timestamp valid_to: u32 + flags: Flags + // Usual app data field, it isn't directly used in the program. + app_data: [u8; 32] +} + +struct Flags { // Either Buy or Sell kind: OrderKind partially_fillable: bool - // Usual app data field, it isn't directly used in the program. - app_data: [u8; 32] } ``` +The fields grouped in `Flags` share a single byte in the encoded form, one bit +each, with the remaining bits reserved and required when decoding 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 d4bc896..c4676e9 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": 3473, + "create_order/happy_path_creates_order_pda_with_expected_body": 4976, "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": 2186, + "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2183, "settle/finalizes_with_no_pushes": 7062, - "settle/pulls_from_multiple_orders": 19927, - "settle/pulls_funds_to_destination": 13527, - "settle/pulls_to_multiple_destinations": 14678, - "settle/pushes_a_single_order": 12369, - "settle/pushes_several_orders_from_different_buffers": 17614, - "settle/pushes_several_orders_from_one_buffer": 17615, - "settle/settles_a_single_order": 12387, - "settle/settles_multiple_orders": 22903, + "settle/pulls_from_multiple_orders": 19921, + "settle/pulls_funds_to_destination": 13524, + "settle/pulls_to_multiple_destinations": 14675, + "settle/pushes_a_single_order": 12366, + "settle/pushes_several_orders_from_different_buffers": 17608, + "settle/pushes_several_orders_from_one_buffer": 17609, + "settle/settles_a_single_order": 12384, + "settle/settles_multiple_orders": 22894, "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": 453, + "create_order/happy_path_creates_order_pda_with_expected_body": 452, "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/client/src/parse.rs b/client/src/parse.rs index 4217186..d923e00 100644 --- a/client/src/parse.rs +++ b/client/src/parse.rs @@ -71,7 +71,7 @@ mod tests { BeginSettle, CreateBuffers, CreateOrder, FinalizeSettle, Initialize, InitializedIntent, }; use cow_settlement_interface::{ - data::intent::{fixtures::sample_intent, OrderKind}, + data::intent::fixtures::sample_intent, fixtures::pubkey_from_seed, instruction::{ fixtures::fake_account_from_array, reclaim_buffer::ReclaimBuffer, @@ -85,7 +85,7 @@ mod tests { fn build(instruction: SettlementInstruction) -> Instruction { let program_id = pubkey_from_seed("program id"); let payer = pubkey_from_seed("payer"); - let intent = sample_intent(OrderKind::Sell, false); + let intent = sample_intent(Default::default()); match instruction { SettlementInstruction::Initialize => Initialize { program_id, diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index 6e1aab6..e38408a 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -1,19 +1,16 @@ //! Order intents and their canonical byte representation. //! -//! Two types live here: +//! The intent has two representations: //! -//! - [`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. //! //! 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 +20,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 { @@ -32,6 +30,67 @@ pub enum OrderKind { Buy = 1, } +/// Collection of [`OrderIntent`] fields that can be represented as a single bit. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] +pub struct Flags { + /// Whether `sell_amount` or `buy_amount` is the exact figure; the + /// other side is treated as the limit (minimum to receive for `Sell`, + /// maximum to spend for `Buy`). + pub kind: OrderKind, + + /// If `true`, the order may be filled across multiple settlements; + /// proceeds and consumption scale proportionally with the amount of + /// the sell side that's been used. If `false`, a single settlement + /// must consume the full sell amount (fill-or-kill). + pub partially_fillable: bool, +} + +impl Flags { + // The bit each field occupies + const PARTIALLY_FILLABLE: u8 = 1 << 1; + const KIND: u8 = 1 << 0; + + /// Every bit the encoding defines; the others are reserved. + const DEFINED: u8 = Self::PARTIALLY_FILLABLE | Self::KIND; +} + +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.kind == OrderKind::Buy { + byte |= Flags::KIND; + } + [byte] + } +} + +impl TryFrom<[u8; 1]> for Flags { + type Error = ProgramError; + + /// Decodes a flags byte, rejecting any reserved bit with + /// [`ProgramError::InvalidInstructionData`]. A reserved bit carries no + /// meaning to this version of the program, so accepting it would give the + /// same flags several encodings, and with them several UIDs. + fn try_from(bytes: [u8; 1]) -> Result { + let [byte] = bytes; + if byte & !Self::DEFINED != 0 { + return Err(ProgramError::InvalidInstructionData); + } + Ok(Flags { + kind: if byte & Self::KIND == 0 { + OrderKind::Sell + } else { + OrderKind::Buy + }, + partially_fillable: byte & Self::PARTIALLY_FILLABLE != 0, + }) + } +} + #[derive(Clone, Debug, Eq, PartialEq, Default)] pub struct OrderIntent { /// Account authorized to create and invalidate this order and whose @@ -70,16 +129,9 @@ pub struct OrderIntent { /// The order cannot be executed after expiration. pub valid_to: u32, - /// Whether `sell_amount` or `buy_amount` is the exact figure; the - /// other side is treated as the limit (minimum to receive for `Sell`, - /// maximum to spend for `Buy`). - pub kind: OrderKind, - - /// If `true`, the order may be filled across multiple settlements; - /// proceeds and consumption scale proportionally with the amount of - /// the sell side that's been used. If `false`, a single settlement - /// must consume the full sell amount (fill-or-kill). - pub partially_fillable: bool, + /// The settings the encoding packs bit by bit into a single byte; see + /// [`Flags`]. + pub flags: Flags, /// Opaque 32 bytes set by the order creator. Not interpreted by the /// settlement program; used off-chain for metadata such as the @@ -87,7 +139,7 @@ pub struct OrderIntent { pub app_data: [u8; 32], } -/// Canonical 214-byte representation of an [`OrderIntent`]. The wire format and +/// Canonical 213-byte representation of an [`OrderIntent`]. The wire format and /// the order UID preimage. /// /// Layout: one character per byte, cell widths proportional to field size, @@ -95,16 +147,16 @@ pub struct OrderIntent { /// annotated below. Amounts and `valid_to` are little-endian encoded. /// /// ```text -/// partially_fillable ─────┐ -/// kind ────┐│ -/// ┌───────────────────────────────┬───────────────────────────────┬───────────────────────────────┬───────────────────────────────┬───────────────────────────────┬───────┬───────┬───┬┬┬───────────────────────────────┐ -/// │ │ │ │ │ │sell_ │buy_ │val│││ │ -/// │ owner │ buy_token_account │ buy_mint │ sell_token_account │ sell_mint │ │ │id_│││ app_data │ -/// │ │ │ │ │ │amount │amount │to │││ │ -/// └───────────────────────────────┴───────────────────────────────┴───────────────────────────────┴───────────────────────────────┴───────────────────────────────┴───────┴───────┴───┴┴┴───────────────────────────────┘ -/// 0 32 64 96 128 160 168 176 180 182 214 +/// flags ────┐ +/// ┌───────────────────────────────┬───────────────────────────────┬───────────────────────────────┬───────────────────────────────┬───────────────────────────────┬───────┬───────┬───┬┬───────────────────────────────┐ +/// │ │ │ │ │ │sell_ │buy_ │val││ │ +/// │ owner │ buy_token_account │ buy_mint │ sell_token_account │ sell_mint │ │ │id_││ app_data │ +/// │ │ │ │ │ │amount │amount │to ││ │ +/// └───────────────────────────────┴───────────────────────────────┴───────────────────────────────┴───────────────────────────────┴───────────────────────────────┴───────┴───────┴───┴┴───────────────────────────────┘ +/// 0 32 64 96 128 160 168 176 180 213 /// 181 /// ``` +/// #[derive(Clone, Debug, Deref, Eq, PartialEq)] pub struct EncodedOrderIntent([u8; Self::SIZE]); @@ -118,11 +170,10 @@ 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 = 214; + pub const SIZE: usize = 213; /// Canonical hash of the bytes. pub fn hash(&self) -> Hash { @@ -130,9 +181,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 @@ -169,8 +219,7 @@ impl From<&OrderIntent> for EncodedOrderIntent { sell_amount, buy_amount, valid_to, - kind, - partially_fillable, + flags, app_data, ) = mut_array_refs![ &mut out, @@ -182,8 +231,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(); @@ -194,8 +242,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 = intent.flags.into(); *app_data = intent.app_data; Self(out) } @@ -221,8 +268,7 @@ impl TryFrom<&[u8; EncodedOrderIntent::SIZE]> for OrderIntent { sell_amount, buy_amount, valid_to, - kind, - partially_fillable, + flags, app_data, ) = array_refs![ bytes, @@ -234,8 +280,7 @@ 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 ]; @@ -248,16 +293,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), - 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), - }, + flags: Flags::try_from(*flags)?, app_data: *app_data, }) } @@ -284,16 +320,15 @@ impl OrderIntent { pub mod fixtures { use proptest::{prelude::*, strategy::Union}; - use super::{EncodedOrderIntent, OrderIntent, OrderKind, Pubkey}; + use super::{Flags, 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 = 180; - pub const PARTIALLY_FILLABLE_OFFSET: usize = KIND_OFFSET + EncodedOrderIntent::WIDTH_KIND; + pub const FLAGS_OFFSET: usize = 180; - pub fn sample_intent(kind: OrderKind, partially_fillable: bool) -> OrderIntent { + pub fn sample_intent(flags: Flags) -> OrderIntent { OrderIntent { owner: Pubkey::new_from_array([0x11; 32]), buy_token_account: Pubkey::new_from_array([0x22; 32]), @@ -303,8 +338,7 @@ pub mod fixtures { sell_amount: 0x0123_4567_89ab_cdef, buy_amount: 0xfedc_ba98_7654_3210, valid_to: 0xdead_beef, - kind, - partially_fillable, + flags, app_data: [0x66; 32], } } @@ -314,6 +348,26 @@ pub mod fixtures { Union::new(ALL_ORDER_KINDS.map(Just)) } + /// Any valid [`Flags`]. + pub fn arb_flags() -> impl Strategy { + (arb_order_kind(), any::()).prop_map(|(kind, partially_fillable)| Flags { + kind, + partially_fillable, + }) + } + + /// Any flags byte the decoder accepts. + pub fn arb_flags_byte() -> impl Strategy { + any::().prop_map(|byte| byte & Flags::DEFINED) + } + + /// Any flags byte the decoder rejects. + pub fn arb_invalid_flags_byte() -> impl Strategy { + any::().prop_filter("must have at least one bit that is undefined", |byte| { + byte & !Flags::DEFINED > 0 + }) + } + /// Any valid [`OrderIntent`]. pub fn arb_order_intent() -> impl Strategy { ( @@ -325,8 +379,7 @@ pub mod fixtures { any::(), any::(), any::(), - arb_order_kind(), - any::(), + arb_flags(), any::<[u8; 32]>(), ) .prop_map( @@ -339,8 +392,7 @@ pub mod fixtures { sell_amount, buy_amount, valid_to, - kind, - pf, + flags, app, )| { OrderIntent { @@ -352,8 +404,7 @@ pub mod fixtures { sell_amount, buy_amount, valid_to, - kind, - partially_fillable: pf, + flags, app_data: app, } }, @@ -363,15 +414,22 @@ pub mod fixtures { #[cfg(test)] mod tests { - use super::fixtures::{sample_intent, KIND_OFFSET, PARTIALLY_FILLABLE_OFFSET}; + use crate::data::intent::fixtures::FLAGS_OFFSET; + + use super::fixtures::sample_intent; 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_flag_shapes() -> impl Iterator { + fixtures::ALL_ORDER_KINDS.into_iter().flat_map(|kind| { + [false, true].into_iter().map(move |partially_fillable| { + sample_intent(Flags { + kind, + partially_fillable, + }) + }) + }) } // Pin each width to the size of the `OrderIntent` field it encodes. The @@ -383,7 +441,7 @@ mod tests { // Any `OrderIntent` works: `size_of_val` only consults the field // type, never the data. - let intent = sample_intent(OrderKind::Sell, false); + let intent = sample_intent(Default::default()); assert_eq!(EncodedOrderIntent::WIDTH_OWNER, size_of_val(&intent.owner)); assert_eq!( @@ -414,10 +472,10 @@ 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) + EncodedOrderIntent::WIDTH_FLAGS, + // in truth if there was a problem here it would actually cause a compilation error + size_of_val::<[u8; 1]>(&Flags::default().into()) ); assert_eq!( EncodedOrderIntent::WIDTH_APP_DATA, @@ -428,9 +486,47 @@ 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 byte = |flags: Flags| <[u8; 1]>::from(flags)[0]; + let cleared = Flags { + kind: OrderKind::Sell, + partially_fillable: false, + }; + assert_eq!(byte(cleared), 0); + + let set_one_by_one = [ + ( + Flags::PARTIALLY_FILLABLE, + Flags { + partially_fillable: true, + ..cleared + }, + ), + ( + Flags::KIND, + Flags { + kind: OrderKind::Buy, + ..cleared + }, + ), + ]; + let mut seen = 0u8; + for (bit, flags) 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!(byte(flags), bit); + } + assert_eq!(seen, Flags::DEFINED); + } + + #[test] + fn roundtrip_all_kind_and_flag_combinations() { + for intent in all_flag_shapes() { let encoded = EncodedOrderIntent::from(&intent); let (decoded, _uid) = EncodedOrderIntent::decode_and_hash(&encoded).expect("example must decode"); @@ -444,8 +540,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_flag_shapes() { + let encoded = EncodedOrderIntent::from(&intent); let (_intent, uid) = EncodedOrderIntent::decode_and_hash(&encoded).expect("example must decode"); assert_eq!(uid, encoded.hash()); @@ -457,43 +553,48 @@ mod tests { fn first_differing_byte(lhs: &[u8], rhs: &[u8]) -> Option { lhs.iter().zip(rhs).position(|(l, r)| l != r) } - 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 sell_false: EncodedOrderIntent = (&sample_intent(Flags { + kind: OrderKind::Sell, + partially_fillable: false, + })) + .into(); + let sell_true: EncodedOrderIntent = (&sample_intent(Flags { + kind: OrderKind::Sell, + partially_fillable: true, + })) + .into(); + let buy_true: EncodedOrderIntent = (&sample_intent(Flags { + kind: OrderKind::Buy, + partially_fillable: true, + })) + .into(); 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() { - let encoded = EncodedOrderIntent::from(&sample_intent(OrderKind::Sell, false)); + fn decode_accepts_defined_flag_bits_only() { + let encoded = EncodedOrderIntent::from(&sample_intent(Default::default())); 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 & !Flags::DEFINED != 0 { + assert_eq!( + decoded.err(), + Some(ProgramError::InvalidInstructionData), + "flags {flags:#04x} sets a reserved bit and must be rejected", + ); + } } } @@ -504,16 +605,22 @@ mod tests { fn hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } - let intent = sample_intent(OrderKind::Buy, true); + let intent = sample_intent(Flags { + kind: OrderKind::Buy, + partially_fillable: true, + }); assert_eq!( hex(intent.uid().as_ref()), - "81e8e5360a749ccea88bfb38ad701de8276421a9bb53bfa7dfda773b8fa813d7", + "eddfac5ab968e8c8843c913f58f0ecb5061948a8558d8073dafe53f6f28d398a", ); } #[test] fn encoding_regression() { - let encoded = EncodedOrderIntent::from(&sample_intent(OrderKind::Buy, true)); + let encoded = EncodedOrderIntent::from(&sample_intent(Flags { + kind: OrderKind::Buy, + partially_fillable: true, + })); let encoding: [u8; EncodedOrderIntent::SIZE] = *encoded; #[rustfmt::skip] let expected: [u8; EncodedOrderIntent::SIZE] = [ @@ -548,10 +655,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 ([0x66; 32]) 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, @@ -567,19 +672,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 @@ -594,50 +689,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 833fb78..52567c2 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -88,7 +88,7 @@ impl OrderAccount { } } -/// Canonical 265-byte representation of an [`OrderAccount`]. The bytes +/// Canonical 264-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 ... 265 +/// 0 1 2 3 11 19 51 ... 264 /// ``` #[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 = 265; + pub const SIZE: usize = 264; /// Single-byte account discriminator. See [`SettlementAccount`]. pub const DISCRIMINATOR: u8 = SettlementAccount::OrderAccount.discriminator(); @@ -263,10 +263,7 @@ pub mod fixtures { use proptest::prelude::*; use super::{OrderAccount, Pubkey}; - use crate::data::intent::{ - fixtures::{arb_order_intent, sample_intent}, - OrderKind, - }; + use crate::data::intent::fixtures::{arb_order_intent, sample_intent}; // Hardcoded but verified in a sanity-check test. pub const DISCRIMINATOR_OFFSET: usize = 0; @@ -281,7 +278,7 @@ pub mod fixtures { amount_withdrawn: 0x0112_2334_4556_6778, amount_received: 0x899a_abbc_cdde_eff0, created_by: Pubkey::new_from_array([0x43; 32]), - intent: sample_intent(OrderKind::Sell, false), + intent: sample_intent(Default::default()), } } @@ -316,10 +313,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}, - OrderKind, - }; + use crate::data::intent::fixtures::{sample_intent, FLAGS_OFFSET}; // Pin each width to the size of the `OrderAccount` field it encodes. The // widths summing to `SIZE` is enforced separately, at compile time, by the @@ -390,9 +384,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 +424,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); } @@ -567,7 +560,7 @@ mod tests { let cancelled = true; let amount_withdrawn = 1337; let amount_received = 31337; - let intent = sample_intent(OrderKind::Sell, false); + let intent = sample_intent(Default::default()); let created_by = Pubkey::new_from_array([0x42u8; 32]); let mut buffer = [0u8; EncodedOrderAccount::SIZE]; @@ -598,7 +591,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 +611,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..1b49af7 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); } @@ -115,9 +115,7 @@ pub mod fixtures { use solana_address::Address; use super::{CreateOrder, Instruction}; - use crate::data::intent::{ - fixtures::sample_intent, EncodedOrderIntent, OrderIntent, OrderKind, - }; + use crate::data::intent::{fixtures::sample_intent, EncodedOrderIntent, OrderIntent}; /// Owner baked into [`valid_intent_bytes`]' sample intent. pub const DEFAULT_OWNER: Address = Address::new_from_array([0x11; 32]); @@ -126,12 +124,12 @@ 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 { owner: DEFAULT_OWNER, - ..sample_intent(OrderKind::Sell, true) + ..sample_intent(Default::default()) })) .into() } diff --git a/programs/settlement/src/create_order.rs b/programs/settlement/src/create_order.rs index 2ee64c8..b7d6c59 100644 --- a/programs/settlement/src/create_order.rs +++ b/programs/settlement/src/create_order.rs @@ -72,7 +72,7 @@ pub fn process_create_order( #[cfg(test)] mod tests { - use cow_settlement_interface::data::intent::{OrderIntent, OrderKind}; + use cow_settlement_interface::data::intent::{Flags, OrderIntent, OrderKind}; use cow_settlement_interface::instruction::create_order::fixtures::{ default_order_data, valid_intent_bytes, DEFAULT_OWNER, NUM_ACCOUNTS, }; @@ -107,11 +107,17 @@ mod tests { fn process_create_order_rejects_invalid_encoded_intent() { let intent: OrderIntent = (&valid_intent_bytes()).try_into().expect("should be valid"); let intent_bytes_buy = EncodedOrderIntent::from(&OrderIntent { - kind: OrderKind::Buy, + flags: Flags { + kind: OrderKind::Buy, + ..intent.flags + }, ..intent }); let intent_bytes_sell = EncodedOrderIntent::from(&OrderIntent { - kind: OrderKind::Sell, + flags: Flags { + kind: OrderKind::Sell, + ..intent.flags + }, ..intent }); fn first_differing_byte(lhs: &[u8], rhs: &[u8]) -> Option { diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 41c5c92..4c86cf3 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -391,11 +391,11 @@ fn validated_final_amounts( .checked_add(amount_out) .ok_or(SettlementError::AmountReceivedOverflow)?; - let (filled, order_amount) = match intent.kind { + let (filled, order_amount) = match intent.flags.kind { OrderKind::Sell => (amount_withdrawn, intent.sell_amount), OrderKind::Buy => (amount_received, intent.buy_amount), }; - if filled != order_amount && !intent.partially_fillable { + if filled != order_amount && !intent.flags.partially_fillable { return Err(SettlementError::OrderNotExactlyFilled); } else if filled > order_amount { return Err(SettlementError::FillExceedsOrderAmount); @@ -408,6 +408,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::Flags; 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}; @@ -435,7 +436,10 @@ mod tests { OrderIntent { sell_amount: self.sell, buy_amount: self.buy, - ..sample_intent(self.kind, self.partially_fillable) + ..sample_intent(Flags { + kind: self.kind, + partially_fillable: self.partially_fillable, + }) } } } diff --git a/programs/settlement/tests/common/order.rs b/programs/settlement/tests/common/order.rs index ba3b3b3..778a13c 100644 --- a/programs/settlement/tests/common/order.rs +++ b/programs/settlement/tests/common/order.rs @@ -1,6 +1,8 @@ //! On-chain order construction shared by the settlement integration tests. -use cow_settlement_client::cow_settlement_interface::data::intent::{OrderIntent, OrderKind}; +use cow_settlement_client::cow_settlement_interface::data::intent::{ + Flags, OrderIntent, OrderKind, +}; use cow_settlement_client::instructions::CreateOrder; use litesvm::LiteSVM; use solana_sdk::{ @@ -24,8 +26,10 @@ pub fn sample_intent(owner: Pubkey, salt: u8) -> OrderIntent { sell_amount: 1_000_000, buy_amount: 2_000_000, valid_to: 0xdead_beef, - kind: OrderKind::Sell, - partially_fillable: true, + flags: Flags { + kind: OrderKind::Sell, + partially_fillable: true, + }, app_data: [salt; 32], } } @@ -125,13 +129,13 @@ impl<'a> OrderBuilder<'a> { /// Set the order's kind (`Sell` or `Buy`). Defaults to `Sell`. pub fn kind(mut self, kind: OrderKind) -> Self { - self.intent.kind = kind; + self.intent.flags.kind = kind; self } /// Set whether the order may be filled partially. Defaults to `true`. pub fn partially_fillable(mut self, partially_fillable: bool) -> Self { - self.intent.partially_fillable = partially_fillable; + self.intent.flags.partially_fillable = partially_fillable; self } diff --git a/programs/settlement/tests/create_order.rs b/programs/settlement/tests/create_order.rs index 001b4b9..ba5196d 100644 --- a/programs/settlement/tests/create_order.rs +++ b/programs/settlement/tests/create_order.rs @@ -1,6 +1,6 @@ use cow_settlement_client::cow_settlement_interface::{ data::{ - intent::{fixtures, EncodedOrderIntent, OrderIntent, OrderKind}, + intent::{fixtures, EncodedOrderIntent, OrderIntent}, order::{EncodedOrderAccount, OrderAccount}, }, instruction::create_order::CreateOrder, @@ -24,7 +24,7 @@ mod common; fn sample_intent(owner: Pubkey) -> OrderIntent { OrderIntent { owner, - ..fixtures::sample_intent(OrderKind::Sell, true) + ..fixtures::sample_intent(Default::default()) } } diff --git a/programs/settlement/tests/reclaim_order.rs b/programs/settlement/tests/reclaim_order.rs index d641ac2..2d4c347 100644 --- a/programs/settlement/tests/reclaim_order.rs +++ b/programs/settlement/tests/reclaim_order.rs @@ -1,5 +1,5 @@ use cow_settlement_client::cow_settlement_interface::{ - data::intent::{fixtures::sample_intent, EncodedOrderIntent, OrderIntent, OrderKind}, + data::intent::{fixtures::sample_intent, EncodedOrderIntent, OrderIntent}, instruction::{create_order::CreateOrder, reclaim_order::ReclaimOrder}, pda::order::find_order_pda, SettlementError, @@ -23,7 +23,7 @@ fn reclaim_sample_intent(owner: Pubkey) -> OrderIntent { OrderIntent { owner, valid_to: VALID_TO, - ..sample_intent(OrderKind::Sell, true) + ..sample_intent(Default::default()) } } diff --git a/test-cli/src/cmd/create_order.rs b/test-cli/src/cmd/create_order.rs index 3bd0fd0..617671c 100644 --- a/test-cli/src/cmd/create_order.rs +++ b/test-cli/src/cmd/create_order.rs @@ -2,7 +2,7 @@ use anyhow::Context as _; use clap::{Args as ClapArgs, Parser}; use cow_settlement_client::{ cow_settlement_interface::{ - data::intent::{OrderIntent, OrderKind}, + data::intent::{Flags, OrderIntent, OrderKind}, pda::order::find_order_pda, }, instructions::CreateOrder, @@ -164,8 +164,10 @@ fn execute(ctx: Context, parsed: ParsedOrder, common: CommonArgs) -> anyhow::Res sell_amount, buy_amount, valid_to: common.valid_to, - kind, - partially_fillable: common.partially_fillable, + flags: Flags { + kind, + partially_fillable: common.partially_fillable, + }, app_data: [0u8; 32], };