From 79d15979d7aa1393d05de4e40334077532b58520 Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Fri, 23 Oct 2026 01:49:48 +0530 Subject: [PATCH 1/2] feat: Implement Undelegate for v2 Add the shared validator-authorized undelegation path backed by DelegationState. Return account ownership in place through the existing owner callback contract, verify the restored bytes and lamports, and close temporary state to its rent payers. Carry allow_undelegation through challenge commitments and add focused tests for paused-cluster exits, callback seed encoding, requester validation, allocation limits, and challenge finalization. --- interface/src/args/mod.rs | 2 + interface/src/args/propose_commitment.rs | 3 + interface/src/args/undelegate.rs | 9 + interface/src/consts.rs | 3 + interface/src/error.rs | 3 + interface/src/instruction.rs | 2 + interface/src/instruction_builder/mod.rs | 2 + .../src/instruction_builder/undelegate.rs | 50 +++ interface/src/lib.rs | 2 + interface/src/pda.rs | 8 + interface/src/state/pending_commitment.rs | 6 + .../fraud_proofs/finalize_commitment.rs | 4 + .../fraud_proofs/propose_commitment.rs | 5 + program/src/processor/internal/mod.rs | 2 + program/src/processor/internal/undelegate.rs | 131 ++++++ program/src/processor/mod.rs | 3 + program/src/processor/undelegate.rs | 171 +++++++ program/tests/test_approve_commitment.rs | 1 + program/tests/test_challenger_reveal.rs | 1 + program/tests/test_finalize_commitment.rs | 24 +- program/tests/test_propose_commitment.rs | 1 + program/tests/test_raise_challenge.rs | 1 + program/tests/test_resolve_dispute.rs | 1 + program/tests/test_undelegate_account.rs | 416 ++++++++++++++++++ 24 files changed, 850 insertions(+), 1 deletion(-) create mode 100644 interface/src/args/undelegate.rs create mode 100644 interface/src/consts.rs create mode 100644 interface/src/instruction_builder/undelegate.rs create mode 100644 program/src/processor/internal/undelegate.rs create mode 100644 program/src/processor/undelegate.rs create mode 100644 program/tests/test_undelegate_account.rs diff --git a/interface/src/args/mod.rs b/interface/src/args/mod.rs index 66d1b7b3..9eb03e75 100644 --- a/interface/src/args/mod.rs +++ b/interface/src/args/mod.rs @@ -11,6 +11,7 @@ mod raise_challenge; mod register_operator; mod register_verifier; mod resolve_dispute; +mod undelegate; mod update_delegation_cluster; mod update_verifier_registry; mod write_state_buffer; @@ -25,6 +26,7 @@ pub use raise_challenge::*; pub use register_operator::*; pub use register_verifier::*; pub use resolve_dispute::*; +pub use undelegate::*; pub use update_delegation_cluster::*; pub use update_verifier_registry::*; pub use write_state_buffer::*; diff --git a/interface/src/args/propose_commitment.rs b/interface/src/args/propose_commitment.rs index 7c7b5f40..fde180b6 100644 --- a/interface/src/args/propose_commitment.rs +++ b/interface/src/args/propose_commitment.rs @@ -11,6 +11,9 @@ pub struct ProposeCommitmentArgs { pub owner: Pubkey, + /// Whether finalization should make the account undelegatable. + pub allow_undelegation: bool, + /// Hash of replay/data-availability pointer bytes. pub da_pointer_hash: [u8; 32], diff --git a/interface/src/args/undelegate.rs b/interface/src/args/undelegate.rs new file mode 100644 index 00000000..28aa2772 --- /dev/null +++ b/interface/src/args/undelegate.rs @@ -0,0 +1,9 @@ +use wheels::variable_offset_layout; + +#[derive(Clone, Debug, PartialEq, Eq)] +#[variable_offset_layout(buffer_offset = 1)] +pub struct UndelegateArgs { + /// Bump for the temporary DLP-owned undelegation buffer PDA. + /// Set by the instruction builder. + pub undelegate_buffer_bump: u8, +} diff --git a/interface/src/consts.rs b/interface/src/consts.rs new file mode 100644 index 00000000..f5406514 --- /dev/null +++ b/interface/src/consts.rs @@ -0,0 +1,3 @@ +/// Discriminator understood by owner programs during undelegation callbacks. +pub const EXTERNAL_UNDELEGATE_DISCRIMINATOR: [u8; 8] = + [196, 28, 41, 206, 48, 37, 51, 167]; diff --git a/interface/src/error.rs b/interface/src/error.rs index fa5c6987..a18ed025 100644 --- a/interface/src/error.rs +++ b/interface/src/error.rs @@ -191,6 +191,9 @@ pub enum DlpError { #[error("Owner program requested undelegation")] OwnerRequestedUndelegation = 55, + #[error("Account data is too large for one-step undelegation")] + AccountDataTooLargeForUndelegation = 56, + #[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 5a518a7b..9859bac6 100644 --- a/interface/src/instruction.rs +++ b/interface/src/instruction.rs @@ -41,6 +41,8 @@ pub enum DlpV2Instruction { CommitFinalizeFromBuffer = 114, /// Grows a delegated account by one step before a large direct finalization. PrepareCommitFinalize = 115, + /// Returns a delegated account to its original owner. + Undelegate = 116, } impl DlpV2Instruction { diff --git a/interface/src/instruction_builder/mod.rs b/interface/src/instruction_builder/mod.rs index 8210273a..69c21618 100644 --- a/interface/src/instruction_builder/mod.rs +++ b/interface/src/instruction_builder/mod.rs @@ -10,6 +10,7 @@ mod raise_challenge; mod register_operator; mod register_verifier; mod resolve_dispute; +mod undelegate; mod update_delegation_cluster; mod update_verifier_registry; mod write_state_buffer; @@ -26,6 +27,7 @@ pub use raise_challenge::*; pub use register_operator::*; pub use register_verifier::*; pub use resolve_dispute::*; +pub use undelegate::*; pub use update_delegation_cluster::*; pub use update_verifier_registry::*; pub use write_state_buffer::*; diff --git a/interface/src/instruction_builder/undelegate.rs b/interface/src/instruction_builder/undelegate.rs new file mode 100644 index 00000000..51b621d1 --- /dev/null +++ b/interface/src/instruction_builder/undelegate.rs @@ -0,0 +1,50 @@ +use solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; +use solana_sdk_ids::system_program; +use wheels::{layout::Encodable, DataLayoutError}; + +use crate::{ + compat::{Compatize, Modernize}, + pda::{delegation_state_pda, undelegate_buffer_pda}, + DlpV2Instruction, UndelegateArgs, +}; + +/// Builds the shared undelegation instruction. +pub fn undelegate( + current_validator: Pubkey, + delegated_account: Pubkey, + original_owner: Pubkey, + delegation_cluster: Pubkey, + delegation_rent_payer: Pubkey, +) -> Result { + let (undelegate_buffer, undelegate_buffer_bump) = + undelegate_buffer_pda(&delegated_account.compatize()); + + Ok(Instruction { + program_id: crate::id().modernize(), + accounts: vec![ + AccountMeta::new(current_validator, true), + AccountMeta::new(delegated_account, false), + AccountMeta::new_readonly(original_owner, false), + AccountMeta::new(undelegate_buffer.modernize(), false), + AccountMeta::new( + delegation_state_pda(&delegated_account.compatize()) + .modernize(), + false, + ), + AccountMeta::new_readonly(delegation_cluster, false), + AccountMeta::new(delegation_rent_payer, false), + AccountMeta::new_readonly(system_program::id(), false), + ], + data: [ + DlpV2Instruction::Undelegate.to_vec(), + UndelegateArgs { + undelegate_buffer_bump, + } + .encode()?, + ] + .concat(), + }) +} diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 1c1711e4..4c2640b0 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -3,6 +3,7 @@ pub use ::solana_program; pub mod args; pub mod compact; pub mod compat; +pub mod consts; #[cfg(feature = "diff")] pub mod diff; @@ -18,6 +19,7 @@ pub mod state; pub mod instruction_builder; pub use args::*; +pub use consts::*; pub use instruction::*; pub use pda::*; pub use state::*; diff --git a/interface/src/pda.rs b/interface/src/pda.rs index 0bce2400..919959c7 100644 --- a/interface/src/pda.rs +++ b/interface/src/pda.rs @@ -8,6 +8,7 @@ pub const VERIFIER_REGISTRY_SEED: &[u8] = b"verifier-registry"; pub const STATE_BUFFER_SEED: &[u8] = b"state-buffer"; pub const PENDING_COMMITMENT_SEED: &[u8] = b"pending-commitment"; pub const CHALLENGE_SEED: &[u8] = b"challenge"; +pub const UNDELEGATE_BUFFER_SEED: &[u8] = b"undelegate-buffer"; // TODO (snawaz): Precompute these addresses if PDA derivation becomes const-safe. @@ -103,3 +104,10 @@ pub fn challenge_pda( ) .0 } + +pub fn undelegate_buffer_pda(delegated_account: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[UNDELEGATE_BUFFER_SEED, delegated_account.as_ref()], + &crate::id(), + ) +} diff --git a/interface/src/state/pending_commitment.rs b/interface/src/state/pending_commitment.rs index 4dc95937..bb1a9736 100644 --- a/interface/src/state/pending_commitment.rs +++ b/interface/src/state/pending_commitment.rs @@ -62,6 +62,12 @@ pub struct PendingCommitment { /// Owner committed by the operator. pub owner: Pubkey, + /// Whether finalization should make the account undelegatable. + pub allow_undelegation: bool, + + /// Keeps the following fixed-width fields eight-byte aligned. + pub _pad_after_allow_undelegation: [u8; 7], + /// Hash binding operator, account, commit sequence number, delegation, DA, /// and state. pub state_commitment_hash: [u8; 32], diff --git a/program/src/processor/fraud_proofs/finalize_commitment.rs b/program/src/processor/fraud_proofs/finalize_commitment.rs index ca5c4d19..989f8a25 100644 --- a/program/src/processor/fraud_proofs/finalize_commitment.rs +++ b/program/src/processor/fraud_proofs/finalize_commitment.rs @@ -257,6 +257,10 @@ pub fn process_finalize_commitment( let mut metadata = DelegationMetadataFast::from_account(delegation_metadata)?; metadata.set_last_commit_id(u64::from(pending.commit_seq())); + if pending.allow_undelegation() { + metadata + .set_undelegation_requester(UndelegationRequester::Validator); + } } drop(delegation_cluster_data); diff --git a/program/src/processor/fraud_proofs/propose_commitment.rs b/program/src/processor/fraud_proofs/propose_commitment.rs index 7dc49c2a..7a482fb6 100644 --- a/program/src/processor/fraud_proofs/propose_commitment.rs +++ b/program/src/processor/fraud_proofs/propose_commitment.rs @@ -242,6 +242,7 @@ pub fn process_propose_commitment( delegation_record: delegation_record.address(), da_pointer_hash: args.da_pointer_hash(), account_state_hash: &account_state_hash, + allow_undelegation: args.allow_undelegation(), verifier_registry: verifier_registry.address(), challenge_window_id, }); @@ -260,6 +261,8 @@ pub fn process_propose_commitment( data_hash, lamports: args.lamports(), owner: *args.owner(), + allow_undelegation: args.allow_undelegation(), + _pad_after_allow_undelegation: [0; 7], state_commitment_hash, verifier_registry: verifier_registry.address().to_bytes().into(), challenge_window_id, @@ -472,6 +475,7 @@ struct StateCommitmentHashInput<'a> { delegation_record: &'a Address, da_pointer_hash: &'a [u8; 32], account_state_hash: &'a [u8; 32], + allow_undelegation: bool, verifier_registry: &'a Address, challenge_window_id: u64, } @@ -486,6 +490,7 @@ fn state_commitment_hash(input: StateCommitmentHashInput<'_>) -> [u8; 32] { input.delegation_record.as_ref(), input.da_pointer_hash, input.account_state_hash, + &[input.allow_undelegation as u8], input.verifier_registry.as_ref(), &input.challenge_window_id.to_le_bytes(), ]) diff --git a/program/src/processor/internal/mod.rs b/program/src/processor/internal/mod.rs index fe84489c..d7ac9805 100644 --- a/program/src/processor/internal/mod.rs +++ b/program/src/processor/internal/mod.rs @@ -1,3 +1,5 @@ mod commit_finalize; +mod undelegate; pub(crate) use commit_finalize::*; +pub(crate) use undelegate::*; diff --git a/program/src/processor/internal/undelegate.rs b/program/src/processor/internal/undelegate.rs new file mode 100644 index 00000000..793da67a --- /dev/null +++ b/program/src/processor/internal/undelegate.rs @@ -0,0 +1,131 @@ +use delegation_program_interface as dpi; +use dpi::{consts::EXTERNAL_UNDELEGATE_DISCRIMINATOR, UNDELEGATE_BUFFER_SEED}; +use pinocchio::{ + cpi::{invoke_signed, Seed, Signer}, + error::ProgramError, + instruction::{InstructionAccount, InstructionView}, + AccountView, ProgramResult, +}; + +use crate::{ + legacy::processor::fast::utils::pda::{ + close_pda, create_pda, AccountSpace, + }, + require_eq_keys, +}; + +#[allow(clippy::too_many_arguments)] +pub(crate) fn return_account_to_owner( + payer: &AccountView, + delegated_account: &AccountView, + owner_program: &AccountView, + undelegate_buffer: &AccountView, + system_program: &AccountView, + undelegate_buffer_bump: u8, + callback_data: &[u8], +) -> ProgramResult { + let account_lamports = delegated_account.lamports(); + + if delegated_account.is_data_empty() { + unsafe { + delegated_account.assign(owner_program.address()); + } + return Ok(()); + } + + let bump = [undelegate_buffer_bump]; + let signer_seeds = [ + Seed::from(UNDELEGATE_BUFFER_SEED), + Seed::from(delegated_account.address().as_ref()), + Seed::from(&bump), + ]; + let signers = [Signer::from(&signer_seeds)]; + + create_pda( + undelegate_buffer, + &crate::fast::ID, + AccountSpace::CurrentRent(delegated_account.data_len()), + &signers, + payer, + )?; + + undelegate_buffer + .try_borrow_mut()? + .copy_from_slice(delegated_account.try_borrow()?.as_ref()); + delegated_account.try_borrow_mut()?.fill(0); + unsafe { + delegated_account.assign(owner_program.address()); + } + + invoke_signed( + &InstructionView { + program_id: owner_program.address(), + data: callback_data, + accounts: &[ + InstructionAccount::new( + delegated_account.address(), + true, + false, + ), + InstructionAccount::new( + undelegate_buffer.address(), + true, + true, + ), + InstructionAccount::new(payer.address(), true, true), + InstructionAccount::new(system_program.address(), false, false), + ], + }, + &[delegated_account, undelegate_buffer, payer, system_program], + &signers, + )?; + + require_eq_keys!( + unsafe { delegated_account.owner() }, + owner_program.address(), + ProgramError::InvalidAccountOwner + ); + if delegated_account.lamports() != account_lamports { + return Err( + dpi::error::DlpError::InvalidValidatorBalanceAfterCPI.into() + ); + } + if delegated_account.try_borrow()?.as_ref() + != undelegate_buffer.try_borrow()?.as_ref() + { + return Err(dpi::error::DlpError::InvalidAccountDataAfterCPI.into()); + } + + close_pda(undelegate_buffer, payer) +} + +pub(crate) fn owner_callback_data( + state: &dpi::DelegationStateView<'_>, +) -> Result, ProgramError> { + let seeds = state + .seeds() + .map_err(|_| ProgramError::InvalidAccountData)?; + let capacity = EXTERNAL_UNDELEGATE_DISCRIMINATOR + .len() + .checked_add(4) + .and_then(|len| len.checked_add(state.encoded_seeds().len())) + .and_then(|len| len.checked_add(seeds.len().saturating_mul(3))) + .ok_or(dpi::error::DlpError::Overflow)?; + let mut data = Vec::with_capacity(capacity); + data.extend_from_slice(&EXTERNAL_UNDELEGATE_DISCRIMINATOR); + data.extend_from_slice( + &u32::try_from(seeds.len()) + .map_err(|_| dpi::error::DlpError::Overflow)? + .to_le_bytes(), + ); + for seed in seeds.iter() { + data.extend_from_slice( + &u32::try_from(seed.as_slice().len()) + .map_err(|_| dpi::error::DlpError::Overflow)? + .to_le_bytes(), + ); + data.extend_from_slice(seed.as_slice()); + } + + Ok(data) +} diff --git a/program/src/processor/mod.rs b/program/src/processor/mod.rs index 6b76be2a..1af415c5 100644 --- a/program/src/processor/mod.rs +++ b/program/src/processor/mod.rs @@ -4,6 +4,7 @@ mod delegate; mod direct; mod fraud_proofs; pub(crate) mod internal; +mod undelegate; pub use bootstrap::*; pub use delegate::*; @@ -11,6 +12,7 @@ pub use direct::*; use dpi::DlpV2Instruction; pub use fraud_proofs::*; use pinocchio::{AccountView, ProgramResult}; +pub use undelegate::*; #[inline(never)] pub fn process_instruction( @@ -65,5 +67,6 @@ pub fn process_instruction( DlpV2Instruction::PrepareCommitFinalize => { process_prepare_commit_finalize(accounts, data) } + DlpV2Instruction::Undelegate => process_undelegate(accounts, data), } } diff --git a/program/src/processor/undelegate.rs b/program/src/processor/undelegate.rs new file mode 100644 index 00000000..84d2e4dc --- /dev/null +++ b/program/src/processor/undelegate.rs @@ -0,0 +1,171 @@ +use delegation_program_interface as dpi; +use dpi::{ + error::DlpError, + pda::{ + DELEGATION_CLUSTER_SEED, DELEGATION_STATE_SEED, UNDELEGATE_BUFFER_SEED, + }, + DelegationCluster, DelegationState, UndelegateArgs, UndelegationRequester, +}; +use pinocchio::{ + address::address_eq, error::ProgramError, AccountView, ProgramResult, +}; +use wheels::{ + layout::Decodable, require, require_eq, require_eq_keys, require_ge, + require_n_accounts, require_owned_by, require_signer, +}; + +use crate::{ + legacy::processor::fast::utils::pda::close_pda, + processor::internal::{owner_callback_data, return_account_to_owner}, + requires::{require_uninitialized_account, StandardCtx}, +}; + +/// Return a delegated account to its original owner. +/// +/// Accounts: +/// 0: `[signer, writable]` current cluster validator and buffer rent payer +/// 1: `[writable]` delegated account +/// 2: `[]` original owner program +/// 3: `[writable]` temporary undelegation buffer PDA +/// 4: `[writable]` DelegationState PDA +/// 5: `[]` DelegationCluster PDA +/// 6: `[writable]` DelegationState rent payer +/// 7: `[]` system program, forwarded to the owner callback +#[inline(never)] +pub fn process_undelegate( + accounts: &[AccountView], + data: &[u8], +) -> ProgramResult { + let [current_validator, delegated_account, owner_program, undelegate_buffer, delegation_state, delegation_cluster, delegation_rent_payer, system_program] = + require_n_accounts!(accounts, 8); + + require_signer!(current_validator); + require!(current_validator.is_writable(), ProgramError::Immutable); + require_owned_by!(delegated_account, &crate::fast::ID); + require!(delegated_account.is_writable(), ProgramError::Immutable); + require!(delegation_rent_payer.is_writable(), ProgramError::Immutable); + require_eq_keys!( + system_program.address(), + &pinocchio_system::ID, + ProgramError::IncorrectProgramId + ); + + let args = UndelegateArgs::decode(data)?; + require!( + delegated_account.data_len() + <= pinocchio::account::MAX_PERMITTED_DATA_INCREASE, + DlpError::AccountDataTooLargeForUndelegation + ); + + let state_data = delegation_state.try_borrow()?; + let state = DelegationState::decode(state_data.as_ref())?; + let state_bump = [state.bump()]; + dpi::require_initialized_pda!( + delegation_state, + &[ + DELEGATION_STATE_SEED, + delegated_account.address().as_ref(), + &state_bump, + ], + &crate::fast::ID, + true + ); + require_eq!( + &state.discriminator(), + &DelegationState::DISCRIMINATOR, + ProgramError::InvalidAccountData + ); + require_eq_keys!( + state.delegated_account(), + delegated_account.address(), + DlpError::InvalidDelegatedAccount + ); + require_eq_keys!( + state.delegation_cluster(), + delegation_cluster.address(), + ProgramError::InvalidAccountData + ); + require_eq_keys!( + state.original_owner(), + owner_program.address(), + ProgramError::InvalidAccountOwner + ); + require_eq_keys!( + state.rent_payer(), + delegation_rent_payer.address(), + DlpError::InvalidReimbursementAddressForDelegationRent + ); + require_ge!( + delegated_account.lamports(), + state.lamports(), + DlpError::InvalidDelegatedState + ); + match state.undelegation_requester() { + value if value == UndelegationRequester::Validator.value() => {} + value if value == UndelegationRequester::None.value() => { + return Err(DlpError::NotUndelegatable.into()); + } + value if value == UndelegationRequester::OwnerProgram.value() => { + return Err(DlpError::OwnerRequestedUndelegation.into()); + } + _ => return Err(ProgramError::InvalidAccountData), + } + let callback_data = owner_callback_data(&state)?; + drop(state_data); + + let cluster_data = delegation_cluster.try_borrow()?; + let cluster = DelegationCluster::decode(cluster_data.as_ref())?; + let cluster_bump = [cluster.bump()]; + dpi::require_initialized_pda!( + delegation_cluster, + &[ + DELEGATION_CLUSTER_SEED, + cluster.cluster_id().as_ref(), + &cluster_bump, + ], + &crate::fast::ID, + false + ); + require_eq!( + &cluster.discriminator(), + &DelegationCluster::DISCRIMINATOR, + ProgramError::InvalidAccountData + ); + require_eq_keys!( + cluster.current_validator(), + current_validator.address(), + DlpError::InvalidAuthority + ); + drop(cluster_data); + + let undelegate_buffer_bump = [args.undelegate_buffer_bump()]; + dpi::require_pda!( + undelegate_buffer, + &[ + UNDELEGATE_BUFFER_SEED, + delegated_account.address().as_ref(), + &undelegate_buffer_bump, + ], + &crate::fast::ID, + true + ); + require_uninitialized_account( + undelegate_buffer, + true, + StandardCtx::new("undelegation buffer"), + )?; + if !delegated_account.is_data_empty() { + require!(owner_program.executable(), ProgramError::InvalidAccountData); + } + + return_account_to_owner( + current_validator, + delegated_account, + owner_program, + undelegate_buffer, + system_program, + args.undelegate_buffer_bump(), + &callback_data, + )?; + close_pda(delegation_state, delegation_rent_payer) +} diff --git a/program/tests/test_approve_commitment.rs b/program/tests/test_approve_commitment.rs index c340eb79..2bb58f63 100644 --- a/program/tests/test_approve_commitment.rs +++ b/program/tests/test_approve_commitment.rs @@ -459,6 +459,7 @@ fn propose_commitment_args() -> ProposeCommitmentArgs { commit_seq: 1, lamports: 1_000, owner: Pubkey::new_from_array([7; 32]), + allow_undelegation: false, da_pointer_hash: [9; 32], er_slot: Some(42), } diff --git a/program/tests/test_challenger_reveal.rs b/program/tests/test_challenger_reveal.rs index 63b7be46..83ba7feb 100644 --- a/program/tests/test_challenger_reveal.rs +++ b/program/tests/test_challenger_reveal.rs @@ -499,6 +499,7 @@ async fn propose( commit_seq: env.commit_seq, lamports: env.committed_lamports, owner: env.committed_owner, + allow_undelegation: false, da_pointer_hash: [9; 32], er_slot: Some(42), }, diff --git a/program/tests/test_finalize_commitment.rs b/program/tests/test_finalize_commitment.rs index 28d51c07..2db3b2f5 100644 --- a/program/tests/test_finalize_commitment.rs +++ b/program/tests/test_finalize_commitment.rs @@ -10,7 +10,7 @@ use dpi::{ delegation_metadata_pda_from_delegated_account, delegation_record_pda_from_delegated_account, }, - state::{DelegationMetadata, DelegationRecord}, + state::{DelegationMetadata, DelegationRecord, UndelegationRequester}, }, pda::pending_commitment_pda, PendingCommitment, ProposeCommitmentArgs, RegisterOperatorArgs, @@ -76,6 +76,25 @@ async fn test_finalize_commitment() { ); } +#[tokio::test] +async fn test_finalize_commitment_records_validator_undelegation_request() { + let mut env = + setup_finalize_commitment_env(LAMPORTS_PER_SOL, LAMPORTS_PER_SOL).await; + env.allow_undelegation = true; + + propose(&mut env).await.unwrap(); + approve(&mut env).await.unwrap(); + warp_past_challenge_window(&mut env).await; + finalize(&mut env).await.unwrap(); + + assert_eq!( + read_delegation_metadata(&mut env) + .await + .undelegation_requester, + UndelegationRequester::Validator + ); +} + #[tokio::test] async fn test_finalize_commitment_fails_before_window_closes() { let mut env = @@ -230,6 +249,7 @@ struct FinalizeCommitmentEnv { committed_owner: Pubkey, committed_lamports: u64, commit_seq: u32, + allow_undelegation: bool, final_state_data: Vec, } @@ -351,6 +371,7 @@ async fn setup_finalize_commitment_env( committed_owner, committed_lamports, commit_seq, + allow_undelegation: false, final_state_data, } } @@ -366,6 +387,7 @@ async fn propose( commit_seq: env.commit_seq, lamports: env.committed_lamports, owner: env.committed_owner, + allow_undelegation: env.allow_undelegation, da_pointer_hash: [9; 32], er_slot: Some(42), }, diff --git a/program/tests/test_propose_commitment.rs b/program/tests/test_propose_commitment.rs index d453f4f8..8a4ebf00 100644 --- a/program/tests/test_propose_commitment.rs +++ b/program/tests/test_propose_commitment.rs @@ -548,6 +548,7 @@ fn propose_commitment_args() -> ProposeCommitmentArgs { commit_seq: 1, lamports: 1_000, owner: Pubkey::new_from_array([7; 32]), + allow_undelegation: false, da_pointer_hash: [9; 32], er_slot: Some(42), } diff --git a/program/tests/test_raise_challenge.rs b/program/tests/test_raise_challenge.rs index 4c832e1f..24528a0e 100644 --- a/program/tests/test_raise_challenge.rs +++ b/program/tests/test_raise_challenge.rs @@ -375,6 +375,7 @@ async fn propose(env: &mut RaiseChallengeEnv) -> Result<(), BanksClientError> { commit_seq: env.commit_seq, lamports: env.committed_lamports, owner: env.committed_owner, + allow_undelegation: false, da_pointer_hash: [9; 32], er_slot: Some(42), }, diff --git a/program/tests/test_resolve_dispute.rs b/program/tests/test_resolve_dispute.rs index b865d8a1..0cb16394 100644 --- a/program/tests/test_resolve_dispute.rs +++ b/program/tests/test_resolve_dispute.rs @@ -552,6 +552,7 @@ async fn propose(env: &mut ResolveDisputeEnv) -> Result<(), BanksClientError> { commit_seq: env.commit_seq, lamports: env.committed_lamports, owner: env.committed_owner, + allow_undelegation: false, da_pointer_hash: [9; 32], er_slot: Some(42), }, diff --git a/program/tests/test_undelegate_account.rs b/program/tests/test_undelegate_account.rs new file mode 100644 index 00000000..48820054 --- /dev/null +++ b/program/tests/test_undelegate_account.rs @@ -0,0 +1,416 @@ +use delegation_program::solana_program; +use delegation_program_interface as dpi; +use dpi::{ + consts::EXTERNAL_UNDELEGATE_DISCRIMINATOR, + instruction_builder::undelegate, + pda::{ + delegation_cluster_pda, delegation_state_pda, undelegate_buffer_pda, + }, + DelegationCluster, DelegationSeed, DelegationState, UndelegationRequester, +}; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + native_token::LAMPORTS_PER_SOL, + program_error::ProgramError, + pubkey::Pubkey, + rent::Rent, +}; +use solana_program_test::{ + processor, BanksClient, BanksClientError, ProgramTest, +}; +use solana_sdk::{ + account::Account, + instruction::InstructionError, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, +}; +use solana_sdk_ids::system_program; +use wheels::layout::Encodable; + +const OWNER_PROGRAM_ID: Pubkey = + solana_sdk::pubkey!("8qbHbw2BbbTHBW1sbeqakYXV7xPC7rULpRvbi3L6jUjL"); +const ACCOUNT_DATA: &[u8] = b"finalized account state"; +const ACCOUNT_SEED: &[u8] = b"undelegate-account-test"; +const ACCOUNT_LAMPORTS: u64 = LAMPORTS_PER_SOL; + +struct UndelegateTestContext { + banks: BanksClient, + payer: Keypair, + current_validator: Keypair, + delegated_account: Pubkey, + delegation_state: Pubkey, + delegation_cluster: Pubkey, + delegation_rent_payer: Pubkey, + original_owner: Pubkey, + state_rent: u64, +} + +#[tokio::test] +async fn test_undelegate_assigns_account_without_recreating_it() { + let context = setup_undelegate_test( + ACCOUNT_DATA.to_vec(), + OWNER_PROGRAM_ID, + UndelegationRequester::Validator, + true, + ) + .await; + let validator_lamports_before = context + .banks + .get_account(context.current_validator.pubkey()) + .await + .unwrap() + .unwrap() + .lamports; + let rent_payer_lamports_before = context + .banks + .get_account(context.delegation_rent_payer) + .await + .unwrap() + .unwrap() + .lamports; + + submit_undelegate(&context, &context.current_validator) + .await + .unwrap(); + + let delegated_account = context + .banks + .get_account(context.delegated_account) + .await + .unwrap() + .unwrap(); + assert_eq!(delegated_account.owner, OWNER_PROGRAM_ID); + assert_eq!(delegated_account.data, ACCOUNT_DATA); + assert_eq!(delegated_account.lamports, ACCOUNT_LAMPORTS); + assert!(context + .banks + .get_account(context.delegation_state) + .await + .unwrap() + .is_none()); + assert!(context + .banks + .get_account(undelegate_buffer_pda(&context.delegated_account).0) + .await + .unwrap() + .is_none()); + assert_eq!( + context + .banks + .get_account(context.current_validator.pubkey()) + .await + .unwrap() + .unwrap() + .lamports, + validator_lamports_before + ); + assert_eq!( + context + .banks + .get_account(context.delegation_rent_payer) + .await + .unwrap() + .unwrap() + .lamports, + rent_payer_lamports_before + context.state_rent + ); +} + +#[tokio::test] +async fn test_undelegate_empty_system_account_without_callback() { + let context = setup_undelegate_test( + vec![], + system_program::id(), + UndelegationRequester::Validator, + false, + ) + .await; + + submit_undelegate(&context, &context.current_validator) + .await + .unwrap(); + + let delegated_account = context + .banks + .get_account(context.delegated_account) + .await + .unwrap() + .unwrap(); + assert_eq!(delegated_account.owner, system_program::id()); + assert!(delegated_account.data.is_empty()); + assert_eq!(delegated_account.lamports, ACCOUNT_LAMPORTS); +} + +#[tokio::test] +async fn test_undelegate_rejects_unrequested_and_owner_requested_accounts() { + for requester in [ + UndelegationRequester::None, + UndelegationRequester::OwnerProgram, + ] { + let context = setup_undelegate_test( + ACCOUNT_DATA.to_vec(), + OWNER_PROGRAM_ID, + requester, + false, + ) + .await; + let error = submit_undelegate(&context, &context.current_validator) + .await + .unwrap_err(); + assert_custom_error( + error, + match requester { + UndelegationRequester::None => { + dpi::error::DlpError::NotUndelegatable + } + UndelegationRequester::OwnerProgram => { + dpi::error::DlpError::OwnerRequestedUndelegation + } + UndelegationRequester::Validator => unreachable!(), + }, + ); + } +} + +#[tokio::test] +async fn test_undelegate_rejects_data_above_one_growth_step() { + let context = setup_undelegate_test( + vec![1; pinocchio::account::MAX_PERMITTED_DATA_INCREASE + 1], + OWNER_PROGRAM_ID, + UndelegationRequester::Validator, + false, + ) + .await; + + assert_custom_error( + submit_undelegate(&context, &context.current_validator) + .await + .unwrap_err(), + dpi::error::DlpError::AccountDataTooLargeForUndelegation, + ); +} + +async fn submit_undelegate( + context: &UndelegateTestContext, + validator: &Keypair, +) -> Result<(), BanksClientError> { + let instruction = undelegate( + validator.pubkey(), + context.delegated_account, + context.original_owner, + context.delegation_cluster, + context.delegation_rent_payer, + ) + .unwrap(); + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&context.payer.pubkey()), + &[&context.payer, validator], + context.banks.get_latest_blockhash().await.unwrap(), + ); + + context.banks.process_transaction(transaction).await +} + +async fn setup_undelegate_test( + account_data: Vec, + original_owner: Pubkey, + requester: UndelegationRequester, + paused: bool, +) -> UndelegateTestContext { + let mut program_test = ProgramTest::default(); + program_test.prefer_bpf(true); + program_test.add_program("delegation_program", dpi::ID, None); + program_test.prefer_bpf(false); + program_test.add_program( + "undelegate-account-owner", + OWNER_PROGRAM_ID, + processor!(owner_program_processor), + ); + + let current_validator = Keypair::new(); + let delegation_rent_payer = Pubkey::new_unique(); + let (delegated_account, delegated_account_bump, encoded_seeds) = + if original_owner == system_program::id() { + (Pubkey::new_unique(), 0, vec![]) + } else { + let (delegated_account, bump) = + Pubkey::find_program_address(&[ACCOUNT_SEED], &original_owner); + ( + delegated_account, + bump, + DelegationSeed { + value: ACCOUNT_SEED.to_vec(), + } + .encode() + .unwrap(), + ) + }; + let cluster_id = Pubkey::new_unique(); + let delegation_cluster = delegation_cluster_pda(&cluster_id); + let (_, cluster_bump) = Pubkey::find_program_address( + &[dpi::pda::DELEGATION_CLUSTER_SEED, cluster_id.as_ref()], + &dpi::id(), + ); + let delegation_state = delegation_state_pda(&delegated_account); + let (_, state_bump) = Pubkey::find_program_address( + &[dpi::pda::DELEGATION_STATE_SEED, delegated_account.as_ref()], + &dpi::id(), + ); + + program_test.add_account( + current_validator.pubkey(), + Account { + lamports: 10 * LAMPORTS_PER_SOL, + data: vec![], + owner: system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); + program_test.add_account( + delegation_rent_payer, + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); + program_test.add_account( + delegated_account, + Account { + lamports: ACCOUNT_LAMPORTS, + data: account_data, + owner: dpi::id(), + executable: false, + rent_epoch: 0, + }, + ); + + let cluster_data = DelegationCluster { + discriminator: DelegationCluster::DISCRIMINATOR, + bump: cluster_bump, + cluster_id, + config_authority: Pubkey::new_unique(), + validator_rotation_authority: Pubkey::new_unique(), + current_validator: current_validator.pubkey(), + paused, + protocol_fee_vault: Pubkey::new_unique(), + challenge_config: None, + } + .encode() + .unwrap(); + program_test.add_account( + delegation_cluster, + Account { + lamports: Rent::default().minimum_balance(cluster_data.len()), + data: cluster_data, + owner: dpi::id(), + executable: false, + rent_epoch: 0, + }, + ); + + let state_data = DelegationState { + discriminator: DelegationState::DISCRIMINATOR, + bump: state_bump, + delegated_account, + delegation_cluster, + original_owner, + rent_payer: delegation_rent_payer, + delegation_slot: 0, + lamports: ACCOUNT_LAMPORTS, + last_commit_seq: 1, + undelegation_requester: requester.value(), + delegated_account_bump, + seed_count: u8::from(!encoded_seeds.is_empty()), + encoded_seeds, + } + .encode() + .unwrap(); + let state_rent = Rent::default().minimum_balance(state_data.len()); + program_test.add_account( + delegation_state, + Account { + lamports: state_rent, + data: state_data, + owner: dpi::id(), + executable: false, + rent_epoch: 0, + }, + ); + + let (banks, payer, _) = program_test.start().await; + UndelegateTestContext { + banks, + payer, + current_validator, + delegated_account, + delegation_state, + delegation_cluster, + delegation_rent_payer, + original_owner, + state_rent, + } +} + +fn owner_program_processor( + program_id: &Pubkey, + accounts: &[AccountInfo], + data: &[u8], +) -> ProgramResult { + let expected_callback_data = [ + EXTERNAL_UNDELEGATE_DISCRIMINATOR.as_slice(), + 1_u32.to_le_bytes().as_slice(), + (ACCOUNT_SEED.len() as u32).to_le_bytes().as_slice(), + ACCOUNT_SEED, + ] + .concat(); + if program_id != &OWNER_PROGRAM_ID || data != expected_callback_data { + return Err(ProgramError::InvalidInstructionData); + } + + let mut accounts = accounts.iter(); + let delegated_account = next_account_info(&mut accounts)?; + let undelegate_buffer = next_account_info(&mut accounts)?; + let payer = next_account_info(&mut accounts)?; + let system_program_account = next_account_info(&mut accounts)?; + if delegated_account.owner != program_id + || !delegated_account.is_writable + || undelegate_buffer.owner != &dpi::id() + || !undelegate_buffer.is_signer + || !payer.is_signer + || system_program_account.key != &system_program::id() + { + return Err(ProgramError::InvalidAccountData); + } + + let buffer_data = undelegate_buffer.try_borrow_data()?; + let mut delegated_data = delegated_account.try_borrow_mut_data()?; + if delegated_data.len() != buffer_data.len() { + return Err(ProgramError::InvalidAccountData); + } + delegated_data.copy_from_slice(&buffer_data); + Ok(()) +} + +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:?}" + ); +} From 96f970d8aa945a9d5487bdda6a17a7c69b81f523 Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Fri, 23 Oct 2026 01:56:10 +0530 Subject: [PATCH 2/2] Simplify the pull request template --- .github/pull_request_template.md | 49 ++++---------------------------- 1 file changed, 6 insertions(+), 43 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f0edc54b..939bef51 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,45 +1,8 @@ + -> ⚠️ NOTE: Use notes like this to emphasize something important about the PR. -> -> This could include other PRs this PR is built on top of; API breaking changes; reasons for why the PR is on hold; or anything else you would like to draw attention to. + -| Status | Type | ⚠️ Core Change | Issue | -| :---: | :---: | :---: | :--: | -| Ready/Hold | Feature/Bug/Tooling/Refactor/Hotfix | Yes/No | [Link]() | - -## Problem - -_What problem are you trying to solve?_ - - -## Solution - -_How did you solve the problem?_ - - -## Before & After Screenshots - -_Insert screenshots of example code output_ - -**BEFORE**: -[insert screenshot here] - -**AFTER**: -[insert screenshot here] - - -## Other changes (e.g. bug fixes, small refactors) - - -## Deploy Notes - -_Notes regarding deployment of the contained body of work. These should note any -new dependencies, new scripts, etc._ - -**New scripts**: - -- `script` : script details - -**New dependencies**: - -- `dependency` : dependency details \ No newline at end of file +Closes #