diff --git a/bench-report.json b/bench-report.json index c4676e9..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": 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": 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, @@ -43,9 +43,9 @@ "settle/pushes_several_orders_from_one_buffer": 17609, "settle/settles_a_single_order": 12384, "settle/settles_multiple_orders": 22894, - "transfer_authority/manager_can_transfer_manager": 3170, - "transfer_authority/manager_can_transfer_reclaim_authority": 3172, - "transfer_authority/reclaim_authority_can_transfer_itself": 3175 + "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..ef41e3c --- /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::attach(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::{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[..], + &StateInitArgs { + 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..a4b80c8 100644 --- a/interface/src/data/state.rs +++ b/interface/src/data/state.rs @@ -1,272 +1,299 @@ -//! 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_account_view::{AccountView, Ref}; 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, - } +/// 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, } +} - /// [`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, - } +/// [`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, } +} - /// 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) - } +/// The parameters used to initialize the state account. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StateInitArgs { + /// The [`Role::Manager`] holder. + pub manager: Pubkey, + /// The [`Role::ReclaimAuthority`] holder. + pub reclaim_authority: Pubkey, +} - /// 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, +/// 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 attach(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)) } -} - -/// Writes the canonical [`EncodedStateAccount`] encoding of `account` into -/// `buffer`. -pub fn write_account(buffer: &mut [u8; EncodedStateAccount::SIZE], account: &StateAccount) { - let StateAccount { - 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 + fn header(&self) -> &[u8; WIDTH_HEADER] { + self.0 + .first_chunk::() + .expect("header length is guaranteed by any constructor of `StateAccount`") } -} -impl From for EncodedStateAccount { - fn from(account: StateAccount) -> Self { - let mut out = [0u8; Self::SIZE]; - write_account(&mut out, &account); - Self(out) + /// 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) } } -impl From for [u8; EncodedStateAccount::SIZE] { - fn from(account: StateAccount) -> Self { - EncodedStateAccount::from(account).into() +impl<'a> StateAccount> { + pub fn from_account(account: &'a AccountView) -> Result { + Self::attach(account.try_borrow()?) } } -impl TryFrom<[u8; EncodedStateAccount::SIZE]> for StateAccount { - type Error = ProgramError; - - fn try_from(bytes: [u8; EncodedStateAccount::SIZE]) -> Result { - let slots = EncodedStateAccount::slots(&bytes); - - 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, args: &StateInitArgs) -> Result { + { + let slots = header_slots_mut( + bytes + .first_chunk_mut::() + .ok_or(ProgramError::AccountDataTooSmall)?, + ); + *slots.discriminator = [DISCRIMINATOR]; + *slots.manager = args.manager.to_bytes(); + *slots.reclaim_authority = args.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(|| StateInitArgs { + 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 + } + + #[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()[..] + ); + } + + #[test] + fn reads_role_holders_from_the_header() { + let bytes = header_bytes(); + let state = StateAccount::attach(&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 new_rejects_wrong_discriminator() { + let mut bytes = header_bytes(); + bytes[DISCRIMINATOR_OFFSET] = 0xff; + assert_eq!( + StateAccount::attach(&bytes[..]).err(), + Some(ProgramError::InvalidAccountData), + ); + } + + #[test] + fn new_rejects_too_short_buffer() { + let bytes = header_bytes(); + assert_eq!( + StateAccount::attach(&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::attach(&bytes[..]) + .expect("valid header") + .authority(role) + }); + + StateAccount::attach(&mut bytes[..]) + .expect("valid header") + .set_authority(target, &new_holder); + + 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); } } - /// 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) => { + /// Generates one test, `set_authority_updates_only_`, for `$role`. + macro_rules! set_authority_test { + ($name:ident: $role:expr) => { #[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 - ); + assert_set_authority_updates_only($role); } }; } - role_accessor_test!(manager_accessors_match_named_fields: Role::Manager => manager); - role_accessor_test!(reclaim_authority_accessors_match_named_fields: Role::ReclaimAuthority => reclaim_authority); + set_authority_test!(set_authority_updates_only_manager: Role::Manager); + set_authority_test!(set_authority_updates_only_reclaim_authority: Role::ReclaimAuthority); #[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 new_accepts_a_longer_account_and_reads_the_header() { + let mut bytes = header_bytes().to_vec(); + bytes.push(0x42); - #[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); + 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), + SAMPLE_HEADER.reclaim_authority + ); } #[test] - fn widths_match_field_sizes() { - use core::mem::{size_of, size_of_val}; - - // Any `StateAccount` works: `size_of_val` only consults the field type, - // never the data. - let StateAccount { - manager, - reclaim_authority, - } = sample_account(); - - assert_eq!(EncodedStateAccount::W_MANAGER, size_of_val(&manager)); + fn initialize_rejects_too_small_buffer() { + let mut bytes = [0u8; WIDTH_HEADER - 1]; assert_eq!( - EncodedStateAccount::W_RECLAIM_AUTHORITY, - size_of_val(&reclaim_authority) + StateAccount::initialize(&mut bytes[..], &SAMPLE_HEADER).err(), + Some(ProgramError::AccountDataTooSmall), ); - - assert_eq!(EncodedStateAccount::SIZE, size_of::()); } mod proptest { @@ -275,31 +302,24 @@ mod tests { use super::*; proptest! { + /// 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 account = StateAccount { + let init_args = StateInitArgs { 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[..], &init_args).expect("header fits"); - prop_assert_eq!(re_encoded, encoded); + let state = StateAccount::attach(&bytes[..]).expect("valid header"); + prop_assert_eq!(state.authority(Role::Manager), init_args.manager); + prop_assert_eq!(state.authority(Role::ReclaimAuthority), init_args.reclaim_authority); } } } diff --git a/interface/src/lib.rs b/interface/src/lib.rs index ea4ec27..3f61256 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 { @@ -328,4 +331,16 @@ mod tests { fn role_try_from_matches_manager() { assert_eq!(Role::try_from(0), Ok(Role::Manager)); } + + #[test] + fn all_roles_lists_every_role_in_discriminator_order() { + // The roles `try_from` accepts, discovered independently of `Role::ALL`. + // 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.as_slice(), every_role.as_slice()); + } } diff --git a/programs/settlement/src/initialize.rs b/programs/settlement/src/initialize.rs index 71fbe96..6920dea 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::{StateAccount, StateInitArgs, 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()?, + &StateInitArgs { manager, reclaim_authority, }, - ); + )?; Ok(()) } diff --git a/programs/settlement/src/reclaim_buffer.rs b/programs/settlement/src/reclaim_buffer.rs index 30e57d8..c9f3d72 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::from_account(state_pda)?.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::{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, @@ -108,6 +102,21 @@ mod tests { const TOKEN_PROGRAM: usize = 3; const BUFFER_PDA: usize = 4; + /// State account bytes for planting a well-formed state PDA in tests. + fn state_account_bytes(init_args: &StateInitArgs) -> [u8; WIDTH_HEADER] { + let mut bytes = [0u8; WIDTH_HEADER]; + StateAccount::initialize(&mut bytes[..], init_args).expect("header fits"); + bytes + } + + /// The [`Header`] planted by [`base_accounts`]. + fn base_init_args() -> StateInitArgs { + StateInitArgs { + manager: MANAGER, + reclaim_authority: AUTHORITY, + } + } + fn empty_buffer_data(mint: Address, state_pda: Address) -> Vec { let mut data = vec![0; SplTokenAccount::LEN]; SplTokenAccount { @@ -128,13 +137,7 @@ 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_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 @@ -184,13 +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, - &*EncodedStateAccount::from(StateAccount { - manager: MANAGER, - reclaim_authority: AUTHORITY, - }), - ); + accounts[STATE_PDA] = + fake_account_with_data(UNRELATED, &state_account_bytes(&base_init_args())); assert_rejects(accounts, SettlementError::StateAccountMismatch.into()); } @@ -211,13 +209,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..8bd498f 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::attach(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..f26ec2c 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::attach(&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;