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`
- ix-data:
commit_seq, total_len, offset, chunk - accounts: payer signer, authority signer, StateBuffer, delegated account, system program
| 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`- ix-data:
commit_seq, state_buffer_bump, pending_commitment_bump - accounts: authority signer, StateBuffer, delegated account, stored rent payer, PendingCommitment
| 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`- ix-data:
commit_seq, lamports, owner, da_pointer_hash, er_slot - accounts: current validator signer, OperatorBond, PendingCommitment, finalized operator StateBuffer, delegated account, DelegationRecord, DelegationMetadata, DelegationCluster, VerifierRegistry, system program
| 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`- ix-data:
empty - accounts: verifier signer, VerifierBond, PendingCommitment
| 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`- ix-data:
state_commitment_hash, challenge_hash, stake_lamports - accounts: challenger signer, Challenge, PendingCommitment, DelegationCluster, system program
| 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`- ix-data:
empty - accounts: cranker, PendingCommitment, DelegationCluster
| 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`- ix-data:
empty - accounts: cranker, PendingCommitment, Challenge, fee vault
| 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`- ix-data:
empty - accounts: beneficiary signer, PayoutTimelock
| Beneficiary | Low priority. Needed only if dispute payouts are delayed through `PayoutTimelock`; otherwise `ResolveDispute` can pay immediately. |
-| `CloseTerminalAccounts`- ix-data:
close_kind - accounts: recipient, account to close, terminal parent account
| Cranker or recipient | Low priority. Closes terminal records and buffers after their parent commitment or challenge can no longer change. |
+| `CloseTerminalAccounts`- ix-data:
close_kind - accounts: recipient, account to close, terminal parent account
| 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..0da607c0
--- /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 sequence number 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..dcfed1ca 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,
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..828d5855
--- /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; 9],
+ 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);