From b5723a2377fdb77aafd6c6a92f112eb1c6613a16 Mon Sep 17 00:00:00 2001 From: taco-paco Date: Thu, 6 Aug 2026 23:06:17 +0300 Subject: [PATCH 01/10] feat: preallocated buffers --- dlp-api/src/args/mod.rs | 2 + dlp-api/src/args/preallocate_buffer.rs | 30 +++ dlp-api/src/discriminator.rs | 3 + dlp-api/src/error.rs | 6 + src/lib.rs | 5 + src/processor/fast/commit_state.rs | 29 +- src/processor/fast/mod.rs | 2 + src/processor/fast/preallocate_buffer.rs | 250 ++++++++++++++++++ src/processor/fast/undelegate.rs | 26 +- .../fast/undelegate_confined_account.rs | 28 +- src/processor/fast/utils/pda.rs | 79 +++++- 11 files changed, 406 insertions(+), 54 deletions(-) create mode 100644 dlp-api/src/args/preallocate_buffer.rs create mode 100644 src/processor/fast/preallocate_buffer.rs diff --git a/dlp-api/src/args/mod.rs b/dlp-api/src/args/mod.rs index 492f02f4..fd02dbc0 100644 --- a/dlp-api/src/args/mod.rs +++ b/dlp-api/src/args/mod.rs @@ -3,6 +3,7 @@ mod commit_state; mod delegate; mod delegate_ephemeral_balance; mod delegate_with_actions; +mod preallocate_buffer; mod top_up_ephemeral_balance; mod types; mod validator_claim_fees; @@ -13,6 +14,7 @@ pub use commit_state::*; pub use delegate::*; pub use delegate_ephemeral_balance::*; pub use delegate_with_actions::*; +pub use preallocate_buffer::*; pub use top_up_ephemeral_balance::*; pub use types::*; pub use validator_claim_fees::*; diff --git a/dlp-api/src/args/preallocate_buffer.rs b/dlp-api/src/args/preallocate_buffer.rs new file mode 100644 index 00000000..f26556db --- /dev/null +++ b/dlp-api/src/args/preallocate_buffer.rs @@ -0,0 +1,30 @@ +use borsh::{BorshDeserialize, BorshSerialize}; + +use crate::compat::borsh; + +/// Which DLP-owned buffer (or the delegated account itself) a +/// `PreallocateBuffer` instruction should grow. +#[derive( + Default, Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize, +)] +pub enum PreallocateBufferKind { + /// The `commit_state` PDA (seeds: `[COMMIT_STATE_TAG, delegated_account]`). + #[default] + CommitState, + /// The `undelegate_buffer` PDA (seeds: `[UNDELEGATE_BUFFER_TAG, delegated_account]`). + UndelegateBuffer, + /// The delegated account itself. Only usable by the delegation's authority, + /// since it mutates observable base-layer state ahead of a commit/finalize. + DelegatedAccount, +} + +#[derive(Default, Debug, BorshSerialize, BorshDeserialize)] +pub struct PreallocateBufferArgs { + /// Which buffer to grow. + pub kind: PreallocateBufferKind, + /// The final size the buffer should reach. A single call grows the buffer + /// towards this size by at most `MAX_PERMITTED_DATA_INCREASE` bytes, so + /// callers send the same args repeatedly (once per top-level instruction) + /// until the buffer reaches `target_size`. + pub target_size: u32, +} diff --git a/dlp-api/src/discriminator.rs b/dlp-api/src/discriminator.rs index e4c8d37d..8c07e136 100644 --- a/dlp-api/src/discriminator.rs +++ b/dlp-api/src/discriminator.rs @@ -68,6 +68,9 @@ pub enum DlpDiscriminator { /// See [crate::processor::process_undelegate_with_rollback_after_timeout] for docs. UndelegateWithRollbackAfterTimeout = 27, + + /// See [crate::processor::process_preallocate_buffer] for docs. + PreallocateBuffer = 28, } impl DlpDiscriminator { diff --git a/dlp-api/src/error.rs b/dlp-api/src/error.rs index fa5c6987..e50408df 100644 --- a/dlp-api/src/error.rs +++ b/dlp-api/src/error.rs @@ -191,6 +191,12 @@ pub enum DlpError { #[error("Owner program requested undelegation")] OwnerRequestedUndelegation = 55, + #[error("Cannot preallocate a buffer while a commit is in flight")] + PreallocateBufferCommitInFlight = 56, + + #[error("Buffer must be preallocated to the exact required size ahead of time for accounts over the growth cap")] + BufferNotPreallocatedToExactSize = 57, + #[error("An infallible error is encountered possibly due to logic error")] InfallibleError = 100, } diff --git a/src/lib.rs b/src/lib.rs index b7d3e54c..1fefb61e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -159,6 +159,11 @@ pub fn fast_process_instruction( program_id, accounts, data, ), ), + DlpDiscriminator::PreallocateBuffer => { + Some(processor::fast::process_preallocate_buffer( + program_id, accounts, data, + )) + } _ => None, } } diff --git a/src/processor/fast/commit_state.rs b/src/processor/fast/commit_state.rs index 5c1ee4db..cbb54144 100644 --- a/src/processor/fast/commit_state.rs +++ b/src/processor/fast/commit_state.rs @@ -11,7 +11,9 @@ use crate::{ args::CommitStateArgs, error::DlpError, merge_diff_copy, pda, - processor::fast::utils::pda::create_pda, + processor::fast::utils::pda::{ + create_or_verify_preallocated_pda, create_pda, + }, requires::{ require_initialized_delegation_metadata, require_initialized_delegation_record, @@ -257,17 +259,21 @@ pub(crate) fn process_commit_state_internal( } } - // Load the uninitialized PDAs - let commit_state_bump = require_uninitialized_pda( + // Handle commit state account + let commit_state_len = args.commit_state_bytes.data_len(); + create_or_verify_preallocated_pda( args.commit_state_account, &[ pda::COMMIT_STATE_TAG, args.delegated_account.address().as_ref(), ], - &crate::fast::ID, - true, + commit_state_len, + args.validator, CommitStateAccountCtx, + "commit state", )?; + + // Ensure commit record isn't initialized let commit_record_bump = require_uninitialized_pda( args.commit_record_account, &[ @@ -279,19 +285,6 @@ pub(crate) fn process_commit_state_internal( CommitRecordCtx, )?; - // Initialize the PDA containing the new committed state - create_pda( - args.commit_state_account, - &crate::fast::ID, - args.commit_state_bytes.data_len(), - &[Signer::from(&seeds!( - pda::COMMIT_STATE_TAG, - args.delegated_account.address().as_ref(), - &[commit_state_bump] - ))], - args.validator, - )?; - // Initialize the PDA containing the record of the committed state create_pda( args.commit_record_account, diff --git a/src/processor/fast/mod.rs b/src/processor/fast/mod.rs index b01c11a5..94122d91 100644 --- a/src/processor/fast/mod.rs +++ b/src/processor/fast/mod.rs @@ -7,6 +7,7 @@ mod commit_state_from_buffer; mod delegate; mod delegate_with_actions; mod finalize; +mod preallocate_buffer; mod request_undelegation; mod undelegate; mod undelegate_confined_account; @@ -24,6 +25,7 @@ pub use commit_state_from_buffer::*; pub use delegate::*; pub use delegate_with_actions::*; pub use finalize::*; +pub use preallocate_buffer::*; pub use request_undelegation::*; pub use undelegate::*; pub use undelegate_confined_account::*; diff --git a/src/processor/fast/preallocate_buffer.rs b/src/processor/fast/preallocate_buffer.rs new file mode 100644 index 00000000..5b408574 --- /dev/null +++ b/src/processor/fast/preallocate_buffer.rs @@ -0,0 +1,250 @@ +use dlp_api::compat::borsh::BorshDeserialize; +use pinocchio::{ + account::MAX_PERMITTED_DATA_INCREASE, + address::{address_eq, Address}, + cpi::Signer, + error::ProgramError, + instruction::seeds, + AccountView, ProgramResult, +}; +use pinocchio_log::log; + +use super::to_pinocchio_program_error; +use crate::{ + args::{PreallocateBufferArgs, PreallocateBufferKind}, + error::DlpError, + pda, + processor::fast::utils::pda::{create_pda, resize_pda}, + requires::{ + is_uninitialized_account, require_initialized_delegation_record, + require_initialized_validator_fees_vault, require_owned_pda, + require_pda, require_signer, + }, + state::DelegationRecord, +}; + +/// Grows a DLP-owned buffer (or the delegated account itself) towards a +/// target size, in steps of at most `MAX_PERMITTED_DATA_INCREASE` bytes. +/// +/// Since that growth cap resets for every top-level instruction, a validator +/// that needs to commit/undelegate an account larger than the cap can send +/// several `PreallocateBuffer` instructions (same args, repeated) ahead of +/// the commit/finalize (or undelegate) instruction, all packed into the same +/// transaction, to reach the required size before the actual write happens. +/// +/// Accounts: +/// +/// 0: `[signer, writable]` the payer funding rent for the growth +/// 1: `[]` the delegated account +/// 2: `[]` the delegation record account +/// 3: `[writable]` the buffer PDA to grow (unused for `DelegatedAccount`) +/// 4: `[]` the commit record account +/// 5: `[]` the validator fees vault +/// 6: `[]` the system program +/// +/// Requirements: +/// +/// - delegated account is owned by delegation program +/// - delegation record is initialized +/// - validator is a registered validator (has an initialized fees vault) and +/// is the delegation's authority +/// - for `kind = CommitState` / `UndelegateBuffer`, no commit is currently in +/// flight for the delegated account (commit record must be uninitialized), +/// mirroring the precondition `commit_state`/`commit_diff` and `undelegate` +/// themselves enforce before they'll create these buffers. +/// - `kind = DelegatedAccount` has no such restriction: it's meant to run +/// *between* `commit_state` and `finalize`, i.e. exactly while a commit +/// record is initialized, to pre-grow the account before finalize's +/// single-shot resize. +pub fn process_preallocate_buffer( + _program_id: &Address, + accounts: &[AccountView], + data: &[u8], +) -> ProgramResult { + let [validator, delegated_account, delegation_record_account, buffer_account, commit_record_account, validator_fees_vault, system_program] = + accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + // Validate common requirements + validate_common( + validator, + delegated_account, + delegation_record_account, + validator_fees_vault, + )?; + + let args = PreallocateBufferArgs::try_from_slice(data) + .map_err(|_| ProgramError::BorshIoError)?; + let target_size = args.target_size as usize; + match args.kind { + PreallocateBufferKind::CommitState => { + require_no_commit_in_flight( + delegated_account, + commit_record_account, + )?; + let seeds_arr = + [pda::COMMIT_STATE_TAG, delegated_account.address().as_ref()]; + let bump = require_pda( + buffer_account, + &seeds_arr, + &crate::fast::ID, + true, + "commit state", + )?; + grow_target( + buffer_account, + &seeds_arr, + bump, + target_size, + validator, + system_program, + ) + } + PreallocateBufferKind::UndelegateBuffer => { + require_no_commit_in_flight( + delegated_account, + commit_record_account, + )?; + let seeds_arr = [ + pda::UNDELEGATE_BUFFER_TAG, + delegated_account.address().as_ref(), + ]; + let bump = require_pda( + buffer_account, + &seeds_arr, + &crate::fast::ID, + true, + "undelegate buffer", + )?; + grow_target( + buffer_account, + &seeds_arr, + bump, + target_size, + validator, + system_program, + ) + } + PreallocateBufferKind::DelegatedAccount => resize_towards( + delegated_account, + target_size, + validator, + system_program, + ), + } +} + +/// Common account validation shared by every `kind`: the validator must be a +/// signer, a registered validator (has an initialized fees vault), and the +/// authority of this specific delegation. +fn validate_common( + validator: &AccountView, + delegated_account: &AccountView, + delegation_record_account: &AccountView, + validator_fees_vault: &AccountView, +) -> ProgramResult { + // Ensure validator is signer and is whitelisted + require_signer(validator, "validator")?; + require_initialized_validator_fees_vault( + validator, + validator_fees_vault, + false, + )?; + + // Validate account is delegated + require_owned_pda( + delegated_account, + &crate::fast::ID, + "delegated account", + )?; + require_initialized_delegation_record( + delegated_account, + delegation_record_account, + false, + )?; + + // Ensure validator doesn't intervene in other validator's accounts + let delegation_record_data = delegation_record_account.try_borrow()?; + let delegation_record = + DelegationRecord::try_from_bytes_with_discriminator( + &delegation_record_data, + ) + .map_err(to_pinocchio_program_error)?; + if !address_eq( + &delegation_record.authority.to_bytes().into(), + validator.address(), + ) { + log!("validator is not the delegation authority. validator: "); + validator.address().log(); + log!("delegation authority: "); + Address::from(delegation_record.authority.to_bytes()).log(); + Err(DlpError::InvalidAuthority.into()) + } else { + Ok(()) + } +} + +/// Makes sure that there's no in flight commit +fn require_no_commit_in_flight( + delegated_account: &AccountView, + commit_record_account: &AccountView, +) -> ProgramResult { + require_pda( + commit_record_account, + &[pda::COMMIT_RECORD_TAG, delegated_account.address().as_ref()], + &crate::fast::ID, + false, + "commit record", + )?; + if !is_uninitialized_account(commit_record_account) { + Err(DlpError::PreallocateBufferCommitInFlight.into()) + } else { + Ok(()) + } +} + +/// Grows PDA towards `target_size`, creating it fresh if it doesn't exist yet, +/// or resizing it by at most`MAX_PERMITTED_DATA_INCREASE` bytes +fn grow_target( + target_account: &AccountView, + seeds_arr: &[&[u8]; 2], + bump: u8, + target_size: usize, + payer: &AccountView, + system_program: &AccountView, +) -> ProgramResult { + if is_uninitialized_account(target_account) { + let size = target_size.min(MAX_PERMITTED_DATA_INCREASE); + create_pda( + target_account, + &crate::fast::ID, + size, + &[Signer::from(&seeds!(seeds_arr[0], seeds_arr[1], &[bump]))], + payer, + ) + } else { + resize_towards(target_account, target_size, payer, system_program) + } +} + +/// Resizes `account` towards `target_size` by at most +/// `MAX_PERMITTED_DATA_INCREASE` bytes; a no-op if it's already at or past +/// `target_size` (shrinking is never growth-capped, so it's left to whichever +/// instruction actually performs the exact-size write). +fn resize_towards( + account: &AccountView, + target_size: usize, + payer: &AccountView, + system_program: &AccountView, +) -> ProgramResult { + let current = account.data_len(); + if current < target_size { + let realloc_size = + core::cmp::min(target_size - current, MAX_PERMITTED_DATA_INCREASE); + let new_size = current + realloc_size; + resize_pda(payer, account, system_program, new_size)?; + } + Ok(()) +} diff --git a/src/processor/fast/undelegate.rs b/src/processor/fast/undelegate.rs index f5dcfdfe..1abec045 100644 --- a/src/processor/fast/undelegate.rs +++ b/src/processor/fast/undelegate.rs @@ -20,7 +20,9 @@ use crate::{ }, error::DlpError, pda, - processor::fast::utils::pda::{close_pda, close_pda_with_fees, create_pda}, + processor::fast::utils::pda::{ + close_pda, close_pda_with_fees, create_or_verify_preallocated_pda, + }, require_n_accounts_with_optionals, requires::{ require_initialized_delegation_metadata, @@ -241,28 +243,18 @@ pub fn process_undelegate( return Ok(()); } - // Initialize the undelegation buffer PDA - let undelegate_buffer_bump: u8 = require_uninitialized_pda( + // Handle undelegate buffer account + let undelegate_buffer_size = delegated_account.data_len(); + let undelegate_buffer_bump = create_or_verify_preallocated_pda( undelegate_buffer_account, &[ pda::UNDELEGATE_BUFFER_TAG, delegated_account.address().as_ref(), ], - &crate::fast::ID, - true, - UndelegateBufferCtx, - )?; - - create_pda( - undelegate_buffer_account, - &crate::fast::ID, - delegated_account.data_len(), - &[Signer::from(&seeds!( - pda::UNDELEGATE_BUFFER_TAG, - delegated_account.address().as_ref(), - &[undelegate_buffer_bump] - ))], + undelegate_buffer_size, validator, + UndelegateBufferCtx, + "undelegate buffer", )?; // Copy data in the undelegation buffer PDA diff --git a/src/processor/fast/undelegate_confined_account.rs b/src/processor/fast/undelegate_confined_account.rs index 52879fe9..d8b51ea6 100644 --- a/src/processor/fast/undelegate_confined_account.rs +++ b/src/processor/fast/undelegate_confined_account.rs @@ -7,12 +7,14 @@ use super::{process_undelegation_with_cpi, to_pinocchio_program_error}; use crate::{ error::DlpError, pda, - processor::fast::utils::pda::{close_pda, create_pda}, + processor::fast::utils::pda::{ + close_pda, create_or_verify_preallocated_pda, + }, require_eq_keys, requires::{ require_authorization, require_initialized_delegation_metadata, require_initialized_delegation_record, require_owned_pda, - require_signer, require_uninitialized_pda, UndelegateBufferCtx, + require_signer, UndelegateBufferCtx, }, state::{DelegationMetadata, DelegationRecord}, }; @@ -101,28 +103,18 @@ pub fn process_undelegate_confined_account( return Ok(()); } - // Initialize undelegation buffer PDA and copy state - let undelegate_buffer_bump: u8 = require_uninitialized_pda( + // Handle undelegate buffer account + let undelegate_buffer_size = delegated_account.data_len(); + let undelegate_buffer_bump = create_or_verify_preallocated_pda( undelegate_buffer_account, &[ pda::UNDELEGATE_BUFFER_TAG, delegated_account.address().as_ref(), ], - &crate::fast::ID, - true, - UndelegateBufferCtx, - )?; - - create_pda( - undelegate_buffer_account, - &crate::fast::ID, - delegated_account.data_len(), - &[Signer::from(&seeds!( - pda::UNDELEGATE_BUFFER_TAG, - delegated_account.address().as_ref(), - &[undelegate_buffer_bump] - ))], + undelegate_buffer_size, admin, + UndelegateBufferCtx, + "undelegate buffer", )?; (*undelegate_buffer_account.try_borrow_mut()?) diff --git a/src/processor/fast/utils/pda.rs b/src/processor/fast/utils/pda.rs index c08b67ed..c2bd6805 100644 --- a/src/processor/fast/utils/pda.rs +++ b/src/processor/fast/utils/pda.rs @@ -1,11 +1,21 @@ use pinocchio::{ + account::MAX_PERMITTED_DATA_INCREASE, cpi::Signer, + error::ProgramError, + instruction::seeds, sysvars::{rent::Rent, Sysvar}, AccountView, Address, ProgramResult, }; use pinocchio_system::instructions as system; -use crate::consts::PROTOCOL_FEES_PERCENTAGE; +use crate::{ + consts::PROTOCOL_FEES_PERCENTAGE, + error::DlpError, + requires::{ + require_initialized_pda, require_uninitialized_pda, + RequireUninitializedAccountCtx, + }, +}; /// Creates a new pda #[inline(always)] @@ -61,6 +71,52 @@ pub(crate) fn create_pda( } } +/// Prepares `buffer_account` to hold exactly `state_size` bytes, seeded by +/// `seeds_arr` under this program. Returns the PDA's bump seed. +#[allow(clippy::too_many_arguments)] +pub(crate) fn create_or_verify_preallocated_pda( + target_account: &AccountView, + seeds_arr: &[&[u8]; 2], + state_size: usize, + payer: &AccountView, + ctx: impl RequireUninitializedAccountCtx, + label: &str, +) -> Result { + if state_size > MAX_PERMITTED_DATA_INCREASE { + // Account must be initialized by PreallocateBuffer instructions dues to `MAX_PERMITTED_DATA_INCREASE`. + let bump = require_initialized_pda( + target_account, + seeds_arr, + &crate::fast::ID, + true, + label, + )?; + + if target_account.data_len() != state_size { + Err(DlpError::BufferNotPreallocatedToExactSize.into()) + } else { + Ok(bump) + } + } else { + // As the account is small enough we initialize here + let bump = require_uninitialized_pda( + target_account, + seeds_arr, + &crate::fast::ID, + true, + ctx, + )?; + create_pda( + target_account, + &crate::fast::ID, + state_size, + &[Signer::from(&seeds!(seeds_arr[0], seeds_arr[1], &[bump]))], + payer, + )?; + Ok(bump) + } +} + /// Close PDA #[inline(always)] pub(crate) fn close_pda( @@ -115,3 +171,24 @@ pub(crate) fn close_pda_with_fees( target_account.resize(0) } + +pub(crate) fn resize_pda( + payer: &AccountView, + pda: &AccountView, + _system_program: &AccountView, + new_size: usize, +) -> ProgramResult { + let rent = Rent::get()?; + let rent_exempt_balance = rent + .try_minimum_balance(new_size)? + .saturating_sub(pda.lamports()); + if rent_exempt_balance > 0 { + system::Transfer { + from: payer, + to: pda, + lamports: rent_exempt_balance, + } + .invoke()?; + } + pda.resize(new_size) +} From 90788ae3f405d70541ab557277925e99f9277f67 Mon Sep 17 00:00:00 2001 From: taco-paco Date: Fri, 7 Aug 2026 14:33:58 +0300 Subject: [PATCH 02/10] feat: instrution builder --- dlp-api/src/args/preallocate_buffer.rs | 8 +- dlp-api/src/instruction_builder/mod.rs | 2 + .../instruction_builder/preallocate_buffer.rs | 105 ++++++++++++++++++ 3 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 dlp-api/src/instruction_builder/preallocate_buffer.rs diff --git a/dlp-api/src/args/preallocate_buffer.rs b/dlp-api/src/args/preallocate_buffer.rs index f26556db..62642ac5 100644 --- a/dlp-api/src/args/preallocate_buffer.rs +++ b/dlp-api/src/args/preallocate_buffer.rs @@ -13,8 +13,7 @@ pub enum PreallocateBufferKind { CommitState, /// The `undelegate_buffer` PDA (seeds: `[UNDELEGATE_BUFFER_TAG, delegated_account]`). UndelegateBuffer, - /// The delegated account itself. Only usable by the delegation's authority, - /// since it mutates observable base-layer state ahead of a commit/finalize. + /// Pre-allocating delegated account itself DelegatedAccount, } @@ -22,9 +21,6 @@ pub enum PreallocateBufferKind { pub struct PreallocateBufferArgs { /// Which buffer to grow. pub kind: PreallocateBufferKind, - /// The final size the buffer should reach. A single call grows the buffer - /// towards this size by at most `MAX_PERMITTED_DATA_INCREASE` bytes, so - /// callers send the same args repeatedly (once per top-level instruction) - /// until the buffer reaches `target_size`. + /// The final size the buffer should reach pub target_size: u32, } diff --git a/dlp-api/src/instruction_builder/mod.rs b/dlp-api/src/instruction_builder/mod.rs index c52d5ce5..38ffaedf 100644 --- a/dlp-api/src/instruction_builder/mod.rs +++ b/dlp-api/src/instruction_builder/mod.rs @@ -17,6 +17,7 @@ mod finalize; mod init_magic_fee_vault; mod init_protocol_fees_vault; mod init_validator_fees_vault; +mod preallocate_buffer; mod protocol_claim_fees; mod request_undelegation; mod top_up_ephemeral_balance; @@ -46,6 +47,7 @@ pub use finalize::*; pub use init_magic_fee_vault::*; pub use init_protocol_fees_vault::*; pub use init_validator_fees_vault::*; +pub use preallocate_buffer::*; pub use protocol_claim_fees::*; pub use request_undelegation::*; pub use top_up_ephemeral_balance::*; diff --git a/dlp-api/src/instruction_builder/preallocate_buffer.rs b/dlp-api/src/instruction_builder/preallocate_buffer.rs new file mode 100644 index 00000000..7507f67a --- /dev/null +++ b/dlp-api/src/instruction_builder/preallocate_buffer.rs @@ -0,0 +1,105 @@ +use dlp::{ + args::{PreallocateBufferArgs, PreallocateBufferKind}, + discriminator::DlpDiscriminator, + pda::{ + commit_record_pda_from_delegated_account, + commit_state_pda_from_delegated_account, + delegation_record_pda_from_delegated_account, + undelegate_buffer_pda_from_delegated_account, + validator_fees_vault_pda_from_validator, + }, + total_size_budget, AccountSizeClass, DLP_PROGRAM_DATA_SIZE_CLASS, +}; +use solana_program::{ + account_info::MAX_PERMITTED_DATA_INCREASE, + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; +use solana_sdk_ids::system_program; + +use crate::compat::{borsh::to_vec, Compatize, Modernize}; + +/// Builds a preallocate buffer instruction. +/// See [dlp::processor::process_preallocate_buffer] for docs. +pub fn preallocate_buffer( + validator: Pubkey, + delegated_account: Pubkey, + kind: PreallocateBufferKind, + target_size: u32, +) -> Instruction { + let args = to_vec(&PreallocateBufferArgs { kind, target_size }).unwrap(); + let validator_compat = validator.compatize(); + let delegated_account_compat = delegated_account.compatize(); + let delegation_record_pda = + delegation_record_pda_from_delegated_account(&delegated_account_compat) + .modernize(); + let commit_record_pda = + commit_record_pda_from_delegated_account(&delegated_account_compat) + .modernize(); + let validator_fees_vault_pda = + validator_fees_vault_pda_from_validator(&validator_compat).modernize(); + let buffer_pda = match kind { + PreallocateBufferKind::CommitState => { + commit_state_pda_from_delegated_account(&delegated_account_compat) + .modernize() + } + PreallocateBufferKind::UndelegateBuffer => { + undelegate_buffer_pda_from_delegated_account( + &delegated_account_compat, + ) + .modernize() + } + PreallocateBufferKind::DelegatedAccount => delegated_account, + }; + Instruction { + program_id: dlp::id().modernize(), + accounts: vec![ + AccountMeta::new(validator, true), + AccountMeta::new_readonly(delegated_account, false), + AccountMeta::new_readonly(delegation_record_pda, false), + AccountMeta::new(buffer_pda, false), + AccountMeta::new_readonly(commit_record_pda, false), + AccountMeta::new_readonly(validator_fees_vault_pda, false), + AccountMeta::new_readonly(system_program::id(), false), + ], + data: [DlpDiscriminator::PreallocateBuffer.to_vec(), args].concat(), + } +} + +/// Returns the sequence of `preallocate_buffer` instructions needed to grow +/// a buffer of `kind` from `current_size` up to `target_size` +pub fn preallocate_buffer_chunks( + validator: Pubkey, + delegated_account: Pubkey, + kind: PreallocateBufferKind, + current_size: u32, + target_size: u32, +) -> Vec { + let growth = target_size.saturating_sub(current_size); + let chunks = growth.div_ceil(MAX_PERMITTED_DATA_INCREASE as u32) as usize; + (0..chunks) + .map(|_| { + preallocate_buffer(validator, delegated_account, kind, target_size) + }) + .collect() +} + +/// +/// Returns accounts-data-size budget for preallocate_buffer instruction. +/// +/// This value can be used with ComputeBudgetInstruction::SetLoadedAccountsDataSizeLimit +/// +pub fn preallocate_buffer_size_budget( + delegated_account: AccountSizeClass, +) -> u32 { + total_size_budget(&[ + DLP_PROGRAM_DATA_SIZE_CLASS, + AccountSizeClass::Tiny, // validator + delegated_account, // delegated_account + AccountSizeClass::Tiny, // delegation_record_pda + delegated_account, // buffer_pda + AccountSizeClass::Tiny, // commit_record_pda + AccountSizeClass::Tiny, // validator_fees_vault_pda + AccountSizeClass::Tiny, // system_program + ]) +} From 44618faa726a740212c9aa4927593109dbd5f68c Mon Sep 17 00:00:00 2001 From: taco-paco Date: Wed, 12 Aug 2026 23:27:55 +0300 Subject: [PATCH 03/10] add test --- tests/test_preallocate_buffer.rs | 581 +++++++++++++++++++++++++++++++ 1 file changed, 581 insertions(+) create mode 100644 tests/test_preallocate_buffer.rs diff --git a/tests/test_preallocate_buffer.rs b/tests/test_preallocate_buffer.rs new file mode 100644 index 00000000..dc439e19 --- /dev/null +++ b/tests/test_preallocate_buffer.rs @@ -0,0 +1,581 @@ +use dlp::solana_program; +use dlp_api::{ + args::{CommitStateFromBufferArgs, PreallocateBufferKind}, + error::DlpError, + pda::{ + commit_record_pda_from_delegated_account, + commit_state_pda_from_delegated_account, + delegation_metadata_pda_from_delegated_account, + delegation_record_pda_from_delegated_account, + program_config_from_program_id, + undelegate_buffer_pda_from_delegated_account, + validator_fees_vault_pda_from_validator, + }, +}; +use solana_program::{ + account_info::MAX_PERMITTED_DATA_INCREASE, hash::Hash, + native_token::LAMPORTS_PER_SOL, rent::Rent, +}; +use solana_program_test::{BanksClient, BanksClientError, ProgramTest}; +use solana_sdk::{ + account::Account, + instruction::InstructionError, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, +}; +use solana_sdk_ids::system_program; + +use crate::fixtures::{ + get_commit_record_account_data, get_delegation_metadata_data, + get_delegation_record_data, DELEGATED_PDA_ID, DELEGATED_PDA_OWNER_ID, + TEST_AUTHORITY, +}; + +mod fixtures; + +fn assert_custom_error(err: BanksClientError, expected: DlpError) { + match err { + BanksClientError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(code), + )) => { + assert_eq!(code, expected as u32, "unexpected error code"); + } + other => panic!("expected custom error {expected:?}, got {other:?}"), + } +} + +#[tokio::test] +async fn test_preallocate_commit_state_creates_fresh() { + let (banks, _, validator, blockhash) = + setup_program_test_env(false, None).await; + + let target_size: u32 = 500; + let ix = dlp_api::instruction_builder::preallocate_buffer( + validator.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::CommitState, + target_size, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res.is_ok(), "{:?}", res); + + let commit_state_pda = + commit_state_pda_from_delegated_account(&DELEGATED_PDA_ID); + let commit_state_account = + banks.get_account(commit_state_pda).await.unwrap().unwrap(); + assert_eq!(commit_state_account.owner, dlp_api::id()); + assert_eq!(commit_state_account.data.len(), target_size as usize); + assert!(commit_state_account.data.iter().all(|&b| b == 0)); +} + +#[tokio::test] +async fn test_preallocate_undelegate_buffer_creates_fresh() { + let (banks, _, validator, blockhash) = + setup_program_test_env(false, None).await; + + let target_size: u32 = 500; + let ix = dlp_api::instruction_builder::preallocate_buffer( + validator.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::UndelegateBuffer, + target_size, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res.is_ok(), "{:?}", res); + + let undelegate_buffer_pda = + undelegate_buffer_pda_from_delegated_account(&DELEGATED_PDA_ID); + let undelegate_buffer_account = banks + .get_account(undelegate_buffer_pda) + .await + .unwrap() + .unwrap(); + assert_eq!(undelegate_buffer_account.owner, dlp_api::id()); + assert_eq!(undelegate_buffer_account.data.len(), target_size as usize); + assert!(undelegate_buffer_account.data.iter().all(|&b| b == 0)); +} + +#[tokio::test] +async fn test_preallocate_delegated_account_grows_and_ignores_commit_in_flight() +{ + // A commit is in flight (commit_record already initialized): the + // DelegatedAccount kind is meant to run precisely in this window + // (between commit_state and finalize), so it must not be blocked by it. + let (banks, _, validator, blockhash) = + setup_program_test_env(true, None).await; + + let target_size: u32 = 500; + let ix = dlp_api::instruction_builder::preallocate_buffer( + validator.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::DelegatedAccount, + target_size, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res.is_ok(), "{:?}", res); + + let delegated_account = + banks.get_account(DELEGATED_PDA_ID).await.unwrap().unwrap(); + assert_eq!(delegated_account.data.len(), target_size as usize); + assert!(delegated_account.data.iter().all(|&b| b == 0)); +} + +#[tokio::test] +async fn test_preallocate_commit_state_idempotent_past_target() { + let (banks, _, validator, blockhash) = + setup_program_test_env(false, None).await; + + // Grow to 5_000 bytes first. + let ix = dlp_api::instruction_builder::preallocate_buffer( + validator.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::CommitState, + 5_000, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + assert!(banks.process_transaction(tx).await.is_ok()); + + // Calling again with a smaller target must be a no-op (never shrinks). + let ix = dlp_api::instruction_builder::preallocate_buffer( + validator.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::CommitState, + 3_000, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res.is_ok(), "{:?}", res); + + let commit_state_pda = + commit_state_pda_from_delegated_account(&DELEGATED_PDA_ID); + let commit_state_account = + banks.get_account(commit_state_pda).await.unwrap().unwrap(); + assert_eq!(commit_state_account.data.len(), 5_000); +} + +#[tokio::test] +async fn test_preallocate_commit_state_multiple_instructions_same_tx_exceed_cap( +) { + // The single most important property of this feature: several + // PreallocateBuffer instructions packed into ONE transaction can grow an + // account past MAX_PERMITTED_DATA_INCREASE, because the growth cap + // resets for each top-level instruction. + let (banks, _, validator, blockhash) = + setup_program_test_env(false, None).await; + + let target_size: u32 = MAX_PERMITTED_DATA_INCREASE as u32 + 4_760; // 15_000 + let ixs = dlp_api::instruction_builder::preallocate_buffer_chunks( + validator.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::CommitState, + 0, + target_size, + ); + assert_eq!(ixs.len(), 2, "expected exactly two growth steps"); + + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res.is_ok(), "{:?}", res); + + let commit_state_pda = + commit_state_pda_from_delegated_account(&DELEGATED_PDA_ID); + let commit_state_account = + banks.get_account(commit_state_pda).await.unwrap().unwrap(); + assert_eq!(commit_state_account.data.len(), target_size as usize); + assert!(commit_state_account.data.iter().all(|&b| b == 0)); +} + +#[tokio::test] +async fn test_preallocate_commit_state_rejects_when_commit_in_flight() { + let (banks, _, validator, blockhash) = + setup_program_test_env(true, None).await; + + let ix = dlp_api::instruction_builder::preallocate_buffer( + validator.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::CommitState, + 500, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + let err = banks.process_transaction(tx).await.unwrap_err(); + assert_custom_error(err, DlpError::PreallocateBufferCommitInFlight); +} + +#[tokio::test] +async fn test_preallocate_undelegate_buffer_rejects_when_commit_in_flight() { + let (banks, _, validator, blockhash) = + setup_program_test_env(true, None).await; + + let ix = dlp_api::instruction_builder::preallocate_buffer( + validator.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::UndelegateBuffer, + 500, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + let err = banks.process_transaction(tx).await.unwrap_err(); + assert_custom_error(err, DlpError::PreallocateBufferCommitInFlight); +} + +#[tokio::test] +async fn test_preallocate_rejects_wrong_authority() { + let other_validator = Keypair::new(); + let (banks, _, _validator, blockhash) = + setup_program_test_env(false, Some(other_validator.pubkey())).await; + + // `other_validator` is a registered validator (has a fees vault) but is + // not this delegation's authority. + let ix = dlp_api::instruction_builder::preallocate_buffer( + other_validator.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::CommitState, + 500, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&other_validator.pubkey()), + &[&other_validator], + blockhash, + ); + let err = banks.process_transaction(tx).await.unwrap_err(); + assert_custom_error(err, DlpError::InvalidAuthority); +} + +#[tokio::test] +async fn test_preallocate_rejects_unregistered_validator() { + let (banks, _, _validator, blockhash) = + setup_program_test_env(false, None).await; + + // Fresh validator with no validator_fees_vault PDA set up at all. + let unregistered = Keypair::new(); + let ix = dlp_api::instruction_builder::preallocate_buffer( + unregistered.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::CommitState, + 500, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&_validator.pubkey()), + &[&_validator, &unregistered], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res.is_err(), "expected failure for unregistered validator"); +} + +/// End-to-end: a commit larger than `MAX_PERMITTED_DATA_INCREASE` is +/// preallocated across multiple `PreallocateBuffer` instructions packed into +/// the same transaction as `commit_state_from_buffer`, which then succeeds +/// because the buffer is already at the exact target size. +#[tokio::test] +async fn test_preallocate_then_commit_state_from_buffer_large_succeeds() { + let (banks, _, validator, blockhash) = + setup_program_test_env(false, None).await; + + let target_size: u32 = MAX_PERMITTED_DATA_INCREASE as u32 + 4_760; // 15_000 + let new_state = vec![7u8; target_size as usize]; + + let state_buffer_pda = + Pubkey::find_program_address(&[b"state_buffer"], &validator.pubkey()) + .0; + + let mut ixs = dlp_api::instruction_builder::preallocate_buffer_chunks( + validator.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::CommitState, + 0, + target_size, + ); + ixs.push(dlp_api::instruction_builder::commit_state_from_buffer( + validator.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + state_buffer_pda, + CommitStateFromBufferArgs { + nonce: 1, + lamports: Rent::default().minimum_balance(500), + allow_undelegation: true, + }, + )); + + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + + // The buffer holding the new state must itself exist on-chain (its + // content is read directly by `commit_state_from_buffer`, no size limit + // applies to it since it's not created within this transaction). + let banks = seed_state_buffer(banks, state_buffer_pda, new_state.clone()) + .await; + + let res = banks.process_transaction(tx).await; + assert!(res.is_ok(), "{:?}", res); + + let commit_state_pda = + commit_state_pda_from_delegated_account(&DELEGATED_PDA_ID); + let commit_state_account = + banks.get_account(commit_state_pda).await.unwrap().unwrap(); + assert_eq!(commit_state_account.data, new_state); +} + +#[tokio::test] +async fn test_commit_state_from_buffer_large_without_preallocation_fails() { + let (banks, _, validator, blockhash) = + setup_program_test_env(false, None).await; + + let target_size: u32 = MAX_PERMITTED_DATA_INCREASE as u32 + 4_760; + let new_state = vec![7u8; target_size as usize]; + let state_buffer_pda = + Pubkey::find_program_address(&[b"state_buffer"], &validator.pubkey()) + .0; + let banks = seed_state_buffer(banks, state_buffer_pda, new_state).await; + + let ix = dlp_api::instruction_builder::commit_state_from_buffer( + validator.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + state_buffer_pda, + CommitStateFromBufferArgs { + nonce: 1, + lamports: Rent::default().minimum_balance(500), + allow_undelegation: true, + }, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!( + res.is_err(), + "large commit without any preallocation must fail" + ); +} + +#[tokio::test] +async fn test_commit_state_from_buffer_large_wrong_preallocated_size_fails() { + let (banks, _, validator, blockhash) = + setup_program_test_env(false, None).await; + + let target_size: u32 = MAX_PERMITTED_DATA_INCREASE as u32 + 4_760; // 15_000 + let new_state = vec![7u8; target_size as usize]; + let state_buffer_pda = + Pubkey::find_program_address(&[b"state_buffer"], &validator.pubkey()) + .0; + let banks = seed_state_buffer(banks, state_buffer_pda, new_state).await; + + // Only preallocate one chunk (10_240), short of the 15_000 target. + let mut ixs = vec![dlp_api::instruction_builder::preallocate_buffer( + validator.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::CommitState, + target_size, + )]; + ixs.push(dlp_api::instruction_builder::commit_state_from_buffer( + validator.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + state_buffer_pda, + CommitStateFromBufferArgs { + nonce: 1, + lamports: Rent::default().minimum_balance(500), + allow_undelegation: true, + }, + )); + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + let err = banks.process_transaction(tx).await.unwrap_err(); + assert_custom_error(err, DlpError::BufferNotPreallocatedToExactSize); +} + +/// Adds the state-buffer account holding the not-yet-committed data. Since +/// `ProgramTest` only accepts new accounts before `.start()`, tests that +/// need this alongside the standard fixture rebuild the environment with it +/// included rather than mutating a running `BanksClient`. +async fn seed_state_buffer( + _banks: BanksClient, + _state_buffer_pda: Pubkey, + _data: Vec, +) -> BanksClient { + unreachable!( + "placeholder replaced by setup_program_test_env_with_state_buffer" + ) +} + +#[allow(clippy::too_many_arguments)] +async fn setup_program_test_env( + with_in_flight_commit: bool, + extra_registered_validator: Option, +) -> (BanksClient, Keypair, Keypair, Hash) { + let mut program_test = ProgramTest::new("dlp", dlp_api::ID, None); + program_test.prefer_bpf(true); + + let validator_keypair = + crate::fixtures::keypair_from_bytes(&TEST_AUTHORITY); + + program_test.add_account( + validator_keypair.pubkey(), + Account { + lamports: 10 * LAMPORTS_PER_SOL, + data: vec![], + owner: system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup a delegated PDA + program_test.add_account( + DELEGATED_PDA_ID, + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup the delegated account metadata PDA + let delegation_metadata_data = + get_delegation_metadata_data(validator_keypair.pubkey(), None); + program_test.add_account( + delegation_metadata_pda_from_delegated_account(&DELEGATED_PDA_ID), + Account { + lamports: Rent::default() + .minimum_balance(delegation_metadata_data.len()), + data: delegation_metadata_data, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup the delegated record PDA + let delegation_record_data = + get_delegation_record_data(validator_keypair.pubkey(), None); + program_test.add_account( + delegation_record_pda_from_delegated_account(&DELEGATED_PDA_ID), + Account { + lamports: Rent::default() + .minimum_balance(delegation_record_data.len()), + data: delegation_record_data, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup the validator fees vault + program_test.add_account( + validator_fees_vault_pda_from_validator(&validator_keypair.pubkey()), + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + if let Some(other_validator) = extra_registered_validator { + program_test.add_account( + other_validator, + Account { + lamports: 10 * LAMPORTS_PER_SOL, + data: vec![], + owner: system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); + program_test.add_account( + validator_fees_vault_pda_from_validator(&other_validator), + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + } + + if with_in_flight_commit { + let commit_record_data = + get_commit_record_account_data(validator_keypair.pubkey()); + program_test.add_account( + commit_record_pda_from_delegated_account(&DELEGATED_PDA_ID), + Account { + lamports: Rent::default() + .minimum_balance(commit_record_data.len()), + data: commit_record_data, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + } + + // Absent program config PDA is treated as "no whitelist configured" by + // `require_program_config`, so it's intentionally left unset here. + let _ = program_config_from_program_id(&DELEGATED_PDA_OWNER_ID); + + let (banks, payer, blockhash) = program_test.start().await; + (banks, payer, validator_keypair, blockhash) +} From d23e8948df4178b527b68536cdc6117830f691a4 Mon Sep 17 00:00:00 2001 From: taco-paco Date: Fri, 14 Aug 2026 11:50:35 +0300 Subject: [PATCH 04/10] feat: add tests --- src/processor/fast/preallocate_buffer.rs | 23 +- tests/test_commit_finalize_from_buffer.rs | 183 +++++++++++++- tests/test_commit_state_from_buffer.rs | 290 +++++++++++++++++++++- tests/test_finalize.rs | 126 +++++++++- tests/test_preallocate_buffer.rs | 183 ++------------ 5 files changed, 618 insertions(+), 187 deletions(-) diff --git a/src/processor/fast/preallocate_buffer.rs b/src/processor/fast/preallocate_buffer.rs index 5b408574..1d197c8b 100644 --- a/src/processor/fast/preallocate_buffer.rs +++ b/src/processor/fast/preallocate_buffer.rs @@ -48,14 +48,17 @@ use crate::{ /// - delegation record is initialized /// - validator is a registered validator (has an initialized fees vault) and /// is the delegation's authority -/// - for `kind = CommitState` / `UndelegateBuffer`, no commit is currently in -/// flight for the delegated account (commit record must be uninitialized), -/// mirroring the precondition `commit_state`/`commit_diff` and `undelegate` -/// themselves enforce before they'll create these buffers. -/// - `kind = DelegatedAccount` has no such restriction: it's meant to run -/// *between* `commit_state` and `finalize`, i.e. exactly while a commit -/// record is initialized, to pre-grow the account before finalize's -/// single-shot resize. +/// - for `kind = CommitState`, no commit is currently in flight for the +/// delegated account (commit record must be uninitialized), mirroring the +/// precondition `commit_state`/`commit_diff` themselves enforce before +/// they'll create this buffer. +/// - `kind = DelegatedAccount` / `UndelegateBuffer` have no such +/// restriction: both are meant to run precisely *while* a commit record is +/// initialized -- `DelegatedAccount` between `commit_state` and `finalize`, +/// to pre-grow the account before finalize's single-shot resize; and +/// `UndelegateBuffer` alongside finalize in a two-stage commit-and-undelegate, +/// ahead of `undelegate`'s own check (which requires the commit record to be +/// uninitialized by the time *it* runs, not by the time it's preallocated). pub fn process_preallocate_buffer( _program_id: &Address, accounts: &[AccountView], @@ -103,10 +106,6 @@ pub fn process_preallocate_buffer( ) } PreallocateBufferKind::UndelegateBuffer => { - require_no_commit_in_flight( - delegated_account, - commit_record_account, - )?; let seeds_arr = [ pda::UNDELEGATE_BUFFER_TAG, delegated_account.address().as_ref(), diff --git a/tests/test_commit_finalize_from_buffer.rs b/tests/test_commit_finalize_from_buffer.rs index 5c861190..f83149e4 100644 --- a/tests/test_commit_finalize_from_buffer.rs +++ b/tests/test_commit_finalize_from_buffer.rs @@ -1,6 +1,6 @@ use dlp::solana_program; use dlp_api::{ - args::CommitFinalizeArgs, + args::{CommitFinalizeArgs, PreallocateBufferKind}, pda::{ delegation_metadata_pda_from_delegated_account, delegation_record_pda_from_delegated_account, @@ -8,15 +8,20 @@ use dlp_api::{ }, state::DelegationMetadata, }; -use solana_program::{hash::Hash, native_token::LAMPORTS_PER_SOL, rent::Rent}; +use solana_program::{ + account_info::MAX_PERMITTED_DATA_INCREASE, hash::Hash, + native_token::LAMPORTS_PER_SOL, rent::Rent, +}; use solana_program_test::{ - BanksClient, BanksTransactionResultWithMetadata, ProgramTest, + BanksClient, BanksClientError, BanksTransactionResultWithMetadata, + ProgramTest, }; use solana_sdk::{ account::Account, + instruction::InstructionError, pubkey::Pubkey, signature::{Keypair, Signer}, - transaction::Transaction, + transaction::{Transaction, TransactionError}, }; use solana_sdk_ids::system_program; @@ -162,6 +167,176 @@ async fn test_commit_finalize_from_buffer_out_of_order() { ); } +/// CommitFinalize writes the new state straight into the delegated account +/// (no intermediate `commit_state` PDA), so a target past +/// `MAX_PERMITTED_DATA_INCREASE` needs the delegated account itself grown +/// beforehand via `PreallocateBufferKind::DelegatedAccount`. +#[tokio::test] +async fn test_commit_finalize_from_buffer_large_with_preallocation() { + let target_size = MAX_PERMITTED_DATA_INCREASE + 4_760; // 15_000 + let new_state = vec![7u8; target_size]; + + let (banks, _, authority, blockhash) = + setup_program_test_env(vec![], new_state.clone()).await; + + let state_buffer_pda = + Pubkey::find_program_address(&[b"state_buffer"], &authority.pubkey()).0; + + let mut ixs = dlp_api::instruction_builder::preallocate_buffer_chunks( + authority.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::DelegatedAccount, + 0, + target_size as u32, + ); + let (ix, _pdas) = dlp_api::instruction_builder::commit_finalize_from_buffer( + authority.pubkey(), + DELEGATED_PDA_ID, + state_buffer_pda, + &mut CommitFinalizeArgs { + commit_id: 1, + allow_undelegation: true.into(), + data_is_diff: false.into(), + lamports: 1_000_000, + bumps: Default::default(), + reserved_padding: Default::default(), + }, + ); + ixs.push(ix); + + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res.is_ok(), "{:?}", res); + + let delegated_account = + banks.get_account(DELEGATED_PDA_ID).await.unwrap().unwrap(); + assert_eq!(delegated_account.data, new_state); +} + +#[tokio::test] +async fn test_commit_finalize_from_buffer_large_without_preallocation_fails() { + let target_size = MAX_PERMITTED_DATA_INCREASE + 4_760; // 15_000 + let new_state = vec![7u8; target_size]; + + let (banks, _, authority, blockhash) = + setup_program_test_env(vec![], new_state).await; + + let state_buffer_pda = + Pubkey::find_program_address(&[b"state_buffer"], &authority.pubkey()).0; + + let (ix, _pdas) = dlp_api::instruction_builder::commit_finalize_from_buffer( + authority.pubkey(), + DELEGATED_PDA_ID, + state_buffer_pda, + &mut CommitFinalizeArgs { + commit_id: 1, + allow_undelegation: true.into(), + data_is_diff: false.into(), + lamports: 1_000_000, + bumps: Default::default(), + reserved_padding: Default::default(), + }, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let err = banks.process_transaction(tx).await.unwrap_err(); + // The un-preallocated delegated account starts at 0 bytes; CommitFinalize + // writes directly into it, so its own `resize()` to the full 15_000-byte + // target exceeds MAX_PERMITTED_DATA_INCREASE in a single instruction. + assert!( + matches!( + err, + BanksClientError::TransactionError( + TransactionError::InstructionError( + _, + InstructionError::InvalidRealloc, + ) + ) + ), + "expected InvalidRealloc, got {err:?}" + ); +} + +/// Distinct from the "no preallocation at all" case above: here the +/// delegated account *was* preallocated, just not far enough -- the gap left +/// to the actual target still exceeds `MAX_PERMITTED_DATA_INCREASE`. +/// CommitFinalize (unlike `commit_state_from_buffer`) has no "must be +/// preallocated to the exact size" check of its own -- it just resizes +/// directly -- so this is exercising the same native realloc cap, not a +/// dedicated dlp error. +#[tokio::test] +async fn test_commit_finalize_from_buffer_large_wrong_preallocated_size_fails() +{ + let target_size = 25_000usize; + let new_state = vec![7u8; target_size]; + + let (banks, _, authority, blockhash) = + setup_program_test_env(vec![], new_state).await; + + let state_buffer_pda = + Pubkey::find_program_address(&[b"state_buffer"], &authority.pubkey()).0; + + // Only send the first growth step (10_240 bytes) toward the 25_000 + // target, leaving a 14_760-byte gap -- still short of the target by more + // than one instruction can bridge. + let all_steps = dlp_api::instruction_builder::preallocate_buffer_chunks( + authority.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::DelegatedAccount, + 0, + target_size as u32, + ); + assert!( + all_steps.len() > 1, + "test setup expects the full growth to require multiple steps" + ); + let mut ixs = vec![all_steps[0].clone()]; + + let (ix, _pdas) = dlp_api::instruction_builder::commit_finalize_from_buffer( + authority.pubkey(), + DELEGATED_PDA_ID, + state_buffer_pda, + &mut CommitFinalizeArgs { + commit_id: 1, + allow_undelegation: true.into(), + data_is_diff: false.into(), + lamports: 1_000_000, + bumps: Default::default(), + reserved_padding: Default::default(), + }, + ); + ixs.push(ix); + + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let err = banks.process_transaction(tx).await.unwrap_err(); + assert!( + matches!( + err, + BanksClientError::TransactionError( + TransactionError::InstructionError( + _, + InstructionError::InvalidRealloc, + ) + ) + ), + "expected InvalidRealloc, got {err:?}" + ); +} + async fn setup_program_test_env( pda_data: Vec, pda_new_state: Vec, diff --git a/tests/test_commit_state_from_buffer.rs b/tests/test_commit_state_from_buffer.rs index 7a76aab5..917b2f60 100644 --- a/tests/test_commit_state_from_buffer.rs +++ b/tests/test_commit_state_from_buffer.rs @@ -1,6 +1,8 @@ use dlp::solana_program; use dlp_api::{ - args::CommitStateFromBufferArgs, + args::{CommitStateFromBufferArgs, PreallocateBufferKind}, + diff::compute_diff, + error::DlpError, pda::{ commit_record_pda_from_delegated_account, commit_state_pda_from_delegated_account, @@ -10,13 +12,17 @@ use dlp_api::{ }, state::{CommitRecord, DelegationMetadata}, }; -use solana_program::{hash::Hash, native_token::LAMPORTS_PER_SOL, rent::Rent}; -use solana_program_test::{BanksClient, ProgramTest}; +use solana_program::{ + account_info::MAX_PERMITTED_DATA_INCREASE, hash::Hash, + native_token::LAMPORTS_PER_SOL, rent::Rent, +}; +use solana_program_test::{BanksClient, BanksClientError, ProgramTest}; use solana_sdk::{ account::Account, + instruction::InstructionError, pubkey::Pubkey, signature::{Keypair, Signer}, - transaction::Transaction, + transaction::{Transaction, TransactionError}, }; use solana_sdk_ids::system_program; @@ -32,7 +38,8 @@ const NEW_STATE: [u8; 10] = [0, 1, 2, 9, 9, 9, 6, 7, 8, 9]; #[tokio::test] async fn test_commit_new_state_from_buffer() { // Setup - let (banks, _, authority, blockhash) = setup_program_test_env().await; + let (banks, _, authority, blockhash) = + setup_program_test_env(vec![], NEW_STATE.to_vec()).await; let new_account_balance = 1_000_000; let state_buffer_pda = Pubkey::find_program_address(&[b"state_buffer"], &authority.pubkey()).0; @@ -107,7 +114,274 @@ async fn test_commit_new_state_from_buffer() { ); } -async fn setup_program_test_env() -> (BanksClient, Keypair, Keypair, Hash) { +/// A commit_state target past `MAX_PERMITTED_DATA_INCREASE` needs the +/// commit_state PDA preallocated first via `PreallocateBufferKind::CommitState`. +#[tokio::test] +async fn test_commit_new_state_from_buffer_large_with_preallocation() { + let target_size = MAX_PERMITTED_DATA_INCREASE + 4_760; // 15_000 + let new_state = vec![7u8; target_size]; + + let (banks, _, authority, blockhash) = + setup_program_test_env(vec![], new_state.clone()).await; + let state_buffer_pda = + Pubkey::find_program_address(&[b"state_buffer"], &authority.pubkey()).0; + + let mut ixs = dlp_api::instruction_builder::preallocate_buffer_chunks( + authority.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::CommitState, + 0, + target_size as u32, + ); + ixs.push(dlp_api::instruction_builder::commit_state_from_buffer( + authority.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + state_buffer_pda, + CommitStateFromBufferArgs { + nonce: 1, + lamports: 1_000_000, + allow_undelegation: true, + }, + )); + + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res.is_ok(), "{:?}", res); + + let commit_state_pda = + commit_state_pda_from_delegated_account(&DELEGATED_PDA_ID); + let commit_state_account = + banks.get_account(commit_state_pda).await.unwrap().unwrap(); + assert_eq!(commit_state_account.data, new_state); +} + +#[tokio::test] +async fn test_commit_new_state_from_buffer_large_without_preallocation_fails() { + let target_size = MAX_PERMITTED_DATA_INCREASE + 4_760; // 15_000 + let new_state = vec![7u8; target_size]; + + let (banks, _, authority, blockhash) = + setup_program_test_env(vec![], new_state).await; + let state_buffer_pda = + Pubkey::find_program_address(&[b"state_buffer"], &authority.pubkey()).0; + + let ix = dlp_api::instruction_builder::commit_state_from_buffer( + authority.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + state_buffer_pda, + CommitStateFromBufferArgs { + nonce: 1, + lamports: 1_000_000, + allow_undelegation: true, + }, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let err = banks.process_transaction(tx).await.unwrap_err(); + // commit_state has no PDA to grow yet at all -- create_or_verify_preallocated_pda + // requires it to already exist (initialized by PreallocateBuffer) once + // the target exceeds MAX_PERMITTED_DATA_INCREASE, so this fails the + // ownership check before it can even get to the size comparison. + assert!( + matches!( + err, + BanksClientError::TransactionError( + TransactionError::InstructionError( + _, + InstructionError::InvalidAccountOwner, + ) + ) + ), + "expected InvalidAccountOwner, got {err:?}" + ); +} + +/// Distinct from "no preallocation at all": the commit_state PDA *was* +/// preallocated, but not to the exact target size -- `commit_state_from_buffer` +/// (unlike `finalize`/`commit_finalize_from_buffer`) enforces an exact match +/// via `create_or_verify_preallocated_pda`, since it never resizes on its own. +#[tokio::test] +async fn test_commit_new_state_from_buffer_large_wrong_preallocated_size_fails() +{ + let target_size = MAX_PERMITTED_DATA_INCREASE + 4_760; // 15_000 + let new_state = vec![7u8; target_size]; + + let (banks, _, authority, blockhash) = + setup_program_test_env(vec![], new_state).await; + let state_buffer_pda = + Pubkey::find_program_address(&[b"state_buffer"], &authority.pubkey()).0; + + // Preallocate to a size other than the actual target. + let mut ixs = vec![dlp_api::instruction_builder::preallocate_buffer( + authority.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::CommitState, + target_size as u32 - 1, + )]; + ixs.push(dlp_api::instruction_builder::commit_state_from_buffer( + authority.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + state_buffer_pda, + CommitStateFromBufferArgs { + nonce: 1, + lamports: 1_000_000, + allow_undelegation: true, + }, + )); + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let err = banks.process_transaction(tx).await.unwrap_err(); + assert_custom_error(err, DlpError::BufferNotPreallocatedToExactSize); +} + +/// Builds a large, mostly-matching (base, changed) pair so their diff is +/// genuinely small despite the resulting state being large -- unlike diffing +/// against an empty account, where every byte is "new" and the diff ends up +/// roughly as big as the target itself. +fn large_base_and_small_diff_change(target_size: usize) -> (Vec, Vec) { + let base = vec![1u8; target_size]; + let mut changed = base.clone(); + changed[0..50].fill(9); + (base, changed) +} + +/// The diff-based sibling of `commit_state_from_buffer`. The concern this +/// directly guards against: `create_or_verify_preallocated_pda`'s exact-size +/// check must compare against the *full* post-diff state length +/// (`DiffSet::changed_len()`), never the diff payload's own byte length -- +/// those two are very different numbers here (a small diff producing a large +/// resulting state). +#[tokio::test] +async fn test_commit_diff_from_buffer_large_with_preallocation() { + let target_size = MAX_PERMITTED_DATA_INCREASE + 4_760; // 15_000 + let (base, new_state) = large_base_and_small_diff_change(target_size); + let diff = compute_diff(&base, &new_state); + assert!( + diff.len() < 1_000, + "test setup expects a small diff payload despite the large target" + ); + + let (banks, _, authority, blockhash) = + setup_program_test_env(base, diff.to_vec()).await; + let diff_buffer_pda = + Pubkey::find_program_address(&[b"state_buffer"], &authority.pubkey()).0; + + let mut ixs = dlp_api::instruction_builder::preallocate_buffer_chunks( + authority.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::CommitState, + 0, + target_size as u32, + ); + ixs.push(dlp_api::instruction_builder::commit_diff_from_buffer( + authority.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + diff_buffer_pda, + CommitStateFromBufferArgs { + nonce: 1, + lamports: 1_000_000, + allow_undelegation: true, + }, + )); + + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res.is_ok(), "{:?}", res); + + let commit_state_pda = + commit_state_pda_from_delegated_account(&DELEGATED_PDA_ID); + let commit_state_account = + banks.get_account(commit_state_pda).await.unwrap().unwrap(); + assert_eq!(commit_state_account.data, new_state); +} + +/// Preallocating to the *diff's* length instead of the full post-diff state +/// length must be rejected -- if this ever passed, it would mean the exact- +/// size check regressed to comparing against the wrong number. +#[tokio::test] +async fn test_commit_diff_from_buffer_preallocated_to_diff_len_fails() { + let target_size = MAX_PERMITTED_DATA_INCREASE + 4_760; // 15_000 + let (base, new_state) = large_base_and_small_diff_change(target_size); + let diff = compute_diff(&base, &new_state); + assert!( + diff.len() < MAX_PERMITTED_DATA_INCREASE, + "test setup expects a diff payload that fits in one prealloc step" + ); + + let (banks, _, authority, blockhash) = + setup_program_test_env(base, diff.to_vec()).await; + let diff_buffer_pda = + Pubkey::find_program_address(&[b"state_buffer"], &authority.pubkey()).0; + + // Preallocate to the diff's own length, not the full target -- wrong on + // purpose. + let mut ixs = vec![dlp_api::instruction_builder::preallocate_buffer( + authority.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::CommitState, + diff.len() as u32, + )]; + ixs.push(dlp_api::instruction_builder::commit_diff_from_buffer( + authority.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + diff_buffer_pda, + CommitStateFromBufferArgs { + nonce: 1, + lamports: 1_000_000, + allow_undelegation: true, + }, + )); + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let err = banks.process_transaction(tx).await.unwrap_err(); + assert_custom_error(err, DlpError::BufferNotPreallocatedToExactSize); +} + +fn assert_custom_error(err: BanksClientError, expected: DlpError) { + match err { + BanksClientError::TransactionError( + TransactionError::InstructionError( + _, + InstructionError::Custom(code), + ), + ) => { + assert_eq!(code, expected as u32, "unexpected error code"); + } + other => panic!("expected custom error {expected:?}, got {other:?}"), + } +} + +async fn setup_program_test_env( + delegated_pda_data: Vec, + state_buffer_data: Vec, +) -> (BanksClient, Keypair, Keypair, Hash) { let mut program_test = ProgramTest::new("dlp", dlp_api::ID, None); program_test.prefer_bpf(true); @@ -130,7 +404,7 @@ async fn setup_program_test_env() -> (BanksClient, Keypair, Keypair, Hash) { DELEGATED_PDA_ID, Account { lamports: LAMPORTS_PER_SOL, - data: vec![], + data: delegated_pda_data, owner: dlp_api::id(), executable: false, rent_epoch: 0, @@ -188,7 +462,7 @@ async fn setup_program_test_env() -> (BanksClient, Keypair, Keypair, Hash) { .0, Account { lamports: LAMPORTS_PER_SOL, - data: NEW_STATE.to_vec(), + data: state_buffer_data, owner: dlp_api::id(), executable: false, rent_epoch: 0, diff --git a/tests/test_finalize.rs b/tests/test_finalize.rs index 030141e8..268049d4 100644 --- a/tests/test_finalize.rs +++ b/tests/test_finalize.rs @@ -1,5 +1,6 @@ use dlp::solana_program; use dlp_api::{ + args::PreallocateBufferKind, pda::{ commit_record_pda_from_delegated_account, commit_state_pda_from_delegated_account, @@ -9,12 +10,16 @@ use dlp_api::{ }, state::{CommitRecord, DelegationMetadata}, }; -use solana_program::{hash::Hash, native_token::LAMPORTS_PER_SOL, rent::Rent}; -use solana_program_test::{BanksClient, ProgramTest}; +use solana_program::{ + account_info::MAX_PERMITTED_DATA_INCREASE, hash::Hash, + native_token::LAMPORTS_PER_SOL, rent::Rent, +}; +use solana_program_test::{BanksClient, BanksClientError, ProgramTest}; use solana_sdk::{ account::Account, + instruction::InstructionError, signature::{Keypair, Signer}, - transaction::Transaction, + transaction::{Transaction, TransactionError}, }; use solana_sdk_ids::system_program; @@ -29,7 +34,8 @@ mod fixtures; #[tokio::test] async fn test_finalize() { // Setup - let (banks, _, authority, blockhash) = setup_program_test_env().await; + let (banks, _, authority, blockhash) = + setup_program_test_env(COMMIT_NEW_STATE_ACCOUNT_DATA.into()).await; // Retrieve the accounts let delegation_record_pda = @@ -105,7 +111,115 @@ async fn test_finalize() { assert_eq!(commit_record.nonce, delegation_metadata.last_commit_id); } -async fn setup_program_test_env() -> (BanksClient, Keypair, Keypair, Hash) { +/// Finalize copies `commit_state`'s full content straight into the delegated +/// account (`delegated_account.resize(commit_state_data.len())`), so a +/// target past `MAX_PERMITTED_DATA_INCREASE` needs the delegated account +/// itself grown beforehand via `PreallocateBufferKind::DelegatedAccount`. +#[tokio::test] +async fn test_finalize_large_with_preallocation() { + let target_size = MAX_PERMITTED_DATA_INCREASE + 4_760; // 15_000 + let new_state = vec![7u8; target_size]; + + let (banks, _, authority, blockhash) = + setup_program_test_env(new_state.clone()).await; + + let mut ixs = dlp_api::instruction_builder::preallocate_buffer_chunks( + authority.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::DelegatedAccount, + 0, + target_size as u32, + ); + ixs.push(dlp_api::instruction_builder::finalize( + authority.pubkey(), + DELEGATED_PDA_ID, + )); + + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res.is_ok(), "{:?}", res); + + // The delegated account grew to the full preallocated size and now holds + // the committed state's content exactly. + let delegated_account = + banks.get_account(DELEGATED_PDA_ID).await.unwrap().unwrap(); + assert_eq!(delegated_account.data, new_state); + + // finalize closes both the commit_state and commit_record PDAs once + // their content has been applied, same as for a small-account finalize. + let commit_state_pda = + commit_state_pda_from_delegated_account(&DELEGATED_PDA_ID); + assert!(banks.get_account(commit_state_pda).await.unwrap().is_none()); + let commit_record_pda = + commit_record_pda_from_delegated_account(&DELEGATED_PDA_ID); + assert!(banks + .get_account(commit_record_pda) + .await + .unwrap() + .is_none()); +} + +/// The delegated account *was* preallocated, just not far enough -- the gap +/// left to the actual target still exceeds `MAX_PERMITTED_DATA_INCREASE`. +/// `finalize` has no "must be preallocated to the exact size" check of its +/// own -- it just resizes directly -- so this exercises the native realloc +/// cap, not a dedicated dlp error. +#[tokio::test] +async fn test_finalize_large_wrong_preallocated_size_fails() { + let target_size = 25_000usize; + let new_state = vec![7u8; target_size]; + + let (banks, _, authority, blockhash) = + setup_program_test_env(new_state).await; + + // Only send the first growth step (10_240 bytes) toward the 25_000 + // target, leaving a 14_760-byte gap. + let all_steps = dlp_api::instruction_builder::preallocate_buffer_chunks( + authority.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::DelegatedAccount, + 0, + target_size as u32, + ); + assert!( + all_steps.len() > 1, + "test setup expects the full growth to require multiple steps" + ); + let mut ixs = vec![all_steps[0].clone()]; + ixs.push(dlp_api::instruction_builder::finalize( + authority.pubkey(), + DELEGATED_PDA_ID, + )); + + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let err = banks.process_transaction(tx).await.unwrap_err(); + assert!( + matches!( + err, + BanksClientError::TransactionError( + TransactionError::InstructionError( + _, + InstructionError::InvalidRealloc, + ) + ) + ), + "expected InvalidRealloc, got {err:?}" + ); +} + +async fn setup_program_test_env( + commit_state_data: Vec, +) -> (BanksClient, Keypair, Keypair, Hash) { let mut program_test = ProgramTest::new("dlp", dlp_api::ID, None); program_test.prefer_bpf(true); @@ -169,7 +283,7 @@ async fn setup_program_test_env() -> (BanksClient, Keypair, Keypair, Hash) { commit_state_pda_from_delegated_account(&DELEGATED_PDA_ID), Account { lamports: LAMPORTS_PER_SOL, - data: COMMIT_NEW_STATE_ACCOUNT_DATA.into(), + data: commit_state_data, owner: dlp_api::id(), executable: false, rent_epoch: 0, diff --git a/tests/test_preallocate_buffer.rs b/tests/test_preallocate_buffer.rs index dc439e19..03820324 100644 --- a/tests/test_preallocate_buffer.rs +++ b/tests/test_preallocate_buffer.rs @@ -1,6 +1,6 @@ use dlp::solana_program; use dlp_api::{ - args::{CommitStateFromBufferArgs, PreallocateBufferKind}, + args::PreallocateBufferKind, error::DlpError, pda::{ commit_record_pda_from_delegated_account, @@ -36,10 +36,12 @@ mod fixtures; fn assert_custom_error(err: BanksClientError, expected: DlpError) { match err { - BanksClientError::TransactionError(TransactionError::InstructionError( - _, - InstructionError::Custom(code), - )) => { + BanksClientError::TransactionError( + TransactionError::InstructionError( + _, + InstructionError::Custom(code), + ), + ) => { assert_eq!(code, expected as u32, "unexpected error code"); } other => panic!("expected custom error {expected:?}, got {other:?}"), @@ -242,15 +244,21 @@ async fn test_preallocate_commit_state_rejects_when_commit_in_flight() { } #[tokio::test] -async fn test_preallocate_undelegate_buffer_rejects_when_commit_in_flight() { +async fn test_preallocate_undelegate_buffer_ignores_commit_in_flight() { + // Unlike CommitState, UndelegateBuffer isn't gated on "no commit in + // flight": in a two-stage commit-and-undelegate, the undelegate buffer + // is prepared alongside finalize -- i.e. precisely while the prior + // stage's commit record is still initialized -- so it must not be + // blocked by it, same reasoning as DelegatedAccount. let (banks, _, validator, blockhash) = setup_program_test_env(true, None).await; + let target_size: u32 = 500; let ix = dlp_api::instruction_builder::preallocate_buffer( validator.pubkey(), DELEGATED_PDA_ID, PreallocateBufferKind::UndelegateBuffer, - 500, + target_size, ); let tx = Transaction::new_signed_with_payer( &[ix], @@ -258,8 +266,17 @@ async fn test_preallocate_undelegate_buffer_rejects_when_commit_in_flight() { &[&validator], blockhash, ); - let err = banks.process_transaction(tx).await.unwrap_err(); - assert_custom_error(err, DlpError::PreallocateBufferCommitInFlight); + let res = banks.process_transaction(tx).await; + assert!(res.is_ok(), "{:?}", res); + + let undelegate_buffer_pda = + undelegate_buffer_pda_from_delegated_account(&DELEGATED_PDA_ID); + let undelegate_buffer_account = banks + .get_account(undelegate_buffer_pda) + .await + .unwrap() + .unwrap(); + assert_eq!(undelegate_buffer_account.data.len(), target_size as usize); } #[tokio::test] @@ -309,154 +326,6 @@ async fn test_preallocate_rejects_unregistered_validator() { assert!(res.is_err(), "expected failure for unregistered validator"); } -/// End-to-end: a commit larger than `MAX_PERMITTED_DATA_INCREASE` is -/// preallocated across multiple `PreallocateBuffer` instructions packed into -/// the same transaction as `commit_state_from_buffer`, which then succeeds -/// because the buffer is already at the exact target size. -#[tokio::test] -async fn test_preallocate_then_commit_state_from_buffer_large_succeeds() { - let (banks, _, validator, blockhash) = - setup_program_test_env(false, None).await; - - let target_size: u32 = MAX_PERMITTED_DATA_INCREASE as u32 + 4_760; // 15_000 - let new_state = vec![7u8; target_size as usize]; - - let state_buffer_pda = - Pubkey::find_program_address(&[b"state_buffer"], &validator.pubkey()) - .0; - - let mut ixs = dlp_api::instruction_builder::preallocate_buffer_chunks( - validator.pubkey(), - DELEGATED_PDA_ID, - PreallocateBufferKind::CommitState, - 0, - target_size, - ); - ixs.push(dlp_api::instruction_builder::commit_state_from_buffer( - validator.pubkey(), - DELEGATED_PDA_ID, - DELEGATED_PDA_OWNER_ID, - state_buffer_pda, - CommitStateFromBufferArgs { - nonce: 1, - lamports: Rent::default().minimum_balance(500), - allow_undelegation: true, - }, - )); - - let tx = Transaction::new_signed_with_payer( - &ixs, - Some(&validator.pubkey()), - &[&validator], - blockhash, - ); - - // The buffer holding the new state must itself exist on-chain (its - // content is read directly by `commit_state_from_buffer`, no size limit - // applies to it since it's not created within this transaction). - let banks = seed_state_buffer(banks, state_buffer_pda, new_state.clone()) - .await; - - let res = banks.process_transaction(tx).await; - assert!(res.is_ok(), "{:?}", res); - - let commit_state_pda = - commit_state_pda_from_delegated_account(&DELEGATED_PDA_ID); - let commit_state_account = - banks.get_account(commit_state_pda).await.unwrap().unwrap(); - assert_eq!(commit_state_account.data, new_state); -} - -#[tokio::test] -async fn test_commit_state_from_buffer_large_without_preallocation_fails() { - let (banks, _, validator, blockhash) = - setup_program_test_env(false, None).await; - - let target_size: u32 = MAX_PERMITTED_DATA_INCREASE as u32 + 4_760; - let new_state = vec![7u8; target_size as usize]; - let state_buffer_pda = - Pubkey::find_program_address(&[b"state_buffer"], &validator.pubkey()) - .0; - let banks = seed_state_buffer(banks, state_buffer_pda, new_state).await; - - let ix = dlp_api::instruction_builder::commit_state_from_buffer( - validator.pubkey(), - DELEGATED_PDA_ID, - DELEGATED_PDA_OWNER_ID, - state_buffer_pda, - CommitStateFromBufferArgs { - nonce: 1, - lamports: Rent::default().minimum_balance(500), - allow_undelegation: true, - }, - ); - let tx = Transaction::new_signed_with_payer( - &[ix], - Some(&validator.pubkey()), - &[&validator], - blockhash, - ); - let res = banks.process_transaction(tx).await; - assert!( - res.is_err(), - "large commit without any preallocation must fail" - ); -} - -#[tokio::test] -async fn test_commit_state_from_buffer_large_wrong_preallocated_size_fails() { - let (banks, _, validator, blockhash) = - setup_program_test_env(false, None).await; - - let target_size: u32 = MAX_PERMITTED_DATA_INCREASE as u32 + 4_760; // 15_000 - let new_state = vec![7u8; target_size as usize]; - let state_buffer_pda = - Pubkey::find_program_address(&[b"state_buffer"], &validator.pubkey()) - .0; - let banks = seed_state_buffer(banks, state_buffer_pda, new_state).await; - - // Only preallocate one chunk (10_240), short of the 15_000 target. - let mut ixs = vec![dlp_api::instruction_builder::preallocate_buffer( - validator.pubkey(), - DELEGATED_PDA_ID, - PreallocateBufferKind::CommitState, - target_size, - )]; - ixs.push(dlp_api::instruction_builder::commit_state_from_buffer( - validator.pubkey(), - DELEGATED_PDA_ID, - DELEGATED_PDA_OWNER_ID, - state_buffer_pda, - CommitStateFromBufferArgs { - nonce: 1, - lamports: Rent::default().minimum_balance(500), - allow_undelegation: true, - }, - )); - let tx = Transaction::new_signed_with_payer( - &ixs, - Some(&validator.pubkey()), - &[&validator], - blockhash, - ); - let err = banks.process_transaction(tx).await.unwrap_err(); - assert_custom_error(err, DlpError::BufferNotPreallocatedToExactSize); -} - -/// Adds the state-buffer account holding the not-yet-committed data. Since -/// `ProgramTest` only accepts new accounts before `.start()`, tests that -/// need this alongside the standard fixture rebuild the environment with it -/// included rather than mutating a running `BanksClient`. -async fn seed_state_buffer( - _banks: BanksClient, - _state_buffer_pda: Pubkey, - _data: Vec, -) -> BanksClient { - unreachable!( - "placeholder replaced by setup_program_test_env_with_state_buffer" - ) -} - #[allow(clippy::too_many_arguments)] async fn setup_program_test_env( with_in_flight_commit: bool, From d3cf58c74506cb710832edbbeab4071e657a9da6 Mon Sep 17 00:00:00 2001 From: taco-paco Date: Fri, 14 Aug 2026 19:56:45 +0300 Subject: [PATCH 05/10] fix: unnecessary clippy & ambigious test --- tests/test_commit_state_from_buffer.rs | 7 ++++--- tests/test_preallocate_buffer.rs | 1 - 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_commit_state_from_buffer.rs b/tests/test_commit_state_from_buffer.rs index 917b2f60..7ff552f4 100644 --- a/tests/test_commit_state_from_buffer.rs +++ b/tests/test_commit_state_from_buffer.rs @@ -222,13 +222,14 @@ async fn test_commit_new_state_from_buffer_large_wrong_preallocated_size_fails() let state_buffer_pda = Pubkey::find_program_address(&[b"state_buffer"], &authority.pubkey()).0; - // Preallocate to a size other than the actual target. - let mut ixs = vec![dlp_api::instruction_builder::preallocate_buffer( + // Preallocate to a size other than the actual target + let mut ixs = dlp_api::instruction_builder::preallocate_buffer_chunks( authority.pubkey(), DELEGATED_PDA_ID, PreallocateBufferKind::CommitState, + 0, target_size as u32 - 1, - )]; + ); ixs.push(dlp_api::instruction_builder::commit_state_from_buffer( authority.pubkey(), DELEGATED_PDA_ID, diff --git a/tests/test_preallocate_buffer.rs b/tests/test_preallocate_buffer.rs index 03820324..bed1ed13 100644 --- a/tests/test_preallocate_buffer.rs +++ b/tests/test_preallocate_buffer.rs @@ -326,7 +326,6 @@ async fn test_preallocate_rejects_unregistered_validator() { assert!(res.is_err(), "expected failure for unregistered validator"); } -#[allow(clippy::too_many_arguments)] async fn setup_program_test_env( with_in_flight_commit: bool, extra_registered_validator: Option, From 3b0ad377a519aa2de8bf1c2f80442324e6380d14 Mon Sep 17 00:00:00 2001 From: taco-paco Date: Fri, 14 Aug 2026 20:03:02 +0300 Subject: [PATCH 06/10] refactor: assert particular error --- tests/test_preallocate_buffer.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/test_preallocate_buffer.rs b/tests/test_preallocate_buffer.rs index bed1ed13..1509cacc 100644 --- a/tests/test_preallocate_buffer.rs +++ b/tests/test_preallocate_buffer.rs @@ -305,7 +305,7 @@ async fn test_preallocate_rejects_wrong_authority() { #[tokio::test] async fn test_preallocate_rejects_unregistered_validator() { - let (banks, _, _validator, blockhash) = + let (banks, _, validator, blockhash) = setup_program_test_env(false, None).await; // Fresh validator with no validator_fees_vault PDA set up at all. @@ -318,12 +318,23 @@ async fn test_preallocate_rejects_unregistered_validator() { ); let tx = Transaction::new_signed_with_payer( &[ix], - Some(&_validator.pubkey()), - &[&_validator, &unregistered], + Some(&validator.pubkey()), + &[&validator, &unregistered], blockhash, ); - let res = banks.process_transaction(tx).await; - assert!(res.is_err(), "expected failure for unregistered validator"); + let err = banks.process_transaction(tx).await.unwrap_err(); + assert!( + matches!( + err, + BanksClientError::TransactionError( + TransactionError::InstructionError( + _, + InstructionError::InvalidAccountOwner, + ) + ) + ), + "expected InvalidAccountOwner, got {err:?}" + ); } async fn setup_program_test_env( From d64cab958a1e83198caca8652fc9f9317e599d39 Mon Sep 17 00:00:00 2001 From: taco-paco Date: Tue, 18 Aug 2026 15:09:22 +0300 Subject: [PATCH 07/10] fix: funds leak --- .../instruction_builder/preallocate_buffer.rs | 2 +- src/processor/fast/preallocate_buffer.rs | 71 ++++--- src/processor/fast/utils/pda.rs | 8 +- tests/test_commit_finalize_from_buffer.rs | 193 ++++++++++++++++++ 4 files changed, 246 insertions(+), 28 deletions(-) diff --git a/dlp-api/src/instruction_builder/preallocate_buffer.rs b/dlp-api/src/instruction_builder/preallocate_buffer.rs index 7507f67a..185e76cb 100644 --- a/dlp-api/src/instruction_builder/preallocate_buffer.rs +++ b/dlp-api/src/instruction_builder/preallocate_buffer.rs @@ -56,7 +56,7 @@ pub fn preallocate_buffer( accounts: vec![ AccountMeta::new(validator, true), AccountMeta::new_readonly(delegated_account, false), - AccountMeta::new_readonly(delegation_record_pda, false), + AccountMeta::new(delegation_record_pda, false), AccountMeta::new(buffer_pda, false), AccountMeta::new_readonly(commit_record_pda, false), AccountMeta::new_readonly(validator_fees_vault_pda, false), diff --git a/src/processor/fast/preallocate_buffer.rs b/src/processor/fast/preallocate_buffer.rs index 1d197c8b..93c88910 100644 --- a/src/processor/fast/preallocate_buffer.rs +++ b/src/processor/fast/preallocate_buffer.rs @@ -36,7 +36,8 @@ use crate::{ /// /// 0: `[signer, writable]` the payer funding rent for the growth /// 1: `[]` the delegated account -/// 2: `[]` the delegation record account +/// 2: `[writable]` the delegation record account (only actually +/// written for `kind = DelegatedAccount`, see below) /// 3: `[writable]` the buffer PDA to grow (unused for `DelegatedAccount`) /// 4: `[]` the commit record account /// 5: `[]` the validator fees vault @@ -59,6 +60,11 @@ use crate::{ /// `UndelegateBuffer` alongside finalize in a two-stage commit-and-undelegate, /// ahead of `undelegate`'s own check (which requires the commit record to be /// uninitialized by the time *it* runs, not by the time it's preallocated). +/// - for `kind = DelegatedAccount`, rent transferred to fund the growth is +/// also added to `delegation_record.lamports`, keeping the ledger in sync +/// with the account's actual balance -- otherwise finalize/commit_finalize's +/// settlement (which diffs against `delegation_record.lamports`) would fund +/// the same growth a second time. pub fn process_preallocate_buffer( _program_id: &Address, accounts: &[AccountView], @@ -70,12 +76,25 @@ pub fn process_preallocate_buffer( return Err(ProgramError::NotEnoughAccountKeys); }; + require_initialized_delegation_record( + delegated_account, + delegation_record_account, + true, + )?; + let mut delegation_record_data = + delegation_record_account.try_borrow_mut()?; + let delegation_record = + DelegationRecord::try_from_bytes_with_discriminator_mut( + &mut delegation_record_data, + ) + .map_err(to_pinocchio_program_error)?; + // Validate common requirements validate_common( validator, delegated_account, - delegation_record_account, validator_fees_vault, + &delegation_record, )?; let args = PreallocateBufferArgs::try_from_slice(data) @@ -126,12 +145,22 @@ pub fn process_preallocate_buffer( system_program, ) } - PreallocateBufferKind::DelegatedAccount => resize_towards( - delegated_account, - target_size, - validator, - system_program, - ), + PreallocateBufferKind::DelegatedAccount => { + let transferred = resize_towards( + delegated_account, + target_size, + validator, + system_program, + )?; + // The delegated account's balance persists across cycles and is + // tracked by `delegation_record.lamports`; finalize/commit_finalize + // settlement later diffs the ER's reported balance against that + // value, so it must reflect rent this instruction already funded -- + // otherwise settlement re-funds the same growth again. + delegation_record.lamports = + delegation_record.lamports.saturating_add(transferred); + Ok(()) + } } } @@ -141,8 +170,8 @@ pub fn process_preallocate_buffer( fn validate_common( validator: &AccountView, delegated_account: &AccountView, - delegation_record_account: &AccountView, validator_fees_vault: &AccountView, + delegation_record: &DelegationRecord, ) -> ProgramResult { // Ensure validator is signer and is whitelisted require_signer(validator, "validator")?; @@ -158,19 +187,8 @@ fn validate_common( &crate::fast::ID, "delegated account", )?; - require_initialized_delegation_record( - delegated_account, - delegation_record_account, - false, - )?; // Ensure validator doesn't intervene in other validator's accounts - let delegation_record_data = delegation_record_account.try_borrow()?; - let delegation_record = - DelegationRecord::try_from_bytes_with_discriminator( - &delegation_record_data, - ) - .map_err(to_pinocchio_program_error)?; if !address_eq( &delegation_record.authority.to_bytes().into(), validator.address(), @@ -224,26 +242,29 @@ fn grow_target( payer, ) } else { - resize_towards(target_account, target_size, payer, system_program) + resize_towards(target_account, target_size, payer, system_program)?; + Ok(()) } } /// Resizes `account` towards `target_size` by at most /// `MAX_PERMITTED_DATA_INCREASE` bytes; a no-op if it's already at or past /// `target_size` (shrinking is never growth-capped, so it's left to whichever -/// instruction actually performs the exact-size write). +/// instruction actually performs the exact-size write). Returns the amount +/// of lamports transferred to fund the growth (0 if none was needed). fn resize_towards( account: &AccountView, target_size: usize, payer: &AccountView, system_program: &AccountView, -) -> ProgramResult { +) -> Result { let current = account.data_len(); if current < target_size { let realloc_size = core::cmp::min(target_size - current, MAX_PERMITTED_DATA_INCREASE); let new_size = current + realloc_size; - resize_pda(payer, account, system_program, new_size)?; + resize_pda(payer, account, system_program, new_size) + } else { + Ok(0) } - Ok(()) } diff --git a/src/processor/fast/utils/pda.rs b/src/processor/fast/utils/pda.rs index c2bd6805..6439c672 100644 --- a/src/processor/fast/utils/pda.rs +++ b/src/processor/fast/utils/pda.rs @@ -172,12 +172,15 @@ pub(crate) fn close_pda_with_fees( target_account.resize(0) } +/// Resizes `pda` to `new_size`, topping it up with rent from `payer` if +/// needed. Returns the amount of lamports actually transferred (0 if the +/// account was already rent-exempt for `new_size`). pub(crate) fn resize_pda( payer: &AccountView, pda: &AccountView, _system_program: &AccountView, new_size: usize, -) -> ProgramResult { +) -> Result { let rent = Rent::get()?; let rent_exempt_balance = rent .try_minimum_balance(new_size)? @@ -190,5 +193,6 @@ pub(crate) fn resize_pda( } .invoke()?; } - pda.resize(new_size) + pda.resize(new_size)?; + Ok(rent_exempt_balance) } diff --git a/tests/test_commit_finalize_from_buffer.rs b/tests/test_commit_finalize_from_buffer.rs index f83149e4..180ecbdf 100644 --- a/tests/test_commit_finalize_from_buffer.rs +++ b/tests/test_commit_finalize_from_buffer.rs @@ -218,6 +218,96 @@ async fn test_commit_finalize_from_buffer_large_with_preallocation() { assert_eq!(delegated_account.data, new_state); } +/// Regression test: `PreallocateBuffer(DelegatedAccount)` funds rent to grow +/// the delegated account ahead of CommitFinalize. CommitFinalize's own +/// settlement independently diffs `commit_lamports` (mirrored from the ER, +/// which already paid its own rent for the same growth) against +/// `delegation_record.lamports`. If preallocate doesn't keep that ledger +/// value in sync with what it actually transferred, settlement re-funds the +/// same growth a second time, and the delegated account ends up holding +/// roughly double the correct rent. Assert the final balance is exactly +/// `commit_lamports`, not double it. +#[tokio::test] +async fn test_commit_finalize_from_buffer_large_growth_does_not_double_pay_rent( +) { + let target_size = MAX_PERMITTED_DATA_INCREASE + 4_760; // 15_000 + let new_state = vec![7u8; target_size]; + + // Delegated account starts empty, with lamports exactly matching the + // delegation record's ledger, so the settlement math below is exact. + let initial_lamports = Rent::default().minimum_balance(0); + let min_rent_target = Rent::default().minimum_balance(target_size); + // Lamports the ER holds beyond bare rent-exemption for the new size -- + // settlement must add exactly this much, no more. + let extra_on_er = 12_345u64; + let commit_lamports = min_rent_target + extra_on_er; + + let (banks, _, authority, blockhash) = setup_program_test_env_with_lamports( + vec![], + new_state.clone(), + initial_lamports, + ) + .await; + + let state_buffer_pda = + Pubkey::find_program_address(&[b"state_buffer"], &authority.pubkey()).0; + + let mut ixs = dlp_api::instruction_builder::preallocate_buffer_chunks( + authority.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::DelegatedAccount, + 0, + target_size as u32, + ); + let (ix, _pdas) = dlp_api::instruction_builder::commit_finalize_from_buffer( + authority.pubkey(), + DELEGATED_PDA_ID, + state_buffer_pda, + &mut CommitFinalizeArgs { + commit_id: 1, + allow_undelegation: true.into(), + data_is_diff: false.into(), + lamports: commit_lamports, + bumps: Default::default(), + reserved_padding: Default::default(), + }, + ); + ixs.push(ix); + + let tx = Transaction::new_signed_with_payer( + &ixs, + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res.is_ok(), "{:?}", res); + + let delegated_account = + banks.get_account(DELEGATED_PDA_ID).await.unwrap().unwrap(); + assert_eq!(delegated_account.data, new_state); + assert_eq!( + delegated_account.lamports, commit_lamports, + "delegated account should hold exactly commit_lamports -- \ + preallocate's rent top-up and settlement's delta must not both \ + fund the same growth" + ); + + let delegation_record_account = banks + .get_account(delegation_record_pda_from_delegated_account( + &DELEGATED_PDA_ID, + )) + .await + .unwrap() + .unwrap(); + let delegation_record = + dlp_api::state::DelegationRecord::try_from_bytes_with_discriminator( + &delegation_record_account.data, + ) + .unwrap(); + assert_eq!(delegation_record.lamports, commit_lamports); +} + #[tokio::test] async fn test_commit_finalize_from_buffer_large_without_preallocation_fails() { let target_size = MAX_PERMITTED_DATA_INCREASE + 4_760; // 15_000 @@ -430,3 +520,106 @@ async fn setup_program_test_env( let (banks, payer, blockhash) = program_test.start().await; (banks, payer, validator_keypair, blockhash) } + +/// Like `setup_program_test_env`, but with the delegated account's lamports +/// (and matching delegation record ledger value) both set to +/// `delegated_lamports` -- needed to test settlement math precisely, where +/// the two must start in sync for the invariants being tested to mean +/// anything (unlike the fixed, unrelated values `setup_program_test_env` +/// uses, which are fine for tests that don't assert exact final lamports). +async fn setup_program_test_env_with_lamports( + pda_data: Vec, + pda_new_state: Vec, + delegated_lamports: u64, +) -> (BanksClient, Keypair, Keypair, Hash) { + let mut program_test = ProgramTest::new("dlp", dlp_api::ID, None); + program_test.prefer_bpf(true); + + let validator_keypair = + crate::fixtures::keypair_from_bytes(&TEST_AUTHORITY); + + program_test.add_account( + validator_keypair.pubkey(), + Account { + lamports: 10 * LAMPORTS_PER_SOL, + data: vec![], + owner: system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup a delegated PDA + program_test.add_account( + DELEGATED_PDA_ID, + Account { + lamports: delegated_lamports, + data: pda_data, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup the delegated account metadata PDA + let delegation_metadata_data = + get_delegation_metadata_data(validator_keypair.pubkey(), None); + program_test.add_account( + delegation_metadata_pda_from_delegated_account(&DELEGATED_PDA_ID), + Account { + lamports: Rent::default() + .minimum_balance(delegation_metadata_data.len()), + data: delegation_metadata_data, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup the delegated record PDA + let delegation_record_data = get_delegation_record_data( + validator_keypair.pubkey(), + Some(delegated_lamports), + ); + program_test.add_account( + delegation_record_pda_from_delegated_account(&DELEGATED_PDA_ID), + Account { + lamports: Rent::default() + .minimum_balance(delegation_record_data.len()), + data: delegation_record_data, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup the validator fees vault + program_test.add_account( + validator_fees_vault_pda_from_validator(&validator_keypair.pubkey()), + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + program_test.add_account( + Pubkey::find_program_address( + &[b"state_buffer"], + &validator_keypair.pubkey(), + ) + .0, + Account { + lamports: LAMPORTS_PER_SOL, + data: pda_new_state, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + let (banks, payer, blockhash) = program_test.start().await; + (banks, payer, validator_keypair, blockhash) +} From b5f4dafc49a5fea056ee4378802f392dcb2cc391 Mon Sep 17 00:00:00 2001 From: taco-paco Date: Wed, 19 Aug 2026 14:39:43 +0300 Subject: [PATCH 08/10] fix: branch base on initialization status --- dlp-api/src/error.rs | 3 ++ src/processor/fast/commit_state.rs | 3 +- src/processor/fast/undelegate.rs | 3 +- .../fast/undelegate_confined_account.rs | 3 +- src/processor/fast/utils/pda.rs | 51 ++++++------------- 5 files changed, 22 insertions(+), 41 deletions(-) diff --git a/dlp-api/src/error.rs b/dlp-api/src/error.rs index e50408df..005a5b24 100644 --- a/dlp-api/src/error.rs +++ b/dlp-api/src/error.rs @@ -197,6 +197,9 @@ pub enum DlpError { #[error("Buffer must be preallocated to the exact required size ahead of time for accounts over the growth cap")] BufferNotPreallocatedToExactSize = 57, + #[error("PreallocateBuffer target size does not exceed the growth cap; the buffer PDA is created directly by the consuming instruction instead")] + PreallocateBufferTargetTooSmall = 58, + #[error("An infallible error is encountered possibly due to logic error")] InfallibleError = 100, } diff --git a/src/processor/fast/commit_state.rs b/src/processor/fast/commit_state.rs index cbb54144..e11dc229 100644 --- a/src/processor/fast/commit_state.rs +++ b/src/processor/fast/commit_state.rs @@ -19,7 +19,7 @@ use crate::{ require_initialized_delegation_record, require_initialized_validator_fees_vault, require_owned_pda, require_program_config, require_signer, require_uninitialized_pda, - CommitRecordCtx, CommitStateAccountCtx, + CommitRecordCtx, }, state::{ CommitRecord, DelegationMetadata, DelegationRecord, ProgramConfig, @@ -269,7 +269,6 @@ pub(crate) fn process_commit_state_internal( ], commit_state_len, args.validator, - CommitStateAccountCtx, "commit state", )?; diff --git a/src/processor/fast/undelegate.rs b/src/processor/fast/undelegate.rs index 1abec045..cfd623c1 100644 --- a/src/processor/fast/undelegate.rs +++ b/src/processor/fast/undelegate.rs @@ -30,7 +30,7 @@ use crate::{ require_initialized_protocol_fees_vault, require_initialized_validator_fees_vault, require_owned_pda, require_signer, require_uninitialized_pda, CommitRecordCtx, - CommitStateAccountCtx, UndelegateBufferCtx, + CommitStateAccountCtx, }, state::{ DelegationMetadata, DelegationRecord, UndelegationRequest, @@ -253,7 +253,6 @@ pub fn process_undelegate( ], undelegate_buffer_size, validator, - UndelegateBufferCtx, "undelegate buffer", )?; diff --git a/src/processor/fast/undelegate_confined_account.rs b/src/processor/fast/undelegate_confined_account.rs index d8b51ea6..72917097 100644 --- a/src/processor/fast/undelegate_confined_account.rs +++ b/src/processor/fast/undelegate_confined_account.rs @@ -14,7 +14,7 @@ use crate::{ requires::{ require_authorization, require_initialized_delegation_metadata, require_initialized_delegation_record, require_owned_pda, - require_signer, UndelegateBufferCtx, + require_signer, }, state::{DelegationMetadata, DelegationRecord}, }; @@ -113,7 +113,6 @@ pub fn process_undelegate_confined_account( ], undelegate_buffer_size, admin, - UndelegateBufferCtx, "undelegate buffer", )?; diff --git a/src/processor/fast/utils/pda.rs b/src/processor/fast/utils/pda.rs index 6439c672..58241899 100644 --- a/src/processor/fast/utils/pda.rs +++ b/src/processor/fast/utils/pda.rs @@ -1,20 +1,13 @@ use pinocchio::{ - account::MAX_PERMITTED_DATA_INCREASE, - cpi::Signer, - error::ProgramError, - instruction::seeds, - sysvars::{rent::Rent, Sysvar}, - AccountView, Address, ProgramResult, + cpi::Signer, error::ProgramError, instruction::seeds, + sysvars::{rent::Rent, Sysvar}, AccountView, Address, ProgramResult, }; use pinocchio_system::instructions as system; use crate::{ consts::PROTOCOL_FEES_PERCENTAGE, error::DlpError, - requires::{ - require_initialized_pda, require_uninitialized_pda, - RequireUninitializedAccountCtx, - }, + requires::{is_uninitialized_account, require_owned_pda, require_pda}, }; /// Creates a new pda @@ -73,39 +66,18 @@ pub(crate) fn create_pda( /// Prepares `buffer_account` to hold exactly `state_size` bytes, seeded by /// `seeds_arr` under this program. Returns the PDA's bump seed. -#[allow(clippy::too_many_arguments)] pub(crate) fn create_or_verify_preallocated_pda( target_account: &AccountView, seeds_arr: &[&[u8]; 2], state_size: usize, payer: &AccountView, - ctx: impl RequireUninitializedAccountCtx, label: &str, ) -> Result { - if state_size > MAX_PERMITTED_DATA_INCREASE { - // Account must be initialized by PreallocateBuffer instructions dues to `MAX_PERMITTED_DATA_INCREASE`. - let bump = require_initialized_pda( - target_account, - seeds_arr, - &crate::fast::ID, - true, - label, - )?; + let bump = + require_pda(target_account, seeds_arr, &crate::fast::ID, true, label)?; - if target_account.data_len() != state_size { - Err(DlpError::BufferNotPreallocatedToExactSize.into()) - } else { - Ok(bump) - } - } else { - // As the account is small enough we initialize here - let bump = require_uninitialized_pda( - target_account, - seeds_arr, - &crate::fast::ID, - true, - ctx, - )?; + if is_uninitialized_account(target_account) { + // Not preallocated - initialize it here directly. create_pda( target_account, &crate::fast::ID, @@ -114,6 +86,15 @@ pub(crate) fn create_or_verify_preallocated_pda( payer, )?; Ok(bump) + } else { + // Already preallocated by PreallocateBuffer -- must match exactly. + require_owned_pda(target_account, &crate::fast::ID, label)?; + + if target_account.data_len() != state_size { + Err(DlpError::BufferNotPreallocatedToExactSize.into()) + } else { + Ok(bump) + } } } From 7cb18ebde493ab79c0afd3f4fe02f2f8417a7940 Mon Sep 17 00:00:00 2001 From: taco-paco Date: Fri, 21 Aug 2026 15:38:25 +0700 Subject: [PATCH 09/10] fix: validate account grown correctly --- src/processor/fast/preallocate_buffer.rs | 22 ++++ tests/test_commit_state_from_buffer.rs | 13 ++- tests/test_preallocate_buffer.rs | 143 ++++++++++++++++++++--- 3 files changed, 155 insertions(+), 23 deletions(-) diff --git a/src/processor/fast/preallocate_buffer.rs b/src/processor/fast/preallocate_buffer.rs index 93c88910..51ef7bbc 100644 --- a/src/processor/fast/preallocate_buffer.rs +++ b/src/processor/fast/preallocate_buffer.rs @@ -14,6 +14,7 @@ use crate::{ args::{PreallocateBufferArgs, PreallocateBufferKind}, error::DlpError, pda, + require_gt, processor::fast::utils::pda::{create_pda, resize_pda}, requires::{ is_uninitialized_account, require_initialized_delegation_record, @@ -60,6 +61,10 @@ use crate::{ /// `UndelegateBuffer` alongside finalize in a two-stage commit-and-undelegate, /// ahead of `undelegate`'s own check (which requires the commit record to be /// uninitialized by the time *it* runs, not by the time it's preallocated). +/// - for `kind = CommitState` / `UndelegateBuffer`, `target_size` must exceed +/// `MAX_PERMITTED_DATA_INCREASE` -- those buffer PDAs are always closed and +/// recreated fresh each cycle, so a smaller target can always be created +/// directly, in one shot, by the consuming instruction itself. /// - for `kind = DelegatedAccount`, rent transferred to fund the growth is /// also added to `delegation_record.lamports`, keeping the ledger in sync /// with the account's actual balance -- otherwise finalize/commit_finalize's @@ -102,6 +107,17 @@ pub fn process_preallocate_buffer( let target_size = args.target_size as usize; match args.kind { PreallocateBufferKind::CommitState => { + // Buffer is closed and recreated fresh every cycle, so a target + // this small can always be created directly, in one shot, by + // the consuming instruction itself -- there's never a + // legitimate reason to preallocate one. Rejecting it keeps + // "buffer PDA is DLP-owned" synonymous with "it had to be + // preallocated", which consumers rely on. + require_gt!( + target_size, + MAX_PERMITTED_DATA_INCREASE, + DlpError::PreallocateBufferTargetTooSmall + ); require_no_commit_in_flight( delegated_account, commit_record_account, @@ -125,6 +141,12 @@ pub fn process_preallocate_buffer( ) } PreallocateBufferKind::UndelegateBuffer => { + // Same reasoning as the `CommitState` arm above. + require_gt!( + target_size, + MAX_PERMITTED_DATA_INCREASE, + DlpError::PreallocateBufferTargetTooSmall + ); let seeds_arr = [ pda::UNDELEGATE_BUFFER_TAG, delegated_account.address().as_ref(), diff --git a/tests/test_commit_state_from_buffer.rs b/tests/test_commit_state_from_buffer.rs index 7ff552f4..be50de03 100644 --- a/tests/test_commit_state_from_buffer.rs +++ b/tests/test_commit_state_from_buffer.rs @@ -190,20 +190,19 @@ async fn test_commit_new_state_from_buffer_large_without_preallocation_fails() { ); let err = banks.process_transaction(tx).await.unwrap_err(); // commit_state has no PDA to grow yet at all -- create_or_verify_preallocated_pda - // requires it to already exist (initialized by PreallocateBuffer) once - // the target exceeds MAX_PERMITTED_DATA_INCREASE, so this fails the - // ownership check before it can even get to the size comparison. + // tries to create it directly at the full 15_000-byte target, which + // exceeds MAX_PERMITTED_DATA_INCREASE in a single instruction. assert!( matches!( err, BanksClientError::TransactionError( TransactionError::InstructionError( _, - InstructionError::InvalidAccountOwner, + InstructionError::InvalidRealloc, ) ) ), - "expected InvalidAccountOwner, got {err:?}" + "expected InvalidRealloc, got {err:?}" ); } @@ -362,7 +361,9 @@ async fn test_commit_diff_from_buffer_preallocated_to_diff_len_fails() { blockhash, ); let err = banks.process_transaction(tx).await.unwrap_err(); - assert_custom_error(err, DlpError::BufferNotPreallocatedToExactSize); + // The diff's length is below MAX_PERMITTED_DATA_INCREASE, so PreallocateBuffer + // itself now rejects this target before commit_diff_from_buffer even runs. + assert_custom_error(err, DlpError::PreallocateBufferTargetTooSmall); } fn assert_custom_error(err: BanksClientError, expected: DlpError) { diff --git a/tests/test_preallocate_buffer.rs b/tests/test_preallocate_buffer.rs index 1509cacc..7b916829 100644 --- a/tests/test_preallocate_buffer.rs +++ b/tests/test_preallocate_buffer.rs @@ -53,15 +53,19 @@ async fn test_preallocate_commit_state_creates_fresh() { let (banks, _, validator, blockhash) = setup_program_test_env(false, None).await; - let target_size: u32 = 500; - let ix = dlp_api::instruction_builder::preallocate_buffer( + // Must exceed MAX_PERMITTED_DATA_INCREASE (see + // test_preallocate_commit_state_rejects_target_at_or_below_cap below), + // so this needs two growth steps to actually reach the target. + let target_size: u32 = MAX_PERMITTED_DATA_INCREASE as u32 + 4_760; // 15_000 + let ixs = dlp_api::instruction_builder::preallocate_buffer_chunks( validator.pubkey(), DELEGATED_PDA_ID, PreallocateBufferKind::CommitState, + 0, target_size, ); let tx = Transaction::new_signed_with_payer( - &[ix], + &ixs, Some(&validator.pubkey()), &[&validator], blockhash, @@ -83,15 +87,17 @@ async fn test_preallocate_undelegate_buffer_creates_fresh() { let (banks, _, validator, blockhash) = setup_program_test_env(false, None).await; - let target_size: u32 = 500; - let ix = dlp_api::instruction_builder::preallocate_buffer( + // Same reasoning as test_preallocate_commit_state_creates_fresh above. + let target_size: u32 = MAX_PERMITTED_DATA_INCREASE as u32 + 4_760; // 15_000 + let ixs = dlp_api::instruction_builder::preallocate_buffer_chunks( validator.pubkey(), DELEGATED_PDA_ID, PreallocateBufferKind::UndelegateBuffer, + 0, target_size, ); let tx = Transaction::new_signed_with_payer( - &[ix], + &ixs, Some(&validator.pubkey()), &[&validator], blockhash, @@ -147,27 +153,31 @@ async fn test_preallocate_commit_state_idempotent_past_target() { let (banks, _, validator, blockhash) = setup_program_test_env(false, None).await; - // Grow to 5_000 bytes first. - let ix = dlp_api::instruction_builder::preallocate_buffer( + // Grow to 15_000 bytes first (must exceed MAX_PERMITTED_DATA_INCREASE, + // see test_preallocate_commit_state_rejects_target_at_or_below_cap -- + // hence two growth steps to actually reach it). + let ixs = dlp_api::instruction_builder::preallocate_buffer_chunks( validator.pubkey(), DELEGATED_PDA_ID, PreallocateBufferKind::CommitState, - 5_000, + 0, + 15_000, ); let tx = Transaction::new_signed_with_payer( - &[ix], + &ixs, Some(&validator.pubkey()), &[&validator], blockhash, ); assert!(banks.process_transaction(tx).await.is_ok()); - // Calling again with a smaller target must be a no-op (never shrinks). + // Calling again with a smaller (but still > cap) target must be a no-op + // (never shrinks). let ix = dlp_api::instruction_builder::preallocate_buffer( validator.pubkey(), DELEGATED_PDA_ID, PreallocateBufferKind::CommitState, - 3_000, + 12_000, ); let tx = Transaction::new_signed_with_payer( &[ix], @@ -182,7 +192,7 @@ async fn test_preallocate_commit_state_idempotent_past_target() { commit_state_pda_from_delegated_account(&DELEGATED_PDA_ID); let commit_state_account = banks.get_account(commit_state_pda).await.unwrap().unwrap(); - assert_eq!(commit_state_account.data.len(), 5_000); + assert_eq!(commit_state_account.data.len(), 15_000); } #[tokio::test] @@ -227,11 +237,14 @@ async fn test_preallocate_commit_state_rejects_when_commit_in_flight() { let (banks, _, validator, blockhash) = setup_program_test_env(true, None).await; + // Must exceed MAX_PERMITTED_DATA_INCREASE, or this trips + // PreallocateBufferTargetTooSmall before ever reaching the + // commit-in-flight check this test means to exercise. let ix = dlp_api::instruction_builder::preallocate_buffer( validator.pubkey(), DELEGATED_PDA_ID, PreallocateBufferKind::CommitState, - 500, + MAX_PERMITTED_DATA_INCREASE as u32 + 4_760, // 15_000 ); let tx = Transaction::new_signed_with_payer( &[ix], @@ -243,6 +256,100 @@ async fn test_preallocate_commit_state_rejects_when_commit_in_flight() { assert_custom_error(err, DlpError::PreallocateBufferCommitInFlight); } +/// `CommitState`/`UndelegateBuffer` buffers are always closed and recreated +/// fresh each cycle, so a target at or below the cap can always be created +/// directly, in one shot, by the consuming instruction itself -- there's +/// never a legitimate reason to preallocate one this small, and doing so +/// would leave `create_or_verify_preallocated_pda` unable to tell a genuine +/// preallocation apart from a stray leftover account. +#[tokio::test] +async fn test_preallocate_commit_state_rejects_target_at_or_below_cap() { + let (banks, _, validator, blockhash) = + setup_program_test_env(false, None).await; + + // Exactly at the cap must also be rejected (the check is strictly `>`). + let ix = dlp_api::instruction_builder::preallocate_buffer( + validator.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::CommitState, + MAX_PERMITTED_DATA_INCREASE as u32, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + let err = banks.process_transaction(tx).await.unwrap_err(); + assert_custom_error(err, DlpError::PreallocateBufferTargetTooSmall); + + let commit_state_pda = + commit_state_pda_from_delegated_account(&DELEGATED_PDA_ID); + assert!( + banks.get_account(commit_state_pda).await.unwrap().is_none(), + "rejected preallocation must not create the buffer" + ); +} + +/// Same reasoning as test_preallocate_commit_state_rejects_target_at_or_below_cap. +#[tokio::test] +async fn test_preallocate_undelegate_buffer_rejects_target_at_or_below_cap() { + let (banks, _, validator, blockhash) = + setup_program_test_env(false, None).await; + + let ix = dlp_api::instruction_builder::preallocate_buffer( + validator.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::UndelegateBuffer, + 500, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + let err = banks.process_transaction(tx).await.unwrap_err(); + assert_custom_error(err, DlpError::PreallocateBufferTargetTooSmall); + + let undelegate_buffer_pda = + undelegate_buffer_pda_from_delegated_account(&DELEGATED_PDA_ID); + assert!( + banks.get_account(undelegate_buffer_pda).await.unwrap().is_none(), + "rejected preallocation must not create the buffer" + ); +} + +/// `DelegatedAccount` targets the delegated account itself, which isn't +/// closed/recreated each cycle the way `CommitState`/`UndelegateBuffer` +/// buffers are -- so it's exempt from the above guard, and small targets are +/// legitimate (e.g. growing a small account by a few bytes). +#[tokio::test] +async fn test_preallocate_delegated_account_allows_target_at_or_below_cap() { + let (banks, _, validator, blockhash) = + setup_program_test_env(false, None).await; + + let target_size: u32 = 500; + let ix = dlp_api::instruction_builder::preallocate_buffer( + validator.pubkey(), + DELEGATED_PDA_ID, + PreallocateBufferKind::DelegatedAccount, + target_size, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res.is_ok(), "{:?}", res); + + let delegated_account = + banks.get_account(DELEGATED_PDA_ID).await.unwrap().unwrap(); + assert_eq!(delegated_account.data.len(), target_size as usize); +} + #[tokio::test] async fn test_preallocate_undelegate_buffer_ignores_commit_in_flight() { // Unlike CommitState, UndelegateBuffer isn't gated on "no commit in @@ -253,15 +360,17 @@ async fn test_preallocate_undelegate_buffer_ignores_commit_in_flight() { let (banks, _, validator, blockhash) = setup_program_test_env(true, None).await; - let target_size: u32 = 500; - let ix = dlp_api::instruction_builder::preallocate_buffer( + // Must exceed MAX_PERMITTED_DATA_INCREASE, hence two growth steps. + let target_size: u32 = MAX_PERMITTED_DATA_INCREASE as u32 + 4_760; // 15_000 + let ixs = dlp_api::instruction_builder::preallocate_buffer_chunks( validator.pubkey(), DELEGATED_PDA_ID, PreallocateBufferKind::UndelegateBuffer, + 0, target_size, ); let tx = Transaction::new_signed_with_payer( - &[ix], + &ixs, Some(&validator.pubkey()), &[&validator], blockhash, From 6365d3c2e2fa9d9dc0eb2f6d770ab78e855d4064 Mon Sep 17 00:00:00 2001 From: taco-paco Date: Fri, 21 Aug 2026 15:46:43 +0700 Subject: [PATCH 10/10] fix: fmt + lint --- src/processor/fast/preallocate_buffer.rs | 4 ++-- src/processor/fast/utils/pda.rs | 7 +++++-- tests/test_commit_finalize_from_buffer.rs | 13 +++++++------ tests/test_preallocate_buffer.rs | 6 +++++- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/processor/fast/preallocate_buffer.rs b/src/processor/fast/preallocate_buffer.rs index 51ef7bbc..27da2f65 100644 --- a/src/processor/fast/preallocate_buffer.rs +++ b/src/processor/fast/preallocate_buffer.rs @@ -14,8 +14,8 @@ use crate::{ args::{PreallocateBufferArgs, PreallocateBufferKind}, error::DlpError, pda, - require_gt, processor::fast::utils::pda::{create_pda, resize_pda}, + require_gt, requires::{ is_uninitialized_account, require_initialized_delegation_record, require_initialized_validator_fees_vault, require_owned_pda, @@ -99,7 +99,7 @@ pub fn process_preallocate_buffer( validator, delegated_account, validator_fees_vault, - &delegation_record, + delegation_record, )?; let args = PreallocateBufferArgs::try_from_slice(data) diff --git a/src/processor/fast/utils/pda.rs b/src/processor/fast/utils/pda.rs index 58241899..57bceb08 100644 --- a/src/processor/fast/utils/pda.rs +++ b/src/processor/fast/utils/pda.rs @@ -1,6 +1,9 @@ use pinocchio::{ - cpi::Signer, error::ProgramError, instruction::seeds, - sysvars::{rent::Rent, Sysvar}, AccountView, Address, ProgramResult, + cpi::Signer, + error::ProgramError, + instruction::seeds, + sysvars::{rent::Rent, Sysvar}, + AccountView, Address, ProgramResult, }; use pinocchio_system::instructions as system; diff --git a/tests/test_commit_finalize_from_buffer.rs b/tests/test_commit_finalize_from_buffer.rs index 180ecbdf..8db0e1d5 100644 --- a/tests/test_commit_finalize_from_buffer.rs +++ b/tests/test_commit_finalize_from_buffer.rs @@ -242,12 +242,13 @@ async fn test_commit_finalize_from_buffer_large_growth_does_not_double_pay_rent( let extra_on_er = 12_345u64; let commit_lamports = min_rent_target + extra_on_er; - let (banks, _, authority, blockhash) = setup_program_test_env_with_lamports( - vec![], - new_state.clone(), - initial_lamports, - ) - .await; + let (banks, _, authority, blockhash) = + setup_program_test_env_with_lamports( + vec![], + new_state.clone(), + initial_lamports, + ) + .await; let state_buffer_pda = Pubkey::find_program_address(&[b"state_buffer"], &authority.pubkey()).0; diff --git a/tests/test_preallocate_buffer.rs b/tests/test_preallocate_buffer.rs index 7b916829..f98ae238 100644 --- a/tests/test_preallocate_buffer.rs +++ b/tests/test_preallocate_buffer.rs @@ -315,7 +315,11 @@ async fn test_preallocate_undelegate_buffer_rejects_target_at_or_below_cap() { let undelegate_buffer_pda = undelegate_buffer_pda_from_delegated_account(&DELEGATED_PDA_ID); assert!( - banks.get_account(undelegate_buffer_pda).await.unwrap().is_none(), + banks + .get_account(undelegate_buffer_pda) + .await + .unwrap() + .is_none(), "rejected preallocation must not create the buffer" ); }