From e6eac03a162787c2309c22dce9654f7ad4999ce0 Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Wed, 28 Oct 2026 19:29:43 +0530 Subject: [PATCH 1/4] feat: Add cleanup for v2 StateBuffers Store the original rent payer in each StateBuffer and add an authority-signed, idempotent close instruction that returns the account rent to that payer. Reject cleanup while a linked commitment is still active, and allow cleanup after settlement or an abandoned partial upload. --- docs/design/mimd-0024-fraud-proofs.md | 27 +- interface/src/args/close_state_buffer.rs | 14 + interface/src/args/mod.rs | 2 + interface/src/error.rs | 6 + interface/src/instruction.rs | 2 + .../instruction_builder/close_state_buffer.rs | 47 +++ interface/src/instruction_builder/mod.rs | 2 + interface/src/pda.rs | 17 +- interface/src/state/state_buffer.rs | 7 +- program/src/processor/close_state_buffer.rs | 147 ++++++++ .../fraud_proofs/write_state_buffer.rs | 1 + program/src/processor/mod.rs | 5 + program/tests/test_close_state_buffer.rs | 351 ++++++++++++++++++ ...est_primary_commit_finalize_from_buffer.rs | 28 +- program/tests/test_v2_write_state_buffer.rs | 1 + 15 files changed, 643 insertions(+), 14 deletions(-) create mode 100644 interface/src/args/close_state_buffer.rs create mode 100644 interface/src/instruction_builder/close_state_buffer.rs create mode 100644 program/src/processor/close_state_buffer.rs create mode 100644 program/tests/test_close_state_buffer.rs diff --git a/docs/design/mimd-0024-fraud-proofs.md b/docs/design/mimd-0024-fraud-proofs.md index ea2f6c97..dd7c88c3 100644 --- a/docs/design/mimd-0024-fraud-proofs.md +++ b/docs/design/mimd-0024-fraud-proofs.md @@ -457,7 +457,7 @@ pub struct SelectedVerifier { /// PDA: `["state-buffer", account, commit_seq, authority]` /// Created by: first `WriteStateBuffer`. /// Frozen by: final `WriteStateBuffer` chunk. -/// Closed by: `CloseTerminalAccounts`. +/// Closed by: `CloseStateBuffer` when no live commitment needs it. /// /// Header and extendable bytes for an opened state payload. pub struct StateBuffer { @@ -466,6 +466,9 @@ pub struct StateBuffer { /// Challenger authority is used for the challenged state. pub authority: Pubkey, + /// Initial payer that receives the buffer's reclaimed rent. + pub rent_payer: Pubkey, + /// Account whose state payload is being opened. pub account_pubkey: Pubkey, @@ -597,6 +600,7 @@ Commitment, approval, challenge, resolution, and finalization instructions. | Instruction | Expected invoker | Description | | --- | --- | --- | | `WriteStateBuffer` | Authority for a known state-buffer flow | System-level instruction. Creates, grows, writes, and finalizes a DLP-owned `StateBuffer` PDA using the fixed `["state-buffer", account, commit_seq, authority]` seed pattern. The instruction remains cluster-neutral; consuming instructions validate the payload, authority, and cluster relationship. | +| `CloseStateBuffer` | Buffer authority | Closes a consumed or abandoned buffer and returns its rent to the payer recorded by the first write. Cleanup is idempotent and rejects buffers still needed by a live commitment. | | `ProposeCommitment` | Current validator | Requires `Challenge` mode, verifies the delegation's cluster and current validator, consumes only cluster-scoped bond and registry accounts, selects verifiers, and starts the challenge window. `DelegationRecord` and `DelegationMetadata` are temporary v1-layout dependencies until their v2 replacement exists. | | `ApproveCommitment` | Selected verifier | Records approval from the selected verifier. v2 MVP requires exactly one selected verifier, so no verifier index is needed. Duplicate approvals do not increase the count. | | `RaiseChallenge` | Challenger | Requires the cluster stored by the pending commitment, locks challenger stake, stores the salted `challenge_hash`, and blocks normal finalization until reveal, timeout, or resolution. It does not consume challenger state bytes. | @@ -616,7 +620,7 @@ These instructions can be skipped while implementing the first usable v2 flow. | `ExtendChallengeWindow` | Cranker | Low priority. Extends an under-approved commitment according to the commitment's cluster policy, or expires it after the maximum extensions. Initial v2 can expire under-approved commitments instead. | | `MarkChallengerRevealTimeout` | Cranker | Deferred. Closes an unrevealed challenge after the reveal timeout, applies the configured challenger penalty, and unblocks or expires the pending commitment according to final policy. | | `ClaimPayout` | Beneficiary | Low priority. Needed only if dispute payouts are delayed through `PayoutTimelock`; otherwise `ResolveDispute` can pay immediately. | -| `CloseTerminalAccounts` | Cranker or recipient | Low priority. Closes terminal records and buffers after their parent commitment or challenge can no longer change. | +| `CloseTerminalAccounts` | Cranker or recipient | Low priority. Closes the remaining terminal commitment and challenge accounts after their parent state can no longer change. `StateBuffer` cleanup uses `CloseStateBuffer`. | ### Obsolete Or Remove-Candidate Instructions @@ -633,12 +637,12 @@ These instructions are not part of the current v2 MVP flow. DLP owns the buffer account because the buffer is part of the fraud-proof state machine, not only temporary transport. -The first write creates the `StateBuffer` PDA, writes the fixed fields, and -allocates up to the maximum account growth allowed in one instruction. Later -writes grow the same PDA by at most that limit and append more bytes. Each new -write must use `offset == payload.len()`; this keeps the MVP simple and avoids a -separate chunks bitmap account. An exact old chunk may be repeated as an -idempotent retry. +The first write creates the `StateBuffer` PDA, records its rent payer, writes the +fixed fields, and allocates up to the maximum account growth allowed in one +instruction. Later writes grow the same PDA by at most that limit and append +more bytes. Each new write must use `offset == payload.len()`; this keeps the MVP +simple and avoids a separate chunks bitmap account. An exact old chunk may be +repeated as an idempotent retry. The account layout is: @@ -674,6 +678,13 @@ When `payload.len() == total_len`, `WriteStateBuffer` hashes the payload and set `finalized = true`. After that, the buffer cannot accept new bytes; exact duplicate retries remain valid. +`CloseStateBuffer` requires the buffer authority to sign and always returns rent +to the payer recorded by the first write. It can close partial abandoned uploads, +finalized abandoned uploads, and buffers consumed by Direct settlement. If the +canonical `PendingCommitment` exists, cleanup is allowed only after that +commitment is finalized, expired, or cancelled. Repeating cleanup after the +buffer is already closed succeeds without moving lamports again. + Consuming instructions must check: - buffer PDA seeds match `account_pubkey`, `commit_seq`, and `authority`; diff --git a/interface/src/args/close_state_buffer.rs b/interface/src/args/close_state_buffer.rs new file mode 100644 index 00000000..7a4cbd33 --- /dev/null +++ b/interface/src/args/close_state_buffer.rs @@ -0,0 +1,14 @@ +use wheels::variable_offset_layout; + +#[derive(Clone, Debug, PartialEq, Eq)] +#[variable_offset_layout(buffer_offset = 1)] +pub struct CloseStateBufferArgs { + /// Commit id that identifies the buffer and its possible commitment. + pub commit_seq: u32, + + /// Canonical StateBuffer PDA bump. Set by the instruction builder. + pub state_buffer_bump: u8, + + /// Canonical PendingCommitment PDA bump. Set by the instruction builder. + pub pending_commitment_bump: u8, +} diff --git a/interface/src/args/mod.rs b/interface/src/args/mod.rs index 9eb03e75..da1dd2d6 100644 --- a/interface/src/args/mod.rs +++ b/interface/src/args/mod.rs @@ -2,6 +2,7 @@ // instruction tag, so v2 instruction args use `buffer_offset = 1`. mod challenger_reveal; +mod close_state_buffer; mod commit_finalize; mod commit_finalize_from_buffer; mod delegate; @@ -17,6 +18,7 @@ mod update_verifier_registry; mod write_state_buffer; pub use challenger_reveal::*; +pub use close_state_buffer::*; pub use commit_finalize::*; pub use commit_finalize_from_buffer::*; pub use delegate::*; diff --git a/interface/src/error.rs b/interface/src/error.rs index a18ed025..036554c3 100644 --- a/interface/src/error.rs +++ b/interface/src/error.rs @@ -194,6 +194,12 @@ pub enum DlpError { #[error("Account data is too large for one-step undelegation")] AccountDataTooLargeForUndelegation = 56, + #[error("Invalid StateBuffer rent payer")] + InvalidStateBufferRentPayer = 57, + + #[error("StateBuffer is still required by a live commitment")] + StateBufferInUse = 58, + #[error("An infallible error is encountered possibly due to logic error")] InfallibleError = 100, } diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs index 9859bac6..acda5e0e 100644 --- a/interface/src/instruction.rs +++ b/interface/src/instruction.rs @@ -43,6 +43,8 @@ pub enum DlpV2Instruction { PrepareCommitFinalize = 115, /// Returns a delegated account to its original owner. Undelegate = 116, + /// Closes a state buffer and returns its rent to the stored payer. + CloseStateBuffer = 117, } impl DlpV2Instruction { diff --git a/interface/src/instruction_builder/close_state_buffer.rs b/interface/src/instruction_builder/close_state_buffer.rs new file mode 100644 index 00000000..d3f236ca --- /dev/null +++ b/interface/src/instruction_builder/close_state_buffer.rs @@ -0,0 +1,47 @@ +use solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; +use wheels::{layout::Encodable, DataLayoutError}; + +use crate::{ + compat::{Compatize, Modernize}, + pda::{pending_commitment_pda_with_bump, state_buffer_pda_with_bump}, + CloseStateBufferArgs, DlpV2Instruction, +}; + +/// Builds an instruction that closes one StateBuffer. +pub fn close_state_buffer( + authority: Pubkey, + account: Pubkey, + commit_seq: u32, + rent_payer: Pubkey, +) -> Result { + let account = account.compatize(); + let authority = authority.compatize(); + let (state_buffer, state_buffer_bump) = + state_buffer_pda_with_bump(&account, commit_seq, &authority); + let (pending_commitment, pending_commitment_bump) = + pending_commitment_pda_with_bump(&account, commit_seq); + + Ok(Instruction { + program_id: crate::id().modernize(), + accounts: vec![ + AccountMeta::new_readonly(authority.modernize(), true), + AccountMeta::new(state_buffer.modernize(), false), + AccountMeta::new_readonly(account.modernize(), false), + AccountMeta::new(rent_payer, false), + AccountMeta::new_readonly(pending_commitment.modernize(), false), + ], + data: [ + DlpV2Instruction::CloseStateBuffer.to_vec(), + CloseStateBufferArgs { + commit_seq, + state_buffer_bump, + pending_commitment_bump, + } + .encode()?, + ] + .concat(), + }) +} diff --git a/interface/src/instruction_builder/mod.rs b/interface/src/instruction_builder/mod.rs index 69c21618..946097b4 100644 --- a/interface/src/instruction_builder/mod.rs +++ b/interface/src/instruction_builder/mod.rs @@ -1,5 +1,6 @@ mod approve_commitment; mod challenger_reveal; +mod close_state_buffer; mod commit_finalize; mod commit_finalize_from_buffer; mod delegate; @@ -17,6 +18,7 @@ mod write_state_buffer; pub use approve_commitment::*; pub use challenger_reveal::*; +pub use close_state_buffer::*; pub use commit_finalize::*; pub use commit_finalize_from_buffer::*; pub use delegate::*; diff --git a/interface/src/pda.rs b/interface/src/pda.rs index 919959c7..9399c4e9 100644 --- a/interface/src/pda.rs +++ b/interface/src/pda.rs @@ -64,6 +64,14 @@ pub fn state_buffer_pda( commit_seq: u32, authority: &Pubkey, ) -> Pubkey { + state_buffer_pda_with_bump(account, commit_seq, authority).0 +} + +pub fn state_buffer_pda_with_bump( + account: &Pubkey, + commit_seq: u32, + authority: &Pubkey, +) -> (Pubkey, u8) { Pubkey::find_program_address( &[ STATE_BUFFER_SEED, @@ -73,10 +81,16 @@ pub fn state_buffer_pda( ], &crate::id(), ) - .0 } pub fn pending_commitment_pda(account: &Pubkey, commit_seq: u32) -> Pubkey { + pending_commitment_pda_with_bump(account, commit_seq).0 +} + +pub fn pending_commitment_pda_with_bump( + account: &Pubkey, + commit_seq: u32, +) -> (Pubkey, u8) { Pubkey::find_program_address( &[ PENDING_COMMITMENT_SEED, @@ -85,7 +99,6 @@ pub fn pending_commitment_pda(account: &Pubkey, commit_seq: u32) -> Pubkey { ], &crate::id(), ) - .0 } pub fn challenge_pda( diff --git a/interface/src/state/state_buffer.rs b/interface/src/state/state_buffer.rs index 4756cc8a..7aaa44f0 100644 --- a/interface/src/state/state_buffer.rs +++ b/interface/src/state/state_buffer.rs @@ -4,7 +4,7 @@ use crate::{compat::Pubkey, error::DlpError}; /// PDA: `["state-buffer", account, commit_seq, authority]`. /// Created by `WriteStateBuffer`. -/// Closed by `CloseTerminalAccounts` after finalize, cancel, or expiry. +/// Closed by `CloseStateBuffer` when no live commitment needs it. #[derive(Clone, Debug, PartialEq, Eq)] #[fixed_offset_layout(buffer_offset = 0)] pub struct StateBuffer { @@ -20,6 +20,9 @@ pub struct StateBuffer { /// challenger identity for a challenger dispute buffer. pub authority: Pubkey, + /// Account that funded this buffer and receives its reclaimed rent. + pub rent_payer: Pubkey, + /// Delegated account whose payload is stored after this header. pub account_pubkey: Pubkey, @@ -50,7 +53,7 @@ pub struct StateBuffer { } impl StateBuffer { - pub const DISCRIMINATOR: [u8; 8] = *b"v2sbuf00"; + pub const DISCRIMINATOR: [u8; 8] = *b"v2sbuf01"; /// Maximum account data bytes a StateBuffer PDA may allocate. pub const MAX_ACCOUNT_DATA_LEN: usize = 10 * 1024 * 1024; diff --git a/program/src/processor/close_state_buffer.rs b/program/src/processor/close_state_buffer.rs new file mode 100644 index 00000000..5d10d79e --- /dev/null +++ b/program/src/processor/close_state_buffer.rs @@ -0,0 +1,147 @@ +use delegation_program_interface as dpi; +use dpi::{ + error::DlpError, + pda::{PENDING_COMMITMENT_SEED, STATE_BUFFER_SEED}, + CloseStateBufferArgs, PendingCommitment, StateBuffer, + PENDING_COMMITMENT_STATUS_CANCELLED, PENDING_COMMITMENT_STATUS_EXPIRED, + PENDING_COMMITMENT_STATUS_FINALIZED, +}; +use pinocchio::{ + address::address_eq, error::ProgramError, AccountView, ProgramResult, +}; +use wheels::{ + layout::Decodable, require, require_eq, require_eq_keys, + require_n_accounts, require_owned_by, require_signer, +}; + +use crate::requires::is_uninitialized_account; + +/// Close a StateBuffer and return its rent to the payer that created it. +/// +/// Accounts: +/// 0: `[signer]` buffer authority +/// 1: `[writable]` StateBuffer PDA +/// 2: `[]` delegated account identified by the buffer +/// 3: `[writable]` stored StateBuffer rent payer +/// 4: `[]` PendingCommitment PDA, initialized or not +#[inline(never)] +pub fn process_close_state_buffer( + accounts: &[AccountView], + data: &[u8], +) -> ProgramResult { + let [authority, state_buffer, delegated_account, rent_payer, pending_commitment] = + require_n_accounts!(accounts, 5); + + require_signer!(authority); + require!(rent_payer.is_writable(), ProgramError::Immutable); + + let args = CloseStateBufferArgs::decode(data)?; + let commit_seq = args.commit_seq().to_le_bytes(); + let state_buffer_bump = [args.state_buffer_bump()]; + dpi::require_pda!( + state_buffer, + &[ + STATE_BUFFER_SEED, + delegated_account.address().as_ref(), + &commit_seq, + authority.address().as_ref(), + &state_buffer_bump, + ], + &crate::fast::ID, + true + ); + + // The close operation is idempotent so cleanup can be retried safely. + if is_uninitialized_account(state_buffer) { + return Ok(()); + } + + require_owned_by!(state_buffer, &crate::fast::ID); + let state_buffer_data = state_buffer.try_borrow()?; + let state = StateBuffer::decode(state_buffer_data.as_ref())?; + require_eq!( + &state.discriminator(), + &StateBuffer::DISCRIMINATOR, + ProgramError::InvalidAccountData + ); + require_eq!( + state.bump(), + args.state_buffer_bump(), + ProgramError::InvalidAccountData + ); + require_eq_keys!( + state.authority(), + authority.address(), + DlpError::InvalidAuthority + ); + require_eq_keys!( + state.account_pubkey(), + delegated_account.address(), + ProgramError::InvalidAccountData + ); + require_eq!( + state.commit_seq(), + args.commit_seq(), + ProgramError::InvalidInstructionData + ); + require_eq_keys!( + state.rent_payer(), + rent_payer.address(), + DlpError::InvalidStateBufferRentPayer + ); + + let pending_commitment_bump = [args.pending_commitment_bump()]; + dpi::require_pda!( + pending_commitment, + &[ + PENDING_COMMITMENT_SEED, + delegated_account.address().as_ref(), + &commit_seq, + &pending_commitment_bump, + ], + &crate::fast::ID, + false + ); + if !is_uninitialized_account(pending_commitment) { + require_owned_by!(pending_commitment, &crate::fast::ID); + let pending_data = pending_commitment.try_borrow()?; + let pending = PendingCommitment::decode(pending_data.as_ref())?; + require_eq!( + &pending.discriminator(), + &PendingCommitment::DISCRIMINATOR, + ProgramError::InvalidAccountData + ); + require_eq_keys!( + pending.account_pubkey(), + delegated_account.address(), + ProgramError::InvalidAccountData + ); + require_eq!( + pending.commit_seq(), + args.commit_seq(), + ProgramError::InvalidAccountData + ); + require!( + matches!( + pending.status(), + PENDING_COMMITMENT_STATUS_FINALIZED + | PENDING_COMMITMENT_STATUS_EXPIRED + | PENDING_COMMITMENT_STATUS_CANCELLED + ), + DlpError::StateBufferInUse + ); + } + + drop(state_buffer_data); + rent_payer.set_lamports( + rent_payer + .lamports() + .checked_add(state_buffer.lamports()) + .ok_or(DlpError::Overflow)?, + ); + state_buffer.set_lamports(0); + unsafe { + state_buffer.assign(&pinocchio_system::ID); + } + state_buffer.resize(0) +} diff --git a/program/src/processor/fraud_proofs/write_state_buffer.rs b/program/src/processor/fraud_proofs/write_state_buffer.rs index 5698d8d2..6d14cd77 100644 --- a/program/src/processor/fraud_proofs/write_state_buffer.rs +++ b/program/src/processor/fraud_proofs/write_state_buffer.rs @@ -109,6 +109,7 @@ pub fn process_write_state_buffer( discriminator: StateBuffer::DISCRIMINATOR, bump: state_buffer_bump, authority: *authority.address(), + rent_payer: *payer.address(), account_pubkey: *delegated_account.address(), commit_seq: args.commit_seq(), data_hash: [0; 32], diff --git a/program/src/processor/mod.rs b/program/src/processor/mod.rs index 1af415c5..a82e2fd9 100644 --- a/program/src/processor/mod.rs +++ b/program/src/processor/mod.rs @@ -1,5 +1,6 @@ use delegation_program_interface as dpi; mod bootstrap; +mod close_state_buffer; mod delegate; mod direct; mod fraud_proofs; @@ -7,6 +8,7 @@ pub(crate) mod internal; mod undelegate; pub use bootstrap::*; +pub use close_state_buffer::*; pub use delegate::*; pub use direct::*; use dpi::DlpV2Instruction; @@ -68,5 +70,8 @@ pub fn process_instruction( process_prepare_commit_finalize(accounts, data) } DlpV2Instruction::Undelegate => process_undelegate(accounts, data), + DlpV2Instruction::CloseStateBuffer => { + process_close_state_buffer(accounts, data) + } } } diff --git a/program/tests/test_close_state_buffer.rs b/program/tests/test_close_state_buffer.rs new file mode 100644 index 00000000..41e04607 --- /dev/null +++ b/program/tests/test_close_state_buffer.rs @@ -0,0 +1,351 @@ +use delegation_program::solana_program; +use delegation_program_interface as dpi; +use dpi::{ + instruction_builder::{close_state_buffer, write_state_buffer}, + pda::{pending_commitment_pda, state_buffer_pda}, + PendingCommitment, StateBuffer, WriteStateBufferArgs, + PENDING_COMMITMENT_STATUS_ACTIVE, PENDING_COMMITMENT_STATUS_FINALIZED, +}; +use solana_program::{ + native_token::LAMPORTS_PER_SOL, pubkey::Pubkey, rent::Rent, +}; +use solana_program_test::{BanksClient, BanksClientError, ProgramTest}; +use solana_sdk::{ + account::Account, + instruction::{Instruction, InstructionError}, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, +}; +use solana_sdk_ids::system_program; +use wheels::layout::{Decodable, Encodable}; + +struct CloseStateBufferTestContext { + banks: BanksClient, + transaction_payer: Keypair, + buffer_payer: Keypair, + authority: Keypair, + delegated_account: Pubkey, + commit_seq: u32, +} + +#[tokio::test] +async fn test_close_state_buffer_returns_rent_and_allows_retry() { + let context = setup_test(None).await; + assert_ne!(context.buffer_payer.pubkey(), context.authority.pubkey()); + + let payer_balance_before = context + .banks + .get_balance(context.buffer_payer.pubkey()) + .await + .unwrap(); + write_buffer(&context, true).await.unwrap(); + + let state_buffer = state_buffer_pda( + &context.delegated_account, + context.commit_seq, + &context.authority.pubkey(), + ); + let state_buffer_account = context + .banks + .get_account(state_buffer) + .await + .unwrap() + .unwrap(); + let state = StateBuffer::decode(&state_buffer_account.data).unwrap(); + assert_eq!(*state.rent_payer(), context.buffer_payer.pubkey()); + assert_eq!( + context + .banks + .get_balance(context.buffer_payer.pubkey()) + .await + .unwrap(), + payer_balance_before - state_buffer_account.lamports + ); + + submit_close(&context, close_instruction(&context)) + .await + .unwrap(); + assert!(context + .banks + .get_account(state_buffer) + .await + .unwrap() + .is_none()); + assert_eq!( + context + .banks + .get_balance(context.buffer_payer.pubkey()) + .await + .unwrap(), + payer_balance_before + ); + + submit_close(&context, close_instruction(&context)) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_close_state_buffer_closes_partial_upload() { + let context = setup_test(None).await; + write_buffer(&context, false).await.unwrap(); + + submit_close(&context, close_instruction(&context)) + .await + .unwrap(); + + assert!(context + .banks + .get_account(state_buffer_pda( + &context.delegated_account, + context.commit_seq, + &context.authority.pubkey(), + )) + .await + .unwrap() + .is_none()); +} + +#[tokio::test] +async fn test_close_state_buffer_validates_authority_and_rent_recipient() { + { + let context = setup_test(None).await; + write_buffer(&context, true).await.unwrap(); + let mut instruction = close_instruction(&context); + instruction.accounts[0].pubkey = context.buffer_payer.pubkey(); + + assert!(submit_close_with_signers( + &context, + instruction, + &[&context.transaction_payer, &context.buffer_payer], + ) + .await + .is_err()); + } + + { + let context = setup_test(None).await; + write_buffer(&context, true).await.unwrap(); + let mut instruction = close_instruction(&context); + instruction.accounts[3].pubkey = Pubkey::new_unique(); + + assert_custom_error( + submit_close(&context, instruction).await.unwrap_err(), + dpi::error::DlpError::InvalidStateBufferRentPayer, + ); + } +} + +#[tokio::test] +async fn test_close_state_buffer_rejects_live_commitment() { + let context = setup_test(Some(PENDING_COMMITMENT_STATUS_ACTIVE)).await; + write_buffer(&context, true).await.unwrap(); + + assert_custom_error( + submit_close(&context, close_instruction(&context)) + .await + .unwrap_err(), + dpi::error::DlpError::StateBufferInUse, + ); +} + +#[tokio::test] +async fn test_close_state_buffer_allows_terminal_commitment() { + let context = setup_test(Some(PENDING_COMMITMENT_STATUS_FINALIZED)).await; + write_buffer(&context, true).await.unwrap(); + + submit_close(&context, close_instruction(&context)) + .await + .unwrap(); +} + +fn close_instruction(context: &CloseStateBufferTestContext) -> Instruction { + close_state_buffer( + context.authority.pubkey(), + context.delegated_account, + context.commit_seq, + context.buffer_payer.pubkey(), + ) + .unwrap() +} + +async fn write_buffer( + context: &CloseStateBufferTestContext, + finalized: bool, +) -> Result<(), BanksClientError> { + let chunk = if finalized { vec![1, 2] } else { vec![1] }; + let instruction = write_state_buffer( + context.buffer_payer.pubkey(), + context.authority.pubkey(), + context.delegated_account, + WriteStateBufferArgs { + commit_seq: context.commit_seq, + total_len: 2, + offset: 0, + chunk, + }, + ); + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&context.transaction_payer.pubkey()), + &[ + &context.transaction_payer, + &context.buffer_payer, + &context.authority, + ], + context.banks.get_latest_blockhash().await.unwrap(), + ); + + context.banks.process_transaction(transaction).await +} + +async fn submit_close( + context: &CloseStateBufferTestContext, + instruction: Instruction, +) -> Result<(), BanksClientError> { + submit_close_with_signers( + context, + instruction, + &[&context.transaction_payer, &context.authority], + ) + .await +} + +async fn submit_close_with_signers( + context: &CloseStateBufferTestContext, + instruction: Instruction, + signers: &[&Keypair], +) -> Result<(), BanksClientError> { + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&context.transaction_payer.pubkey()), + signers, + context.banks.get_latest_blockhash().await.unwrap(), + ); + + context.banks.process_transaction(transaction).await +} + +async fn setup_test(pending_status: Option) -> CloseStateBufferTestContext { + let mut program_test = ProgramTest::default(); + program_test.prefer_bpf(true); + program_test.add_program("delegation_program", dpi::ID, None); + + let buffer_payer = Keypair::new(); + let authority = Keypair::new(); + let delegated_account = Pubkey::new_unique(); + let commit_seq = 7; + for pubkey in [buffer_payer.pubkey(), authority.pubkey()] { + program_test.add_account( + pubkey, + Account { + lamports: 10 * LAMPORTS_PER_SOL, + data: vec![], + owner: system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); + } + program_test.add_account( + delegated_account, + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: dpi::id(), + executable: false, + rent_epoch: 0, + }, + ); + + if let Some(status) = pending_status { + let data = pending_commitment_data( + delegated_account, + commit_seq, + authority.pubkey(), + status, + ); + program_test.add_account( + pending_commitment_pda(&delegated_account, commit_seq), + Account { + lamports: Rent::default().minimum_balance(data.len()), + data, + owner: dpi::id(), + executable: false, + rent_epoch: 0, + }, + ); + } + + let (banks, transaction_payer, _) = program_test.start().await; + CloseStateBufferTestContext { + banks, + transaction_payer, + buffer_payer, + authority, + delegated_account, + commit_seq, + } +} + +fn pending_commitment_data( + account: Pubkey, + commit_seq: u32, + authority: Pubkey, + status: u8, +) -> Vec { + PendingCommitment { + discriminator: PendingCommitment::DISCRIMINATOR, + status, + delegation_cluster: Pubkey::new_unique(), + operator_identity: authority, + operator_bond: Pubkey::new_unique(), + account_pubkey: account, + commit_seq, + delegation_record: Pubkey::new_unique(), + da_pointer_hash: [0; 32], + account_state_hash: [0; 32], + data_hash: [0; 32], + lamports: LAMPORTS_PER_SOL, + owner: Pubkey::new_unique(), + allow_undelegation: false, + _pad_after_allow_undelegation: [0; 7], + state_commitment_hash: [0; 32], + verifier_registry: Pubkey::new_unique(), + challenge_window_id: 0, + posted_slot: 0, + activation_slot: 0, + challenge_window_end_slot: 0, + approval_count: 0, + approval_threshold: 1, + resolver: Pubkey::new_unique(), + min_challenger_stake: 0, + challenger_reveal_timeout_slots: 0, + match_penalty_bps: 0, + active_challenge: None, + resolved_state_source: None, + er_slot: None, + _pad_before_selected_verifiers: [0; 5], + selected_verifiers: vec![], + } + .encode() + .unwrap() +} + +fn assert_custom_error( + error: BanksClientError, + expected: dpi::error::DlpError, +) { + assert!( + matches!( + error, + BanksClientError::TransactionError( + TransactionError::InstructionError( + 0, + InstructionError::Custom(code), + ), + ) if code == expected as u32 + ), + "unexpected error: {error:?}" + ); +} diff --git a/program/tests/test_primary_commit_finalize_from_buffer.rs b/program/tests/test_primary_commit_finalize_from_buffer.rs index 36a92f0f..2a54f230 100644 --- a/program/tests/test_primary_commit_finalize_from_buffer.rs +++ b/program/tests/test_primary_commit_finalize_from_buffer.rs @@ -3,8 +3,8 @@ use delegation_program_interface as dpi; use dpi::{ diff::compute_diff, instruction_builder::{ - commit_finalize_from_buffer, prepare_commit_finalize_instructions, - write_state_buffer, + close_state_buffer, commit_finalize_from_buffer, + prepare_commit_finalize_instructions, write_state_buffer, }, pda::{delegation_cluster_pda, delegation_state_pda, state_buffer_pda}, CommitFinalizeFromBufferArgs, CommitPayloadKind, DelegationCluster, @@ -99,6 +99,29 @@ async fn test_commit_finalize_from_buffer_applies_large_chunked_payload() { assert!(StateBuffer::decode(&buffer_account.data) .unwrap() .finalized()); + + submit_instructions( + &context, + vec![close_state_buffer( + context.current_validator.pubkey(), + context.delegated_account, + 1, + context.payer.pubkey(), + ) + .unwrap()], + ) + .await + .unwrap(); + assert!(context + .banks + .get_account(state_buffer_pda( + &context.delegated_account, + 1, + &context.current_validator.pubkey(), + )) + .await + .unwrap() + .is_none()); } #[tokio::test] @@ -341,6 +364,7 @@ async fn setup_test( discriminator: StateBuffer::DISCRIMINATOR, bump, authority: current_validator.pubkey(), + rent_payer: current_validator.pubkey(), account_pubkey: delegated_account, commit_seq: preloaded.commit_seq, data_hash: preloaded.data_hash, diff --git a/program/tests/test_v2_write_state_buffer.rs b/program/tests/test_v2_write_state_buffer.rs index 6d6b99b0..65dba407 100644 --- a/program/tests/test_v2_write_state_buffer.rs +++ b/program/tests/test_v2_write_state_buffer.rs @@ -52,6 +52,7 @@ async fn test_write_state_buffer_unregistered_authority_one_chunk_finalizes() { assert_eq!(state.discriminator(), StateBuffer::DISCRIMINATOR); assert_eq!(*state.authority(), env.writer.pubkey()); + assert_eq!(*state.rent_payer(), env.payer.pubkey()); assert_eq!(*state.account_pubkey(), env.delegated.pubkey()); assert_eq!(state.commit_seq(), commit_seq); assert_eq!(state.total_len(), data.len() as u32); From 5481fae270e05376189a738aec7d958949499e24 Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Fri, 30 Oct 2026 22:56:19 +0530 Subject: [PATCH 2/4] Spell out commit sequence number in prose --- interface/src/args/close_state_buffer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interface/src/args/close_state_buffer.rs b/interface/src/args/close_state_buffer.rs index 7a4cbd33..0da607c0 100644 --- a/interface/src/args/close_state_buffer.rs +++ b/interface/src/args/close_state_buffer.rs @@ -3,7 +3,7 @@ use wheels::variable_offset_layout; #[derive(Clone, Debug, PartialEq, Eq)] #[variable_offset_layout(buffer_offset = 1)] pub struct CloseStateBufferArgs { - /// Commit id that identifies the buffer and its possible commitment. + /// Commit sequence number that identifies the buffer and its possible commitment. pub commit_seq: u32, /// Canonical StateBuffer PDA bump. Set by the instruction builder. From 846755c8fe6e5625d546a850dcd0ab1bf3a70918 Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Sat, 31 Oct 2026 20:47:21 +0530 Subject: [PATCH 3/4] Keep the unreleased StateBuffer discriminator unchanged --- interface/src/state/state_buffer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interface/src/state/state_buffer.rs b/interface/src/state/state_buffer.rs index 7aaa44f0..dcfed1ca 100644 --- a/interface/src/state/state_buffer.rs +++ b/interface/src/state/state_buffer.rs @@ -53,7 +53,7 @@ pub struct StateBuffer { } impl StateBuffer { - pub const DISCRIMINATOR: [u8; 8] = *b"v2sbuf01"; + pub const DISCRIMINATOR: [u8; 8] = *b"v2sbuf00"; /// Maximum account data bytes a StateBuffer PDA may allocate. pub const MAX_ACCOUNT_DATA_LEN: usize = 10 * 1024 * 1024; From 192ca4d1eb709a2150a562b7face6e379aa807f7 Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Thu, 5 Nov 2026 19:03:55 +0530 Subject: [PATCH 4/4] Update close-buffer fixture for verifier padding --- program/tests/test_close_state_buffer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/program/tests/test_close_state_buffer.rs b/program/tests/test_close_state_buffer.rs index 41e04607..828d5855 100644 --- a/program/tests/test_close_state_buffer.rs +++ b/program/tests/test_close_state_buffer.rs @@ -325,7 +325,7 @@ fn pending_commitment_data( active_challenge: None, resolved_state_source: None, er_slot: None, - _pad_before_selected_verifiers: [0; 5], + _pad_before_selected_verifiers: [0; 9], selected_verifiers: vec![], } .encode()