Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions interface/src/data/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@

pub mod intent;
pub mod order;
pub mod state;
54 changes: 44 additions & 10 deletions interface/src/data/order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pub struct OrderAccount {
pub intent: OrderIntent,
}

/// Canonical 199-byte representation of an [`OrderAccount`]. The bytes
/// Canonical 200-byte representation of an [`OrderAccount`]. The bytes
/// written to/read from the order PDA's data area.
///
/// Layout: one character per byte, cell widths proportional to field size,
Expand All @@ -60,31 +60,40 @@ pub struct OrderAccount {
/// [`EncodedOrderIntent`]; see that type's docs for its inner layout.
///
/// ```text
/// ┌──── cancelled
/// ┌──── discriminator
/// │┌─── cancelled
/// ┌┬───────┬───────┬───────────────────────────────┬─────────────────...─────────────────┐
/// ││amount_│amount_│ │ │
/// ││with- │re- │ created_by │ intent (EncodedOrderIntent) │
/// ││drawn │ceived │ │ │
/// └┴───────┴───────┴───────────────────────────────┴─────────────────...─────────────────┘
/// 0 1 9 17 49 ... 199
/// 0 1 2 10 18 ... 200
/// ```
///
/// The first byte is an IDL-style account discriminator (see
/// [`EncodedOrderAccount::DISCRIMINATOR`]), letting IDL-driven tooling (e.g.
/// Solscan) identify the account type before decoding the rest.
Comment thread
kaze-cow marked this conversation as resolved.
Outdated
#[derive(Clone, Debug, Deref, Eq, PartialEq)]
pub struct EncodedOrderAccount([u8; Self::SIZE]);

impl EncodedOrderAccount {
// Per-field widths, derived from the `OrderAccount` field types.
const W_DISCRIMINATOR: usize = 1;
const W_CANCELLED: usize = size_of::<bool>();
const W_AMOUNT_WITHDRAWN: usize = size_of::<u64>();
const W_AMOUNT_RECEIVED: usize = size_of::<u64>();
const W_CREATED_BY: usize = size_of::<Pubkey>();
const W_INTENT: usize = EncodedOrderIntent::SIZE;

pub const SIZE: usize = 199;
pub const SIZE: usize = 200;

/// Single-byte account discriminator. See [`crate::SettlementAccount`].
pub const DISCRIMINATOR: [u8; 1] = [crate::SettlementAccount::OrderAccount.discriminator()];

/// Decode the account body and compute the embedded intent's UID in one
/// shot, mirroring [`EncodedOrderIntent::decode_and_hash`]. Decoding
/// validates the intent; returns [`ProgramError::InvalidAccountData`] on a
/// decode error.
/// validates the discriminator and the intent; returns
/// [`ProgramError::InvalidAccountData`] on a decode error.
pub fn decode_and_hash(bytes: &[u8; Self::SIZE]) -> Result<(OrderAccount, Hash), ProgramError> {
let order_account = OrderAccount::try_from(*bytes)?;
// The order UID is the hash of the intent's canonical bytes. Decoding
Expand All @@ -111,14 +120,23 @@ pub fn write_account(
created_by: &Pubkey,
encoded_intent: &[u8; EncodedOrderIntent::SIZE],
) {
let (cancelled_slot, amount_withdrawn_slot, amount_received_slot, created_by_slot, intent_slot) = mut_array_refs![
let (
discriminator_slot,
cancelled_slot,
amount_withdrawn_slot,
amount_received_slot,
created_by_slot,
intent_slot,
) = mut_array_refs![
buffer,
EncodedOrderAccount::W_DISCRIMINATOR,
EncodedOrderAccount::W_CANCELLED,
EncodedOrderAccount::W_AMOUNT_WITHDRAWN,
EncodedOrderAccount::W_AMOUNT_RECEIVED,
EncodedOrderAccount::W_CREATED_BY,
EncodedOrderAccount::W_INTENT
];
*discriminator_slot = EncodedOrderAccount::DISCRIMINATOR;
*cancelled_slot = [cancelled as u8];
*amount_withdrawn_slot = amount_withdrawn.to_le_bytes();
*amount_received_slot = amount_received.to_le_bytes();
Expand Down Expand Up @@ -151,15 +169,20 @@ impl TryFrom<[u8; EncodedOrderAccount::SIZE]> for OrderAccount {
type Error = ProgramError;

fn try_from(bytes: [u8; EncodedOrderAccount::SIZE]) -> Result<Self, Self::Error> {
let (cancelled, amount_withdrawn, amount_received, created_by, intent) = array_refs![
let (discriminator, cancelled, amount_withdrawn, amount_received, created_by, intent) = array_refs![
&bytes,
EncodedOrderAccount::W_DISCRIMINATOR,
EncodedOrderAccount::W_CANCELLED,
EncodedOrderAccount::W_AMOUNT_WITHDRAWN,
EncodedOrderAccount::W_AMOUNT_RECEIVED,
EncodedOrderAccount::W_CREATED_BY,
EncodedOrderAccount::W_INTENT
];

if *discriminator != EncodedOrderAccount::DISCRIMINATOR {
return Err(ProgramError::InvalidAccountData);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this check may be superfluous if we decide we want to go more efficient.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Imho it looks reasonable to keep it.


Ok(OrderAccount {
cancelled: match cancelled {
[0] => false,
Expand Down Expand Up @@ -193,8 +216,8 @@ pub mod fixtures {
};

// Hardcoded but verified in a sanity-check test.
pub const CANCELLED_OFFSET: usize = 0;
pub const INTENT_OFFSET: usize = 49;
pub const CANCELLED_OFFSET: usize = 1;
pub const INTENT_OFFSET: usize = 50;

/// Hand-picked example order account wrapping [`sample_intent`].
pub fn sample_account(cancelled: bool) -> OrderAccount {
Expand Down Expand Up @@ -321,6 +344,15 @@ mod tests {
);
}

#[test]
fn decode_rejects_wrong_discriminator() {
let mut bytes: [u8; EncodedOrderAccount::SIZE] =
EncodedOrderAccount::from(sample_account(false)).into();
bytes[0] ^= 0xff;
let err = OrderAccount::try_from(bytes).expect_err("wrong discriminator must be rejected");
assert_eq!(err, ProgramError::InvalidAccountData);
}

#[test]
fn decode_rejects_non_boolean_cancelled() {
let mut bytes: [u8; EncodedOrderAccount::SIZE] =
Expand Down Expand Up @@ -416,6 +448,8 @@ mod tests {
kind in arb_order_kind(),
partially_fillable in any::<bool>(),
) {
bytes[..EncodedOrderAccount::DISCRIMINATOR.len()]
.copy_from_slice(&EncodedOrderAccount::DISCRIMINATOR);
Comment thread
kaze-cow marked this conversation as resolved.
Outdated
bytes[CANCELLED_OFFSET] = cancelled as u8;
bytes[INTENT_OFFSET + KIND_OFFSET] = kind as u8;
bytes[INTENT_OFFSET + PARTIALLY_FILLABLE_OFFSET] = partially_fillable as u8;
Expand Down
40 changes: 40 additions & 0 deletions interface/src/data/state.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//! Settlement state PDA body.
//!
//! The state PDA carries no fields of its own (see [`crate::pda::state`]); its
//! only content is the account discriminator, so that IDL-driven tooling can
//! identify the account type.

use solana_program_error::ProgramError;

use crate::SettlementAccount;

/// Canonical size of the settlement state PDA's body: just the discriminator.
pub const SIZE: usize = 1;

/// Single-byte account discriminator. See [`crate::SettlementAccount`].
pub const DISCRIMINATOR: [u8; SIZE] = [SettlementAccount::SettlementState.discriminator()];

/// Validate that `bytes` carries the expected discriminator.
pub fn decode(bytes: &[u8; SIZE]) -> Result<(), ProgramError> {
if *bytes != DISCRIMINATOR {
return Err(ProgramError::InvalidAccountData);
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn decode_accepts_discriminator() {
assert_eq!(decode(&DISCRIMINATOR), Ok(()));
}

#[test]
fn decode_rejects_wrong_discriminator() {
Comment thread
fedgiac marked this conversation as resolved.
let mut bytes = DISCRIMINATOR;
bytes[0] ^= 0xff;
assert_eq!(decode(&bytes), Err(ProgramError::InvalidAccountData));
}
}
2 changes: 1 addition & 1 deletion interface/src/instruction/initialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ impl From<Initialize> for Instruction {
/// Parsed inputs of an `Initialize` instruction.
pub struct InitializeInput<'a> {
pub payer: &'a AccountView,
pub state_pda: &'a AccountView,
pub state_pda: &'a mut AccountView,
}

impl<'a> InstructionInputParsing<'a> for InitializeInput<'a> {
Expand Down
50 changes: 50 additions & 0 deletions interface/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,30 @@ impl SettlementInstruction {
}
}

/// Identifies the account type a given account's data belongs to, via the
/// single discriminator byte stored at its front. Starts at 128 to keep
/// account discriminators visually distinct from instruction discriminators.
#[derive(Clone, Copy, Debug, Eq, PartialEq, num_enum::TryFromPrimitive)]
#[repr(u8)]
#[num_enum(error_type(
name = ProgramError,
constructor = SettlementAccount::unknown_discriminator,
))]
pub enum SettlementAccount {
OrderAccount = 128,
SettlementState = 129,
}

impl SettlementAccount {
pub const fn discriminator(self) -> u8 {
self as u8
}

fn unknown_discriminator(_: u8) -> ProgramError {
ProgramError::InvalidAccountData
}
}

/// Recover the discriminator from the first byte of the payload and the
/// remaining bytes to parse.
/// Returns `InvalidInstructionData` for an insufficient length or an
Expand Down Expand Up @@ -175,4 +199,30 @@ mod tests {
Ok(SettlementInstruction::BeginSettle)
);
}

#[test]
fn settlement_account_try_from_partitions_all_bytes() {
for i in u8::MIN..=u8::MAX {
match SettlementAccount::try_from(i) {
Ok(account) => assert_eq!(account as u8, i),
Err(err) => assert_eq!(err, ProgramError::InvalidAccountData),
}
}
}

#[test]
fn settlement_account_try_from_matches_order_account() {
assert_eq!(
SettlementAccount::try_from(128),
Ok(SettlementAccount::OrderAccount)
);
}

#[test]
fn settlement_account_discriminators_are_distinct() {
assert_ne!(
SettlementAccount::OrderAccount.discriminator(),
SettlementAccount::SettlementState.discriminator(),
);
}
}
11 changes: 9 additions & 2 deletions programs/settlement/src/initialize.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
//! `Initialize` instruction handler.

use pinocchio::{AccountView, Address, ProgramResult};
use pinocchio::{error::ProgramError, AccountView, Address, ProgramResult};
use settlement_interface::{
data::state,
instruction::{initialize::InitializeInput, InstructionInputParsing},
pda::state::state_pda_seeds,
};
Expand All @@ -25,12 +26,18 @@ pub fn process_initialize(
program_id,
payer,
pda: state_pda,
size: 0,
size: state::SIZE as u64,
owner: program_id,
seeds: state_pda_seeds(),
}
.create()?;

let mut buffer = state_pda.try_borrow_mut()?;
let buffer: &mut [u8; state::SIZE] = (&mut *buffer)
.try_into()
.map_err(|_| ProgramError::AccountDataTooSmall)?;
*buffer = state::DISCRIMINATOR;
Comment thread
kaze-cow marked this conversation as resolved.
Outdated

Ok(())
}

Expand Down
14 changes: 9 additions & 5 deletions programs/settlement/tests/initialize.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use settlement_client::instructions::Initialize;
use settlement_client::settlement_interface::{
instruction::initialize::Initialize as InitializeRaw, pda::state::find_state_pda,
data::state, instruction::initialize::Initialize as InitializeRaw, pda::state::find_state_pda,
};
use solana_sdk::{
pubkey::Pubkey,
Expand All @@ -10,7 +10,7 @@ use solana_sdk::{
mod common;

#[test]
fn happy_path_initializes_empty_state_pda() {
fn happy_path_initializes_state_pda_with_discriminator() {
let (mut svm, program_id, payer) = common::setup();
let (state_pda, _bump) = find_state_pda(&program_id);

Expand All @@ -30,9 +30,13 @@ fn happy_path_initializes_empty_state_pda() {
account.owner, program_id,
"state PDA must be owned by the settlement program"
);
assert!(account.data.is_empty(), "state PDA must be empty");
assert_eq!(
account.data,
state::DISCRIMINATOR,
"state PDA must hold only the Anchor-style discriminator"
Comment thread
kaze-cow marked this conversation as resolved.
Outdated
);

let rent = svm.minimum_balance_for_rent_exemption(0);
let rent = svm.minimum_balance_for_rent_exemption(state::SIZE);
assert_eq!(
account.lamports, rent,
"state PDA must hold exactly the rent minimum: {} != {}",
Expand All @@ -59,7 +63,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(0);
let rent = svm.minimum_balance_for_rent_exemption(state::SIZE);
assert_eq!(
common::lamports(&svm, &funder.pubkey()),
funder_airdrop - rent,
Expand Down