From cab3e70c59c6b75080227ce4ac3827fe6deb80ed Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:17:05 +0200 Subject: [PATCH 01/17] Refactor state PDA accessors --- bench-report.json | 14 +- client/src/lib.rs | 1 + client/src/pda/mod.rs | 3 + client/src/pda/state.rs | 76 +++ interface/src/data/state.rs | 473 ++++++++++-------- interface/src/lib.rs | 26 + programs/settlement/src/initialize.rs | 18 +- programs/settlement/src/reclaim_buffer.rs | 62 ++- programs/settlement/src/transfer_authority.rs | 16 +- programs/settlement/tests/initialize.rs | 28 +- .../settlement/tests/transfer_authority.rs | 47 +- 11 files changed, 454 insertions(+), 310 deletions(-) create mode 100644 client/src/pda/mod.rs create mode 100644 client/src/pda/state.rs diff --git a/bench-report.json b/bench-report.json index 2b9053c..578a488 100644 --- a/bench-report.json +++ b/bench-report.json @@ -29,10 +29,10 @@ "create_buffers/max_buffers_in_one_instruction": 176947, "create_order/happy_path_creates_order_pda_with_expected_body": 7921, "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_buffer/funded_buffer_is_skipped": 6301, + "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7449, + "reclaim_buffer/max_buffers_in_one_instruction": 136503, + "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18045, "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2133, "settle/finalizes_with_no_pushes": 7043, "settle/pulls_from_multiple_orders": 19750, @@ -43,9 +43,9 @@ "settle/pushes_several_orders_from_one_buffer": 17452, "settle/settles_a_single_order": 12285, "settle/settles_multiple_orders": 22679, - "transfer_authority/manager_can_transfer_manager": 3170, - "transfer_authority/manager_can_transfer_reclaim_authority": 3172, - "transfer_authority/reclaim_authority_can_transfer_itself": 3175 + "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": { "create_buffers/happy_path_creates_initialized_buffer_token_account": 303, diff --git a/client/src/lib.rs b/client/src/lib.rs index 5bf9825..9cca48a 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -4,3 +4,4 @@ pub use cow_settlement_interface; pub mod instructions; pub mod parse; +pub mod pda; diff --git a/client/src/pda/mod.rs b/client/src/pda/mod.rs new file mode 100644 index 0000000..27fa065 --- /dev/null +++ b/client/src/pda/mod.rs @@ -0,0 +1,3 @@ +//! Off-chain decoders for the settlement program's PDAs. + +pub mod state; diff --git a/client/src/pda/state.rs b/client/src/pda/state.rs new file mode 100644 index 0000000..0b8e69e --- /dev/null +++ b/client/src/pda/state.rs @@ -0,0 +1,76 @@ +//! Off-chain decoded snapshot of a settlement state account. + +use cow_settlement_interface::{data::state::StateAccount, Pubkey, Role}; +use solana_program_error::ProgramError; + +/// An owned, decoded snapshot of a settlement state account. +/// Similar to [`StateAccount`], but it fully owns its data. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DecodedStateAccount { + pub manager: Pubkey, + pub reclaim_authority: Pubkey, +} + +impl TryFrom<&[u8]> for DecodedStateAccount { + type Error = ProgramError; + + fn try_from(bytes: &[u8]) -> Result { + let state = StateAccount::new(bytes)?; + Ok(Self { + manager: state.authority(Role::Manager), + reclaim_authority: state.authority(Role::ReclaimAuthority), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cow_settlement_interface::data::state::{Header, WIDTH_HEADER}; + use cow_settlement_interface::fixtures::pubkey_from_seed; + + fn state_bytes(manager: &Pubkey, reclaim_authority: &Pubkey) -> [u8; WIDTH_HEADER] { + let mut bytes = [0u8; WIDTH_HEADER]; + StateAccount::initialize( + &mut bytes[..], + &Header { + manager: *manager, + reclaim_authority: *reclaim_authority, + }, + ) + .expect("header fits"); + bytes + } + + #[test] + fn decodes_the_header() { + let manager = pubkey_from_seed("manager"); + let reclaim_authority = pubkey_from_seed("reclaim authority"); + let bytes = state_bytes(&manager, &reclaim_authority); + + let decoded = DecodedStateAccount::try_from(&bytes[..]).expect("valid state account"); + assert_eq!( + decoded, + DecodedStateAccount { + manager, + reclaim_authority, + }, + ); + } + + #[test] + fn rejects_non_state_account() { + // A zeroed buffer: right length, but its leading byte isn't the state + // discriminator. + let bytes = [0u8; WIDTH_HEADER]; + assert!(DecodedStateAccount::try_from(&bytes[..]).is_err()); + } + + #[test] + fn rejects_too_short_account() { + let manager = pubkey_from_seed("manager"); + let reclaim_authority = pubkey_from_seed("reclaim authority"); + let bytes = state_bytes(&manager, &reclaim_authority); + assert!(DecodedStateAccount::try_from(&bytes[..WIDTH_HEADER - 1]).is_err()); + } +} diff --git a/interface/src/data/state.rs b/interface/src/data/state.rs index d0f5bcd..db86817 100644 --- a/interface/src/data/state.rs +++ b/interface/src/data/state.rs @@ -1,272 +1,310 @@ -//! Settlement state PDA body and its canonical byte representation. +//! Settlement state account: its byte layout and the zero-copy accessor over it. //! //! The state PDA (see [`crate::pda::state`]) stores the protocol's authority -//! configuration: for every [`Role`] it holds the current holder. +//! configuration in a fixed header: a discriminator byte followed by the holder +//! of each [`Role`]. +//! +//! ```text +//! ┌──── discriminator +//! ┌┬───────────────────────────────┬───────────────────────────────┐ +//! ││ manager │ reclaim_authority │ +//! └┴───────────────────────────────┴───────────────────────────────┘ +//! 0 1 33 65 +//! └───────────────────────────── header ──────────────────────────┘ +//! ``` +//! +//! [`StateAccount`] is a zero-copy accessor over an account's bytes, generic +//! over the borrow (`&[u8]`, `&mut [u8]`): the reads are available for any +//! borrow, the in-place role write only for a mutable one. It reads and updates +//! the header directly in the account, so the program never copies the account +//! into an owned struct just to inspect a field or flip a role. use core::mem::size_of; +use core::ops::{Deref, DerefMut}; use arrayref::{array_refs, mut_array_refs}; -use derive_more::Deref; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; use crate::{Role, SettlementAccount}; -/// Idiomatic representation of the state PDA's body. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StateAccount { - /// Current [`Role::Manager`]. - pub manager: Pubkey, - /// Current [`Role::ReclaimAuthority`]. - pub reclaim_authority: Pubkey, -} +/// Single-byte account discriminator at the front of the header. +pub const DISCRIMINATOR: u8 = SettlementAccount::SettlementState.discriminator(); -/// Canonical representation of a [`StateAccount`]: the discriminator byte -/// followed by the current holder of each role. -/// -/// ```text -/// ┌──── discriminator -/// ┌┬───────────────────────────────┬───────────────────────────────┐ -/// ││ manager │ reclaim_authority │ -/// └┴───────────────────────────────┴───────────────────────────────┘ -/// 0 1 33 65 -/// ``` -#[derive(Clone, Debug, Deref, Eq, PartialEq)] -pub struct EncodedStateAccount([u8; Self::SIZE]); - -/// A borrowed view over an [`EncodedStateAccount`]'s bytes, split into its +/// Byte width of the discriminator. +const WIDTH_DISCRIMINATOR: usize = size_of::(); + +/// Byte width of an account address (a [`Pubkey`]): each role holder in the +/// header is one. +pub const WIDTH_PUBKEY: usize = size_of::(); + +/// Length of the fixed header: the discriminator byte followed by one holder +/// per [`Role`]. +pub const WIDTH_HEADER: usize = WIDTH_DISCRIMINATOR + 2 * WIDTH_PUBKEY; + +/// A borrowed view over the bytes of a state acc, split into its /// discriminator and per-role slots so each can be named. The slots hold raw /// encoded bytes, not decoded [`Pubkey`]s. -struct StateAccountRef<'a> { - discriminator: &'a [u8; EncodedStateAccount::W_DISCRIMINATOR], - manager: &'a [u8; EncodedStateAccount::W_MANAGER], - reclaim_authority: &'a [u8; EncodedStateAccount::W_RECLAIM_AUTHORITY], +struct HeaderSlots<'a> { + discriminator: &'a [u8; WIDTH_DISCRIMINATOR], + manager: &'a [u8; WIDTH_PUBKEY], + reclaim_authority: &'a [u8; WIDTH_PUBKEY], } -/// The mutable counterpart of [`StateAccountRef`], for in-place writes. -struct StateAccountRefMut<'a> { - discriminator: &'a mut [u8; EncodedStateAccount::W_DISCRIMINATOR], - manager: &'a mut [u8; EncodedStateAccount::W_MANAGER], - reclaim_authority: &'a mut [u8; EncodedStateAccount::W_RECLAIM_AUTHORITY], +/// The mutable counterpart of [`HeaderSlots`], for in-place writes. +struct HeaderSlotsMut<'a> { + discriminator: &'a mut [u8; WIDTH_DISCRIMINATOR], + manager: &'a mut [u8; WIDTH_PUBKEY], + reclaim_authority: &'a mut [u8; WIDTH_PUBKEY], } -impl EncodedStateAccount { - // Per-field widths, derived from the `StateAccount` field types. - const W_DISCRIMINATOR: usize = size_of::(); - const W_MANAGER: usize = size_of::(); - const W_RECLAIM_AUTHORITY: usize = size_of::(); - - pub const SIZE: usize = 65; - - /// Single-byte account discriminator. See [`SettlementAccount`]. - pub const DISCRIMINATOR: u8 = SettlementAccount::SettlementState.discriminator(); - - /// Borrow the encoding split into its discriminator and per-role slots. - /// Naming the layout here once keeps every reader and writer below (and the - /// codec) in step with it. - fn slots(bytes: &[u8; Self::SIZE]) -> StateAccountRef<'_> { - let (discriminator, manager, reclaim_authority) = array_refs![ - bytes, - EncodedStateAccount::W_DISCRIMINATOR, - EncodedStateAccount::W_MANAGER, - EncodedStateAccount::W_RECLAIM_AUTHORITY - ]; - StateAccountRef { - discriminator, - manager, - reclaim_authority, - } - } - - /// [`Self::slots`] over a mutable buffer, for in-place writes. - fn slots_mut(bytes: &mut [u8; Self::SIZE]) -> StateAccountRefMut<'_> { - let (discriminator, manager, reclaim_authority) = mut_array_refs![ - bytes, - EncodedStateAccount::W_DISCRIMINATOR, - EncodedStateAccount::W_MANAGER, - EncodedStateAccount::W_RECLAIM_AUTHORITY - ]; - StateAccountRefMut { - discriminator, - manager, - reclaim_authority, - } - } - - /// Current holder of `role`, read directly from the encoded bytes. - /// - /// The bytes are assumed to be a valid encoding of the canonical state PDA: - /// only `Initialize` can create an account at that address, so an account of - /// the right size there is necessarily one this program wrote. - pub fn authority(bytes: &[u8; Self::SIZE], role: Role) -> Pubkey { - let state = Self::slots(bytes); - let slot = match role { - Role::Manager => state.manager, - Role::ReclaimAuthority => state.reclaim_authority, - }; - Pubkey::new_from_array(*slot) - } - - /// Mutable slot holding `role`'s current holder, for an in-place update that - /// leaves the discriminator and the other roles untouched. - /// - /// The bytes are assumed to be a valid encoding of the canonical state PDA: - /// only `Initialize` can create an account at that address, so an account of - /// the right size there is necessarily one this program wrote. - pub fn authority_mut( - bytes: &mut [u8; Self::SIZE], - role: Role, - ) -> &mut [u8; size_of::()] { - let state = Self::slots_mut(bytes); - match role { - Role::Manager => state.manager, - Role::ReclaimAuthority => state.reclaim_authority, - } +/// Split the header into its named slots. +fn header_slots(header: &[u8; WIDTH_HEADER]) -> HeaderSlots<'_> { + let (discriminator, manager, reclaim_authority) = + array_refs![header, WIDTH_DISCRIMINATOR, WIDTH_PUBKEY, WIDTH_PUBKEY]; + HeaderSlots { + discriminator, + manager, + reclaim_authority, } } -/// Writes the canonical [`EncodedStateAccount`] encoding of `account` into -/// `buffer`. -pub fn write_account(buffer: &mut [u8; EncodedStateAccount::SIZE], account: &StateAccount) { - let StateAccount { +/// [`header_slots`] over a mutable header, for in-place writes. +fn header_slots_mut(header: &mut [u8; WIDTH_HEADER]) -> HeaderSlotsMut<'_> { + let (discriminator, manager, reclaim_authority) = + mut_array_refs![header, WIDTH_DISCRIMINATOR, WIDTH_PUBKEY, WIDTH_PUBKEY]; + HeaderSlotsMut { + discriminator, manager, reclaim_authority, - } = account; - let slots = EncodedStateAccount::slots_mut(buffer); - *slots.discriminator = [EncodedStateAccount::DISCRIMINATOR]; - *slots.manager = manager.to_bytes(); - *slots.reclaim_authority = reclaim_authority.to_bytes(); -} - -impl From for [u8; EncodedStateAccount::SIZE] { - fn from(encoded: EncodedStateAccount) -> Self { - encoded.0 } } -impl From for EncodedStateAccount { - fn from(account: StateAccount) -> Self { - let mut out = [0u8; Self::SIZE]; - write_account(&mut out, &account); - Self(out) - } +/// The role holders that make up a state account's header. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Header { + /// The [`Role::Manager`] holder. + pub manager: Pubkey, + /// The [`Role::ReclaimAuthority`] holder. + pub reclaim_authority: Pubkey, } -impl From for [u8; EncodedStateAccount::SIZE] { - fn from(account: StateAccount) -> Self { - EncodedStateAccount::from(account).into() +/// A zero-copy accessor over a settlement state account's canonical byte +/// representation: the discriminator byte followed by the current holder of +/// each [`Role`]. +/// +/// `T` is the borrow backing it, anything that dereferences to the account's +/// bytes: `&[u8]` grant read access; `&mut [u8]` grants write access. +pub struct StateAccount(T); + +impl> StateAccount { + /// Wrap an account's bytes, checking they begin with the settlement-state + /// discriminator and are at least a full header long. Every accessor relies + /// on that guarantee not to panic. + pub fn new(bytes: T) -> Result { + let header = bytes + .first_chunk::() + .ok_or(ProgramError::InvalidAccountData)?; + if header_slots(header).discriminator != &[DISCRIMINATOR] { + return Err(ProgramError::InvalidAccountData); + } + Ok(Self(bytes)) } -} -impl TryFrom<[u8; EncodedStateAccount::SIZE]> for StateAccount { - type Error = ProgramError; + fn header(&self) -> &[u8; WIDTH_HEADER] { + self.0 + .first_chunk::() + .expect("header length is guaranteed by any constructor of `StateAccount`") + } - fn try_from(bytes: [u8; EncodedStateAccount::SIZE]) -> Result { - let slots = EncodedStateAccount::slots(&bytes); + /// Current holder of `role`. + pub fn authority(&self, role: Role) -> Pubkey { + let slots = header_slots(self.header()); + let holder = match role { + Role::Manager => slots.manager, + Role::ReclaimAuthority => slots.reclaim_authority, + }; + Pubkey::new_from_array(*holder) + } +} - if *slots.discriminator != [EncodedStateAccount::DISCRIMINATOR] { - return Err(ProgramError::InvalidAccountData); +impl> StateAccount { + /// Stamp a fresh header (discriminator + role holders) into a zeroed account + /// and return the accessor over it. + /// + /// The generated data is a full header long, which is needed for the read + /// accessors not to panic. + pub fn initialize(mut bytes: T, header: &Header) -> Result { + { + let slots = header_slots_mut( + bytes + .first_chunk_mut::() + .ok_or(ProgramError::AccountDataTooSmall)?, + ); + *slots.discriminator = [DISCRIMINATOR]; + *slots.manager = header.manager.to_bytes(); + *slots.reclaim_authority = header.reclaim_authority.to_bytes(); } - - Ok(StateAccount { - manager: Pubkey::new_from_array(*slots.manager), - reclaim_authority: Pubkey::new_from_array(*slots.reclaim_authority), - }) + Ok(Self(bytes)) } -} -impl TryFrom for StateAccount { - type Error = ProgramError; + fn header_mut(&mut self) -> &mut [u8; WIDTH_HEADER] { + let bytes: &mut [u8] = &mut self.0; + bytes + .first_chunk_mut::() + .expect("header length is guaranteed by `new`") + } - fn try_from(encoded: EncodedStateAccount) -> Result { - StateAccount::try_from(encoded.0) + /// Set `role`'s holder to `new` in place. + pub fn set_authority(&mut self, role: Role, new: &Pubkey) { + let slots = header_slots_mut(self.header_mut()); + let holder = match role { + Role::Manager => slots.manager, + Role::ReclaimAuthority => slots.reclaim_authority, + }; + *holder = new.to_bytes(); } } #[cfg(test)] mod tests { + use std::sync::LazyLock; + use super::*; use crate::fixtures::pubkey_from_seed; - /// Byte offset of the account discriminator within the encoding. + /// Byte offset of the discriminator within the account. const DISCRIMINATOR_OFFSET: usize = 0; - fn sample_account() -> StateAccount { - StateAccount { - manager: pubkey_from_seed("sample_account's manager"), - reclaim_authority: pubkey_from_seed("sample_account's reclaim authority"), - } + static SAMPLE_HEADER: LazyLock
= LazyLock::new(|| Header { + manager: pubkey_from_seed("SAMPLE_HEADER's sample manager"), + reclaim_authority: pubkey_from_seed("SAMPLE_HEADER's sample reclaim authority"), + }); + + /// State account bytes stamped with [`SAMPLE_HEADER`]. + fn header_bytes() -> [u8; WIDTH_HEADER] { + let mut bytes = [0u8; WIDTH_HEADER]; + StateAccount::initialize(&mut bytes[..], &SAMPLE_HEADER).expect("header fits"); + bytes } - /// Generates one test for a [`Role`], asserting that the encoded read - /// accessor returns the role's named field and the mutable accessor updates - /// only that field in place. - macro_rules! role_accessor_test { - ($name:ident: $role:expr => $field:ident) => { - #[test] - fn $name() { - let account = sample_account(); - let mut bytes: [u8; EncodedStateAccount::SIZE] = - EncodedStateAccount::from(account.clone()).into(); - - assert_eq!( - EncodedStateAccount::authority(&bytes, $role), - account.$field - ); - - let new_authority = pubkey_from_seed("role_accessor_test's new authority"); - *EncodedStateAccount::authority_mut(&mut bytes, $role) = new_authority.to_bytes(); - - let mut expected = account; - expected.$field = new_authority; - assert_eq!( - StateAccount::try_from(bytes).expect("should decode"), - expected - ); - } - }; + #[test] + fn header_has_the_canonical_wire_layout() { + assert_eq!(WIDTH_HEADER, 65); + + let bytes = header_bytes(); + assert_eq!(bytes[0], SettlementAccount::SettlementState.discriminator()); + assert_eq!(&bytes[1..33], &SAMPLE_HEADER.manager.to_bytes()[..]); + assert_eq!( + &bytes[33..65], + &SAMPLE_HEADER.reclaim_authority.to_bytes()[..] + ); } - role_accessor_test!(manager_accessors_match_named_fields: Role::Manager => manager); - role_accessor_test!(reclaim_authority_accessors_match_named_fields: Role::ReclaimAuthority => reclaim_authority); + #[test] + fn reads_role_holders_from_the_header() { + let bytes = header_bytes(); + let state = StateAccount::new(&bytes[..]).expect("valid header"); + assert_eq!(state.authority(Role::Manager), SAMPLE_HEADER.manager); + assert_eq!( + state.authority(Role::ReclaimAuthority), + SAMPLE_HEADER.reclaim_authority + ); + } #[test] - fn decode_rejects_wrong_discriminator() { - let mut bytes: [u8; EncodedStateAccount::SIZE] = - EncodedStateAccount::from(sample_account()).into(); - bytes[0] ^= 0xff; - let err = StateAccount::try_from(bytes).expect_err("wrong discriminator must be rejected"); - assert_eq!(err, ProgramError::InvalidAccountData); + fn initialize_round_trips_a_header() { + let header = Header { + manager: pubkey_from_seed("manager"), + reclaim_authority: pubkey_from_seed("reclaim authority"), + }; + + let mut bytes = [0u8; WIDTH_HEADER]; + StateAccount::initialize(&mut bytes[..], &header).expect("header fits"); + + let state = StateAccount::new(&bytes[..]).expect("valid header"); + let read_back = Header { + manager: state.authority(Role::Manager), + reclaim_authority: state.authority(Role::ReclaimAuthority), + }; + assert_eq!(read_back, header); } #[test] - fn direct_write_account_matches_state_account_encoding() { - let account = sample_account(); - let mut buffer = [0u8; EncodedStateAccount::SIZE]; - write_account(&mut buffer, &account); - let direct = EncodedStateAccount(buffer); - let via_state_account = EncodedStateAccount::from(account); - assert_eq!(direct, via_state_account); + fn new_rejects_wrong_discriminator() { + let mut bytes = header_bytes(); + bytes[DISCRIMINATOR_OFFSET] = 0xff; + assert_eq!( + StateAccount::new(&bytes[..]).err(), + Some(ProgramError::InvalidAccountData), + ); } #[test] - fn widths_match_field_sizes() { - use core::mem::{size_of, size_of_val}; + fn new_rejects_too_short_buffer() { + let bytes = header_bytes(); + assert_eq!( + StateAccount::new(&bytes[..WIDTH_HEADER - 1]).err(), + Some(ProgramError::InvalidAccountData), + ); + } + + /// Sets `target`'s holder and asserts it changed while every other role's + /// holder stayed put. + fn assert_set_authority_updates_only(target: Role) { + let new_holder = pubkey_from_seed("set_authority's new holder"); + + let mut bytes = header_bytes(); + let before = Role::ALL.map(|role| { + StateAccount::new(&bytes[..]) + .expect("valid header") + .authority(role) + }); + + StateAccount::new(&mut bytes[..]) + .expect("valid header") + .set_authority(target, &new_holder); + + let state = StateAccount::new(&bytes[..]).expect("valid header"); + for (role, prior) in Role::ALL.into_iter().zip(before) { + let expected = if role == target { new_holder } else { prior }; + assert_eq!(state.authority(role), expected); + } + } + + /// Generates one test, `set_authority_updates_only_`, for `$role`. + macro_rules! set_authority_test { + ($name:ident: $role:expr) => { + #[test] + fn $name() { + assert_set_authority_updates_only($role); + } + }; + } - // Any `StateAccount` works: `size_of_val` only consults the field type, - // never the data. - let StateAccount { - manager, - reclaim_authority, - } = sample_account(); + set_authority_test!(set_authority_updates_only_manager: Role::Manager); + set_authority_test!(set_authority_updates_only_reclaim_authority: Role::ReclaimAuthority); - assert_eq!(EncodedStateAccount::W_MANAGER, size_of_val(&manager)); + #[test] + fn new_accepts_a_longer_account_and_reads_the_header() { + let mut bytes = header_bytes().to_vec(); + bytes.push(0x42); + + let state = StateAccount::new(&bytes[..]).expect("header with trailing bytes is valid"); + assert_eq!(state.authority(Role::Manager), SAMPLE_HEADER.manager); assert_eq!( - EncodedStateAccount::W_RECLAIM_AUTHORITY, - size_of_val(&reclaim_authority) + state.authority(Role::ReclaimAuthority), + SAMPLE_HEADER.reclaim_authority ); + } - assert_eq!(EncodedStateAccount::SIZE, size_of::()); + #[test] + fn initialize_rejects_too_small_buffer() { + let mut bytes = [0u8; WIDTH_HEADER - 1]; + assert_eq!( + StateAccount::initialize(&mut bytes[..], &SAMPLE_HEADER).err(), + Some(ProgramError::AccountDataTooSmall), + ); } mod proptest { @@ -275,31 +313,24 @@ mod tests { use super::*; proptest! { + /// The encode roundtrip: any two role holders written with + /// `initialize` read back unchanged. #[test] fn account_encode_roundtrip( manager in any::<[u8; 32]>(), reclaim_authority in any::<[u8; 32]>(), ) { - let account = StateAccount { + let header = Header { manager: Pubkey::new_from_array(manager), reclaim_authority: Pubkey::new_from_array(reclaim_authority), }; - let encoded = EncodedStateAccount::from(account.clone()); - let decoded = StateAccount::try_from(encoded).expect("should decode after encoding"); - prop_assert_eq!(decoded, account); - } - - #[test] - fn account_decode_roundtrip( - mut bytes in any::<[u8; EncodedStateAccount::SIZE]>(), - ) { - bytes[DISCRIMINATOR_OFFSET] = EncodedStateAccount::DISCRIMINATOR; - let encoded = EncodedStateAccount(bytes); - let decoded = StateAccount::try_from(encoded.clone()).expect("should decode from valid bytes"); - let re_encoded = EncodedStateAccount::from(decoded); + let mut bytes = [0u8; WIDTH_HEADER]; + StateAccount::initialize(&mut bytes[..], &header).expect("header fits"); - prop_assert_eq!(re_encoded, encoded); + let state = StateAccount::new(&bytes[..]).expect("valid header"); + prop_assert_eq!(state.authority(Role::Manager), header.manager); + prop_assert_eq!(state.authority(Role::ReclaimAuthority), header.reclaim_authority); } } } diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 0e019aa..3e380de 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -54,6 +54,9 @@ pub enum Role { } impl Role { + /// Every [`Role`] variant, in discriminant order. + pub const ALL: [Self; 2] = [Role::Manager, Role::ReclaimAuthority]; + /// The single wire byte that selects this role in the authority-transfer /// instruction. pub fn discriminator(self) -> u8 { @@ -249,6 +252,8 @@ pub mod fixtures { #[cfg(test)] mod tests { + use std::collections::HashSet; + use super::*; #[test] @@ -329,4 +334,25 @@ mod tests { fn role_try_from_matches_manager() { assert_eq!(Role::try_from(0), Ok(Role::Manager)); } + + #[test] + fn all_roles_lists_every_role() { + // The roles `try_from` accepts, discovered independently of `Role::ALL`. + // Adding a `Role` variant makes this set diverge from `Role::ALL`, + // failing here until `Role::ALL` is updated. + let every_role: Vec = (u8::MIN..=u8::MAX) + .filter_map(|byte| Role::try_from(byte).ok()) + .collect(); + + assert_eq!(Role::ALL.len(), every_role.len()); + for role in every_role { + assert!(Role::ALL.contains(&role)); + } + } + + #[test] + fn all_roles_has_no_duplicates() { + let unique: HashSet = Role::ALL.iter().map(|role| role.discriminator()).collect(); + assert_eq!(unique.len(), Role::ALL.len(), "`Role::ALL` has duplicates"); + } } diff --git a/programs/settlement/src/initialize.rs b/programs/settlement/src/initialize.rs index 71fbe96..41c5d1b 100644 --- a/programs/settlement/src/initialize.rs +++ b/programs/settlement/src/initialize.rs @@ -1,11 +1,11 @@ //! `Initialize` instruction handler. use cow_settlement_interface::{ - data::state::{write_account, EncodedStateAccount, StateAccount}, + data::state::{Header, StateAccount, WIDTH_HEADER}, instruction::{initialize::InitializeInput, InstructionInputParsing}, pda::state::state_pda_seeds, }; -use pinocchio::{error::ProgramError, AccountView, Address, ProgramResult}; +use pinocchio::{AccountView, Address, ProgramResult}; use crate::processor::CanonicalPda; @@ -30,7 +30,7 @@ pub fn process_initialize( program_id, payer, pda: state_pda, - size: EncodedStateAccount::SIZE as u64, + size: WIDTH_HEADER as u64, owner: program_id, seeds: state_pda_seeds(), } @@ -38,17 +38,13 @@ pub fn process_initialize( // A copied `AccountView` handle writes through to the same runtime account. let mut state_pda = *state_pda; - let mut buffer = state_pda.try_borrow_mut()?; - let buffer: &mut [u8; EncodedStateAccount::SIZE] = (&mut *buffer) - .try_into() - .map_err(|_| ProgramError::AccountDataTooSmall)?; - write_account( - buffer, - &StateAccount { + StateAccount::initialize( + state_pda.try_borrow_mut()?, + &Header { manager, reclaim_authority, }, - ); + )?; Ok(()) } diff --git a/programs/settlement/src/reclaim_buffer.rs b/programs/settlement/src/reclaim_buffer.rs index 30e57d8..a983270 100644 --- a/programs/settlement/src/reclaim_buffer.rs +++ b/programs/settlement/src/reclaim_buffer.rs @@ -6,7 +6,7 @@ //! reclaiming a set of buffers succeeds even when none of them were closed. use cow_settlement_interface::{ - data::state::EncodedStateAccount, + data::state::StateAccount, instruction::{ create_buffer::SPL_TOKEN_PROGRAM_ID, reclaim_buffer::ReclaimBufferInput, InstructionInputParsing, @@ -37,14 +37,8 @@ pub fn process_reclaim_buffer( } with_state_pda_signer(program_id, state_pda, |state_signer| { - let reclaim_authority_pubkey: Pubkey = { - let data = state_pda.try_borrow()?; - let bytes: &[u8; EncodedStateAccount::SIZE] = data - .as_ref() - .try_into() - .map_err(|_| ProgramError::InvalidAccountData)?; - EncodedStateAccount::authority(bytes, Role::ReclaimAuthority) - }; + let reclaim_authority_pubkey: Pubkey = + StateAccount::new(state_pda.try_borrow()?)?.authority(Role::ReclaimAuthority); if !reclaim_authority.is_signer() || reclaim_authority.address() != &reclaim_authority_pubkey { @@ -79,7 +73,7 @@ pub fn process_reclaim_buffer( #[cfg(test)] mod tests { - use cow_settlement_interface::data::state::StateAccount; + use cow_settlement_interface::data::state::{Header, StateAccount, WIDTH_HEADER}; use cow_settlement_interface::instruction::fixtures::{ fake_account, fake_account_owned_by, fake_account_with_data, fake_sequential_accounts, fake_signer, @@ -108,6 +102,21 @@ mod tests { const TOKEN_PROGRAM: usize = 3; const BUFFER_PDA: usize = 4; + /// A state-account header for planting a well-formed state PDA in tests. + fn state_header(header: &Header) -> [u8; WIDTH_HEADER] { + let mut bytes = [0u8; WIDTH_HEADER]; + StateAccount::initialize(&mut bytes[..], header).expect("header fits"); + bytes + } + + /// The [`Header`] planted by [`base_accounts`]. + fn base_header() -> Header { + Header { + manager: MANAGER, + reclaim_authority: AUTHORITY, + } + } + fn empty_buffer_data(mint: Address, state_pda: Address) -> Vec { let mut data = vec![0; SplTokenAccount::LEN]; SplTokenAccount { @@ -128,22 +137,16 @@ mod tests { let state_pda = Address::find_program_address(&state_pda_seeds(), &PROGRAM_ID).0; [ - fake_account_with_data( - state_pda, - &*EncodedStateAccount::from(StateAccount { - manager: MANAGER, - reclaim_authority: AUTHORITY, - }), - ), // state PDA - fake_signer(AUTHORITY), // reclaim authority - fake_account(recipient), // reclaim recipient - fake_account(SPL_TOKEN_PROGRAM_ID), // token program + fake_account_with_data(state_pda, &state_header(&base_header())), // state PDA + fake_signer(AUTHORITY), // reclaim authority + fake_account(recipient), // reclaim recipient + fake_account(SPL_TOKEN_PROGRAM_ID), // token program fake_account_owned_by( find_buffer_pda(&PROGRAM_ID, &mint).0, SPL_TOKEN_PROGRAM_ID, &empty_buffer_data(mint, state_pda), ), // buffer PDA - fake_account(mint), // mint + fake_account(mint), // mint ] } @@ -184,13 +187,7 @@ mod tests { #[test] fn process_reclaim_buffer_rejects_wrong_state_pda() { let mut accounts = base_accounts(); - accounts[STATE_PDA] = fake_account_with_data( - UNRELATED, - &*EncodedStateAccount::from(StateAccount { - manager: MANAGER, - reclaim_authority: AUTHORITY, - }), - ); + accounts[STATE_PDA] = fake_account_with_data(UNRELATED, &state_header(&base_header())); assert_rejects(accounts, SettlementError::StateAccountMismatch.into()); } @@ -211,13 +208,12 @@ mod tests { fn process_reclaim_buffer_rejects_zeroed_state_pda() { let mut accounts = base_accounts(); - // Allocated to the right size but never initialized, so its reclaim - // authority reads back as the all-zero address, which no real signer can - // match. + // Allocated to the right size but never initialized: its leading byte + // isn't the state discriminator, so it isn't a valid state account. let state_pda = *accounts[STATE_PDA].address(); - accounts[STATE_PDA] = fake_account_with_data(state_pda, &[0; EncodedStateAccount::SIZE]); + accounts[STATE_PDA] = fake_account_with_data(state_pda, &[0; WIDTH_HEADER]); - assert_rejects(accounts, SettlementError::ReclaimAuthorityMismatch.into()); + assert_rejects(accounts, ProgramError::InvalidAccountData); } #[test] diff --git a/programs/settlement/src/transfer_authority.rs b/programs/settlement/src/transfer_authority.rs index bf0d513..519859c 100644 --- a/programs/settlement/src/transfer_authority.rs +++ b/programs/settlement/src/transfer_authority.rs @@ -4,7 +4,7 @@ //! The transfer must come from the manager or the role's current holder. use cow_settlement_interface::{ - data::state::EncodedStateAccount, + data::state::StateAccount, instruction::{transfer_authority::TransferAuthorityInput, InstructionInputParsing}, Role, SettlementError, }; @@ -31,22 +31,16 @@ pub fn process_transfer_authority( } // A copied `AccountView` writes through to the same runtime account, so the - // mutable borrow to update the role goes through this local copy. + // mutable view goes through this local copy. let mut state_pda = *state_pda; - let mut data = state_pda.try_borrow_mut()?; - let bytes: &mut [u8; EncodedStateAccount::SIZE] = data - .as_mut() - .try_into() - .map_err(|_| ProgramError::InvalidAccountData)?; + let mut state = StateAccount::new(state_pda.try_borrow_mut()?)?; let signer_key = signer.address(); - if signer_key != &EncodedStateAccount::authority(bytes, Role::Manager) - && signer_key != &EncodedStateAccount::authority(bytes, role) - { + if signer_key != &state.authority(Role::Manager) && signer_key != &state.authority(role) { return Err(SettlementError::UnauthorizedAuthorityTransfer.into()); } - *EncodedStateAccount::authority_mut(bytes, role) = new_authority.to_bytes(); + state.set_authority(role, &new_authority); Ok(()) } diff --git a/programs/settlement/tests/initialize.rs b/programs/settlement/tests/initialize.rs index d2aa7f8..244ff0e 100644 --- a/programs/settlement/tests/initialize.rs +++ b/programs/settlement/tests/initialize.rs @@ -1,9 +1,9 @@ use cow_settlement_client::cow_settlement_interface::{ - data::state::{EncodedStateAccount, StateAccount}, - instruction::initialize::Initialize as InitializeRaw, + data::state::WIDTH_HEADER, instruction::initialize::Initialize as InitializeRaw, pda::state::find_state_pda, }; use cow_settlement_client::instructions::Initialize; +use cow_settlement_client::pda::state::DecodedStateAccount; use solana_sdk::signature::Signer; use crate::common::{ @@ -39,17 +39,23 @@ fn happy_path_initializes_state_pda_with_expected_data() { account.owner, program_id, "state PDA must be owned by the settlement program" ); - let expected_body: [u8; EncodedStateAccount::SIZE] = StateAccount { - reclaim_authority, - manager, - } - .into(); + let decoded = DecodedStateAccount::try_from(&account.data[..]) + .expect("state PDA must decode as a settlement state account"); + assert_eq!( + decoded, + DecodedStateAccount { + manager, + reclaim_authority, + }, + "state PDA body must record the manager and reclaim authority" + ); assert_eq!( - account.data, expected_body, - "state PDA body must match the expected layout (discriminator + authorities)" + account.data.len(), + WIDTH_HEADER, + "a freshly initialized state PDA is exactly a header long" ); - let rent = svm.minimum_balance_for_rent_exemption(EncodedStateAccount::SIZE); + let rent = svm.minimum_balance_for_rent_exemption(WIDTH_HEADER); assert_eq!( account.lamports, rent, "state PDA must hold exactly the rent minimum: {} != {}", @@ -94,7 +100,7 @@ fn funding_payer_can_differ_from_fee_payer() { // The rent came out of the funder, not the fee payer: the funder paid no // transaction fee, so its balance dropped by exactly the PDA rent. - let rent = svm.minimum_balance_for_rent_exemption(EncodedStateAccount::SIZE); + let rent = svm.minimum_balance_for_rent_exemption(WIDTH_HEADER); assert_eq!( common::lamports(&svm, &funder.pubkey()), funder_airdrop - rent, diff --git a/programs/settlement/tests/transfer_authority.rs b/programs/settlement/tests/transfer_authority.rs index d0cc00f..a938502 100644 --- a/programs/settlement/tests/transfer_authority.rs +++ b/programs/settlement/tests/transfer_authority.rs @@ -1,8 +1,8 @@ //! Integration tests for the authority transfer instruction. use cow_settlement_client::cow_settlement_interface::{ - data::state::EncodedStateAccount, instruction::transfer_authority::fixtures::ROLE_OFFSET, - Instruction, Role, SettlementError, + data::state::StateAccount, instruction::transfer_authority::fixtures::ROLE_OFFSET, Instruction, + Role, SettlementError, }; use cow_settlement_client::instructions::TransferAuthority; use litesvm::LiteSVM; @@ -24,11 +24,9 @@ fn read_authority(svm: &LiteSVM, state_pda: &Pubkey, role: Role) -> Pubkey { let account = svm .get_account(state_pda) .expect("state PDA should exist after initialize"); - let bytes: [u8; EncodedStateAccount::SIZE] = account - .data - .try_into() - .expect("state PDA data should be exactly the encoded size"); - EncodedStateAccount::authority(&bytes, role) + StateAccount::new(&account.data[..]) + .expect("state PDA should be a valid state account") + .authority(role) } /// Runs a `TransferAuthority` that should succeed: `signer` transfers `role` to @@ -80,13 +78,29 @@ fn assert_transfer_rejected( assert_instruction_error(res, to_instruction_error(expected)); } -/// Generates one integration test, ` transfers `. Two forms: +/// Asserts that `signer` may transfer *only* `allowed`: every other role (see +/// [`Role::ALL`]) is rejected with `expected`. Adding a `Role` extends the +/// coverage automatically. +fn assert_transfers_only( + svm: &mut LiteSVM, + params: &InitializedParams, + signer: &Keypair, + allowed: Role, + expected: SettlementError, +) { + for role in Role::ALL.into_iter().filter(|&role| role != allowed) { + assert_transfer_rejected(svm, params, role, signer, expected); + } +} + +/// Generates one integration test. Two forms: /// -/// - "Entry transfers Role", for successes -/// - "Entry transfers Role, error Error", for reverts +/// - "Entry transfers Role" — asserts that transfer succeeds. +/// - "Entry transfers only Role, error Error" — asserts every *other* role is +/// rejected with Error. /// -/// "Entry" names a keypair field of [`InitializedParams`]. -/// "Error" is the expected [`SettlementError`]. +/// "Entry" names a keypair field of [`InitializedParams`]; "Error" is the +/// expected [`SettlementError`]. macro_rules! transfer_authority_test { ($name:ident: $signer:ident transfers $role:expr) => { #[test] @@ -96,11 +110,11 @@ macro_rules! transfer_authority_test { } }; - ($name:ident: $signer:ident transfers $role:expr, error $err:expr) => { + ($name:ident: $signer:ident transfers only $allowed:expr, error $err:expr) => { #[test] fn $name() { let (mut svm, params) = setup_init(); - assert_transfer_rejected(&mut svm, ¶ms, $role, ¶ms.$signer, $err); + assert_transfers_only(&mut svm, ¶ms, ¶ms.$signer, $allowed, $err); } }; } @@ -111,8 +125,9 @@ transfer_authority_test!(manager_can_transfer_manager: manager transfers Role::M transfer_authority_test!(manager_can_transfer_reclaim_authority: manager transfers Role::ReclaimAuthority); transfer_authority_test!(reclaim_authority_can_transfer_itself: reclaim transfers Role::ReclaimAuthority); -// A non-manager authority may not touch the manager role. -transfer_authority_test!(reclaim_authority_cannot_transfer_the_manager: reclaim transfers Role::Manager, error SettlementError::UnauthorizedAuthorityTransfer); +// A non-manager authority may transfer only its own role; every other role is +// rejected. +transfer_authority_test!(reclaim_authority_cannot_transfer_other_roles: reclaim transfers only Role::ReclaimAuthority, error SettlementError::UnauthorizedAuthorityTransfer); /// Index of the signer account in a `TransferAuthority` instruction. const SIGNER_INDEX: usize = 0; From e4ec645de6eee94d59cb5337c822f1b2e6818b53 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:26:28 +0200 Subject: [PATCH 02/17] Add instruction to add a solver --- bench-report.json | 50 +-- client/src/instructions.rs | 24 ++ client/src/parse.rs | 17 +- interface/src/data/state.rs | 115 ++++++- interface/src/instruction/add_solver.rs | 257 ++++++++++++++ interface/src/instruction/mod.rs | 1 + interface/src/lib.rs | 6 + programs/settlement/Cargo.toml | 2 +- programs/settlement/src/add_solver.rs | 119 +++++++ programs/settlement/src/lib.rs | 5 + programs/settlement/tests/add_solvers.rs | 314 ++++++++++++++++++ programs/settlement/tests/common/benchmark.rs | 2 + programs/settlement/tests/common/mod.rs | 11 - programs/settlement/tests/reclaim_buffer.rs | 16 +- .../settlement/tests/settle_limit_prices.rs | 15 +- 15 files changed, 900 insertions(+), 54 deletions(-) create mode 100644 interface/src/instruction/add_solver.rs create mode 100644 programs/settlement/src/add_solver.rs create mode 100644 programs/settlement/tests/add_solvers.rs diff --git a/bench-report.json b/bench-report.json index 578a488..d6f415a 100644 --- a/bench-report.json +++ b/bench-report.json @@ -1,5 +1,7 @@ { "accounts": { + "add_solver/add_with_many_existing_solvers": 5, + "add_solver/adds_a_solver": 5, "create_buffers/happy_path_creates_initialized_buffer_token_account": 6, "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 10, "create_buffers/max_buffers_in_one_instruction": 64, @@ -24,30 +26,34 @@ "transfer_authority/reclaim_authority_can_transfer_itself": 4 }, "compute_units": { - "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, - "initialize/happy_path_initializes_state_pda_with_expected_data": 4526, - "reclaim_buffer/funded_buffer_is_skipped": 6301, - "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7449, - "reclaim_buffer/max_buffers_in_one_instruction": 136503, - "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18045, - "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2133, - "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, - "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": 5111, + "add_solver/adds_a_solver": 4667, + "create_buffers/happy_path_creates_initialized_buffer_token_account": 10346, + "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21744, + "create_buffers/max_buffers_in_one_instruction": 177041, + "create_order/happy_path_creates_order_pda_with_expected_body": 7925, + "initialize/happy_path_initializes_state_pda_with_expected_data": 4530, + "reclaim_buffer/funded_buffer_is_skipped": 6310, + "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7458, + "reclaim_buffer/max_buffers_in_one_instruction": 136627, + "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18057, + "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2135, + "settle/finalizes_with_no_pushes": 7051, + "settle/pulls_from_multiple_orders": 19778, + "settle/pulls_funds_to_destination": 13435, + "settle/pulls_to_multiple_destinations": 14583, + "settle/pushes_a_single_order": 12281, + "settle/pushes_several_orders_from_different_buffers": 17475, + "settle/pushes_several_orders_from_one_buffer": 17474, + "settle/settles_a_single_order": 12299, + "settle/settles_multiple_orders": 22711, + "transfer_authority/manager_can_transfer_manager": 3177, + "transfer_authority/manager_can_transfer_reclaim_authority": 3179, + "transfer_authority/reclaim_authority_can_transfer_itself": 3183 }, "transaction_bytes": { + "add_solver/add_with_many_existing_solvers": 366, + "add_solver/adds_a_solver": 366, "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, diff --git a/client/src/instructions.rs b/client/src/instructions.rs index ef0bea9..aeb8c21 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -247,6 +247,30 @@ impl From for Instruction { } } +/// Inserts `solver` into the state PDA's solver list. `manager` authorizes the +/// change and must be the current manager; `payer` funds the account's growth. +/// Both sign. +pub struct AddSolver { + pub program_id: Pubkey, + pub manager: Pubkey, + pub payer: Pubkey, + pub solver: Pubkey, +} + +impl From for Instruction { + fn from(builder: AddSolver) -> Self { + let (state_pda, _bump) = find_state_pda(&builder.program_id); + cow_settlement_interface::instruction::add_solver::AddSolver { + program_id: builder.program_id, + manager: builder.manager, + payer: builder.payer, + state_pda, + solver: builder.solver, + } + .into() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/client/src/parse.rs b/client/src/parse.rs index 4217186..da3e8a1 100644 --- a/client/src/parse.rs +++ b/client/src/parse.rs @@ -5,6 +5,7 @@ use cow_settlement_interface::{ instruction::{ + add_solver::AddSolverInput, create_buffer::CreateBufferInput, create_order::CreateOrderInput, initialize::InitializeInput, @@ -28,6 +29,7 @@ pub enum ParsedInstruction<'a, A> { ReclaimOrder(ReclaimOrderInput<'a, A>), ReclaimBuffer(ReclaimBufferInput<'a, A>), TransferAuthority(TransferAuthorityInput<'a, A>), + AddSolver(AddSolverInput<'a, A>), } /// Parses any settlement instruction by its discriminator. @@ -61,6 +63,9 @@ pub fn parse_instruction<'a, A>( SettlementInstruction::TransferAuthority => ParsedInstruction::TransferAuthority( TransferAuthorityInput::parse_body(remaining_data, accounts)?, ), + SettlementInstruction::AddSolver => { + ParsedInstruction::AddSolver(AddSolverInput::parse_body(remaining_data, accounts)?) + } }) } @@ -68,7 +73,8 @@ pub fn parse_instruction<'a, A>( mod tests { use super::*; use crate::instructions::{ - BeginSettle, CreateBuffers, CreateOrder, FinalizeSettle, Initialize, InitializedIntent, + AddSolver, BeginSettle, CreateBuffers, CreateOrder, FinalizeSettle, Initialize, + InitializedIntent, }; use cow_settlement_interface::{ data::intent::{fixtures::sample_intent, OrderKind}, @@ -145,6 +151,13 @@ mod tests { new_authority: payer, } .into(), + SettlementInstruction::AddSolver => AddSolver { + program_id, + manager: payer, + payer, + solver: pubkey_from_seed("solver"), + } + .into(), } } @@ -162,6 +175,7 @@ mod tests { SettlementInstruction::ReclaimOrder, SettlementInstruction::ReclaimBuffer, SettlementInstruction::TransferAuthority, + SettlementInstruction::AddSolver, ] { let ix = build(expected); let accounts: Vec<_> = ix @@ -180,6 +194,7 @@ mod tests { ParsedInstruction::ReclaimOrder(_) => SettlementInstruction::ReclaimOrder, ParsedInstruction::ReclaimBuffer(_) => SettlementInstruction::ReclaimBuffer, ParsedInstruction::TransferAuthority(_) => SettlementInstruction::TransferAuthority, + ParsedInstruction::AddSolver(_) => SettlementInstruction::AddSolver, }; assert_eq!(actual, expected); } diff --git a/interface/src/data/state.rs b/interface/src/data/state.rs index db86817..e89e9ac 100644 --- a/interface/src/data/state.rs +++ b/interface/src/data/state.rs @@ -1,23 +1,24 @@ //! Settlement state account: its byte layout and the zero-copy accessor over it. //! //! The state PDA (see [`crate::pda::state`]) stores the protocol's authority -//! configuration in a fixed header: a discriminator byte followed by the holder -//! of each [`Role`]. +//! configuration in a fixed header (a discriminator byte followed by the holder +//! of each [`Role`]), then the list of approved solvers, packed and sorted +//! ascending by address. //! //! ```text //! ┌──── discriminator -//! ┌┬───────────────────────────────┬───────────────────────────────┐ -//! ││ manager │ reclaim_authority │ -//! └┴───────────────────────────────┴───────────────────────────────┘ -//! 0 1 33 65 -//! └───────────────────────────── header ──────────────────────────┘ +//! ┌┬───────────────────────────────┬───────────────────────────────┬───────────────────────────────┬───── ... ─────┬───────────────────────────────┐ +//! ││ manager │ reclaim_authority │ solver[0] │ other solvers │ solver[N-1] │ +//! └┴───────────────────────────────┴───────────────────────────────┴───────────────────────────────┴───── ... ─────┴───────────────────────────────┘ +//! 0 1 33 65 97 +//! └───────────────────────────── header ──────────────────────────┘└─────────────────────────────── N sorted solvers ─────────────────────────────┘ //! ``` //! //! [`StateAccount`] is a zero-copy accessor over an account's bytes, generic //! over the borrow (`&[u8]`, `&mut [u8]`): the reads are available for any //! borrow, the in-place role write only for a mutable one. It reads and updates -//! the header directly in the account, so the program never copies the account -//! into an owned struct just to inspect a field or flip a role. +//! the account data directly, so the program never copies the account into an +//! owned struct. use core::mem::size_of; use core::ops::{Deref, DerefMut}; @@ -35,7 +36,7 @@ pub const DISCRIMINATOR: u8 = SettlementAccount::SettlementState.discriminator() const WIDTH_DISCRIMINATOR: usize = size_of::(); /// Byte width of an account address (a [`Pubkey`]): each role holder in the -/// header is one. +/// header and each solver in the list is one. pub const WIDTH_PUBKEY: usize = size_of::(); /// Length of the fixed header: the discriminator byte followed by one holder @@ -126,6 +127,31 @@ impl> StateAccount { }; Pubkey::new_from_array(*holder) } + + /// The sorted solver list that follows the header, as fixed-width entries. + /// A trailing partial entry (only possible on a corrupt account) is ignored + /// and an uninitialized/too-small account returns an empty slice. + fn solver_region(&self) -> &[[u8; WIDTH_PUBKEY]] { + let region = self.0.get(WIDTH_HEADER..).unwrap_or_default(); + let (solvers, _partial) = region.as_chunks::(); + solvers + } + + /// Locate `solver` in the sorted solver list, mirroring + /// [`slice::binary_search`]: `Ok(index)` if present, `Err(index)` with the + /// slot it would occupy otherwise. The list is sorted by address bytes. + pub fn solver_search(&self, solver: &Pubkey) -> Result { + let seek = solver.to_bytes(); + self.solver_region() + .binary_search_by(|probe| probe.cmp(&seek)) + } + + /// The stored solvers, in order (sorted ascending by address). + pub fn solvers(&self) -> impl Iterator + '_ { + self.solver_region() + .iter() + .map(|raw| Pubkey::new_from_array(*raw)) + } } impl> StateAccount { @@ -188,6 +214,18 @@ mod tests { bytes } + /// [`header_bytes`] followed by `solvers`, stored sorted ascending by address + /// as the on-chain list always is, so callers can pass them in any order. + fn state_bytes(solvers: &[Pubkey]) -> Vec { + let mut solvers = solvers.to_vec(); + solvers.sort(); + let mut bytes = header_bytes().to_vec(); + for solver in &solvers { + bytes.extend_from_slice(&solver.to_bytes()); + } + bytes + } + #[test] fn header_has_the_canonical_wire_layout() { assert_eq!(WIDTH_HEADER, 65); @@ -298,6 +336,63 @@ mod tests { ); } + /// Three solvers in arbitrary order; `state_bytes` stores them sorted, as the + /// on-chain list always is. Returns both the stored bytes and the sorted + /// order the reads should reflect. + fn sample_solvers() -> (Vec, [Pubkey; 3]) { + let solvers = [ + pubkey_from_seed("sample_solver's solver a"), + pubkey_from_seed("sample_solver's solver b"), + pubkey_from_seed("sample_solver's solver c"), + ]; + let bytes = state_bytes(&solvers); + let mut sorted = solvers; + sorted.sort(); + (bytes, sorted) + } + + #[test] + fn solvers_is_empty_without_any_stored() { + let bytes = state_bytes(&[]); + let state = StateAccount::new(&bytes[..]).expect("valid header"); + assert_eq!(state.solvers().count(), 0); + } + + #[test] + fn solvers_lists_stored_solvers_sorted() { + let (bytes, sorted) = sample_solvers(); + let state = StateAccount::new(&bytes[..]).expect("valid header"); + assert_eq!(state.solvers().collect::>(), sorted); + } + + #[test] + fn solver_search_on_empty_list() { + let bytes = state_bytes(&[]); + let state = StateAccount::new(&bytes[..]).expect("valid header"); + let absent = pubkey_from_seed("absent solver"); + assert_eq!(state.solver_search(&absent), Err(0)); + } + + #[test] + fn solver_search_finds_present() { + let (bytes, sorted) = sample_solvers(); + let state = StateAccount::new(&bytes[..]).expect("valid header"); + for (index, solver) in sorted.iter().enumerate() { + assert_eq!(state.solver_search(solver), Ok(index)); + } + } + + #[test] + fn solver_search_locates_absent() { + let (bytes, sorted) = sample_solvers(); + let state = StateAccount::new(&bytes[..]).expect("valid header"); + // An absent solver isn't found; its reported slot is where inserting it + // would keep the list sorted. + let absent = pubkey_from_seed("absent solver"); + let slot = state.solver_search(&absent).expect_err("absent solver"); + assert_eq!(slot, sorted.partition_point(|s| s < &absent)); + } + #[test] fn initialize_rejects_too_small_buffer() { let mut bytes = [0u8; WIDTH_HEADER - 1]; diff --git a/interface/src/instruction/add_solver.rs b/interface/src/instruction/add_solver.rs new file mode 100644 index 0000000..3348fbb --- /dev/null +++ b/interface/src/instruction/add_solver.rs @@ -0,0 +1,257 @@ +//! `AddSolver` instruction builder and parser. +//! +//! It inserts a solver into the sorted solver list stored in the state PDA (see +//! [`crate::data::state`]). Only the manager may authorize it. The state PDA +//! grows by one solver, so a `payer` funds the extra rent through a `Transfer` +//! from the system program. + +use core::mem::size_of; + +use solana_instruction::{AccountMeta, Instruction}; +use solana_program_error::ProgramError; +use solana_pubkey::Pubkey; + +pub use solana_system_interface::program::ID as SYSTEM_PROGRAM_ID; + +use crate::instruction::InstructionInputParsing; +use crate::SettlementInstruction; + +/// Builder for an `AddSolver` instruction. +/// +/// `manager` authorizes the change and must be the state PDA's current manager; +/// it signs but doesn't pay. `payer` funds the extra rent and signs the funding +/// transfer. `solver` is inserted into the sorted solver list; adding one +/// already present fails. +/// +/// Wire format: `[discriminator=8, solver (32 bytes)]`. +/// Required accounts: `[manager (S), payer (W,S), state_pda (W), +/// system_program (R)]`. The system program must be available for the +/// rent-funding `Transfer` CPI but doesn't need to sit at a specific position. +pub struct AddSolver { + pub program_id: Pubkey, + pub manager: Pubkey, + pub payer: Pubkey, + pub state_pda: Pubkey, + pub solver: Pubkey, +} + +impl From for Instruction { + fn from(builder: AddSolver) -> Self { + let mut data = vec![SettlementInstruction::AddSolver.discriminator()]; + data.extend_from_slice(&builder.solver.to_bytes()); + Instruction { + program_id: builder.program_id, + accounts: vec![ + AccountMeta::new_readonly(builder.manager, true), + AccountMeta::new(builder.payer, true), + AccountMeta::new(builder.state_pda, false), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + ], + data, + } + } +} + +/// Parsed inputs of an `AddSolver` instruction. +pub struct AddSolverInput<'a, A> { + pub manager: &'a A, + pub payer: &'a A, + pub state_pda: &'a A, + pub solver: Pubkey, +} + +impl<'a, A> InstructionInputParsing<'a, A> for AddSolverInput<'a, A> { + const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::AddSolver; + + fn parse_body(instruction_data: &[u8], accounts: &'a [A]) -> Result { + let solver: &[u8; size_of::()] = instruction_data + .try_into() + .map_err(|_| ProgramError::InvalidInstructionData)?; + let solver = Pubkey::new_from_array(*solver); + + // Accounts: [manager (S), payer (W,S), state_pda (W), system_program (R)]. + // The system program needs to be present for the `Transfer` CPI but + // doesn't need to be referenced directly and can be at any later position. + let [manager, payer, state_pda, _system, ..] = accounts else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + Ok(Self { + manager, + payer, + state_pda, + solver, + }) + } +} + +/// Test scaffolding for `AddSolver` parsing and handling, shared by this crate's +/// tests and the settlement program's via the `test-fixtures` feature. +#[cfg(any(test, feature = "test-fixtures"))] +pub mod fixtures { + use solana_address::Address; + use solana_instruction::Instruction; + + use super::AddSolver; + + /// Number of accounts `AddSolver` expects: manager, payer, state PDA, system + /// program. + pub const NUM_ACCOUNTS: usize = 4; + + /// `AddSolver` instruction data with placeholder addresses, for failure cases + /// where the actual addresses don't matter. + pub fn add_solver_data() -> Vec { + let zero = Address::default(); + Instruction::from(AddSolver { + program_id: zero, + manager: zero, + payer: zero, + state_pda: zero, + solver: zero, + }) + .data + } +} + +#[cfg(test)] +mod tests { + use super::fixtures::{add_solver_data, NUM_ACCOUNTS}; + use super::*; + use crate::fixtures::pubkey_from_seed; + use crate::instruction::fixtures::{fake_account, fake_sequential_accounts}; + use crate::instruction::tests::{ + assert_readonly_nonsigner, assert_readonly_signer, assert_writable_nonsigner, + assert_writable_signer, + }; + use solana_account_view::AccountView; + + #[test] + fn add_solver_input_parses_valid_input() { + let program_id = pubkey_from_seed("program id"); + let manager = fake_account(pubkey_from_seed("manager")); + let payer = fake_account(pubkey_from_seed("payer")); + let state_pda = fake_account(pubkey_from_seed("state pda")); + let system_program = fake_account(pubkey_from_seed("system program")); + let solver = pubkey_from_seed("solver"); + + let data = Instruction::from(AddSolver { + program_id, + manager: *manager.address(), + payer: *payer.address(), + state_pda: *state_pda.address(), + solver, + }) + .data; + let accounts = [manager, payer, state_pda, system_program]; + + let AddSolverInput { + manager: parsed_manager, + payer: parsed_payer, + state_pda: parsed_state_pda, + solver: parsed_solver, + } = AddSolverInput::parse(&data, &accounts).expect("parse should succeed"); + + assert_eq!(parsed_manager.address(), manager.address()); + assert_eq!(parsed_payer.address(), payer.address()); + assert_eq!(parsed_state_pda.address(), state_pda.address()); + assert_eq!(parsed_solver, solver); + } + + #[test] + fn add_solver_input_rejects_long_data() { + let mut data = add_solver_data(); + data.push(42); // trailing byte + let accounts = fake_sequential_accounts::(); + assert_eq!( + AddSolverInput::parse(&data, &accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn add_solver_input_rejects_short_data() { + let mut data = add_solver_data(); + data.pop(); // one byte short + let accounts = fake_sequential_accounts::(); + assert_eq!( + AddSolverInput::parse(&data, &accounts).err(), + Some(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn add_solver_input_rejects_missing_accounts() { + let data = add_solver_data(); + let mut accounts: Vec = fake_sequential_accounts::().into(); + accounts.pop(); + assert_eq!( + AddSolverInput::parse(&data, &accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + ); + } + + #[test] + fn instruction_data_has_expected_layout() { + let solver = pubkey_from_seed("solver"); + let Instruction { data, .. } = AddSolver { + program_id: pubkey_from_seed("program id"), + manager: pubkey_from_seed("manager"), + payer: pubkey_from_seed("payer"), + state_pda: pubkey_from_seed("state pda"), + solver, + } + .into(); + + assert_eq!(data.len(), 1 + size_of::()); + assert_eq!(data[0], SettlementInstruction::AddSolver.discriminator()); + assert_eq!(&data[1..], &solver.to_bytes()); + } + + #[test] + fn instruction_data_regression() { + let solver = Pubkey::new_from_array([0x11; 32]); + let Instruction { data, .. } = AddSolver { + program_id: pubkey_from_seed("program id"), + manager: pubkey_from_seed("manager"), + payer: pubkey_from_seed("payer"), + state_pda: pubkey_from_seed("state pda"), + solver, + } + .into(); + + #[rustfmt::skip] + let expected: [u8; 1 + size_of::()] = [ + // discriminator (AddSolver = 8) + 0x08, + // solver + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, + ]; + assert_eq!(data, expected); + } + + #[test] + fn instruction_has_expected_accounts() { + let manager = pubkey_from_seed("manager"); + let payer = pubkey_from_seed("payer"); + let state_pda = pubkey_from_seed("state pda"); + let Instruction { accounts, .. } = AddSolver { + program_id: pubkey_from_seed("program id"), + manager, + payer, + state_pda, + solver: pubkey_from_seed("solver"), + } + .into(); + + assert_eq!(accounts.len(), 4); + // The manager authorizes the change; the payer funds the extra rent; the + // state PDA is grown and written; the system program is only referenced. + assert_readonly_signer(&accounts[0], manager); + assert_writable_signer(&accounts[1], payer); + assert_writable_nonsigner(&accounts[2], state_pda); + assert_readonly_nonsigner(&accounts[3], SYSTEM_PROGRAM_ID); + } +} diff --git a/interface/src/instruction/mod.rs b/interface/src/instruction/mod.rs index d073f24..6da3a24 100644 --- a/interface/src/instruction/mod.rs +++ b/interface/src/instruction/mod.rs @@ -8,6 +8,7 @@ use solana_program_error::ProgramError; use crate::{recover_discriminator, SettlementInstruction}; +pub mod add_solver; pub mod create_buffer; pub mod create_order; pub mod initialize; diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 3e380de..f06cd9c 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -25,6 +25,7 @@ pub enum SettlementInstruction { ReclaimOrder = 5, ReclaimBuffer = 6, TransferAuthority = 7, + AddSolver = 8, } impl SettlementInstruction { @@ -222,6 +223,11 @@ 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, + /// `AddSolver`'s manager account isn't a signer, or doesn't match the + /// `manager` recorded in the settlement state PDA. + UnauthorizedSolverManagement = 35, + /// `AddSolver`'s solver is already in the state PDA's solver list. + SolverAlreadyExists = 36, } impl From for u32 { diff --git a/programs/settlement/Cargo.toml b/programs/settlement/Cargo.toml index 7638cf5..723eeb5 100644 --- a/programs/settlement/Cargo.toml +++ b/programs/settlement/Cargo.toml @@ -18,7 +18,7 @@ name = "cow_settlement" crate-type = ["cdylib", "lib"] [dependencies] -pinocchio = { workspace = true, features = ["cpi"] } +pinocchio = { workspace = true, features = ["cpi", "account-resize"] } pinocchio-system.workspace = true pinocchio-token.workspace = true cow-settlement-interface.workspace = true diff --git a/programs/settlement/src/add_solver.rs b/programs/settlement/src/add_solver.rs new file mode 100644 index 0000000..77de5cd --- /dev/null +++ b/programs/settlement/src/add_solver.rs @@ -0,0 +1,119 @@ +//! `AddSolver` instruction handler. +//! +//! Inserts a solver into the sorted solver list that follows the state PDA +//! header, keeping it sorted so the list stays binary-searchable. Only the +//! manager may authorize it. The account grows by one solver, so the `payer` +//! funds the extra rent through a `Transfer` before the account is resized. + +use cow_settlement_interface::{ + data::state::{StateAccount, WIDTH_HEADER, WIDTH_PUBKEY}, + instruction::{add_solver::AddSolverInput, InstructionInputParsing}, + Role, SettlementError, +}; +use pinocchio::{ + sysvars::{rent::Rent, Sysvar}, + AccountView, Address, ProgramResult, Resize, +}; +use pinocchio_system::instructions::Transfer; + +use crate::processor::check_state_pda; + +pub fn process_add_solver( + program_id: &Address, + accounts: &mut [AccountView], + instruction_data: &[u8], +) -> ProgramResult { + let AddSolverInput { + manager, + payer, + state_pda, + solver, + } = AddSolverInput::parse(instruction_data, accounts)?; + + check_state_pda(program_id, state_pda)?; + + // Only the manager may change the solver list. Reading also validates the + // account, and the search finds where the new solver sorts in. + let index = { + let state = StateAccount::new(state_pda.try_borrow()?)?; + if !manager.is_signer() || manager.address() != &state.authority(Role::Manager) { + return Err(SettlementError::UnauthorizedSolverManagement.into()); + } + match state.solver_search(&solver) { + Ok(_) => return Err(SettlementError::SolverAlreadyExists.into()), + Err(index) => index, + } + }; + + // Every length and offset below is bounded by the account's data length, + // which the runtime caps at 10 MiB, so this arithmetic always fits in `usize` + // and these checks can never trip. + let old_len = state_pda.data_len(); + let new_len = old_len + .checked_add(WIDTH_PUBKEY) + .expect("grown account length fits in usize"); + + // Growing the account must keep it rent-exempt: the payer funds the extra + // rent. This CPI runs before any data borrow so the account is free to grow. + let shortfall = Rent::get()? + .try_minimum_balance(new_len)? + .saturating_sub(state_pda.lamports()); + if shortfall > 0 { + Transfer { + from: payer, + to: state_pda, + lamports: shortfall, + } + .invoke()?; + } + + // Grow the account, shift the solvers at or after the insertion point right + // by one slot, and write the new solver into the gap. + let offset = WIDTH_HEADER + .checked_add(index.checked_mul(WIDTH_PUBKEY).expect("bound by new_len")) + .expect("bound by new_len"); + let gap_end = offset.checked_add(WIDTH_PUBKEY).expect("bound by new_len"); + + let mut state_pda = *state_pda; + state_pda.resize(new_len)?; + let mut data = state_pda.try_borrow_mut()?; + data.copy_within(offset..old_len, gap_end); + data[offset..gap_end].copy_from_slice(&solver.to_bytes()); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use cow_settlement_interface::instruction::add_solver::fixtures::{ + add_solver_data, NUM_ACCOUNTS, + }; + use cow_settlement_interface::instruction::fixtures::fake_sequential_accounts; + use pinocchio::error::ProgramError; + + const PROGRAM_ID: Address = Address::new_from_array([0xc0; 32]); + + #[test] + fn process_add_solver_propagates_parse_error() { + let mut data = add_solver_data(); + data.push(0); // trailing byte triggers a parse error + let mut accounts = fake_sequential_accounts::(); + assert_eq!( + process_add_solver(&PROGRAM_ID, &mut accounts, &data), + Err(ProgramError::InvalidInstructionData), + ); + } + + #[test] + fn process_add_solver_rejects_non_canonical_state_pda() { + // `fake_sequential_accounts` puts the state PDA at `[3; 32]`, which is + // not the canonical state PDA for this program. + let data = add_solver_data(); + let mut accounts = fake_sequential_accounts::(); + assert_eq!( + process_add_solver(&PROGRAM_ID, &mut accounts, &data), + Err(SettlementError::StateAccountMismatch.into()), + ); + } +} diff --git a/programs/settlement/src/lib.rs b/programs/settlement/src/lib.rs index af2dfa5..d60fa23 100644 --- a/programs/settlement/src/lib.rs +++ b/programs/settlement/src/lib.rs @@ -1,5 +1,6 @@ //! On-chain CoW Protocol settlement program. +mod add_solver; mod create_buffer; mod create_order; mod initialize; @@ -9,6 +10,7 @@ mod reclaim_order; mod settle; mod transfer_authority; +use add_solver::process_add_solver; use cow_settlement_interface::{recover_discriminator, SettlementInstruction}; use create_buffer::process_create_buffer; use create_order::process_create_order; @@ -52,5 +54,8 @@ pub fn process_instruction( SettlementInstruction::TransferAuthority => { process_transfer_authority(program_id, accounts, instruction_data) } + SettlementInstruction::AddSolver => { + process_add_solver(program_id, accounts, instruction_data) + } } } diff --git a/programs/settlement/tests/add_solvers.rs b/programs/settlement/tests/add_solvers.rs new file mode 100644 index 0000000..a16edad --- /dev/null +++ b/programs/settlement/tests/add_solvers.rs @@ -0,0 +1,314 @@ +//! Integration tests for the solver list stored in the state PDA: adding solvers +//! (kept sorted, growing the account and funding the extra rent) and the manager +//! gate on adding them. + +use cow_settlement_client::cow_settlement_interface::{ + data::state::{StateAccount, WIDTH_HEADER, WIDTH_PUBKEY}, + Instruction, SettlementError, +}; +use cow_settlement_client::instructions::AddSolver; +use litesvm::LiteSVM; +use solana_sdk::{ + instruction::InstructionError, + pubkey::Pubkey, + signature::Signer, + transaction::{Transaction, TransactionError}, +}; +use solana_system_interface::MAX_PERMITTED_DATA_LENGTH; + +use crate::common::{ + assert_instruction_error, + benchmark::{send_transaction_metered, BenchLabel}, + create_account_at, lamports, setup_init, to_instruction_error, unique_keypair, + InitializedParams, +}; + +mod common; + +/// Assert the solver list's storage invariant: solvers are stored strictly +/// ascending by address (sorted, with no duplicates). This is what lets the +/// program binary-search the list, so every read below re-checks it. +#[track_caller] +fn assert_solver_invariant(solvers: &[Pubkey]) { + assert!( + solvers.is_sorted_by(|a, b| a < b), + "invariant violated: solver list must be strictly ascending by address: {solvers:?}", + ); +} + +/// The solver list currently stored in the state PDA, in stored order. Reading it +/// also re-checks the storage invariant (see [`assert_solver_invariant`]), so +/// every test that inspects the list enforces it, not just the ones that compare +/// against a sorted expectation. +#[track_caller] +fn solvers(svm: &LiteSVM, state_pda: &Pubkey) -> Vec { + let data = svm + .get_account(state_pda) + .expect("state PDA should exist") + .data; + let solvers: Vec = StateAccount::new(&data[..]) + .expect("state PDA should be a valid state account") + .solvers() + .collect(); + assert_solver_invariant(&solvers); + solvers +} + +/// Build an `AddSolver` transaction authorized by the manager and paid by the +/// payer, both of which sign. Split from [`add_solver`] so the happy-path test +/// can submit the same transaction through the metered send. +fn add_solver_tx(svm: &LiteSVM, params: &InitializedParams, solver: &Pubkey) -> Transaction { + let ix = AddSolver { + program_id: params.program_id, + manager: params.manager.pubkey(), + payer: params.payer.pubkey(), + solver: *solver, + }; + common::signed_tx(svm, ¶ms.payer, ¶ms.manager, ix) +} + +/// Send an [`add_solver_tx`]. +fn add_solver( + svm: &mut LiteSVM, + params: &InitializedParams, + solver: &Pubkey, +) -> Result<(), TransactionError> { + let tx = add_solver_tx(svm, params, solver); + svm.send_transaction(tx).map(|_| ()).map_err(|e| e.err) +} + +#[test] +fn adds_a_solver() { + let (mut svm, params) = setup_init(); + let before = lamports(&svm, ¶ms.state_pda); + + assert_eq!(solvers(&svm, ¶ms.state_pda), vec![]); + + let solver = unique_keypair().pubkey(); + let tx = add_solver_tx(&svm, ¶ms, &solver); + send_transaction_metered(&mut svm, tx, BenchLabel::AddSolver) + .expect("adding a solver should succeed"); + + assert_eq!(solvers(&svm, ¶ms.state_pda), vec![solver]); + + // The account grew by exactly one solver and stayed rent-exempt (its balance + // rose to the larger rent minimum, funded by the payer). + let account = svm + .get_account(¶ms.state_pda) + .expect("state PDA exists"); + assert_eq!(account.data.len(), WIDTH_HEADER + WIDTH_PUBKEY); + assert_eq!( + account.lamports, + svm.minimum_balance_for_rent_exemption(account.data.len()), + ); + assert!(account.lamports > before, "the payer funded the extra rent"); +} + +#[test] +fn adds_a_solver_without_extra_rent() { + let (mut svm, params) = setup_init(); + + // Pre-fund the state PDA so it holds one more than the rent minimum for the + // grown size. + let mut account = svm + .get_account(¶ms.state_pda) + .expect("state PDA exists"); + account.lamports = svm + .minimum_balance_for_rent_exemption(WIDTH_HEADER + WIDTH_PUBKEY) + .strict_add(1); + svm.set_account(params.state_pda, account) + .expect("set_account should succeed"); + let before = lamports(&svm, ¶ms.state_pda); + + let solver = unique_keypair().pubkey(); + let tx = add_solver_tx(&svm, ¶ms, &solver); + svm.send_transaction(tx) + .expect("adding a solver should succeed"); + + assert_eq!(solvers(&svm, ¶ms.state_pda), vec![solver]); + + // The account grew by one solver and its balance is unchanged: it was already + // rent-exempt for the new size, so the payer funded nothing. + let account = svm + .get_account(¶ms.state_pda) + .expect("state PDA exists"); + assert_eq!(account.data.len(), WIDTH_HEADER + WIDTH_PUBKEY); + assert_eq!(account.lamports, before, "no extra rent was pulled"); +} + +/// Solvers are stored sorted no matter the order they're added in. Adds many +/// solvers in hash order (effectively unsorted) and checks the stored list came +/// out sorted. +#[test] +fn keeps_solvers_sorted() { + let (mut svm, params) = setup_init(); + + const COUNT: usize = 500; + let mut added: Vec = (0..COUNT).map(|_| unique_keypair().pubkey()).collect(); + for solver in &added { + add_solver(&mut svm, ¶ms, solver).expect("adding a solver should succeed"); + } + + added.sort(); + assert_eq!(solvers(&svm, ¶ms.state_pda), added); +} + +#[test] +fn rejects_adding_an_existing_solver() { + let (mut svm, params) = setup_init(); + let solver = unique_keypair().pubkey(); + add_solver(&mut svm, ¶ms, &solver).expect("first add should succeed"); + + // The re-add is an identical message; move past the first transaction's + // blockhash so it isn't rejected as a duplicate before reaching the program. + svm.expire_blockhash(); + assert_instruction_error( + add_solver(&mut svm, ¶ms, &solver), + to_instruction_error(SettlementError::SolverAlreadyExists), + ); +} + +#[test] +fn rejects_adding_solver_by_non_manager() { + let (mut svm, params) = setup_init(); + let solver = unique_keypair().pubkey(); + + let stranger = unique_keypair(); + let ix = AddSolver { + program_id: params.program_id, + manager: stranger.pubkey(), + payer: params.payer.pubkey(), + solver, + }; + let tx = common::signed_tx(&svm, ¶ms.payer, &stranger, ix); + assert_instruction_error( + svm.send_transaction(tx).map(|_| ()).map_err(|e| e.err), + to_instruction_error(SettlementError::UnauthorizedSolverManagement), + ); +} + +/// Adding a solver still works, and stays sorted, when the list is already +/// large. This test also benchmarks moving a lot of account data. +#[test] +fn add_with_many_existing_solvers() { + let (mut svm, params) = setup_init(); + + /// A deterministic solver address holding `index` big-endian in its leading + /// two bytes and the rest zero, so their relative order is their index's + /// order. + fn indexed_solver(index: u16) -> Pubkey { + let mut bytes = [0u8; 32]; + let leading = index.to_be_bytes(); + bytes[0] = leading[0]; + bytes[1] = leading[1]; + Pubkey::new_from_array(bytes) + } + + // Existing solvers 0x0143, 0x0144, …, written straight into the state PDA after + // its header rather than added one transaction at a time. + const EXISTING: u16 = 1_000; + const NEW_INDEX: u16 = 42; + let mut expected: Vec = (0..=EXISTING) + .filter(|i| *i != NEW_INDEX) + .map(indexed_solver) + .collect(); + let mut account = svm + .get_account(¶ms.state_pda) + .expect("state PDA exists"); + for solver in &expected { + account.data.extend_from_slice(&solver.to_bytes()); + } + account.lamports = svm.minimum_balance_for_rent_exemption(account.data.len()); + svm.set_account(params.state_pda, account) + .expect("set_account should succeed"); + + // Insert 0x0142: it sorts ahead of every existing solver, so it lands at the + // front and the program shifts the whole list. + let extra = indexed_solver(NEW_INDEX); + let tx = add_solver_tx(&svm, ¶ms, &extra); + send_transaction_metered(&mut svm, tx, BenchLabel::AddSolver) + .expect("adding into a large list should succeed"); + + expected.push(extra); + expected.sort(); + assert_eq!(solvers(&svm, ¶ms.state_pda), expected); +} + +#[test] +fn rejects_growing_beyond_the_max_account_size() { + let (mut svm, params) = setup_init(); + + let mut data = svm + .get_account(¶ms.state_pda) + .expect("state PDA exists") + .data; + data.resize(MAX_PERMITTED_DATA_LENGTH as usize, 0); + create_account_at(&mut svm, params.state_pda, ¶ms.program_id, &data); + + let solver = unique_keypair().pubkey(); + let tx = add_solver_tx(&svm, ¶ms, &solver); + let err = svm + .send_transaction(tx) + .expect_err("growing past the max account size should revert") + .err; + + // The revert is the runtime enforcing its account-size limit, not our program: + // our settlement errors surface as `InstructionError::Custom`, whereas this is a + // plain `InvalidArgument` from the rent-exemption sizing check. + assert!( + !matches!( + err, + TransactionError::InstructionError(_, InstructionError::Custom(_)) + ), + "the revert must not be one of our program's errors: {err:?}", + ); + assert_eq!( + err, + TransactionError::InstructionError(0, InstructionError::InvalidArgument), + ); +} + +/// Index of the manager account in an `AddSolver` instruction. +const MANAGER_INDEX: usize = 0; + +#[test] +fn rejects_adding_solver_if_manager_is_not_signer() { + let (mut svm, params) = setup_init(); + let solver = unique_keypair().pubkey(); + + let mut ix: Instruction = AddSolver { + program_id: params.program_id, + manager: params.manager.pubkey(), + payer: params.payer.pubkey(), + solver, + } + .into(); + assert!( + ix.accounts[MANAGER_INDEX].is_signer && !ix.accounts[MANAGER_INDEX].is_writable, + "test sanity check failed: MANAGER_INDEX should point to the manager signer" + ); + ix.accounts[MANAGER_INDEX].is_signer = false; + + let res = common::send(&mut svm, ¶ms.payer, vec![ix]); + assert_instruction_error( + res, + to_instruction_error(SettlementError::UnauthorizedSolverManagement), + ); +} + +#[test] +fn rejects_adding_solver_if_state_pda_is_uninitialized() { + let (mut svm, program_id, payer) = common::setup(); + let manager = unique_keypair(); + let solver = unique_keypair().pubkey(); + + let ix = AddSolver { + program_id, + manager: manager.pubkey(), + payer: payer.pubkey(), + solver, + }; + let tx = common::signed_tx(&svm, &payer, &manager, ix); + let res = svm.send_transaction(tx).map_err(|e| e.err); + assert_instruction_error(res, InstructionError::InvalidAccountData); +} diff --git a/programs/settlement/tests/common/benchmark.rs b/programs/settlement/tests/common/benchmark.rs index 4bc42ed..23f0ee3 100644 --- a/programs/settlement/tests/common/benchmark.rs +++ b/programs/settlement/tests/common/benchmark.rs @@ -24,6 +24,7 @@ pub enum BenchLabel { ReclaimOrder, Settle, TransferAuthority, + AddSolver, } impl fmt::Display for BenchLabel { @@ -38,6 +39,7 @@ impl fmt::Display for BenchLabel { Self::ReclaimOrder => "reclaim_order", Self::Settle => "settle", Self::TransferAuthority => "transfer_authority", + Self::AddSolver => "add_solver", }) } } diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index a5ea200..08cc4f8 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -166,17 +166,6 @@ pub fn assert_instruction_error_at( ); } -/// Convenience wrapper around [`assert_instruction_error_at`] for the common -/// case of asserting a specific [`SettlementError`]. -#[track_caller] -pub fn assert_settlement_error( - ix_idx: u8, - result: Result, - expected: SettlementError, -) { - assert_instruction_error_at(ix_idx, result, to_instruction_error(expected)); -} - pub fn create_account_at(svm: &mut LiteSVM, address: Pubkey, owner: &Pubkey, data: &[u8]) { let lamports = svm.minimum_balance_for_rent_exemption(data.len()); svm.set_account( diff --git a/programs/settlement/tests/reclaim_buffer.rs b/programs/settlement/tests/reclaim_buffer.rs index 22c6975..ad5607d 100644 --- a/programs/settlement/tests/reclaim_buffer.rs +++ b/programs/settlement/tests/reclaim_buffer.rs @@ -13,7 +13,9 @@ use solana_sdk::{ use crate::common::benchmark::{send_transaction_metered, BenchLabel}; use crate::common::buffer::ensure_buffer_exists; -use crate::common::{unique_pubkey, InitializedParams}; +use crate::common::{ + assert_instruction_error, to_instruction_error, unique_pubkey, InitializedParams, +}; mod common; @@ -262,7 +264,7 @@ fn rejects_the_same_buffer_twice_in_one_instruction() { mints: &[mint, mint], }; let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); - common::assert_instruction_error( + assert_instruction_error( svm.send_transaction(tx).map_err(|e| e.err), InstructionError::InvalidAccountData, ); @@ -291,10 +293,9 @@ fn rejects_when_signer_is_not_the_configured_reclaim_authority() { mints: &[mint], }; let tx = common::signed_tx(&svm, &payer, &impostor, ix); - common::assert_settlement_error( - 0, + assert_instruction_error( svm.send_transaction(tx).map_err(|e| e.err), - SettlementError::ReclaimAuthorityMismatch, + to_instruction_error(SettlementError::ReclaimAuthorityMismatch), ); } @@ -335,10 +336,9 @@ fn rejects_when_the_reclaim_authority_does_not_sign() { .expect("instruction should reference the reclaim authority"); authority_meta.is_signer = false; - common::assert_settlement_error( - 0, + assert_instruction_error( common::send(&mut svm, &payer, vec![ix]), - SettlementError::ReclaimAuthorityMismatch, + to_instruction_error(SettlementError::ReclaimAuthorityMismatch), ); assert!( svm.get_account(&buffer_pda).is_some(), diff --git a/programs/settlement/tests/settle_limit_prices.rs b/programs/settlement/tests/settle_limit_prices.rs index 894e64f..55022ce 100644 --- a/programs/settlement/tests/settle_limit_prices.rs +++ b/programs/settlement/tests/settle_limit_prices.rs @@ -7,7 +7,7 @@ //! succeeds or is rejected with the expected error. use crate::common::{ - assert_settlement_error, buffer, + assert_instruction_error_at, buffer, order::OrderBuilder, settlement::{BEGIN_INDEX, FINALIZE_INDEX}, setup, to_instruction_error, token, unique_pubkey, @@ -30,6 +30,19 @@ use solana_sdk::{ mod common; +/// Convenience wrapper around [`assert_instruction_error_at`] for asserting a +/// specific [`SettlementError`] at the instruction that produced it: settlements +/// run as a `[BeginSettle, FinalizeSettle]` pair, so the failing instruction +/// isn't always the first. +#[track_caller] +fn assert_settlement_error( + ix_idx: u8, + result: Result, + expected: SettlementError, +) { + assert_instruction_error_at(ix_idx, result, to_instruction_error(expected)); +} + /// Read `intent`'s order PDA and return its persisted `(amount_withdrawn, /// amount_received)` cumulative fill totals. fn order_fill(svm: &LiteSVM, program_id: &Pubkey, intent: &OrderIntent) -> (u64, u64) { From 7813e832c31aa9392100ceccd8866d0bbcebc4b5 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:40:25 +0200 Subject: [PATCH 03/17] state_header -> state_account_bytes --- programs/settlement/src/reclaim_buffer.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/programs/settlement/src/reclaim_buffer.rs b/programs/settlement/src/reclaim_buffer.rs index a983270..744c65c 100644 --- a/programs/settlement/src/reclaim_buffer.rs +++ b/programs/settlement/src/reclaim_buffer.rs @@ -102,8 +102,8 @@ mod tests { const TOKEN_PROGRAM: usize = 3; const BUFFER_PDA: usize = 4; - /// A state-account header for planting a well-formed state PDA in tests. - fn state_header(header: &Header) -> [u8; WIDTH_HEADER] { + /// State account bytes for planting a well-formed state PDA in tests. + fn state_account_bytes(header: &Header) -> [u8; WIDTH_HEADER] { let mut bytes = [0u8; WIDTH_HEADER]; StateAccount::initialize(&mut bytes[..], header).expect("header fits"); bytes @@ -137,16 +137,16 @@ mod tests { let state_pda = Address::find_program_address(&state_pda_seeds(), &PROGRAM_ID).0; [ - fake_account_with_data(state_pda, &state_header(&base_header())), // state PDA - fake_signer(AUTHORITY), // reclaim authority - fake_account(recipient), // reclaim recipient - fake_account(SPL_TOKEN_PROGRAM_ID), // token program + fake_account_with_data(state_pda, &state_account_bytes(&base_header())), // state PDA + fake_signer(AUTHORITY), // reclaim authority + fake_account(recipient), // reclaim recipient + fake_account(SPL_TOKEN_PROGRAM_ID), // token program fake_account_owned_by( find_buffer_pda(&PROGRAM_ID, &mint).0, SPL_TOKEN_PROGRAM_ID, &empty_buffer_data(mint, state_pda), ), // buffer PDA - fake_account(mint), // mint + fake_account(mint), // mint ] } @@ -187,7 +187,8 @@ mod tests { #[test] fn process_reclaim_buffer_rejects_wrong_state_pda() { let mut accounts = base_accounts(); - accounts[STATE_PDA] = fake_account_with_data(UNRELATED, &state_header(&base_header())); + accounts[STATE_PDA] = + fake_account_with_data(UNRELATED, &state_account_bytes(&base_header())); assert_rejects(accounts, SettlementError::StateAccountMismatch.into()); } From 515547f0ae342b0973166daa52172747a37cc38d Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:47:54 +0200 Subject: [PATCH 04/17] Simplify tests for `Role::ALL` --- interface/src/lib.rs | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/interface/src/lib.rs b/interface/src/lib.rs index bc97b71..3f61256 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -251,8 +251,6 @@ pub mod fixtures { #[cfg(test)] mod tests { - use std::collections::HashSet; - use super::*; #[test] @@ -335,23 +333,14 @@ mod tests { } #[test] - fn all_roles_lists_every_role() { + fn all_roles_lists_every_role_in_discriminator_order() { // The roles `try_from` accepts, discovered independently of `Role::ALL`. - // Adding a `Role` variant makes this set diverge from `Role::ALL`, - // failing here until `Role::ALL` is updated. + // The scan runs over ascending bytes, so this is every role that exists, + // in discriminant order. let every_role: Vec = (u8::MIN..=u8::MAX) .filter_map(|byte| Role::try_from(byte).ok()) .collect(); - assert_eq!(Role::ALL.len(), every_role.len()); - for role in every_role { - assert!(Role::ALL.contains(&role)); - } - } - - #[test] - fn all_roles_has_no_duplicates() { - let unique: HashSet = Role::ALL.iter().map(|role| role.discriminator()).collect(); - assert_eq!(unique.len(), Role::ALL.len(), "`Role::ALL` has duplicates"); + assert_eq!(Role::ALL.as_slice(), every_role.as_slice()); } } From 3e790738266d1686e494574001b7d13f67c25a8a Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:10:03 +0200 Subject: [PATCH 05/17] Implement StateAccount::from_account --- interface/src/data/state.rs | 7 +++++++ programs/settlement/src/reclaim_buffer.rs | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/interface/src/data/state.rs b/interface/src/data/state.rs index db86817..20e8aa4 100644 --- a/interface/src/data/state.rs +++ b/interface/src/data/state.rs @@ -23,6 +23,7 @@ use core::mem::size_of; use core::ops::{Deref, DerefMut}; use arrayref::{array_refs, mut_array_refs}; +use solana_account_view::{AccountView, Ref}; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; @@ -128,6 +129,12 @@ impl> StateAccount { } } +impl<'a> StateAccount> { + pub fn from_account(account: &'a AccountView) -> Result { + Self::new(account.try_borrow()?) + } +} + impl> StateAccount { /// Stamp a fresh header (discriminator + role holders) into a zeroed account /// and return the accessor over it. diff --git a/programs/settlement/src/reclaim_buffer.rs b/programs/settlement/src/reclaim_buffer.rs index 744c65c..62be085 100644 --- a/programs/settlement/src/reclaim_buffer.rs +++ b/programs/settlement/src/reclaim_buffer.rs @@ -38,7 +38,7 @@ pub fn process_reclaim_buffer( with_state_pda_signer(program_id, state_pda, |state_signer| { let reclaim_authority_pubkey: Pubkey = - StateAccount::new(state_pda.try_borrow()?)?.authority(Role::ReclaimAuthority); + StateAccount::from_account(state_pda)?.authority(Role::ReclaimAuthority); if !reclaim_authority.is_signer() || reclaim_authority.address() != &reclaim_authority_pubkey { From 85c2fd58ec14ed437ff29cb5ae91617d5bd803e2 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:12:13 +0200 Subject: [PATCH 06/17] StateAccount::new -> StateAccount::attach --- bench-report.json | 8 +++---- client/src/pda/state.rs | 2 +- interface/src/data/state.rs | 22 +++++++++---------- programs/settlement/src/transfer_authority.rs | 2 +- .../settlement/tests/transfer_authority.rs | 2 +- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/bench-report.json b/bench-report.json index 14ead63..7dee8d7 100644 --- a/bench-report.json +++ b/bench-report.json @@ -29,10 +29,10 @@ "create_buffers/max_buffers_in_one_instruction": 176947, "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": 6301, - "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7449, - "reclaim_buffer/max_buffers_in_one_instruction": 136503, - "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18045, + "reclaim_buffer/funded_buffer_is_skipped": 6324, + "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7472, + "reclaim_buffer/max_buffers_in_one_instruction": 136526, + "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18068, "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2183, "settle/finalizes_with_no_pushes": 7062, "settle/pulls_from_multiple_orders": 19921, diff --git a/client/src/pda/state.rs b/client/src/pda/state.rs index 0b8e69e..9c8b713 100644 --- a/client/src/pda/state.rs +++ b/client/src/pda/state.rs @@ -15,7 +15,7 @@ impl TryFrom<&[u8]> for DecodedStateAccount { type Error = ProgramError; fn try_from(bytes: &[u8]) -> Result { - let state = StateAccount::new(bytes)?; + let state = StateAccount::attach(bytes)?; Ok(Self { manager: state.authority(Role::Manager), reclaim_authority: state.authority(Role::ReclaimAuthority), diff --git a/interface/src/data/state.rs b/interface/src/data/state.rs index 20e8aa4..2d70f55 100644 --- a/interface/src/data/state.rs +++ b/interface/src/data/state.rs @@ -102,7 +102,7 @@ impl> StateAccount { /// Wrap an account's bytes, checking they begin with the settlement-state /// discriminator and are at least a full header long. Every accessor relies /// on that guarantee not to panic. - pub fn new(bytes: T) -> Result { + pub fn attach(bytes: T) -> Result { let header = bytes .first_chunk::() .ok_or(ProgramError::InvalidAccountData)?; @@ -131,7 +131,7 @@ impl> StateAccount { impl<'a> StateAccount> { pub fn from_account(account: &'a AccountView) -> Result { - Self::new(account.try_borrow()?) + Self::attach(account.try_borrow()?) } } @@ -211,7 +211,7 @@ mod tests { #[test] fn reads_role_holders_from_the_header() { let bytes = header_bytes(); - let state = StateAccount::new(&bytes[..]).expect("valid header"); + let state = StateAccount::attach(&bytes[..]).expect("valid header"); assert_eq!(state.authority(Role::Manager), SAMPLE_HEADER.manager); assert_eq!( state.authority(Role::ReclaimAuthority), @@ -229,7 +229,7 @@ mod tests { let mut bytes = [0u8; WIDTH_HEADER]; StateAccount::initialize(&mut bytes[..], &header).expect("header fits"); - let state = StateAccount::new(&bytes[..]).expect("valid header"); + let state = StateAccount::attach(&bytes[..]).expect("valid header"); let read_back = Header { manager: state.authority(Role::Manager), reclaim_authority: state.authority(Role::ReclaimAuthority), @@ -242,7 +242,7 @@ mod tests { let mut bytes = header_bytes(); bytes[DISCRIMINATOR_OFFSET] = 0xff; assert_eq!( - StateAccount::new(&bytes[..]).err(), + StateAccount::attach(&bytes[..]).err(), Some(ProgramError::InvalidAccountData), ); } @@ -251,7 +251,7 @@ mod tests { fn new_rejects_too_short_buffer() { let bytes = header_bytes(); assert_eq!( - StateAccount::new(&bytes[..WIDTH_HEADER - 1]).err(), + StateAccount::attach(&bytes[..WIDTH_HEADER - 1]).err(), Some(ProgramError::InvalidAccountData), ); } @@ -263,16 +263,16 @@ mod tests { let mut bytes = header_bytes(); let before = Role::ALL.map(|role| { - StateAccount::new(&bytes[..]) + StateAccount::attach(&bytes[..]) .expect("valid header") .authority(role) }); - StateAccount::new(&mut bytes[..]) + StateAccount::attach(&mut bytes[..]) .expect("valid header") .set_authority(target, &new_holder); - let state = StateAccount::new(&bytes[..]).expect("valid header"); + let state = StateAccount::attach(&bytes[..]).expect("valid header"); for (role, prior) in Role::ALL.into_iter().zip(before) { let expected = if role == target { new_holder } else { prior }; assert_eq!(state.authority(role), expected); @@ -297,7 +297,7 @@ mod tests { let mut bytes = header_bytes().to_vec(); bytes.push(0x42); - let state = StateAccount::new(&bytes[..]).expect("header with trailing bytes is valid"); + let state = StateAccount::attach(&bytes[..]).expect("header with trailing bytes is valid"); assert_eq!(state.authority(Role::Manager), SAMPLE_HEADER.manager); assert_eq!( state.authority(Role::ReclaimAuthority), @@ -335,7 +335,7 @@ mod tests { let mut bytes = [0u8; WIDTH_HEADER]; StateAccount::initialize(&mut bytes[..], &header).expect("header fits"); - let state = StateAccount::new(&bytes[..]).expect("valid header"); + let state = StateAccount::attach(&bytes[..]).expect("valid header"); prop_assert_eq!(state.authority(Role::Manager), header.manager); prop_assert_eq!(state.authority(Role::ReclaimAuthority), header.reclaim_authority); } diff --git a/programs/settlement/src/transfer_authority.rs b/programs/settlement/src/transfer_authority.rs index 519859c..8bd498f 100644 --- a/programs/settlement/src/transfer_authority.rs +++ b/programs/settlement/src/transfer_authority.rs @@ -33,7 +33,7 @@ pub fn process_transfer_authority( // A copied `AccountView` writes through to the same runtime account, so the // mutable view goes through this local copy. let mut state_pda = *state_pda; - let mut state = StateAccount::new(state_pda.try_borrow_mut()?)?; + let mut state = StateAccount::attach(state_pda.try_borrow_mut()?)?; let signer_key = signer.address(); if signer_key != &state.authority(Role::Manager) && signer_key != &state.authority(role) { diff --git a/programs/settlement/tests/transfer_authority.rs b/programs/settlement/tests/transfer_authority.rs index a938502..f26ec2c 100644 --- a/programs/settlement/tests/transfer_authority.rs +++ b/programs/settlement/tests/transfer_authority.rs @@ -24,7 +24,7 @@ fn read_authority(svm: &LiteSVM, state_pda: &Pubkey, role: Role) -> Pubkey { let account = svm .get_account(state_pda) .expect("state PDA should exist after initialize"); - StateAccount::new(&account.data[..]) + StateAccount::attach(&account.data[..]) .expect("state PDA should be a valid state account") .authority(role) } From 3e2393fcb3a05c371659d23677f126f873e91b0c Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:21:48 +0200 Subject: [PATCH 07/17] Add comment about saturating_sub --- programs/settlement/src/add_solver.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/programs/settlement/src/add_solver.rs b/programs/settlement/src/add_solver.rs index 0579cee..e008446 100644 --- a/programs/settlement/src/add_solver.rs +++ b/programs/settlement/src/add_solver.rs @@ -57,6 +57,8 @@ pub fn process_add_solver( // rent. This CPI runs before any data borrow so the account is free to grow. let shortfall = Rent::get()? .try_minimum_balance(new_len)? + // why saturating: if there's more balance available than rent needed, + // then there's no shortfall, that is, `shortfall == 0``. .saturating_sub(state_pda.lamports()); if shortfall > 0 { Transfer { From 5d0ea61372a9fedb0377c2d6168e1d39d2191e8a Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:29:00 +0200 Subject: [PATCH 08/17] Move rejects_adding_solver_if_manager_is_not_signer next to rejects_adding_solver_by_non_manager --- programs/settlement/tests/add_solvers.rs | 50 ++++++++++++------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/programs/settlement/tests/add_solvers.rs b/programs/settlement/tests/add_solvers.rs index 0a7c01e..8ecf433 100644 --- a/programs/settlement/tests/add_solvers.rs +++ b/programs/settlement/tests/add_solvers.rs @@ -168,6 +168,31 @@ fn rejects_adding_an_existing_solver() { ); } +#[test] +fn rejects_adding_solver_if_manager_is_not_signer() { + let (mut svm, params) = setup_init(); + let solver = unique_keypair().pubkey(); + + let mut ix: Instruction = AddSolver { + program_id: params.program_id, + manager: params.manager.pubkey(), + payer: params.payer.pubkey(), + solver, + } + .into(); + assert!( + ix.accounts[MANAGER_INDEX].is_signer && !ix.accounts[MANAGER_INDEX].is_writable, + "test sanity check failed: MANAGER_INDEX should point to the manager signer" + ); + ix.accounts[MANAGER_INDEX].is_signer = false; + + let res = common::send(&mut svm, ¶ms.payer, vec![ix]); + assert_instruction_error( + res, + to_instruction_error(SettlementError::UnauthorizedSolverManagement), + ); +} + #[test] fn rejects_adding_solver_by_non_manager() { let (mut svm, params) = setup_init(); @@ -271,31 +296,6 @@ fn rejects_growing_beyond_the_max_account_size() { /// Index of the manager account in an `AddSolver` instruction. const MANAGER_INDEX: usize = 0; -#[test] -fn rejects_adding_solver_if_manager_is_not_signer() { - let (mut svm, params) = setup_init(); - let solver = unique_keypair().pubkey(); - - let mut ix: Instruction = AddSolver { - program_id: params.program_id, - manager: params.manager.pubkey(), - payer: params.payer.pubkey(), - solver, - } - .into(); - assert!( - ix.accounts[MANAGER_INDEX].is_signer && !ix.accounts[MANAGER_INDEX].is_writable, - "test sanity check failed: MANAGER_INDEX should point to the manager signer" - ); - ix.accounts[MANAGER_INDEX].is_signer = false; - - let res = common::send(&mut svm, ¶ms.payer, vec![ix]); - assert_instruction_error( - res, - to_instruction_error(SettlementError::UnauthorizedSolverManagement), - ); -} - #[test] fn rejects_adding_solver_if_state_pda_is_uninitialized() { let (mut svm, program_id, payer) = common::setup(); From a18f33110c8da731c8ad0d1ca141040b61e507fb Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:58:48 +0200 Subject: [PATCH 09/17] Clarify strict invariant check --- programs/settlement/tests/add_solvers.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/programs/settlement/tests/add_solvers.rs b/programs/settlement/tests/add_solvers.rs index 8ecf433..2bc2762 100644 --- a/programs/settlement/tests/add_solvers.rs +++ b/programs/settlement/tests/add_solvers.rs @@ -31,6 +31,8 @@ mod common; #[track_caller] fn assert_solver_invariant(solvers: &[Pubkey]) { assert!( + // We use `is_sorted_by` here instead of `is_sorted` because that + // doesn't catch duplicates. solvers.is_sorted_by(|a, b| a < b), "invariant violated: solver list must be strictly ascending by address: {solvers:?}", ); From 9dcc7b6a4c3c6b6b58a96cb87a96bd31264ca567 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:39:55 +0200 Subject: [PATCH 10/17] Add proptest verifying duplicate rejection --- interface/src/data/state.rs | 57 ++++++++++++++++-------- programs/settlement/src/add_solver.rs | 62 +++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 19 deletions(-) diff --git a/interface/src/data/state.rs b/interface/src/data/state.rs index 15b58de..0faccec 100644 --- a/interface/src/data/state.rs +++ b/interface/src/data/state.rs @@ -199,6 +199,38 @@ impl> StateAccount { } } +/// Test scaffolding for building state-account bytes, shared by this crate's +/// tests and the settlement program's via the `test-fixtures` feature. +#[cfg(any(test, feature = "test-fixtures"))] +pub mod fixtures { + use proptest::prelude::*; + use solana_pubkey::Pubkey; + + use super::{Header, StateAccount, WIDTH_HEADER}; + + /// The bytes of a state account: the `header` followed by `solvers`, stored + /// sorted ascending by address as the on-chain list always is (so callers can + /// pass them in any order). + pub fn state_account_bytes(header: &Header, solvers: &[Pubkey]) -> Vec { + let mut sorted = solvers.to_vec(); + sorted.sort(); + let mut bytes = vec![0u8; WIDTH_HEADER]; + StateAccount::initialize(&mut bytes[..], header).expect("header fits"); + for solver in &sorted { + bytes.extend_from_slice(&solver.to_bytes()); + } + bytes + } + + /// Any valid [`Header`]. + pub fn arb_header() -> impl Strategy { + (any::<[u8; 32]>(), any::<[u8; 32]>()).prop_map(|(manager, reclaim_authority)| Header { + manager: Pubkey::new_from_array(manager), + reclaim_authority: Pubkey::new_from_array(reclaim_authority), + }) + } +} + #[cfg(test)] mod tests { use std::sync::LazyLock; @@ -221,16 +253,10 @@ mod tests { bytes } - /// [`header_bytes`] followed by `solvers`, stored sorted ascending by address + /// [`SAMPLE_HEADER`] followed by `solvers`, stored sorted ascending by address /// as the on-chain list always is, so callers can pass them in any order. fn state_bytes(solvers: &[Pubkey]) -> Vec { - let mut solvers = solvers.to_vec(); - solvers.sort(); - let mut bytes = header_bytes().to_vec(); - for solver in &solvers { - bytes.extend_from_slice(&solver.to_bytes()); - } - bytes + super::fixtures::state_account_bytes(&SAMPLE_HEADER, solvers) } #[test] @@ -418,21 +444,14 @@ mod tests { /// The encode roundtrip: any two role holders written with /// `initialize` read back unchanged. #[test] - fn account_encode_roundtrip( - manager in any::<[u8; 32]>(), - reclaim_authority in any::<[u8; 32]>(), - ) { - let header = Header { - manager: Pubkey::new_from_array(manager), - reclaim_authority: Pubkey::new_from_array(reclaim_authority), - }; - + fn account_encode_roundtrip(header in fixtures::arb_header()) { let mut bytes = [0u8; WIDTH_HEADER]; StateAccount::initialize(&mut bytes[..], &header).expect("header fits"); let state = StateAccount::attach(&bytes[..]).expect("valid header"); - prop_assert_eq!(state.authority(Role::Manager), header.manager); - prop_assert_eq!(state.authority(Role::ReclaimAuthority), header.reclaim_authority); + let Header { manager, reclaim_authority } = header; + prop_assert_eq!(state.authority(Role::Manager), manager); + prop_assert_eq!(state.authority(Role::ReclaimAuthority), reclaim_authority); } } } diff --git a/programs/settlement/src/add_solver.rs b/programs/settlement/src/add_solver.rs index e008446..489b7fc 100644 --- a/programs/settlement/src/add_solver.rs +++ b/programs/settlement/src/add_solver.rs @@ -118,4 +118,66 @@ mod tests { Err(SettlementError::StateAccountMismatch.into()), ); } + + mod proptest { + use ::proptest::prelude::*; + + use super::*; + use cow_settlement_interface::data::state::fixtures::{arb_header, state_account_bytes}; + use cow_settlement_interface::fixtures::pubkey_from_seed; + use cow_settlement_interface::instruction::add_solver::AddSolver; + use cow_settlement_interface::instruction::fixtures::{ + fake_account, fake_account_owned_by, fake_signer, + }; + use cow_settlement_interface::pda::state::find_state_pda; + use cow_settlement_interface::{Instruction, Pubkey}; + + proptest! { + #[test] + fn process_add_solver_rejects_an_existing_solver( + header in arb_header(), + // BTreeSet: `.iter()` returns the elements already sorted, and, + // since it's a set, they are also unique. At least one so there's + // an existing solver to re-add. + raw_solvers in ::proptest::collection::btree_set(any::<[u8; 32]>(), 1..50), + pick in any::<::proptest::sample::Index>(), + ) { + let manager = header.manager; + let stored: Vec = + raw_solvers.into_iter().map(Pubkey::new_from_array).collect(); + // Re-add one of the solvers that's already stored. + let existing = stored[pick.index(stored.len())]; + + // Mock the four accounts the handler parses. Only the manager signer + // and the state PDA carry meaning here; the payer and system program + // are never touched, since the reject happens before the + // rent-funding transfer. + let (state_pda_address, _bump) = find_state_pda(&PROGRAM_ID); + let mut accounts = [ + fake_signer(manager), + fake_account(pubkey_from_seed("payer")), + fake_account_owned_by( + state_pda_address, + PROGRAM_ID, + &state_account_bytes(&header, &stored), + ), + fake_account(pubkey_from_seed("system program")), + ]; + + let data = Instruction::from(AddSolver { + program_id: PROGRAM_ID, + manager, + payer: pubkey_from_seed("payer"), + state_pda: state_pda_address, + solver: existing, + }) + .data; + + prop_assert_eq!( + process_add_solver(&PROGRAM_ID, &mut accounts, &data), + Err(SettlementError::SolverAlreadyExists.into()), + ); + } + } + } } From 7d19c0d2d6643c3815e46905c38bf31c8f7ce9d8 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:23:18 +0200 Subject: [PATCH 11/17] Move most resizing operations to interface --- bench-report.json | 14 ++--- interface/src/data/state.rs | 82 +++++++++++++++++++++++++++ programs/settlement/src/add_solver.rs | 34 +++-------- 3 files changed, 98 insertions(+), 32 deletions(-) diff --git a/bench-report.json b/bench-report.json index 28a8fb7..84e9027 100644 --- a/bench-report.json +++ b/bench-report.json @@ -26,18 +26,18 @@ "transfer_authority/reclaim_authority_can_transfer_itself": 4 }, "compute_units": { - "add_solver/add_with_many_existing_solvers": 5111, - "add_solver/adds_a_solver": 4667, + "add_solver/add_with_many_existing_solvers": 5087, + "add_solver/adds_a_solver": 4643, "create_buffers/happy_path_creates_initialized_buffer_token_account": 10346, "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21744, "create_buffers/max_buffers_in_one_instruction": 177041, - "create_order/happy_path_creates_order_pda_with_expected_body": 4980, + "create_order/happy_path_creates_order_pda_with_expected_body": 4979, "initialize/happy_path_initializes_state_pda_with_expected_data": 4530, "reclaim_buffer/funded_buffer_is_skipped": 6333, "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7481, "reclaim_buffer/max_buffers_in_one_instruction": 136650, "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18080, - "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2185, + "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2184, "settle/finalizes_with_no_pushes": 7070, "settle/pulls_from_multiple_orders": 19949, "settle/pulls_funds_to_destination": 13542, @@ -47,9 +47,9 @@ "settle/pushes_several_orders_from_one_buffer": 17631, "settle/settles_a_single_order": 12398, "settle/settles_multiple_orders": 22926, - "transfer_authority/manager_can_transfer_manager": 3177, - "transfer_authority/manager_can_transfer_reclaim_authority": 3179, - "transfer_authority/reclaim_authority_can_transfer_itself": 3183 + "transfer_authority/manager_can_transfer_manager": 3176, + "transfer_authority/manager_can_transfer_reclaim_authority": 3178, + "transfer_authority/reclaim_authority_can_transfer_itself": 3182 }, "transaction_bytes": { "add_solver/add_with_many_existing_solvers": 366, diff --git a/interface/src/data/state.rs b/interface/src/data/state.rs index 0faccec..a7c9371 100644 --- a/interface/src/data/state.rs +++ b/interface/src/data/state.rs @@ -153,6 +153,21 @@ impl> StateAccount { .iter() .map(|raw| Pubkey::new_from_array(*raw)) } + + /// The account's data length after growing it by one solver slot: the size + /// it must be resized to before [`insert_solver_at`](Self::insert_solver_at) + /// can fill that new slot. + /// + /// Returns [`ProgramError::ArithmeticOverflow`] if that length overflows + /// `usize`, which a caller handling data from a real account can treat as + /// unreachable: the runtime caps account data at + /// [`MAX_PERMITTED_DATA_LENGTH`](solana_system_interface::MAX_PERMITTED_DATA_LENGTH). + pub fn grown_len(&self) -> Result { + self.0 + .len() + .checked_add(WIDTH_PUBKEY) + .ok_or(ProgramError::ArithmeticOverflow) + } } impl<'a> StateAccount> { @@ -197,6 +212,34 @@ impl> StateAccount { }; *holder = new.to_bytes(); } + + /// Insert `solver` into the sorted solver list at `index`. + /// + /// This function assumes that the underlying storage (e.g., the account) + /// has already been enlarged to the correct size ([`Self::grown_len`]) and + /// that the index has been computed correctly (is in rage, preserves + /// ordering, and isn't writing a duplicate). + pub fn insert_solver_at(&mut self, index: usize, solver: &Pubkey) { + let data: &mut [u8] = &mut self.0; + + let old_len = data + .len() + .checked_sub(WIDTH_PUBKEY) + .expect("account grown by one solver slot before insertion"); + let offset = WIDTH_HEADER + .checked_add( + index + .checked_mul(WIDTH_PUBKEY) + .expect("insertion index bound by account length"), + ) + .expect("insertion offset bound by account length"); + let gap_end = offset + .checked_add(WIDTH_PUBKEY) + .expect("insertion slot bound by account length"); + + data.copy_within(offset..old_len, gap_end); + data[offset..gap_end].copy_from_slice(&solver.to_bytes()); + } } /// Test scaffolding for building state-account bytes, shared by this crate's @@ -453,6 +496,45 @@ mod tests { prop_assert_eq!(state.authority(Role::Manager), manager); prop_assert_eq!(state.authority(Role::ReclaimAuthority), reclaim_authority); } + + #[test] + fn insert_solver_at_shifts_the_tail_and_writes_the_solver( + header in fixtures::arb_header(), + // Unique and already sorted, being a `BTreeSet`. + raw_solvers in prop::collection::btree_set(any::<[u8; 32]>(), 0..50), + raw_new in any::<[u8; 32]>(), + ) { + prop_assume!(!raw_solvers.contains(&raw_new)); + let stored: Vec = + raw_solvers.into_iter().map(Pubkey::new_from_array).collect(); + let new = Pubkey::new_from_array(raw_new); + + // The slot the new solver sorts into, before any growth. + let mut bytes = fixtures::state_account_bytes(&header, &stored); + let index = StateAccount::attach(&bytes[..]) + .expect("valid header") + .solver_search(&new) + .expect_err("solver is absent"); + + // Grow to the length `grown_len` reports, exactly as the handler + // resizes the account, then let `insert_solver_at` fill the new + // slot. + let grown_len = StateAccount::attach(&bytes[..]) + .expect("valid header") + .grown_len() + .expect("grown length fits"); + prop_assert_eq!(grown_len, bytes.len().strict_add(WIDTH_PUBKEY)); + bytes.resize(grown_len, 0); + StateAccount::attach(&mut bytes[..]) + .expect("valid header") + .insert_solver_at(index, &new); + + let mut expected = stored; + expected.insert(index, new); + let state = StateAccount::attach(&bytes[..]).expect("valid header"); + prop_assert_eq!(state.solvers().collect::>(), expected); + prop_assert_eq!(state.solver_search(&new), Ok(index)); + } } } } diff --git a/programs/settlement/src/add_solver.rs b/programs/settlement/src/add_solver.rs index 489b7fc..a40c734 100644 --- a/programs/settlement/src/add_solver.rs +++ b/programs/settlement/src/add_solver.rs @@ -6,7 +6,7 @@ //! funds the extra rent through a `Transfer` before the account is resized. use cow_settlement_interface::{ - data::state::{StateAccount, WIDTH_HEADER, WIDTH_PUBKEY}, + data::state::StateAccount, instruction::{add_solver::AddSolverInput, InstructionInputParsing}, Role, SettlementError, }; @@ -33,28 +33,20 @@ pub fn process_add_solver( check_state_pda(program_id, state_pda)?; // Only the manager may change the solver list. Reading also validates the - // account, and the search finds where the new solver sorts in. - let index = { + // account, the search finds where the new solver sorts in, and the state + // knows the length it must grow to. + let (index, new_len) = { let state = StateAccount::attach(state_pda.try_borrow()?)?; if !manager.is_signer() || manager.address() != &state.authority(Role::Manager) { return Err(SettlementError::UnauthorizedSolverManagement.into()); } - match state.solver_search(&solver) { + let index = match state.solver_search(&solver) { Ok(_) => return Err(SettlementError::SolverAlreadyExists.into()), Err(index) => index, - } + }; + (index, state.grown_len().expect("grown account length fits in usize")) }; - // Every length and offset below is bounded by the account's data length, - // which the runtime caps at 10 MiB, so this arithmetic always fits in `usize` - // and these checks can never trip. - let old_len = state_pda.data_len(); - let new_len = old_len - .checked_add(WIDTH_PUBKEY) - .expect("grown account length fits in usize"); - - // Growing the account must keep it rent-exempt: the payer funds the extra - // rent. This CPI runs before any data borrow so the account is free to grow. let shortfall = Rent::get()? .try_minimum_balance(new_len)? // why saturating: if there's more balance available than rent needed, @@ -69,18 +61,10 @@ pub fn process_add_solver( .invoke()?; } - // Grow the account, shift the solvers at or after the insertion point right - // by one slot, and write the new solver into the gap. - let offset = WIDTH_HEADER - .checked_add(index.checked_mul(WIDTH_PUBKEY).expect("bound by new_len")) - .expect("bound by new_len"); - let gap_end = offset.checked_add(WIDTH_PUBKEY).expect("bound by new_len"); - let mut state_pda = *state_pda; state_pda.resize(new_len)?; - let mut data = state_pda.try_borrow_mut()?; - data.copy_within(offset..old_len, gap_end); - data[offset..gap_end].copy_from_slice(&solver.to_bytes()); + let mut state = StateAccount::attach(state_pda.try_borrow_mut()?)?; + state.insert_solver_at(index, &solver); Ok(()) } From 8b489e4fa0850c8bc8eaf8109bebe3cf57e6b117 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:26:50 +0200 Subject: [PATCH 12/17] cargo fmt & ambiguous import --- programs/settlement/src/add_solver.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/programs/settlement/src/add_solver.rs b/programs/settlement/src/add_solver.rs index a40c734..e24ae73 100644 --- a/programs/settlement/src/add_solver.rs +++ b/programs/settlement/src/add_solver.rs @@ -44,7 +44,12 @@ pub fn process_add_solver( Ok(_) => return Err(SettlementError::SolverAlreadyExists.into()), Err(index) => index, }; - (index, state.grown_len().expect("grown account length fits in usize")) + ( + index, + state + .grown_len() + .expect("grown account length fits in usize"), + ) }; let shortfall = Rent::get()? From c6844b4966395d8e2edb08a8d5674dfc7b6f7185 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:34:04 +0000 Subject: [PATCH 13/17] Simplify binary search Co-authored-by: Kaze <230549489+kaze-cow@users.noreply.github.com> --- interface/src/data/state.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/interface/src/data/state.rs b/interface/src/data/state.rs index a7c9371..42a46ef 100644 --- a/interface/src/data/state.rs +++ b/interface/src/data/state.rs @@ -143,8 +143,7 @@ impl> StateAccount { /// slot it would occupy otherwise. The list is sorted by address bytes. pub fn solver_search(&self, solver: &Pubkey) -> Result { let seek = solver.to_bytes(); - self.solver_region() - .binary_search_by(|probe| probe.cmp(&seek)) + self.solver_region().binary_search(&seek) } /// The stored solvers, in order (sorted ascending by address). From 5286c3a2505976762e15440f1867d35b37208117 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:40:18 +0200 Subject: [PATCH 14/17] `Header` -> `StateInitArgs` --- client/src/pda/state.rs | 4 +-- interface/src/data/state.rs | 40 +++++++---------------- programs/settlement/src/initialize.rs | 4 +-- programs/settlement/src/reclaim_buffer.rs | 14 ++++---- 4 files changed, 22 insertions(+), 40 deletions(-) diff --git a/client/src/pda/state.rs b/client/src/pda/state.rs index 9c8b713..ef41e3c 100644 --- a/client/src/pda/state.rs +++ b/client/src/pda/state.rs @@ -26,14 +26,14 @@ impl TryFrom<&[u8]> for DecodedStateAccount { #[cfg(test)] mod tests { use super::*; - use cow_settlement_interface::data::state::{Header, WIDTH_HEADER}; + use cow_settlement_interface::data::state::{StateInitArgs, WIDTH_HEADER}; use cow_settlement_interface::fixtures::pubkey_from_seed; fn state_bytes(manager: &Pubkey, reclaim_authority: &Pubkey) -> [u8; WIDTH_HEADER] { let mut bytes = [0u8; WIDTH_HEADER]; StateAccount::initialize( &mut bytes[..], - &Header { + &StateInitArgs { manager: *manager, reclaim_authority: *reclaim_authority, }, diff --git a/interface/src/data/state.rs b/interface/src/data/state.rs index 2d70f55..a4b80c8 100644 --- a/interface/src/data/state.rs +++ b/interface/src/data/state.rs @@ -81,9 +81,9 @@ fn header_slots_mut(header: &mut [u8; WIDTH_HEADER]) -> HeaderSlotsMut<'_> { } } -/// The role holders that make up a state account's header. +/// The parameters used to initialize the state account. #[derive(Clone, Debug, Eq, PartialEq)] -pub struct Header { +pub struct StateInitArgs { /// The [`Role::Manager`] holder. pub manager: Pubkey, /// The [`Role::ReclaimAuthority`] holder. @@ -141,7 +141,7 @@ impl> StateAccount { /// /// The generated data is a full header long, which is needed for the read /// accessors not to panic. - pub fn initialize(mut bytes: T, header: &Header) -> Result { + pub fn initialize(mut bytes: T, args: &StateInitArgs) -> Result { { let slots = header_slots_mut( bytes @@ -149,8 +149,8 @@ impl> StateAccount { .ok_or(ProgramError::AccountDataTooSmall)?, ); *slots.discriminator = [DISCRIMINATOR]; - *slots.manager = header.manager.to_bytes(); - *slots.reclaim_authority = header.reclaim_authority.to_bytes(); + *slots.manager = args.manager.to_bytes(); + *slots.reclaim_authority = args.reclaim_authority.to_bytes(); } Ok(Self(bytes)) } @@ -183,7 +183,7 @@ mod tests { /// Byte offset of the discriminator within the account. const DISCRIMINATOR_OFFSET: usize = 0; - static SAMPLE_HEADER: LazyLock
= LazyLock::new(|| Header { + static SAMPLE_HEADER: LazyLock = LazyLock::new(|| StateInitArgs { manager: pubkey_from_seed("SAMPLE_HEADER's sample manager"), reclaim_authority: pubkey_from_seed("SAMPLE_HEADER's sample reclaim authority"), }); @@ -219,24 +219,6 @@ mod tests { ); } - #[test] - fn initialize_round_trips_a_header() { - let header = Header { - manager: pubkey_from_seed("manager"), - reclaim_authority: pubkey_from_seed("reclaim authority"), - }; - - let mut bytes = [0u8; WIDTH_HEADER]; - StateAccount::initialize(&mut bytes[..], &header).expect("header fits"); - - let state = StateAccount::attach(&bytes[..]).expect("valid header"); - let read_back = Header { - manager: state.authority(Role::Manager), - reclaim_authority: state.authority(Role::ReclaimAuthority), - }; - assert_eq!(read_back, header); - } - #[test] fn new_rejects_wrong_discriminator() { let mut bytes = header_bytes(); @@ -323,21 +305,21 @@ mod tests { /// The encode roundtrip: any two role holders written with /// `initialize` read back unchanged. #[test] - fn account_encode_roundtrip( + fn initializes_accounts_as_expected( manager in any::<[u8; 32]>(), reclaim_authority in any::<[u8; 32]>(), ) { - let header = Header { + let init_args = StateInitArgs { manager: Pubkey::new_from_array(manager), reclaim_authority: Pubkey::new_from_array(reclaim_authority), }; let mut bytes = [0u8; WIDTH_HEADER]; - StateAccount::initialize(&mut bytes[..], &header).expect("header fits"); + StateAccount::initialize(&mut bytes[..], &init_args).expect("header fits"); let state = StateAccount::attach(&bytes[..]).expect("valid header"); - prop_assert_eq!(state.authority(Role::Manager), header.manager); - prop_assert_eq!(state.authority(Role::ReclaimAuthority), header.reclaim_authority); + prop_assert_eq!(state.authority(Role::Manager), init_args.manager); + prop_assert_eq!(state.authority(Role::ReclaimAuthority), init_args.reclaim_authority); } } } diff --git a/programs/settlement/src/initialize.rs b/programs/settlement/src/initialize.rs index 41c5d1b..6920dea 100644 --- a/programs/settlement/src/initialize.rs +++ b/programs/settlement/src/initialize.rs @@ -1,7 +1,7 @@ //! `Initialize` instruction handler. use cow_settlement_interface::{ - data::state::{Header, StateAccount, WIDTH_HEADER}, + data::state::{StateAccount, StateInitArgs, WIDTH_HEADER}, instruction::{initialize::InitializeInput, InstructionInputParsing}, pda::state::state_pda_seeds, }; @@ -40,7 +40,7 @@ pub fn process_initialize( let mut state_pda = *state_pda; StateAccount::initialize( state_pda.try_borrow_mut()?, - &Header { + &StateInitArgs { manager, reclaim_authority, }, diff --git a/programs/settlement/src/reclaim_buffer.rs b/programs/settlement/src/reclaim_buffer.rs index 62be085..c9f3d72 100644 --- a/programs/settlement/src/reclaim_buffer.rs +++ b/programs/settlement/src/reclaim_buffer.rs @@ -73,7 +73,7 @@ pub fn process_reclaim_buffer( #[cfg(test)] mod tests { - use cow_settlement_interface::data::state::{Header, StateAccount, WIDTH_HEADER}; + use cow_settlement_interface::data::state::{StateAccount, StateInitArgs, WIDTH_HEADER}; use cow_settlement_interface::instruction::fixtures::{ fake_account, fake_account_owned_by, fake_account_with_data, fake_sequential_accounts, fake_signer, @@ -103,15 +103,15 @@ mod tests { const BUFFER_PDA: usize = 4; /// State account bytes for planting a well-formed state PDA in tests. - fn state_account_bytes(header: &Header) -> [u8; WIDTH_HEADER] { + fn state_account_bytes(init_args: &StateInitArgs) -> [u8; WIDTH_HEADER] { let mut bytes = [0u8; WIDTH_HEADER]; - StateAccount::initialize(&mut bytes[..], header).expect("header fits"); + StateAccount::initialize(&mut bytes[..], init_args).expect("header fits"); bytes } /// The [`Header`] planted by [`base_accounts`]. - fn base_header() -> Header { - Header { + fn base_init_args() -> StateInitArgs { + StateInitArgs { manager: MANAGER, reclaim_authority: AUTHORITY, } @@ -137,7 +137,7 @@ mod tests { let state_pda = Address::find_program_address(&state_pda_seeds(), &PROGRAM_ID).0; [ - fake_account_with_data(state_pda, &state_account_bytes(&base_header())), // state PDA + fake_account_with_data(state_pda, &state_account_bytes(&base_init_args())), // state PDA fake_signer(AUTHORITY), // reclaim authority fake_account(recipient), // reclaim recipient fake_account(SPL_TOKEN_PROGRAM_ID), // token program @@ -188,7 +188,7 @@ mod tests { fn process_reclaim_buffer_rejects_wrong_state_pda() { let mut accounts = base_accounts(); accounts[STATE_PDA] = - fake_account_with_data(UNRELATED, &state_account_bytes(&base_header())); + fake_account_with_data(UNRELATED, &state_account_bytes(&base_init_args())); assert_rejects(accounts, SettlementError::StateAccountMismatch.into()); } From c46845320318d4fba497864bfa0824e4a56172f2 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:06:24 +0200 Subject: [PATCH 15/17] Fix merge issue --- interface/src/data/state.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/interface/src/data/state.rs b/interface/src/data/state.rs index 4e6cb39..567aa39 100644 --- a/interface/src/data/state.rs +++ b/interface/src/data/state.rs @@ -297,10 +297,10 @@ mod tests { bytes } - /// [`SAMPLE_HEADER`] followed by `solvers`, stored sorted ascending by address + /// [`SAMPLE_INIT_ARGS`] followed by `solvers`, stored sorted ascending by address /// as the on-chain list always is, so callers can pass them in any order. fn state_bytes(solvers: &[Pubkey]) -> Vec { - super::fixtures::state_account_bytes(&SAMPLE_HEADER, solvers) + super::fixtures::state_account_bytes(&SAMPLE_INIT_ARGS, solvers) } #[test] From 641c07ac4155c4f54b464ba31089e77bbc5aa056 Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:10:34 +0200 Subject: [PATCH 16/17] Reduce number of solvers created in test --- programs/settlement/tests/add_solvers.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programs/settlement/tests/add_solvers.rs b/programs/settlement/tests/add_solvers.rs index 2bc2762..b76481d 100644 --- a/programs/settlement/tests/add_solvers.rs +++ b/programs/settlement/tests/add_solvers.rs @@ -145,7 +145,7 @@ fn adds_a_solver_without_extra_rent() { fn keeps_solvers_sorted() { let (mut svm, params) = setup_init(); - const COUNT: usize = 500; + const COUNT: usize = 50; let mut added: Vec = (0..COUNT).map(|_| unique_keypair().pubkey()).collect(); for solver in &added { add_solver(&mut svm, ¶ms, solver).expect("adding a solver should succeed"); From f4964e728953e563792e301a19ff1c185a1c802a Mon Sep 17 00:00:00 2001 From: Federico Giacon <58218759+fedgiac@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:45:40 +0200 Subject: [PATCH 17/17] Move binary search into solver insertion --- bench-report.json | 22 +++---- interface/src/data/state.rs | 93 +++++++++++++++++++-------- programs/settlement/src/add_solver.rs | 89 +++---------------------- 3 files changed, 86 insertions(+), 118 deletions(-) diff --git a/bench-report.json b/bench-report.json index 84e9027..f7a86db 100644 --- a/bench-report.json +++ b/bench-report.json @@ -26,18 +26,18 @@ "transfer_authority/reclaim_authority_can_transfer_itself": 4 }, "compute_units": { - "add_solver/add_with_many_existing_solvers": 5087, - "add_solver/adds_a_solver": 4643, - "create_buffers/happy_path_creates_initialized_buffer_token_account": 10346, - "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21744, - "create_buffers/max_buffers_in_one_instruction": 177041, - "create_order/happy_path_creates_order_pda_with_expected_body": 4979, - "initialize/happy_path_initializes_state_pda_with_expected_data": 4530, + "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": 4978, + "initialize/happy_path_initializes_state_pda_with_expected_data": 4529, "reclaim_buffer/funded_buffer_is_skipped": 6333, "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7481, "reclaim_buffer/max_buffers_in_one_instruction": 136650, "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18080, - "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2184, + "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2183, "settle/finalizes_with_no_pushes": 7070, "settle/pulls_from_multiple_orders": 19949, "settle/pulls_funds_to_destination": 13542, @@ -47,9 +47,9 @@ "settle/pushes_several_orders_from_one_buffer": 17631, "settle/settles_a_single_order": 12398, "settle/settles_multiple_orders": 22926, - "transfer_authority/manager_can_transfer_manager": 3176, - "transfer_authority/manager_can_transfer_reclaim_authority": 3178, - "transfer_authority/reclaim_authority_can_transfer_itself": 3182 + "transfer_authority/manager_can_transfer_manager": 3175, + "transfer_authority/manager_can_transfer_reclaim_authority": 3177, + "transfer_authority/reclaim_authority_can_transfer_itself": 3181 }, "transaction_bytes": { "add_solver/add_with_many_existing_solvers": 366, diff --git a/interface/src/data/state.rs b/interface/src/data/state.rs index 567aa39..e869d71 100644 --- a/interface/src/data/state.rs +++ b/interface/src/data/state.rs @@ -28,7 +28,7 @@ use solana_account_view::{AccountView, Ref}; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; -use crate::{Role, SettlementAccount}; +use crate::{Role, SettlementAccount, SettlementError}; /// Single-byte account discriminator at the front of the header. pub const DISCRIMINATOR: u8 = SettlementAccount::SettlementState.discriminator(); @@ -154,8 +154,8 @@ impl> StateAccount { } /// The account's data length after growing it by one solver slot: the size - /// it must be resized to before [`insert_solver_at`](Self::insert_solver_at) - /// can fill that new slot. + /// it must be resized to before [`insert_solver`](Self::insert_solver) can + /// fill that new slot. /// /// Returns [`ProgramError::ArithmeticOverflow`] if that length overflows /// `usize`, which a caller handling data from a real account can treat as @@ -212,32 +212,46 @@ impl> StateAccount { *holder = new.to_bytes(); } - /// Insert `solver` into the sorted solver list at `index`. + /// Insert `solver` into the sorted solver list, or fail with + /// [`SettlementError::SolverAlreadyExists`] if it is already stored. /// - /// This function assumes that the underlying storage (e.g., the account) - /// has already been enlarged to the correct size ([`Self::grown_len`]) and - /// that the index has been computed correctly (is in rage, preserves - /// ordering, and isn't writing a duplicate). - pub fn insert_solver_at(&mut self, index: usize, solver: &Pubkey) { - let data: &mut [u8] = &mut self.0; + /// The account must already be grown by one solver slot + /// ([`grown_len`](Self::grown_len)): the trailing slot is spare capacity + /// for the new entry, so the live list is every entry but that last one. + /// The solver is placed in sorted order by shifting all entries and + /// inserting the new solver in the initial slot. + /// An error is returned if the solver is already included. + pub fn insert_solver(&mut self, solver: &Pubkey) -> Result<(), ProgramError> { + let (_spare, occupied) = self + .solver_region() + .split_last() + .expect("account grown by one solver slot before insertion"); + let index = match occupied.binary_search(&solver.to_bytes()) { + Ok(_) => return Err(SettlementError::SolverAlreadyExists.into()), + Err(index) => index, + }; - let old_len = data + // Shift the entries at and after `index` up one slot into the spare, + // then write the solver into the gap that opens at `index`. + let data: &mut [u8] = &mut self.0; + let occupied_end = data .len() .checked_sub(WIDTH_PUBKEY) .expect("account grown by one solver slot before insertion"); - let offset = WIDTH_HEADER + let gap = WIDTH_HEADER .checked_add( index .checked_mul(WIDTH_PUBKEY) .expect("insertion index bound by account length"), ) .expect("insertion offset bound by account length"); - let gap_end = offset + let gap_end = gap .checked_add(WIDTH_PUBKEY) .expect("insertion slot bound by account length"); - data.copy_within(offset..old_len, gap_end); - data[offset..gap_end].copy_from_slice(&solver.to_bytes()); + data.copy_within(gap..occupied_end, gap_end); + data[gap..gap_end].copy_from_slice(&solver.to_bytes()); + Ok(()) } } @@ -481,7 +495,7 @@ mod tests { } #[test] - fn insert_solver_at_shifts_the_tail_and_writes_the_solver( + fn insert_solver_inserts_an_absent_solver( header in fixtures::arb_init_params(), // Unique and already sorted, being a `BTreeSet`. raw_solvers in prop::collection::btree_set(any::<[u8; 32]>(), 0..50), @@ -492,16 +506,9 @@ mod tests { raw_solvers.into_iter().map(Pubkey::new_from_array).collect(); let new = Pubkey::new_from_array(raw_new); - // The slot the new solver sorts into, before any growth. + // Grow by one slot, exactly as the handler resizes the account + // before delegating the insert. let mut bytes = fixtures::state_account_bytes(&header, &stored); - let index = StateAccount::attach(&bytes[..]) - .expect("valid header") - .solver_search(&new) - .expect_err("solver is absent"); - - // Grow to the length `grown_len` reports, exactly as the handler - // resizes the account, then let `insert_solver_at` fill the new - // slot. let grown_len = StateAccount::attach(&bytes[..]) .expect("valid header") .grown_len() @@ -510,13 +517,43 @@ mod tests { bytes.resize(grown_len, 0); StateAccount::attach(&mut bytes[..]) .expect("valid header") - .insert_solver_at(index, &new); + .insert_solver(&new) + .expect("absent solver inserts"); let mut expected = stored; - expected.insert(index, new); + expected.push(new); + expected.sort(); let state = StateAccount::attach(&bytes[..]).expect("valid header"); prop_assert_eq!(state.solvers().collect::>(), expected); - prop_assert_eq!(state.solver_search(&new), Ok(index)); + } + + /// `insert_solver` rejects a solver that is already stored and leaves + /// the live list untouched. + #[test] + fn insert_solver_rejects_an_existing_solver( + header in fixtures::arb_init_params(), + // Unique and already sorted, being a `BTreeSet`. + raw_solvers in prop::collection::btree_set(any::<[u8; 32]>(), 1..50), + pick in any::(), + ) { + let stored: Vec = + raw_solvers.into_iter().map(Pubkey::new_from_array).collect(); + let existing = stored[pick.index(stored.len())]; + + // Grow by one slot as the handler does, then try to re-add. + let mut bytes = fixtures::state_account_bytes(&header, &stored); + bytes.resize(bytes.len().strict_add(WIDTH_PUBKEY), 0); + prop_assert_eq!( + StateAccount::attach(&mut bytes[..]) + .expect("valid header") + .insert_solver(&existing), + Err(SettlementError::SolverAlreadyExists.into()), + ); + + // Nothing was written: the stored solvers still read back in + // order (the trailing spare slot is left as the zero pubkey). + let state = StateAccount::attach(&bytes[..]).expect("valid header"); + prop_assert_eq!(state.solvers().take(stored.len()).collect::>(), stored); } } } diff --git a/programs/settlement/src/add_solver.rs b/programs/settlement/src/add_solver.rs index 1674212..244364b 100644 --- a/programs/settlement/src/add_solver.rs +++ b/programs/settlement/src/add_solver.rs @@ -32,24 +32,16 @@ pub fn process_add_solver( check_state_pda(program_id, state_pda)?; - // Only the manager may change the solver list. Reading also validates the - // account, the search finds where the new solver sorts in, and the state - // knows the length it must grow to. - let (index, new_len) = { + // Only the manager may change the solver list. Attaching validates the + // account; `grown_len` is the size it must reach to hold one more solver. + let new_len = { let state = StateAccount::attach(state_pda.try_borrow()?)?; if !manager.is_signer() || manager.address() != &state.authority(Role::Manager) { return Err(SettlementError::UnauthorizedSolverManagement.into()); } - let index = match state.solver_search(&solver) { - Ok(_) => return Err(SettlementError::SolverAlreadyExists.into()), - Err(index) => index, - }; - ( - index, - state - .grown_len() - .expect("grown account length fits in usize"), - ) + state + .grown_len() + .expect("grown account length fits in usize") }; let shortfall = Rent::get()? @@ -66,10 +58,13 @@ pub fn process_add_solver( .invoke()?; } + // Grow the account by one solver slot and insert. A duplicate solver is + // rejected before anything is written; the error reverts the growth and the + // rent transfer along with the rest of the instruction. let mut state_pda = *state_pda; state_pda.resize(new_len)?; let mut state = StateAccount::attach(state_pda.try_borrow_mut()?)?; - state.insert_solver_at(index, &solver); + state.insert_solver(&solver)?; Ok(()) } @@ -107,68 +102,4 @@ mod tests { Err(SettlementError::StateAccountMismatch.into()), ); } - - mod proptest { - use ::proptest::prelude::*; - - use super::*; - use cow_settlement_interface::data::state::fixtures::{ - arb_init_params, state_account_bytes, - }; - use cow_settlement_interface::fixtures::pubkey_from_seed; - use cow_settlement_interface::instruction::add_solver::AddSolver; - use cow_settlement_interface::instruction::fixtures::{ - fake_account, fake_account_owned_by, fake_signer, - }; - use cow_settlement_interface::pda::state::find_state_pda; - use cow_settlement_interface::{Instruction, Pubkey}; - - proptest! { - #[test] - fn process_add_solver_rejects_an_existing_solver( - header in arb_init_params(), - // BTreeSet: `.iter()` returns the elements already sorted, and, - // since it's a set, they are also unique. At least one so there's - // an existing solver to re-add. - raw_solvers in ::proptest::collection::btree_set(any::<[u8; 32]>(), 1..50), - pick in any::<::proptest::sample::Index>(), - ) { - let manager = header.manager; - let stored: Vec = - raw_solvers.into_iter().map(Pubkey::new_from_array).collect(); - // Re-add one of the solvers that's already stored. - let existing = stored[pick.index(stored.len())]; - - // Mock the four accounts the handler parses. Only the manager signer - // and the state PDA carry meaning here; the payer and system program - // are never touched, since the reject happens before the - // rent-funding transfer. - let (state_pda_address, _bump) = find_state_pda(&PROGRAM_ID); - let mut accounts = [ - fake_signer(manager), - fake_account(pubkey_from_seed("payer")), - fake_account_owned_by( - state_pda_address, - PROGRAM_ID, - &state_account_bytes(&header, &stored), - ), - fake_account(pubkey_from_seed("system program")), - ]; - - let data = Instruction::from(AddSolver { - program_id: PROGRAM_ID, - manager, - payer: pubkey_from_seed("payer"), - state_pda: state_pda_address, - solver: existing, - }) - .data; - - prop_assert_eq!( - process_add_solver(&PROGRAM_ID, &mut accounts, &data), - Err(SettlementError::SolverAlreadyExists.into()), - ); - } - } - } }