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 1/6] 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 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 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 6/6] `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()); }