diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 6487cf91..89bb4a31 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -76,6 +76,9 @@ jobs: solana --version solana-keygen new --silent --no-bip39-passphrase + - name: log disk space + run: df -h + - name: run build run: | cargo build diff --git a/dlp-api/src/consts.rs b/dlp-api/src/consts.rs index 4da961fb..db940329 100644 --- a/dlp-api/src/consts.rs +++ b/dlp-api/src/consts.rs @@ -14,6 +14,10 @@ pub const COMMIT_FEE_LAMPORTS: u64 = 100_000; /// Fixed fee per delegation session (0.0003 SOL). pub const SESSION_FEE_LAMPORTS: u64 = 300_000; +/// Default and minimum timeout for requested undelegation. +/// Assuming 1 slot is roughly 400ms, then 9000 slots = 60min. +pub const DEFAULT_UNDELEGATION_REQUEST_TIMEOUT_SLOTS: u64 = 9000; + /// The discriminator for the external undelegate instruction. pub const EXTERNAL_UNDELEGATE_DISCRIMINATOR: [u8; 8] = [196, 28, 41, 206, 48, 37, 51, 167]; diff --git a/dlp-api/src/discriminator.rs b/dlp-api/src/discriminator.rs index e632bfd6..e4c8d37d 100644 --- a/dlp-api/src/discriminator.rs +++ b/dlp-api/src/discriminator.rs @@ -62,6 +62,12 @@ pub enum DlpDiscriminator { /// See [crate::processor::process_delegate_magic_fee_vault] for docs. DelegateMagicFeeVault = 25, + + /// See [crate::processor::process_request_undelegation] for docs. + RequestUndelegation = 26, + + /// See [crate::processor::process_undelegate_with_rollback_after_timeout] for docs. + UndelegateWithRollbackAfterTimeout = 27, } impl DlpDiscriminator { diff --git a/dlp-api/src/error.rs b/dlp-api/src/error.rs index 883d8a1a..fa5c6987 100644 --- a/dlp-api/src/error.rs +++ b/dlp-api/src/error.rs @@ -24,7 +24,7 @@ pub enum DlpError { #[error("Invalid Authority")] InvalidAuthority = 0, - #[error("Account cannot be undelegated, is_undelegatable is false")] + #[error("Account cannot be undelegated, no undelegation requester")] NotUndelegatable = 1, #[error("Unauthorized Operation")] @@ -155,6 +155,42 @@ pub enum DlpError { )] InsufficientRent = 43, + #[error("Undelegation request PDA invalid seeds")] + UndelegationRequestInvalidSeeds = 44, + + #[error("Undelegation request PDA invalid account owner")] + UndelegationRequestInvalidAccountOwner = 45, + + #[error("Undelegation request PDA is already initialized")] + UndelegationRequestAlreadyInitialized = 46, + + #[error("Undelegation request PDA immutable")] + UndelegationRequestImmutable = 47, + + #[error("Invalid undelegation request")] + InvalidUndelegationRequest = 48, + + #[error("Request undelegation is only supported for off-curve delegated accounts")] + RequestUndelegationOnCurveAccount = 49, + + #[error("Undelegation request timeout is below the minimum")] + UndelegationRequestTimeoutTooShort = 50, + + #[error("Undelegation request has not expired")] + UndelegationRequestNotExpired = 51, + + #[error("Invalid pending commit state for request-timeout undelegation")] + InvalidPendingCommitState = 52, + + #[error("Commit id did not match undelegation request checkpoint")] + RollbackCommitIdMismatch = 53, + + #[error("Undelegation request accounts are required")] + MissingUndelegationRequest = 54, + + #[error("Owner program requested undelegation")] + OwnerRequestedUndelegation = 55, + #[error("An infallible error is encountered possibly due to logic error")] InfallibleError = 100, } diff --git a/dlp-api/src/instruction_builder/mod.rs b/dlp-api/src/instruction_builder/mod.rs index adf93ffc..c52d5ce5 100644 --- a/dlp-api/src/instruction_builder/mod.rs +++ b/dlp-api/src/instruction_builder/mod.rs @@ -18,10 +18,12 @@ mod init_magic_fee_vault; mod init_protocol_fees_vault; mod init_validator_fees_vault; mod protocol_claim_fees; +mod request_undelegation; mod top_up_ephemeral_balance; mod types; mod undelegate; mod undelegate_confined_account; +mod undelegate_with_rollback_after_timeout; mod validator_claim_fees; mod whitelist_validator_for_program; @@ -45,9 +47,11 @@ pub use init_magic_fee_vault::*; pub use init_protocol_fees_vault::*; pub use init_validator_fees_vault::*; pub use protocol_claim_fees::*; +pub use request_undelegation::*; pub use top_up_ephemeral_balance::*; pub use types::*; pub use undelegate::*; pub use undelegate_confined_account::*; +pub use undelegate_with_rollback_after_timeout::*; pub use validator_claim_fees::*; pub use whitelist_validator_for_program::*; diff --git a/dlp-api/src/instruction_builder/request_undelegation.rs b/dlp-api/src/instruction_builder/request_undelegation.rs new file mode 100644 index 00000000..985bf60a --- /dev/null +++ b/dlp-api/src/instruction_builder/request_undelegation.rs @@ -0,0 +1,75 @@ +use dlp::{ + discriminator::DlpDiscriminator, + pda::{ + delegation_metadata_pda_from_delegated_account, + delegation_record_pda_from_delegated_account, + undelegation_request_pda_from_delegated_account, + }, + total_size_budget, AccountSizeClass, DLP_PROGRAM_DATA_SIZE_CLASS, +}; +use solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; +use solana_sdk_ids::system_program; + +use crate::compat::{Compatize, Modernize}; + +/// Builds a request undelegation instruction. +/// +/// `delegation_rent_payer` must match the rent payer stored in the delegation +/// metadata PDA. +/// See [dlp::processor::process_request_undelegation] for docs. +pub fn request_undelegation( + delegation_rent_payer: Pubkey, + delegated_account: Pubkey, + owner_program: Pubkey, +) -> Instruction { + let delegated_account_compat = delegated_account.compatize(); + let undelegation_request_pda = + undelegation_request_pda_from_delegated_account( + &delegated_account_compat, + ) + .modernize(); + let delegation_record_pda = + delegation_record_pda_from_delegated_account(&delegated_account_compat) + .modernize(); + let delegation_metadata_pda = + delegation_metadata_pda_from_delegated_account( + &delegated_account_compat, + ) + .modernize(); + Instruction { + program_id: dlp::id().modernize(), + accounts: vec![ + AccountMeta::new(delegation_rent_payer, true), + AccountMeta::new_readonly(delegated_account, true), + AccountMeta::new_readonly(owner_program, false), + AccountMeta::new(undelegation_request_pda, false), + AccountMeta::new_readonly(delegation_record_pda, false), + AccountMeta::new(delegation_metadata_pda, false), + AccountMeta::new_readonly(system_program::id(), false), + ], + data: DlpDiscriminator::RequestUndelegation.to_vec(), + } +} + +/// +/// Returns accounts-data-size budget for request undelegation instruction. +/// +/// This value can be used with ComputeBudgetInstruction::SetLoadedAccountsDataSizeLimit +/// +pub fn request_undelegation_size_budget( + delegated_account: AccountSizeClass, +) -> u32 { + total_size_budget(&[ + DLP_PROGRAM_DATA_SIZE_CLASS, + AccountSizeClass::Tiny, // delegation_rent_payer + delegated_account, // delegated_account + AccountSizeClass::Tiny, // owner_program + AccountSizeClass::Tiny, // undelegation_request_pda + AccountSizeClass::Tiny, // delegation_record_pda + AccountSizeClass::Tiny, // delegation_metadata_pda + AccountSizeClass::Tiny, // system_program + ]) +} diff --git a/dlp-api/src/instruction_builder/undelegate.rs b/dlp-api/src/instruction_builder/undelegate.rs index ca58317c..4d6029ac 100644 --- a/dlp-api/src/instruction_builder/undelegate.rs +++ b/dlp-api/src/instruction_builder/undelegate.rs @@ -6,6 +6,7 @@ use dlp::{ delegation_metadata_pda_from_delegated_account, delegation_record_pda_from_delegated_account, fees_vault_pda, undelegate_buffer_pda_from_delegated_account, + undelegation_request_pda_from_delegated_account, validator_fees_vault_pda_from_validator, }, total_size_budget, AccountSizeClass, DLP_PROGRAM_DATA_SIZE_CLASS, @@ -19,13 +20,53 @@ use solana_sdk_ids::system_program; use crate::compat::{Compatize, Modernize}; /// Builds an undelegate instruction. +/// +/// `delegation_rent_payer` must match the rent payer stored in the delegation +/// metadata PDA. /// See [dlp::processor::process_undelegate] for docs. #[allow(clippy::too_many_arguments)] pub fn undelegate( validator: Pubkey, delegated_account: Pubkey, owner_program: Pubkey, - rent_reimbursement: Pubkey, + delegation_rent_payer: Pubkey, +) -> Instruction { + build_undelegate( + validator, + delegated_account, + owner_program, + delegation_rent_payer, + false, + ) +} + +/// Builds an undelegate instruction that also closes a matching request. +/// +/// `delegation_rent_payer` must match the rent payer stored in the delegation +/// metadata PDA and undelegation request PDA. +/// See [dlp::processor::process_undelegate] for docs. +#[allow(clippy::too_many_arguments)] +pub fn undelegate_with_request( + validator: Pubkey, + delegated_account: Pubkey, + owner_program: Pubkey, + delegation_rent_payer: Pubkey, +) -> Instruction { + build_undelegate( + validator, + delegated_account, + owner_program, + delegation_rent_payer, + true, + ) +} + +fn build_undelegate( + validator: Pubkey, + delegated_account: Pubkey, + owner_program: Pubkey, + delegation_rent_payer: Pubkey, + include_undelegation_request: bool, ) -> Instruction { let validator_compat = validator.compatize(); let delegated_account_compat = delegated_account.compatize(); @@ -49,22 +90,33 @@ pub fn undelegate( let fees_vault_pda = fees_vault_pda().modernize(); let validator_fees_vault_pda = validator_fees_vault_pda_from_validator(&validator_compat).modernize(); + let mut accounts = vec![ + AccountMeta::new(validator, true), + AccountMeta::new(delegated_account, false), + AccountMeta::new_readonly(owner_program, false), + AccountMeta::new(undelegate_buffer_pda, false), + AccountMeta::new_readonly(commit_state_pda, false), + AccountMeta::new_readonly(commit_record_pda, false), + AccountMeta::new(delegation_record_pda, false), + AccountMeta::new(delegation_metadata_pda, false), + AccountMeta::new(delegation_rent_payer, false), + AccountMeta::new(fees_vault_pda, false), + AccountMeta::new(validator_fees_vault_pda, false), + AccountMeta::new_readonly(system_program::id(), false), + ]; + + if include_undelegation_request { + let undelegation_request_pda = + undelegation_request_pda_from_delegated_account( + &delegated_account_compat, + ) + .modernize(); + accounts.push(AccountMeta::new(undelegation_request_pda, false)); + } + Instruction { program_id: dlp::id().modernize(), - accounts: vec![ - AccountMeta::new(validator, true), - AccountMeta::new(delegated_account, false), - AccountMeta::new_readonly(owner_program, false), - AccountMeta::new(undelegate_buffer_pda, false), - AccountMeta::new_readonly(commit_state_pda, false), - AccountMeta::new_readonly(commit_record_pda, false), - AccountMeta::new(delegation_record_pda, false), - AccountMeta::new(delegation_metadata_pda, false), - AccountMeta::new(rent_reimbursement, false), - AccountMeta::new(fees_vault_pda, false), - AccountMeta::new(validator_fees_vault_pda, false), - AccountMeta::new_readonly(system_program::id(), false), - ], + accounts, data: DlpDiscriminator::Undelegate.to_vec(), } } @@ -85,9 +137,36 @@ pub fn undelegate_size_budget(delegated_account: AccountSizeClass) -> u32 { AccountSizeClass::Tiny, // commit_record_pda AccountSizeClass::Tiny, // delegation_record_pda AccountSizeClass::Tiny, // delegation_metadata_pda - AccountSizeClass::Tiny, // rent_reimbursement + AccountSizeClass::Tiny, // delegation_rent_payer + AccountSizeClass::Tiny, // fees_vault_pda + AccountSizeClass::Tiny, // validator_fees_vault_pda + AccountSizeClass::Tiny, // system_program + ]) +} + +/// +/// Returns accounts-data-size budget for undelegate instruction with request +/// accounts. +/// +/// This value can be used with ComputeBudgetInstruction::SetLoadedAccountsDataSizeLimit +/// +pub fn undelegate_with_request_size_budget( + delegated_account: AccountSizeClass, +) -> u32 { + total_size_budget(&[ + DLP_PROGRAM_DATA_SIZE_CLASS, + AccountSizeClass::Tiny, // validator + delegated_account, // delegated_account + AccountSizeClass::Tiny, // owner_program + delegated_account, // undelegate_buffer_pda + delegated_account, // commit_state_pda + AccountSizeClass::Tiny, // commit_record_pda + AccountSizeClass::Tiny, // delegation_record_pda + AccountSizeClass::Tiny, // delegation_metadata_pda + AccountSizeClass::Tiny, // delegation_rent_payer AccountSizeClass::Tiny, // fees_vault_pda AccountSizeClass::Tiny, // validator_fees_vault_pda AccountSizeClass::Tiny, // system_program + AccountSizeClass::Tiny, // undelegation_request_pda ]) } diff --git a/dlp-api/src/instruction_builder/undelegate_with_rollback_after_timeout.rs b/dlp-api/src/instruction_builder/undelegate_with_rollback_after_timeout.rs new file mode 100644 index 00000000..b13d6627 --- /dev/null +++ b/dlp-api/src/instruction_builder/undelegate_with_rollback_after_timeout.rs @@ -0,0 +1,85 @@ +use dlp::{ + discriminator::DlpDiscriminator, + pda::{ + commit_record_pda_from_delegated_account, + commit_state_pda_from_delegated_account, + delegation_metadata_pda_from_delegated_account, + delegation_record_pda_from_delegated_account, + undelegation_request_pda_from_delegated_account, + }, + total_size_budget, AccountSizeClass, DLP_PROGRAM_DATA_SIZE_CLASS, +}; +use solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; + +use crate::compat::{Compatize, Modernize}; + +/// Builds an owner-program-authorized timeout rollback instruction for a +/// requested undelegation. +/// See [dlp::processor::process_undelegate_with_rollback_after_timeout] for docs. +pub fn undelegate_with_rollback_after_timeout( + delegated_account: Pubkey, + owner_program: Pubkey, + delegation_rent_payer: Pubkey, + commit_reimbursement: Pubkey, +) -> Instruction { + let delegated_account_compat = delegated_account.compatize(); + let request_pda = undelegation_request_pda_from_delegated_account( + &delegated_account_compat, + ) + .modernize(); + let delegation_record_pda = + delegation_record_pda_from_delegated_account(&delegated_account_compat) + .modernize(); + let delegation_metadata_pda = + delegation_metadata_pda_from_delegated_account( + &delegated_account_compat, + ) + .modernize(); + let commit_state_pda = + commit_state_pda_from_delegated_account(&delegated_account_compat) + .modernize(); + let commit_record_pda = + commit_record_pda_from_delegated_account(&delegated_account_compat) + .modernize(); + + Instruction { + program_id: dlp::id().modernize(), + accounts: vec![ + AccountMeta::new(delegated_account, true), + AccountMeta::new_readonly(owner_program, false), + AccountMeta::new(request_pda, false), + AccountMeta::new(delegation_record_pda, false), + AccountMeta::new(delegation_metadata_pda, false), + AccountMeta::new(delegation_rent_payer, false), + AccountMeta::new(commit_state_pda, false), + AccountMeta::new(commit_record_pda, false), + AccountMeta::new(commit_reimbursement, false), + ], + data: DlpDiscriminator::UndelegateWithRollbackAfterTimeout.to_vec(), + } +} + +/// +/// Returns accounts-data-size budget for undelegate-with-rollback-after-timeout. +/// +/// This value can be used with ComputeBudgetInstruction::SetLoadedAccountsDataSizeLimit +/// +pub fn undelegate_with_rollback_after_timeout_size_budget( + delegated_account: AccountSizeClass, +) -> u32 { + total_size_budget(&[ + DLP_PROGRAM_DATA_SIZE_CLASS, + delegated_account, // delegated_account + AccountSizeClass::Tiny, // owner_program + AccountSizeClass::Tiny, // undelegation_request_pda + AccountSizeClass::Tiny, // delegation_record_pda + AccountSizeClass::Tiny, // delegation_metadata_pda + AccountSizeClass::Tiny, // delegation_rent_payer + delegated_account, // commit_state_pda + AccountSizeClass::Tiny, // commit_record_pda + AccountSizeClass::Tiny, // commit_reimbursement + ]) +} diff --git a/dlp-api/src/pda.rs b/dlp-api/src/pda.rs index 9bf18858..4f0eb56d 100644 --- a/dlp-api/src/pda.rs +++ b/dlp-api/src/pda.rs @@ -60,6 +60,17 @@ macro_rules! undelegate_buffer_seeds_from_delegated_account { }; } +pub const UNDELEGATION_REQUEST_TAG: &[u8] = b"undelegation-request"; +#[macro_export] +macro_rules! undelegation_request_seeds_from_delegated_account { + ($delegated_account: expr) => { + &[ + $crate::pda::UNDELEGATION_REQUEST_TAG, + &$delegated_account.as_ref(), + ] + }; +} + #[macro_export] macro_rules! fees_vault_seeds { () => { @@ -172,6 +183,16 @@ pub fn undelegate_buffer_pda_from_delegated_account( .0 } +pub fn undelegation_request_pda_from_delegated_account( + delegated_account: &Pubkey, +) -> Pubkey { + Pubkey::find_program_address( + undelegation_request_seeds_from_delegated_account!(delegated_account), + &crate::id(), + ) + .0 +} + pub fn fees_vault_pda() -> Pubkey { Pubkey::find_program_address(fees_vault_seeds!(), &crate::id()).0 } diff --git a/dlp-api/src/requires.rs b/dlp-api/src/requires.rs index 096e27ae..a71d2b86 100644 --- a/dlp-api/src/requires.rs +++ b/dlp-api/src/requires.rs @@ -758,6 +758,16 @@ define_uninitialized_ctx!( immutable = DlpError::CommitRecordImmutable ); +define_uninitialized_ctx!( + UndelegationRequestCtx, + label = "undelegation request", + invalid_seeds = DlpError::UndelegationRequestInvalidSeeds, + invalid_account_owner = DlpError::UndelegationRequestInvalidAccountOwner, + account_already_initialized = + DlpError::UndelegationRequestAlreadyInitialized, + immutable = DlpError::UndelegationRequestImmutable +); + define_uninitialized_ctx!( DelegationRecordCtx, label = "delegation record", diff --git a/dlp-api/src/state/delegation_metadata.rs b/dlp-api/src/state/delegation_metadata.rs index bc3a70c7..79d2f077 100644 --- a/dlp-api/src/state/delegation_metadata.rs +++ b/dlp-api/src/state/delegation_metadata.rs @@ -10,16 +10,53 @@ use crate::{ impl_try_from_bytes_with_discriminator_borsh, require_ge, }; +/// Identifies who requested that a delegated account should move toward +/// undelegation. +#[derive( + BorshSerialize, BorshDeserialize, Clone, Copy, Debug, Eq, PartialEq, +)] +#[repr(u8)] +pub enum UndelegationRequester { + /// No undelegation request has been recorded. + /// + /// Kept backward compatible with legacy `is_undelegatable = false`. + None, + /// The validator requested undelegation through a commit/finalize path. + /// + /// Kept backward compatible with legacy `is_undelegatable = true`. + Validator, + /// The account owner program requested undelegation. + OwnerProgram, +} + +impl UndelegationRequester { + pub fn from_allow_undelegation(allow_undelegation: bool) -> Self { + if allow_undelegation { + Self::Validator + } else { + Self::None + } + } + + pub fn try_from_byte(value: u8) -> Result { + match value { + 0 => Ok(Self::None), + 1 => Ok(Self::Validator), + 2 => Ok(Self::OwnerProgram), + _ => Err(ProgramError::InvalidAccountData), + } + } +} + /// The Delegated Metadata includes Account Seeds, max delegation time, seeds /// and other meta information about the delegated account. /// * Everything necessary at cloning time is instead stored in the delegation record. #[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq)] pub struct DelegationMetadata { - /// The last nonce account had during delegation update - /// Deprecated: The last slot at which the delegation was updated - pub last_update_nonce: u64, - /// Whether the account can be undelegated or not - pub is_undelegatable: bool, + /// Latest finalized commit id applied to the delegated account on base. + pub last_commit_id: u64, + /// Who requested undelegation, stored in the legacy undelegatable byte. + pub undelegation_requester: UndelegationRequester, /// The seeds of the account, used to reopen it on undelegation pub seeds: Vec>, /// The account that paid the rent for the delegation PDAs @@ -37,8 +74,8 @@ impl<'a> DelegationMetadataFast<'a> { require_ge!( account.data_len(), AccountDiscriminator::SPACE - + 8 // last_update_nonce - + 1 // is_undelegatable + + 8 // last_commit_id + + 1 // undelegation_requester + 32 // rent_payer + 4, // seeds (at least 4) ProgramError::InvalidAccountData @@ -49,14 +86,14 @@ impl<'a> DelegationMetadataFast<'a> { }) } - pub fn last_update_nonce(&self) -> u64 { + pub fn last_commit_id(&self) -> u64 { unsafe { ptr::read(self.data.as_ptr().add(AccountDiscriminator::SPACE) as *const u64) } } - pub fn set_last_update_nonce(&mut self, val: u64) { + pub fn set_last_commit_id(&mut self, val: u64) { unsafe { ptr::write( self.data.as_mut_ptr().add(AccountDiscriminator::SPACE) @@ -66,7 +103,7 @@ impl<'a> DelegationMetadataFast<'a> { } } - pub fn replace_last_update_nonce(&mut self, val: u64) -> u64 { + pub fn replace_last_commit_id(&mut self, val: u64) -> u64 { unsafe { ptr::replace( self.data.as_mut_ptr().add(AccountDiscriminator::SPACE) @@ -76,24 +113,25 @@ impl<'a> DelegationMetadataFast<'a> { } } - pub fn set_is_undelegatable(&mut self, val: bool) { - unsafe { - ptr::write( - self.data.as_mut_ptr().add(AccountDiscriminator::SPACE + 8) - as *mut bool, - val, - ) - } + pub fn undelegation_requester( + &self, + ) -> Result { + UndelegationRequester::try_from_byte( + self.data[AccountDiscriminator::SPACE + 8], + ) } - pub fn replace_is_undelegatable(&mut self, val: bool) -> bool { - unsafe { - ptr::replace( - self.data.as_mut_ptr().add(AccountDiscriminator::SPACE + 8) - as *mut bool, - val, - ) - } + pub fn set_undelegation_requester(&mut self, val: UndelegationRequester) { + self.data[AccountDiscriminator::SPACE + 8] = val as u8; + } + + pub fn replace_undelegation_requester( + &mut self, + val: UndelegationRequester, + ) -> Result { + let previous = self.undelegation_requester()?; + self.set_undelegation_requester(val); + Ok(previous) } } @@ -106,8 +144,8 @@ impl AccountWithDiscriminator for DelegationMetadata { impl DelegationMetadata { pub fn serialized_size(&self) -> usize { AccountDiscriminator::SPACE - + 8 // last_update_nonce (u64) - + 1 // is_undelegatable (bool) + + 8 // last_commit_id (u64) + + 1 // undelegation_requester (UndelegationRequester) + 32 // rent_payer (Pubkey) + (4 + self.seeds.iter().map(|s| 4 + s.len()).sum::()) // seeds (Vec>) } @@ -132,8 +170,8 @@ mod tests { 211, 164, 97, 139, 136, 136, 77, ], ], - is_undelegatable: false, - last_update_nonce: 0, + undelegation_requester: UndelegationRequester::None, + last_commit_id: 0, rent_payer: Pubkey::default(), }; @@ -147,4 +185,35 @@ mod tests { assert_eq!(deserialized, original); } + + #[derive(BorshSerialize)] + struct LegacyDelegationMetadata { + pub last_update_nonce: u64, + pub is_undelegatable: bool, + pub seeds: Vec>, + pub rent_payer: Pubkey, + } + + #[test] + fn test_deserializes_legacy_undelegatable_bool() { + for (legacy_value, expected) in [ + (false, UndelegationRequester::None), + (true, UndelegationRequester::Validator), + ] { + let legacy = LegacyDelegationMetadata { + last_update_nonce: 42, + is_undelegatable: legacy_value, + seeds: vec![b"seed".to_vec()], + rent_payer: Pubkey::default(), + }; + + let serialized = to_vec(&legacy).expect("Serialization failed"); + let deserialized: DelegationMetadata = + DelegationMetadata::try_from_slice(&serialized) + .expect("Deserialization failed"); + + assert_eq!(deserialized.undelegation_requester, expected); + assert_eq!(deserialized.last_commit_id, 42); + } + } } diff --git a/dlp-api/src/state/mod.rs b/dlp-api/src/state/mod.rs index 59f89366..de088362 100644 --- a/dlp-api/src/state/mod.rs +++ b/dlp-api/src/state/mod.rs @@ -2,10 +2,12 @@ mod commit_record; mod delegation_metadata; mod delegation_record; mod program_config; +mod undelegation_request; mod utils; pub use commit_record::*; pub use delegation_metadata::*; pub use delegation_record::*; pub use program_config::*; +pub use undelegation_request::*; pub use utils::*; diff --git a/dlp-api/src/state/undelegation_request.rs b/dlp-api/src/state/undelegation_request.rs new file mode 100644 index 00000000..eab1b136 --- /dev/null +++ b/dlp-api/src/state/undelegation_request.rs @@ -0,0 +1,38 @@ +use std::mem::size_of; + +use bytemuck::{Pod, Zeroable}; + +use super::discriminator::{AccountDiscriminator, AccountWithDiscriminator}; +use crate::{ + compat::Pubkey, impl_to_bytes_with_discriminator_zero_copy, + impl_try_from_bytes_with_discriminator_zero_copy, +}; + +/// Request for the validator to undelegate one delegated account. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)] +pub struct UndelegationRequest { + /// Delegated account this request is for. + /// + /// DLP validates the request by PDA seeds, so this is stored only to let + /// validators identify the matching delegated account when scanning + /// request accounts. + pub delegated_account: Pubkey, + /// The first slot at which timeout rollback is allowed. + pub expires_at_slot: u64, +} + +impl AccountWithDiscriminator for UndelegationRequest { + fn discriminator() -> AccountDiscriminator { + AccountDiscriminator::UndelegationRequest + } +} + +impl UndelegationRequest { + pub fn size_with_discriminator() -> usize { + 8 + size_of::() + } +} + +impl_to_bytes_with_discriminator_zero_copy!(UndelegationRequest); +impl_try_from_bytes_with_discriminator_zero_copy!(UndelegationRequest); diff --git a/dlp-api/src/state/utils/discriminator.rs b/dlp-api/src/state/utils/discriminator.rs index 617d1c3d..2c040893 100644 --- a/dlp-api/src/state/utils/discriminator.rs +++ b/dlp-api/src/state/utils/discriminator.rs @@ -9,6 +9,7 @@ pub enum AccountDiscriminator { DelegationMetadata = 102, CommitRecord = 101, ProgramConfig = 103, + UndelegationRequest = 104, } impl AccountDiscriminator { diff --git a/src/lib.rs b/src/lib.rs index b3da9f09..b7d3e54c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ pub(crate) use dlp_api::{ ephemeral_balance_seeds_from_payer, fees_vault_seeds, magic_fee_vault_seeds_from_validator, program_config_seeds_from_program_id, undelegate_buffer_seeds_from_delegated_account, + undelegation_request_seeds_from_delegated_account, validator_fees_vault_seeds_from_validator, }; pub use dlp_api::{id, ID}; @@ -148,6 +149,16 @@ pub fn fast_process_instruction( program_id, accounts, data, )) } + DlpDiscriminator::RequestUndelegation => { + Some(processor::fast::process_request_undelegation( + program_id, accounts, data, + )) + } + DlpDiscriminator::UndelegateWithRollbackAfterTimeout => Some( + processor::fast::process_undelegate_with_rollback_after_timeout( + program_id, accounts, data, + ), + ), _ => None, } } diff --git a/src/processor/fast/commit_state.rs b/src/processor/fast/commit_state.rs index 82495ada..5c1ee4db 100644 --- a/src/processor/fast/commit_state.rs +++ b/src/processor/fast/commit_state.rs @@ -21,6 +21,7 @@ use crate::{ }, state::{ CommitRecord, DelegationMetadata, DelegationRecord, ProgramConfig, + UndelegationRequester, }, DiffSet, }; @@ -157,24 +158,37 @@ pub(crate) fn process_commit_state_internal( .map_err(to_pinocchio_program_error)?; // To preserve correct history of account updates we require sequential commits - if args.commit_record_nonce != delegation_metadata.last_update_nonce + 1 { + if args.commit_record_nonce != delegation_metadata.last_commit_id + 1 { log!( - "Nonce {} is incorrect, previous nonce is {}. Rejecting commit", + "Commit id {} is incorrect, previous commit id is {}. Rejecting commit", args.commit_record_nonce, - delegation_metadata.last_update_nonce + delegation_metadata.last_commit_id ); return Err(DlpError::NonceOutOfOrder.into()); } - // Once the account is marked as undelegatable, any subsequent commit should fail - if delegation_metadata.is_undelegatable { - log!("delegation metadata is already undelegated: "); - args.delegation_metadata_account.address().log(); - return Err(DlpError::AlreadyUndelegated.into()); + match delegation_metadata.undelegation_requester { + UndelegationRequester::Validator => { + log!("delegation metadata already has an undelegation requester: "); + args.delegation_metadata_account.address().log(); + return Err(DlpError::AlreadyUndelegated.into()); + } + UndelegationRequester::OwnerProgram => { + if !args.allow_undelegation { + log!( + "owner program requested undelegation but commit did not allow undelegation: " + ); + args.delegation_metadata_account.address().log(); + return Err(DlpError::OwnerRequestedUndelegation.into()); + } + } + UndelegationRequester::None => { + delegation_metadata.undelegation_requester = + UndelegationRequester::from_allow_undelegation( + args.allow_undelegation, + ); + } } - - // Update delegation metadata undelegation flag - delegation_metadata.is_undelegatable = args.allow_undelegation; delegation_metadata .to_bytes_with_discriminator(&mut delegation_metadata_data.as_mut()) .map_err(to_pinocchio_program_error)?; diff --git a/src/processor/fast/delegate.rs b/src/processor/fast/delegate.rs index 48d40c7e..b768a22d 100644 --- a/src/processor/fast/delegate.rs +++ b/src/processor/fast/delegate.rs @@ -22,7 +22,7 @@ use crate::{ require_owned_pda, require_pda, require_signer, require_uninitialized_pda, DelegationMetadataCtx, DelegationRecordCtx, }, - state::{DelegationMetadata, DelegationRecord}, + state::{DelegationMetadata, DelegationRecord, UndelegationRequester}, }; /// Delegates an account @@ -241,8 +241,8 @@ fn process_delegate_inner( let delegation_metadata = DelegationMetadata { seeds: args.seeds, - last_update_nonce: 0, - is_undelegatable: false, + last_commit_id: 0, + undelegation_requester: UndelegationRequester::None, rent_payer: payer.address().to_bytes().into(), }; diff --git a/src/processor/fast/delegate_with_actions.rs b/src/processor/fast/delegate_with_actions.rs index 840099a3..d78bfd0d 100644 --- a/src/processor/fast/delegate_with_actions.rs +++ b/src/processor/fast/delegate_with_actions.rs @@ -23,7 +23,7 @@ use crate::{ require_owned_pda, require_pda, require_signer, require_uninitialized_pda, DelegationMetadataCtx, DelegationRecordCtx, }, - state::{DelegationMetadata, DelegationRecord}, + state::{DelegationMetadata, DelegationRecord, UndelegationRequester}, }; /// Delegates an account and stores an actions payload. @@ -253,8 +253,8 @@ pub fn process_delegate_with_actions( let delegation_metadata = DelegationMetadata { seeds: args.delegate.seeds, - last_update_nonce: 0, - is_undelegatable: false, + last_commit_id: 0, + undelegation_requester: UndelegationRequester::None, rent_payer: payer.address().to_bytes().into(), }; diff --git a/src/processor/fast/finalize.rs b/src/processor/fast/finalize.rs index 1832a4c0..beb364f4 100644 --- a/src/processor/fast/finalize.rs +++ b/src/processor/fast/finalize.rs @@ -161,7 +161,7 @@ pub fn process_finalize( )?; // Update the delegation metadata - delegation_metadata.last_update_nonce = commit_record.nonce; + delegation_metadata.last_commit_id = commit_record.nonce; delegation_metadata .to_bytes_with_discriminator(&mut delegation_metadata_data.as_mut()) .map_err(to_pinocchio_program_error)?; diff --git a/src/processor/fast/internal/commit_finalize_internal.rs b/src/processor/fast/internal/commit_finalize_internal.rs index 07b04006..0c0abe18 100644 --- a/src/processor/fast/internal/commit_finalize_internal.rs +++ b/src/processor/fast/internal/commit_finalize_internal.rs @@ -4,6 +4,7 @@ use pinocchio::{ sysvars::{rent::Rent, Sysvar}, AccountView, Address, }; +use pinocchio_log::log; use pinocchio_system::instructions as system; use crate::{ @@ -15,7 +16,7 @@ use crate::{ processor::fast::{utils::LamportsOperation, NewState}, require, require_eq, require_eq_keys, require_ge, require_initialized_pda_fast, require_owned_by, require_signer, - state::{DelegationMetadataFast, DelegationRecord}, + state::{DelegationMetadataFast, DelegationRecord, UndelegationRequester}, }; /// Arguments for the commit state internal function @@ -83,14 +84,35 @@ pub(crate) fn process_commit_finalize_internal( args.delegation_metadata_account, )?; - let prev_id = metadata.replace_last_update_nonce(args.commit_id); + let prev_id = metadata.last_commit_id(); require_eq!(args.commit_id, prev_id + 1, DlpError::NonceOutOfOrder); - require!( - !metadata.replace_is_undelegatable(args.allow_undelegation), - DlpError::AlreadyUndelegated - ); + let previous_requester = metadata.undelegation_requester()?; + match previous_requester { + UndelegationRequester::Validator => { + log!("delegation metadata already has an undelegation requester: "); + args.delegation_metadata_account.address().log(); + return Err(DlpError::AlreadyUndelegated.into()); + } + UndelegationRequester::OwnerProgram => { + if !args.allow_undelegation { + log!( + "owner program requested undelegation but commit did not allow undelegation: " + ); + args.delegation_metadata_account.address().log(); + return Err(DlpError::OwnerRequestedUndelegation.into()); + } + } + UndelegationRequester::None => { + metadata.set_undelegation_requester( + UndelegationRequester::from_allow_undelegation( + args.allow_undelegation, + ), + ); + } + } + metadata.set_last_commit_id(args.commit_id); } let mut delegation_record_data = diff --git a/src/processor/fast/mod.rs b/src/processor/fast/mod.rs index 2e975201..b01c11a5 100644 --- a/src/processor/fast/mod.rs +++ b/src/processor/fast/mod.rs @@ -7,8 +7,10 @@ mod commit_state_from_buffer; mod delegate; mod delegate_with_actions; mod finalize; +mod request_undelegation; mod undelegate; mod undelegate_confined_account; +mod undelegate_with_rollback_after_timeout; mod utils; pub(crate) mod internal; @@ -22,8 +24,10 @@ pub use commit_state_from_buffer::*; pub use delegate::*; pub use delegate_with_actions::*; pub use finalize::*; +pub use request_undelegation::*; pub use undelegate::*; pub use undelegate_confined_account::*; +pub use undelegate_with_rollback_after_timeout::*; pub fn to_pinocchio_program_error( error: solana_program::program_error::ProgramError, diff --git a/src/processor/fast/request_undelegation.rs b/src/processor/fast/request_undelegation.rs new file mode 100644 index 00000000..a7538b72 --- /dev/null +++ b/src/processor/fast/request_undelegation.rs @@ -0,0 +1,155 @@ +use dlp_api::consts::DEFAULT_UNDELEGATION_REQUEST_TIMEOUT_SLOTS; +use pinocchio::{ + address::Address, + cpi::Signer, + error::ProgramError, + instruction::seeds, + sysvars::{clock::Clock, Sysvar}, + AccountView, ProgramResult, +}; + +use super::to_pinocchio_program_error; +use crate::{ + error::DlpError, + pda, + processor::{fast::utils::pda::create_pda, utils::curve::is_on_curve_fast}, + require, require_eq_keys, require_n_accounts, + requires::{ + is_uninitialized_account, require_initialized_delegation_metadata, + require_initialized_delegation_record, require_owned_pda, + require_signer, require_uninitialized_pda, UndelegationRequestCtx, + }, + state::{ + DelegationMetadata, DelegationMetadataFast, DelegationRecord, + UndelegationRequest, UndelegationRequester, + }, +}; + +/// Request undelegation for one delegated account. +/// +/// Accounts: +/// +/// 0: `[signer, writable]` delegation rent payer +/// 1: `[signer]` delegated account +/// 2: `[]` owner program of the delegated account +/// 3: `[writable]` undelegation request PDA +/// 4: `[]` delegation record PDA +/// 5: `[writable]` delegation metadata PDA +/// 6: `[]` system program +pub fn process_request_undelegation( + _program_id: &Address, + accounts: &[AccountView], + data: &[u8], +) -> ProgramResult { + let [ + payer, // force multi-line + delegated_account, + owner_program, + undelegation_request_account, + delegation_record_account, + delegation_metadata_account, + _system_program, + ] = require_n_accounts!(accounts, 7); + + require!(data.is_empty(), ProgramError::InvalidInstructionData); + + require_signer(payer, "payer")?; + + require_signer(delegated_account, "delegated account")?; + require_owned_pda( + delegated_account, + &crate::fast::ID, + "delegated account", + )?; + + require!( + !is_on_curve_fast(delegated_account.address()), + DlpError::RequestUndelegationOnCurveAccount + ); + + require_initialized_delegation_record( + delegated_account, + delegation_record_account, + false, + )?; + require_initialized_delegation_metadata( + delegated_account, + delegation_metadata_account, + false, + )?; + + let delegation_record_data = delegation_record_account.try_borrow()?; + let delegation_record = + DelegationRecord::try_from_bytes_with_discriminator( + &delegation_record_data, + ) + .map_err(to_pinocchio_program_error)?; + + require_eq_keys!( + &delegation_record.owner, + owner_program.address(), + ProgramError::InvalidAccountOwner + ); + + let delegation_metadata_data = delegation_metadata_account.try_borrow()?; + let delegation_metadata = + DelegationMetadata::try_from_bytes_with_discriminator( + &delegation_metadata_data, + ) + .map_err(to_pinocchio_program_error)?; + require_eq_keys!( + &delegation_metadata.rent_payer, + payer.address(), + DlpError::InvalidReimbursementAddressForDelegationRent + ); + drop(delegation_metadata_data); + + let request_seeds = &[ + pda::UNDELEGATION_REQUEST_TAG, + delegated_account.address().as_ref(), + ]; + if is_uninitialized_account(undelegation_request_account) { + let created_slot = Clock::get()?.slot; + let expires_at_slot = created_slot + .checked_add(DEFAULT_UNDELEGATION_REQUEST_TIMEOUT_SLOTS) + .ok_or(DlpError::Overflow)?; + + let request_bump = require_uninitialized_pda( + undelegation_request_account, + request_seeds, + &crate::fast::ID, + true, + UndelegationRequestCtx, + )?; + + create_pda( + undelegation_request_account, + &crate::fast::ID, + UndelegationRequest::size_with_discriminator(), + &[Signer::from(&seeds!( + pda::UNDELEGATION_REQUEST_TAG, + delegated_account.address().as_ref(), + &[request_bump] + ))], + payer, + )?; + + let mut delegation_metadata = + DelegationMetadataFast::from_account(delegation_metadata_account)?; + // An explicit owner-program request carries the request PDA used by the + // owner-driven undelegation flow, so it takes precedence over any + // validator-requested undelegation marker already stored in metadata. + delegation_metadata + .set_undelegation_requester(UndelegationRequester::OwnerProgram); + + let request = UndelegationRequest { + delegated_account: *delegated_account.address(), + expires_at_slot, + }; + let mut request_data = undelegation_request_account.try_borrow_mut()?; + request + .to_bytes_with_discriminator(&mut request_data) + .map_err(to_pinocchio_program_error)?; + } + Ok(()) +} diff --git a/src/processor/fast/undelegate.rs b/src/processor/fast/undelegate.rs index 217d1a87..828c8528 100644 --- a/src/processor/fast/undelegate.rs +++ b/src/processor/fast/undelegate.rs @@ -21,15 +21,19 @@ use crate::{ error::DlpError, pda, processor::fast::utils::pda::{close_pda, close_pda_with_fees, create_pda}, + require_n_accounts_with_optionals, requires::{ require_initialized_delegation_metadata, - require_initialized_delegation_record, + require_initialized_delegation_record, require_initialized_pda, require_initialized_protocol_fees_vault, require_initialized_validator_fees_vault, require_owned_pda, require_signer, require_uninitialized_pda, CommitRecordCtx, CommitStateAccountCtx, UndelegateBufferCtx, }, - state::{DelegationMetadata, DelegationRecord}, + state::{ + DelegationMetadata, DelegationRecord, UndelegationRequest, + UndelegationRequester, + }, }; /// Undelegate a delegated account @@ -44,7 +48,7 @@ use crate::{ /// 5: `[]` the commit record PDA /// 6: `[writable]` the delegation record PDA /// 7: `[writable]` the delegation metadata PDA -/// 8: `[]` the rent reimbursement account +/// 8: `[writable]` the delegation rent payer account /// 9: `[writable]` the protocol fees vault account /// 10: `[writable]` the validator fees vault account /// 11: `[]` the system program (TODO (snawaz): soon to be removed from the requirement) @@ -58,9 +62,9 @@ use crate::{ /// - validator fees vault is initialized /// - commit state is uninitialized /// - commit record is uninitialized -/// - delegated account is NOT undelegatable +/// - undelegation has been requested for the delegated account /// - owner program account matches the owner in the delegation record -/// - rent reimbursement account matches the rent payer in the delegation metadata +/// - delegation rent payer account matches the rent payer in the delegation metadata /// /// Steps: /// @@ -79,10 +83,35 @@ pub fn process_undelegate( accounts: &[AccountView], _data: &[u8], ) -> ProgramResult { - let [validator, delegated_account, owner_program, undelegate_buffer_account, commit_state_account, commit_record_account, delegation_record_account, delegation_metadata_account, rent_reimbursement, fees_vault, validator_fees_vault, system_program] = - accounts - else { - return Err(ProgramError::NotEnoughAccountKeys); + let ( + [ + validator, // force multi-line + delegated_account, + owner_program, + undelegate_buffer_account, + commit_state_account, + commit_record_account, + delegation_record_account, + delegation_metadata_account, + delegation_rent_payer, + fees_vault, + validator_fees_vault, + system_program, + ], + optional_accounts, + ) = require_n_accounts_with_optionals!(accounts, 12); + + let request_account = match optional_accounts.len() { + 0 => None, + 1 => Some(&optional_accounts[0]), + _ => return Err(ProgramError::InvalidInstructionData), + }; + + if let Some(undelegation_request_account) = request_account { + require_valid_undelegation_request( + delegated_account, + undelegation_request_account, + )?; }; // Check accounts @@ -152,26 +181,31 @@ pub fn process_undelegate( &delegation_metadata_data, ) .map_err(to_pinocchio_program_error)?; - let delegation_last_update_nonce = delegation_metadata.last_update_nonce; + let delegation_last_commit_id = delegation_metadata.last_commit_id; - // Check if the delegated account is undelegatable - if !delegation_metadata.is_undelegatable { - log!( - "delegation metadata indicates the account is not undelegatable : " - ); + // Check if undelegation has been requested for the delegated account. + if delegation_metadata.undelegation_requester == UndelegationRequester::None + { + log!("delegation metadata has no undelegation requester: "); delegation_metadata_account.address().log(); return Err(DlpError::NotUndelegatable.into()); } + if delegation_metadata.undelegation_requester + == UndelegationRequester::OwnerProgram + && request_account.is_none() + { + return Err(DlpError::MissingUndelegationRequest.into()); + } // Check if the rent payer is correct if !address_eq( &delegation_metadata.rent_payer.to_bytes().into(), - rent_reimbursement.address(), + delegation_rent_payer.address(), ) { log!("Expected rent payer to be : "); Address::from(delegation_metadata.rent_payer.to_bytes()).log(); log!("but got : "); - rent_reimbursement.address().log(); + delegation_rent_payer.address().log(); return Err( DlpError::InvalidReimbursementAddressForDelegationRent.into() ); @@ -190,11 +224,14 @@ pub fn process_undelegate( process_delegation_cleanup( delegation_record_account, delegation_metadata_account, - rent_reimbursement, + delegation_rent_payer, fees_vault, validator_fees_vault, - delegation_last_update_nonce, + delegation_last_commit_id, )?; + if let Some(undelegation_request_account) = request_account { + close_pda(undelegation_request_account, delegation_rent_payer)?; + } return Ok(()); } @@ -248,11 +285,36 @@ pub fn process_undelegate( process_delegation_cleanup( delegation_record_account, delegation_metadata_account, - rent_reimbursement, + delegation_rent_payer, fees_vault, validator_fees_vault, - delegation_last_update_nonce, + delegation_last_commit_id, )?; + if let Some(undelegation_request_account) = request_account { + close_pda(undelegation_request_account, delegation_rent_payer)?; + } + Ok(()) +} + +fn require_valid_undelegation_request( + delegated_account: &AccountView, + undelegation_request_account: &AccountView, +) -> ProgramResult { + require_initialized_pda( + undelegation_request_account, + &[ + pda::UNDELEGATION_REQUEST_TAG, + delegated_account.address().as_ref(), + ], + &crate::fast::ID, + true, + "undelegation request", + )?; + + let request_data = undelegation_request_account.try_borrow()?; + UndelegationRequest::try_from_bytes_with_discriminator(&request_data) + .map_err(to_pinocchio_program_error)?; + Ok(()) } @@ -369,12 +431,12 @@ fn cpi_external_undelegate( fn process_delegation_cleanup( delegation_record_account: &AccountView, delegation_metadata_account: &AccountView, - rent_reimbursement: &AccountView, + delegation_rent_payer: &AccountView, fees_vault: &AccountView, validator_fees_vault: &AccountView, - delegation_last_update_nonce: u64, + delegation_last_commit_id: u64, ) -> ProgramResult { - let commit_count = delegation_last_update_nonce.saturating_sub(1); + let commit_count = delegation_last_commit_id.saturating_sub(1); let commit_fee = COMMIT_FEE_LAMPORTS .checked_mul(commit_count) .ok_or(DlpError::Overflow)?; @@ -384,14 +446,14 @@ fn process_delegation_cleanup( let mut fee_remaining = total_fee_requested.min(total_lamports); close_pda_with_fees( delegation_record_account, - rent_reimbursement, + delegation_rent_payer, fees_vault, validator_fees_vault, &mut fee_remaining, )?; close_pda_with_fees( delegation_metadata_account, - rent_reimbursement, + delegation_rent_payer, fees_vault, validator_fees_vault, &mut fee_remaining, diff --git a/src/processor/fast/undelegate_with_rollback_after_timeout.rs b/src/processor/fast/undelegate_with_rollback_after_timeout.rs new file mode 100644 index 00000000..c22000e9 --- /dev/null +++ b/src/processor/fast/undelegate_with_rollback_after_timeout.rs @@ -0,0 +1,251 @@ +use pinocchio::{ + address::Address, + error::ProgramError, + sysvars::{clock::Clock, Sysvar}, + AccountView, ProgramResult, +}; + +use super::to_pinocchio_program_error; +use crate::{ + error::DlpError, + pda, + processor::fast::utils::pda::close_pda, + require_eq, require_eq_keys, require_ge, require_n_accounts, + requires::{ + is_uninitialized_account, require_initialized_commit_record, + require_initialized_commit_state, + require_initialized_delegation_metadata, + require_initialized_delegation_record, require_initialized_pda, + require_owned_pda, require_pda, require_signer, + }, + state::{ + CommitRecord, DelegationMetadata, DelegationRecord, UndelegationRequest, + }, +}; + +/// Owner-program-authorized rollback after a requested undelegation times out. +/// +/// This returns the currently available base-chain delegated +/// account state. It does not accept or apply any pending validator +/// commit, which means it might cause data-loss! +/// +/// Data-loss Warning: +/// +/// This is a rollback/escape hatch for validator non-response. If the ephemeral +/// validator has newer state that was never finalized on the base chain, that +/// state can be lost from the returned account's perspective. The tradeoff +/// is that the owner program gets the account back with only the last base-chain +/// state available in the delegated account. +/// +/// Authorization: +/// +/// The owner program authorizes rollback by invoking this instruction through +/// CPI and signing for the delegated account, same as the request/re-request +/// path. +/// +/// This instruction releases the delegated account back to the owner program +/// without making an external undelegate CPI, because the owner program is +/// already on the invocation stack. The owner-program wrapper must preserve the +/// delegated account data before invoking DLP and restore it after DLP returns. +/// +/// Accounts: +/// +/// 0: `[signer, writable]` delegated account +/// 1: `[]` owner program of the delegated account +/// 2: `[writable]` undelegation request PDA +/// 3: `[writable]` delegation record PDA +/// 4: `[writable]` delegation metadata PDA +/// 5: `[writable]` delegation rent payer +/// 6: `[writable]` commit state PDA +/// 7: `[writable]` commit record PDA +/// 8: `[writable]` commit reimbursement account +pub fn process_undelegate_with_rollback_after_timeout( + _program_id: &Address, + accounts: &[AccountView], + _data: &[u8], +) -> ProgramResult { + let [delegated_account, owner_program, undelegation_request_account, delegation_record_account, delegation_metadata_account, delegation_rent_payer, commit_state_account, commit_record_account, commit_reimbursement] = + require_n_accounts!(accounts, 9); + + require_signer(delegated_account, "delegated account")?; + require_owned_pda( + delegated_account, + &crate::fast::ID, + "delegated account", + )?; + + let request = + load_valid_request(delegated_account, undelegation_request_account)?; + let current_slot = Clock::get()?.slot; + require_ge!( + current_slot, + request.expires_at_slot, + DlpError::UndelegationRequestNotExpired + ); + + require_initialized_delegation_record( + delegated_account, + delegation_record_account, + true, + )?; + require_initialized_delegation_metadata( + delegated_account, + delegation_metadata_account, + true, + )?; + + let (delegation_owner, delegation_metadata) = { + let delegation_record_data = delegation_record_account.try_borrow()?; + let delegation_record = + DelegationRecord::try_from_bytes_with_discriminator( + &delegation_record_data, + ) + .map_err(to_pinocchio_program_error)?; + + let delegation_metadata_data = + delegation_metadata_account.try_borrow()?; + let delegation_metadata = + DelegationMetadata::try_from_bytes_with_discriminator( + &delegation_metadata_data, + ) + .map_err(to_pinocchio_program_error)?; + + (delegation_record.owner, delegation_metadata) + }; + + require_eq_keys!( + &delegation_owner, + owner_program.address(), + ProgramError::InvalidAccountOwner + ); + require_eq_keys!( + &delegation_metadata.rent_payer, + delegation_rent_payer.address(), + DlpError::InvalidReimbursementAddressForDelegationRent + ); + // If a validator started a commit but did not finish finalizing it before + // timeout, the commit PDAs are cleanup-only inputs. Do not move their data + // into the delegated account. That would turn this rollback path into a + // late validator-state acceptance path. + cleanup_pending_commit( + delegated_account, + commit_state_account, + commit_record_account, + commit_reimbursement, + )?; + + delegated_account.resize(0)?; + unsafe { + delegated_account.assign(owner_program.address()); + } + + close_pda(undelegation_request_account, delegation_rent_payer)?; + close_pda(delegation_record_account, delegation_rent_payer)?; + close_pda(delegation_metadata_account, delegation_rent_payer)?; + + Ok(()) +} + +fn load_valid_request( + delegated_account: &AccountView, + undelegation_request_account: &AccountView, +) -> Result { + require_initialized_pda( + undelegation_request_account, + &[ + pda::UNDELEGATION_REQUEST_TAG, + delegated_account.address().as_ref(), + ], + &crate::fast::ID, + true, + "undelegation request", + )?; + + let request_data = undelegation_request_account.try_borrow()?; + let request = + *UndelegationRequest::try_from_bytes_with_discriminator(&request_data) + .map_err(to_pinocchio_program_error)?; + + Ok(request) +} + +fn cleanup_pending_commit( + delegated_account: &AccountView, + commit_state_account: &AccountView, + commit_record_account: &AccountView, + commit_reimbursement: &AccountView, +) -> ProgramResult { + require_pda( + commit_state_account, + &[pda::COMMIT_STATE_TAG, delegated_account.address().as_ref()], + &crate::fast::ID, + false, + "commit state", + )?; + require_pda( + commit_record_account, + &[pda::COMMIT_RECORD_TAG, delegated_account.address().as_ref()], + &crate::fast::ID, + false, + "commit record", + )?; + + let commit_state_uninitialized = + is_uninitialized_account(commit_state_account); + let commit_record_uninitialized = + is_uninitialized_account(commit_record_account); + + if commit_state_uninitialized && commit_record_uninitialized { + return Ok(()); + } + + require_eq!( + commit_state_uninitialized, + commit_record_uninitialized, + DlpError::InvalidPendingCommitState + ); + + require_initialized_commit_state( + delegated_account, + commit_state_account, + true, + )?; + require_initialized_commit_record( + delegated_account, + commit_record_account, + true, + )?; + + { + let commit_record_data = commit_record_account.try_borrow()?; + let commit_record = CommitRecord::try_from_bytes_with_discriminator( + &commit_record_data, + ) + .map_err(to_pinocchio_program_error)?; + + require_eq_keys!( + &commit_record.account, + delegated_account.address(), + DlpError::InvalidPendingCommitState + ); + require_eq_keys!( + &commit_record.identity, + commit_reimbursement.address(), + DlpError::InvalidPendingCommitState + ); + } + + // Timeout rollback is an owner-program-authorized escape hatch. At this + // point the validator has committed state into the commit PDAs, but that + // state has not been finalized into the delegated account. Applying it here + // would let this rollback path accept fresh validator state, which is + // exactly what the timeout rollback design forbids. + // + // We still close both commit PDAs so the delegated account cannot leave + // orphaned DLP-owned accounts behind. The commit record identity is the + // validator that created the pending commit, so sending both PDA balances to + // that identity refunds commit rent/collateral without treating the pending + // commit as valid state. + close_pda(commit_state_account, commit_reimbursement)?; + close_pda(commit_record_account, commit_reimbursement) +} diff --git a/tests/fixtures/accounts.rs b/tests/fixtures/accounts.rs index 40f06058..ee5ab8be 100644 --- a/tests/fixtures/accounts.rs +++ b/tests/fixtures/accounts.rs @@ -1,6 +1,10 @@ use dlp::solana_program; -use dlp_api::state::{ - CommitRecord, DelegationMetadata, DelegationRecord, ProgramConfig, +use dlp_api::{ + consts::DEFAULT_UNDELEGATION_REQUEST_TIMEOUT_SLOTS, + state::{ + CommitRecord, DelegationMetadata, DelegationRecord, ProgramConfig, + UndelegationRequest, UndelegationRequester, + }, }; use solana_program::{ native_token::LAMPORTS_PER_SOL, pubkey::Pubkey, rent::Rent, @@ -12,7 +16,7 @@ use solana_sdk_ids::system_program; const DEFAULT_DELEGATION_SLOT: u64 = 0; const DEFAULT_COMMIT_FREQUENCY_MS: u64 = 0; const DEFAULT_LAST_UPDATE_EXTERNAL_SLOT: u64 = 0; -const DEFAULT_IS_UNDELEGATABLE: bool = false; +const DEFAULT_UNDELEGATABLE: bool = false; const DEFAULT_SEEDS: &[&[u8]] = &[&[116, 101, 115, 116, 45, 112, 100, 97]]; #[allow(dead_code)] @@ -115,50 +119,66 @@ pub fn create_delegation_record_data( #[allow(dead_code)] pub fn get_delegation_metadata_data_on_curve( rent_payer: Pubkey, - is_undelegatable: Option, + undelegatable: Option, ) -> Vec { create_delegation_metadata_data( rent_payer, &[], - is_undelegatable.unwrap_or(DEFAULT_IS_UNDELEGATABLE), + undelegatable.unwrap_or(DEFAULT_UNDELEGATABLE), ) } #[allow(dead_code)] pub fn get_delegation_metadata_data( rent_payer: Pubkey, - is_undelegatable: Option, + undelegatable: Option, ) -> Vec { - create_delegation_metadata_data( + get_delegation_metadata_data_with_commit_id( + rent_payer, + undelegatable, + DEFAULT_LAST_UPDATE_EXTERNAL_SLOT, + ) +} + +#[allow(dead_code)] +pub fn get_delegation_metadata_data_with_commit_id( + rent_payer: Pubkey, + undelegatable: Option, + last_commit_id: u64, +) -> Vec { + create_delegation_metadata_data_with_commit_id( rent_payer, DEFAULT_SEEDS, - is_undelegatable.unwrap_or(DEFAULT_IS_UNDELEGATABLE), + undelegatable.unwrap_or(DEFAULT_UNDELEGATABLE), + last_commit_id, ) } pub fn create_delegation_metadata_data( rent_payer: Pubkey, seeds: &[&[u8]], - is_undelegatable: bool, + undelegatable: bool, ) -> Vec { - create_delegation_metadata_data_with_nonce( + create_delegation_metadata_data_with_commit_id( rent_payer, seeds, - is_undelegatable, + undelegatable, DEFAULT_LAST_UPDATE_EXTERNAL_SLOT, ) } #[allow(dead_code)] -pub fn create_delegation_metadata_data_with_nonce( +pub fn create_delegation_metadata_data_with_commit_id( rent_payer: Pubkey, seeds: &[&[u8]], - is_undelegatable: bool, - last_update_nonce: u64, + undelegatable: bool, + last_commit_id: u64, ) -> Vec { let delegation_metadata = DelegationMetadata { - last_update_nonce, - is_undelegatable, + last_commit_id, + undelegation_requester: UndelegationRequester::from_allow_undelegation( + undelegatable, + ), seeds: seeds.iter().map(|s| s.to_vec()).collect(), rent_payer, }; @@ -198,3 +218,28 @@ pub fn create_program_config_data(approved_validator: Pubkey) -> Vec { .unwrap(); bytes } + +#[allow(dead_code)] +pub fn create_undelegation_request_data( + delegated_account: Pubkey, + created_slot: u64, +) -> Vec { + create_undelegation_request_data_with_expiry( + delegated_account, + created_slot + DEFAULT_UNDELEGATION_REQUEST_TIMEOUT_SLOTS, + ) +} + +#[allow(dead_code)] +pub fn create_undelegation_request_data_with_expiry( + delegated_account: Pubkey, + expires_at_slot: u64, +) -> Vec { + let request = UndelegationRequest { + delegated_account, + expires_at_slot, + }; + let mut bytes = vec![0u8; UndelegationRequest::size_with_discriminator()]; + request.to_bytes_with_discriminator(&mut bytes).unwrap(); + bytes +} diff --git a/tests/test_call_handler_v2.rs b/tests/test_call_handler_v2.rs index d280016e..716b01c1 100644 --- a/tests/test_call_handler_v2.rs +++ b/tests/test_call_handler_v2.rs @@ -1,7 +1,7 @@ use dlp::solana_program; use dlp_api::{ args::CallHandlerArgs, - compat::borsh::{self, to_vec, BorshDeserialize, BorshSerialize}, + compat::borsh::{to_vec, BorshDeserialize, BorshSerialize}, ephemeral_balance_seeds_from_payer, pda::{ commit_record_pda_from_delegated_account, diff --git a/tests/test_commit_fees_on_undelegation.rs b/tests/test_commit_fees_on_undelegation.rs index 98caa13e..76b36ca5 100644 --- a/tests/test_commit_fees_on_undelegation.rs +++ b/tests/test_commit_fees_on_undelegation.rs @@ -17,7 +17,7 @@ use solana_sdk::{ use solana_sdk_ids::system_program; use crate::fixtures::{ - create_delegation_metadata_data_with_nonce, get_delegation_record_data, + create_delegation_metadata_data_with_commit_id, get_delegation_record_data, DELEGATED_PDA_ID, DELEGATED_PDA_OWNER_ID, TEST_AUTHORITY, }; @@ -37,12 +37,13 @@ async fn test_commit_fees_on_undelegation() { let delegation_record_data = get_delegation_record_data(validator.pubkey(), None); - let delegation_metadata_data = create_delegation_metadata_data_with_nonce( - validator.pubkey(), - &[], - true, - 101, - ); + let delegation_metadata_data = + create_delegation_metadata_data_with_commit_id( + validator.pubkey(), + &[], + true, + 101, + ); let record_rent = Rent::default().minimum_balance(delegation_record_data.len()); @@ -123,13 +124,14 @@ async fn setup_program_test_env() -> (BanksClient, Keypair, Keypair, Hash) { }, ); - // Setup the delegated account metadata PDA with high nonce - let delegation_metadata_data = create_delegation_metadata_data_with_nonce( - validator.pubkey(), - &[], - true, - 101, - ); + // Setup the delegated account metadata PDA with high commit id + let delegation_metadata_data = + create_delegation_metadata_data_with_commit_id( + validator.pubkey(), + &[], + true, + 101, + ); program_test.add_account( delegation_metadata_pda_from_delegated_account(&DELEGATED_PDA_ID), Account { diff --git a/tests/test_commit_finalize.rs b/tests/test_commit_finalize.rs index 37d29261..3f78ecf8 100644 --- a/tests/test_commit_finalize.rs +++ b/tests/test_commit_finalize.rs @@ -115,7 +115,10 @@ async fn run_test_commit_finalize( ) .unwrap(); - assert!(delegation_metadata.is_undelegatable); + assert_eq!( + delegation_metadata.undelegation_requester, + dlp_api::state::UndelegationRequester::Validator + ); } #[tokio::test] diff --git a/tests/test_commit_finalize_from_buffer.rs b/tests/test_commit_finalize_from_buffer.rs index fd40443d..5c861190 100644 --- a/tests/test_commit_finalize_from_buffer.rs +++ b/tests/test_commit_finalize_from_buffer.rs @@ -68,7 +68,7 @@ async fn test_commit_finalize_from_buffer_perf() { let metadata = metadata.unwrap(); - assertables::assert_lt!(metadata.compute_units_consumed, 1400); + assertables::assert_lt!(metadata.compute_units_consumed, 1450); assert_eq!( metadata.log_messages.len(), @@ -93,7 +93,10 @@ async fn test_commit_finalize_from_buffer_perf() { ) .unwrap(); - assert!(delegation_metadata.is_undelegatable); + assert_eq!( + delegation_metadata.undelegation_requester, + dlp_api::state::UndelegationRequester::Validator + ); } #[tokio::test] diff --git a/tests/test_commit_on_curve.rs b/tests/test_commit_on_curve.rs index 2265a727..17319739 100644 --- a/tests/test_commit_on_curve.rs +++ b/tests/test_commit_on_curve.rs @@ -91,7 +91,10 @@ async fn test_commit_on_curve() { &delegation_metadata_account.data, ) .unwrap(); - assert!(delegation_metadata.is_undelegatable); + assert_eq!( + delegation_metadata.undelegation_requester, + dlp_api::state::UndelegationRequester::Validator + ); } async fn setup_program_test_env() -> (BanksClient, Keypair, Keypair, Hash) { diff --git a/tests/test_commit_state.rs b/tests/test_commit_state.rs index 73a312f2..c6221217 100644 --- a/tests/test_commit_state.rs +++ b/tests/test_commit_state.rs @@ -97,7 +97,10 @@ async fn test_commit_new_state() { &delegation_metadata_account.data, ) .unwrap(); - assert!(delegation_metadata.is_undelegatable); + assert_eq!( + delegation_metadata.undelegation_requester, + dlp_api::state::UndelegationRequester::Validator + ); } #[tokio::test] diff --git a/tests/test_commit_state_from_buffer.rs b/tests/test_commit_state_from_buffer.rs index b986cb29..7a76aab5 100644 --- a/tests/test_commit_state_from_buffer.rs +++ b/tests/test_commit_state_from_buffer.rs @@ -101,7 +101,10 @@ async fn test_commit_new_state_from_buffer() { &delegation_metadata_account.data, ) .unwrap(); - assert!(delegation_metadata.is_undelegatable); + assert_eq!( + delegation_metadata.undelegation_requester, + dlp_api::state::UndelegationRequester::Validator + ); } async fn setup_program_test_env() -> (BanksClient, Keypair, Keypair, Hash) { diff --git a/tests/test_commit_state_with_program_config.rs b/tests/test_commit_state_with_program_config.rs index 736e3ffb..b3365b97 100644 --- a/tests/test_commit_state_with_program_config.rs +++ b/tests/test_commit_state_with_program_config.rs @@ -112,7 +112,10 @@ async fn test_commit_new_state(valid_config: bool) { &delegation_metadata_account.data, ) .unwrap(); - assert!(delegation_metadata.is_undelegatable); + assert_eq!( + delegation_metadata.undelegation_requester, + dlp_api::state::UndelegationRequester::Validator + ); } } diff --git a/tests/test_delegate_on_curve.rs b/tests/test_delegate_on_curve.rs index 02a19153..a37a3aec 100644 --- a/tests/test_delegate_on_curve.rs +++ b/tests/test_delegate_on_curve.rs @@ -130,7 +130,10 @@ async fn test_delegate_on_curve() { &delegation_metadata.data, ) .unwrap(); - assert!(!delegation_metadata.is_undelegatable); + assert_eq!( + delegation_metadata.undelegation_requester, + dlp_api::state::UndelegationRequester::None + ); } async fn setup_program_test_env() -> (BanksClient, Keypair, Keypair, Hash) { diff --git a/tests/test_finalize.rs b/tests/test_finalize.rs index 83f6a779..030141e8 100644 --- a/tests/test_finalize.rs +++ b/tests/test_finalize.rs @@ -102,7 +102,7 @@ async fn test_finalize() { &delegation_metadata_account.data, ) .unwrap(); - assert_eq!(commit_record.nonce, delegation_metadata.last_update_nonce); + assert_eq!(commit_record.nonce, delegation_metadata.last_commit_id); } async fn setup_program_test_env() -> (BanksClient, Keypair, Keypair, Hash) { diff --git a/tests/test_lamports_settlement.rs b/tests/test_lamports_settlement.rs index df7bdd47..40cf41e7 100644 --- a/tests/test_lamports_settlement.rs +++ b/tests/test_lamports_settlement.rs @@ -456,7 +456,6 @@ async fn test_commit_and_finalize_lamports_settlement() { banks: &mut base_banks, authority: &validator, fee_payer: &payer, - blockhash, new_delegated_account_lamports: lamports_on_ephem, nonce: 1, allow_undelegation: false, @@ -470,7 +469,6 @@ async fn test_commit_and_finalize_lamports_settlement() { banks: &mut base_banks, authority: &validator, fee_payer: &payer, - blockhash, label: "first finalize", delegated_account: delegated.pubkey(), }) @@ -503,7 +501,6 @@ async fn test_commit_and_finalize_lamports_settlement() { banks: &mut base_banks, authority: &validator, fee_payer: &payer, - blockhash, new_delegated_account_lamports: lamports_on_ephem, nonce: 2, allow_undelegation: false, @@ -517,7 +514,6 @@ async fn test_commit_and_finalize_lamports_settlement() { banks: &mut base_banks, authority: &validator, fee_payer: &payer, - blockhash, label: "second finalize", delegated_account: delegated.pubkey(), }) @@ -552,7 +548,6 @@ async fn test_commit_and_finalize_lamports_settlement() { banks: &mut base_banks, authority: &validator, fee_payer: &payer, - blockhash, new_delegated_account_lamports: lamports_on_ephem, nonce: 3, allow_undelegation: false, @@ -566,7 +561,6 @@ async fn test_commit_and_finalize_lamports_settlement() { banks: &mut base_banks, authority: &validator, fee_payer: &payer, - blockhash, label: "third finalize", delegated_account: delegated.pubkey(), }) @@ -601,7 +595,6 @@ async fn test_commit_and_finalize_lamports_settlement() { banks: &mut base_banks, authority: &validator, fee_payer: &payer, - blockhash, new_delegated_account_lamports: lamports_on_ephem, nonce: 4, allow_undelegation: false, @@ -615,7 +608,6 @@ async fn test_commit_and_finalize_lamports_settlement() { banks: &mut base_banks, authority: &validator, fee_payer: &payer, - blockhash, label: "fourth finalize", delegated_account: delegated.pubkey(), }) @@ -1079,7 +1071,6 @@ struct CommitStateWithNonceArgs<'a> { banks: &'a mut BanksClient, authority: &'a Keypair, fee_payer: &'a Keypair, - blockhash: Hash, new_delegated_account_lamports: u64, nonce: u64, allow_undelegation: bool, @@ -1224,7 +1215,10 @@ async fn commit_new_state(args: CommitNewStateArgs<'_>) { &delegation_metadata_account.data, ) .unwrap(); - assert!(delegation_metadata.is_undelegatable); + assert_eq!( + delegation_metadata.undelegation_requester, + dlp_api::state::UndelegationRequester::Validator + ); } async fn commit_state_with_nonce(args: CommitStateWithNonceArgs<'_>) { @@ -1261,7 +1255,6 @@ struct FinalizeWithFeePayerArgs<'a> { banks: &'a mut BanksClient, authority: &'a Keypair, fee_payer: &'a Keypair, - blockhash: Hash, label: &'a str, delegated_account: Pubkey, } diff --git a/tests/test_request_undelegation.rs b/tests/test_request_undelegation.rs new file mode 100644 index 00000000..22c0428b --- /dev/null +++ b/tests/test_request_undelegation.rs @@ -0,0 +1,841 @@ +use dlp::solana_program; +use dlp_api::{ + args::{CommitFinalizeArgs, CommitStateArgs}, + consts::DEFAULT_UNDELEGATION_REQUEST_TIMEOUT_SLOTS, + error::DlpError, + pda::{ + commit_record_pda_from_delegated_account, + commit_state_pda_from_delegated_account, + delegation_metadata_pda_from_delegated_account, + delegation_record_pda_from_delegated_account, fees_vault_pda, + undelegation_request_pda_from_delegated_account, + validator_fees_vault_pda_from_validator, + }, + state::{DelegationMetadata, UndelegationRequest, UndelegationRequester}, +}; +use solana_program::{ + account_info::AccountInfo, + entrypoint::ProgramResult, + hash::Hash, + instruction::{AccountMeta, Instruction}, + native_token::LAMPORTS_PER_SOL, + program::invoke_signed, + program_error::ProgramError, + pubkey::Pubkey, + rent::Rent, +}; +use solana_program_test::{ + processor, read_file, BanksClient, BanksClientError, ProgramTest, +}; +use solana_sdk::{ + account::Account, + instruction::InstructionError, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, +}; +use solana_sdk_ids::system_program; + +use crate::fixtures::{ + create_undelegation_request_data, get_commit_record_account_data, + get_delegation_metadata_data, get_delegation_metadata_data_on_curve, + get_delegation_record_data, get_delegation_record_on_curve_data, + keypair_from_bytes, COMMIT_NEW_STATE_ACCOUNT_DATA, DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, ON_CURVE_KEYPAIR, TEST_AUTHORITY, +}; + +mod fixtures; + +const TEST_PDA_SEED: &[u8] = b"test-pda"; + +#[tokio::test] +async fn test_request_undelegation_creates_request_and_is_idempotent() { + let SetupContext { + banks, + authority, + blockhash, + .. + } = setup_env(SetupConfig { + with_request_wrapper: true, + ..Default::default() + }) + .await; + + let ix = request_undelegation_from_owner_program(authority.pubkey()); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + + banks.process_transaction(tx).await.unwrap(); + + let request_pda = + undelegation_request_pda_from_delegated_account(&DELEGATED_PDA_ID); + let request_account = + banks.get_account(request_pda).await.unwrap().unwrap(); + assert_eq!(request_account.owner, dlp_api::id()); + + let request = UndelegationRequest::try_from_bytes_with_discriminator( + &request_account.data, + ) + .unwrap(); + assert_eq!(request.delegated_account, DELEGATED_PDA_ID); + assert!( + request.expires_at_slot >= DEFAULT_UNDELEGATION_REQUEST_TIMEOUT_SLOTS + ); + + let delegation_metadata_pda = + delegation_metadata_pda_from_delegated_account(&DELEGATED_PDA_ID); + let delegation_metadata_account = banks + .get_account(delegation_metadata_pda) + .await + .unwrap() + .unwrap(); + let delegation_metadata = + DelegationMetadata::try_from_bytes_with_discriminator( + &delegation_metadata_account.data, + ) + .unwrap(); + assert_eq!( + delegation_metadata.undelegation_requester, + UndelegationRequester::OwnerProgram + ); + + let second_ix = request_undelegation_from_owner_program(authority.pubkey()); + let second_tx = Transaction::new_signed_with_payer( + &[second_ix], + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + banks.process_transaction(second_tx).await.unwrap(); + + let request_after = banks.get_account(request_pda).await.unwrap().unwrap(); + assert_eq!(request_after.data, request_account.data); +} + +#[tokio::test] +async fn test_request_undelegation_rejects_missing_delegated_signer() { + let SetupContext { + banks, + payer, + blockhash, + .. + } = setup_env(SetupConfig { + with_request_wrapper: true, + ..Default::default() + }) + .await; + + let request_pda = + undelegation_request_pda_from_delegated_account(&DELEGATED_PDA_ID); + let ix = Instruction { + program_id: dlp_api::id(), + accounts: vec![ + AccountMeta::new(payer.pubkey(), true), + AccountMeta::new(DELEGATED_PDA_ID, false), + AccountMeta::new_readonly(DELEGATED_PDA_OWNER_ID, false), + AccountMeta::new(request_pda, false), + AccountMeta::new_readonly( + delegation_record_pda_from_delegated_account(&DELEGATED_PDA_ID), + false, + ), + AccountMeta::new( + delegation_metadata_pda_from_delegated_account( + &DELEGATED_PDA_ID, + ), + false, + ), + AccountMeta::new_readonly(system_program::id(), false), + ], + data: dlp_api::discriminator::DlpDiscriminator::RequestUndelegation + .to_vec(), + }; + + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res.is_err()); +} + +#[tokio::test] +async fn test_request_undelegation_rejects_on_curve_delegated_account() { + let SetupContext { + banks, + payer, + delegated_on_curve, + delegated_account, + blockhash, + .. + } = setup_env(SetupConfig { + delegated_account: DelegatedAccountSetup::OnCurveKeypair, + ..Default::default() + }) + .await; + + let ix = dlp_api::instruction_builder::request_undelegation( + payer.pubkey(), + delegated_account, + system_program::id(), + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer, &delegated_on_curve], + blockhash, + ); + + let res = banks.process_transaction(tx).await; + assert!(res.is_err()); +} + +#[tokio::test] +async fn test_undelegate_with_request_closes_request() { + let SetupContext { + banks, + authority, + blockhash, + .. + } = setup_env(SetupConfig { + metadata_undelegatable: true, + with_commit_accounts: true, + with_owner_program: true, + with_fee_accounts: true, + with_request_account: true, + ..Default::default() + }) + .await; + + let request_pda = + undelegation_request_pda_from_delegated_account(&DELEGATED_PDA_ID); + + let ix_finalize = dlp_api::instruction_builder::finalize( + authority.pubkey(), + DELEGATED_PDA_ID, + ); + let ix_undelegate = dlp_api::instruction_builder::undelegate_with_request( + authority.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + authority.pubkey(), + ); + + let tx = Transaction::new_signed_with_payer( + &[ix_finalize, ix_undelegate], + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + banks.process_transaction(tx).await.unwrap(); + + let request_account = banks.get_account(request_pda).await.unwrap(); + assert!(request_account.is_none()); +} + +#[tokio::test] +async fn test_commit_state_rejects_owner_request_without_allow() { + let SetupContext { + banks, + authority, + blockhash, + .. + } = setup_env(SetupConfig { + with_request_wrapper: true, + with_fee_accounts: true, + ..Default::default() + }) + .await; + + let request_ix = + request_undelegation_from_owner_program(authority.pubkey()); + let commit_ix = dlp_api::instruction_builder::commit_state( + authority.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + CommitStateArgs { + data: COMMIT_NEW_STATE_ACCOUNT_DATA.to_vec(), + nonce: 1, + allow_undelegation: false, + lamports: LAMPORTS_PER_SOL, + }, + ); + let tx = Transaction::new_signed_with_payer( + &[request_ix, commit_ix], + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let err = banks.process_transaction(tx).await.unwrap_err(); + assert_custom_error(err, DlpError::OwnerRequestedUndelegation); +} + +#[tokio::test] +async fn test_commit_state_preserves_owner_request_with_allow() { + let SetupContext { + banks, + authority, + blockhash, + .. + } = setup_env(SetupConfig { + with_request_wrapper: true, + with_fee_accounts: true, + ..Default::default() + }) + .await; + + let request_ix = + request_undelegation_from_owner_program(authority.pubkey()); + let commit_ix = dlp_api::instruction_builder::commit_state( + authority.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + CommitStateArgs { + data: COMMIT_NEW_STATE_ACCOUNT_DATA.to_vec(), + nonce: 1, + allow_undelegation: true, + lamports: LAMPORTS_PER_SOL, + }, + ); + let tx = Transaction::new_signed_with_payer( + &[request_ix, commit_ix], + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + banks.process_transaction(tx).await.unwrap(); + + assert_delegation_metadata_requester( + &banks, + UndelegationRequester::OwnerProgram, + ) + .await; +} + +#[tokio::test] +async fn test_commit_finalize_rejects_owner_request_without_allow() { + let SetupContext { + banks, + authority, + blockhash, + .. + } = setup_env(SetupConfig { + with_request_wrapper: true, + with_fee_accounts: true, + ..Default::default() + }) + .await; + + let request_ix = + request_undelegation_from_owner_program(authority.pubkey()); + let mut args = CommitFinalizeArgs { + commit_id: 1, + lamports: LAMPORTS_PER_SOL, + allow_undelegation: false.into(), + data_is_diff: false.into(), + bumps: Default::default(), + reserved_padding: Default::default(), + }; + let (commit_finalize_ix, _) = dlp_api::instruction_builder::commit_finalize( + authority.pubkey(), + DELEGATED_PDA_ID, + &mut args, + &COMMIT_NEW_STATE_ACCOUNT_DATA, + ); + let tx = Transaction::new_signed_with_payer( + &[request_ix, commit_finalize_ix], + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let err = banks.process_transaction(tx).await.unwrap_err(); + assert_custom_error(err, DlpError::OwnerRequestedUndelegation); +} + +#[tokio::test] +async fn test_commit_finalize_preserves_owner_request_with_allow() { + let SetupContext { + banks, + authority, + blockhash, + .. + } = setup_env(SetupConfig { + with_request_wrapper: true, + with_fee_accounts: true, + ..Default::default() + }) + .await; + + let request_ix = + request_undelegation_from_owner_program(authority.pubkey()); + let mut args = CommitFinalizeArgs { + commit_id: 1, + lamports: LAMPORTS_PER_SOL, + allow_undelegation: true.into(), + data_is_diff: false.into(), + bumps: Default::default(), + reserved_padding: Default::default(), + }; + let (commit_finalize_ix, _) = dlp_api::instruction_builder::commit_finalize( + authority.pubkey(), + DELEGATED_PDA_ID, + &mut args, + &COMMIT_NEW_STATE_ACCOUNT_DATA, + ); + let tx = Transaction::new_signed_with_payer( + &[request_ix, commit_finalize_ix], + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + banks.process_transaction(tx).await.unwrap(); + + assert_delegation_metadata_requester( + &banks, + UndelegationRequester::OwnerProgram, + ) + .await; +} + +#[tokio::test] +async fn test_undelegate_owner_program_request_without_request_accounts_rejected( +) { + let SetupContext { + banks, + authority, + blockhash, + .. + } = setup_env(SetupConfig { + with_request_wrapper: true, + with_commit_accounts: true, + with_fee_accounts: true, + ..Default::default() + }) + .await; + + let request_ix = + request_undelegation_from_owner_program(authority.pubkey()); + let request_tx = Transaction::new_signed_with_payer( + &[request_ix], + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + banks.process_transaction(request_tx).await.unwrap(); + + let finalize_ix = dlp_api::instruction_builder::finalize( + authority.pubkey(), + DELEGATED_PDA_ID, + ); + let finalize_tx = Transaction::new_signed_with_payer( + &[finalize_ix], + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + banks.process_transaction(finalize_tx).await.unwrap(); + + let undelegate_ix = dlp_api::instruction_builder::undelegate( + authority.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + authority.pubkey(), + ); + let undelegate_tx = Transaction::new_signed_with_payer( + &[undelegate_ix], + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let err = banks.process_transaction(undelegate_tx).await.unwrap_err(); + assert_custom_error(err, DlpError::MissingUndelegationRequest); +} + +async fn assert_delegation_metadata_requester( + banks: &BanksClient, + expected_requester: UndelegationRequester, +) { + let delegation_metadata_pda = + delegation_metadata_pda_from_delegated_account(&DELEGATED_PDA_ID); + let delegation_metadata_account = banks + .get_account(delegation_metadata_pda) + .await + .unwrap() + .unwrap(); + let delegation_metadata = + DelegationMetadata::try_from_bytes_with_discriminator( + &delegation_metadata_account.data, + ) + .unwrap(); + assert_eq!( + delegation_metadata.undelegation_requester, + expected_requester + ); +} + +fn assert_custom_error(err: BanksClientError, expected: DlpError) { + assert!( + matches!( + err, + BanksClientError::TransactionError( + TransactionError::InstructionError( + _, + InstructionError::Custom(code), + ) + ) if code == expected as u32 + ), + "expected {expected:?}, got {err:?}" + ); +} + +fn imaginary_program_processor_requesting_undelegation_through_cpi( + program_id: &Pubkey, + accounts: &[AccountInfo], + data: &[u8], +) -> ProgramResult { + let [payer, delegated_account, owner_program, request_account, delegation_record_account, delegation_metadata_account, system_program_account, dlp_program] = + accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + if owner_program.key != program_id { + return Err(ProgramError::IncorrectProgramId); + } + + let mut ix = dlp_api::instruction_builder::request_undelegation( + *payer.key, + *delegated_account.key, + *program_id, + ); + ix.data.extend_from_slice(data); + let (_, bump) = Pubkey::find_program_address(&[TEST_PDA_SEED], program_id); + let bump_seed = [bump]; + invoke_signed( + &ix, + &[ + payer.clone(), + delegated_account.clone(), + owner_program.clone(), + request_account.clone(), + delegation_record_account.clone(), + delegation_metadata_account.clone(), + system_program_account.clone(), + dlp_program.clone(), + ], + &[&[TEST_PDA_SEED, &bump_seed]], + ) +} + +/// +/// Note this instruction invokes an imaginary "owner program" which then calls +/// the DLP program to request undelegation, which is why "data" doesn't contain +/// any "instruction discriminator" because the imaginary program doesn't +/// require it. See imaginary_program_processor_requesting_undelegation_through_cpi() +/// which is supposed to be the processor of the imaginary program. +/// +fn request_undelegation_from_owner_program( + delegation_rent_payer: Pubkey, +) -> Instruction { + Instruction { + program_id: DELEGATED_PDA_OWNER_ID, + accounts: vec![ + AccountMeta::new(delegation_rent_payer, true), + AccountMeta::new(DELEGATED_PDA_ID, false), + AccountMeta::new_readonly(DELEGATED_PDA_OWNER_ID, false), + AccountMeta::new( + undelegation_request_pda_from_delegated_account( + &DELEGATED_PDA_ID, + ), + false, + ), + AccountMeta::new_readonly( + delegation_record_pda_from_delegated_account(&DELEGATED_PDA_ID), + false, + ), + AccountMeta::new( + delegation_metadata_pda_from_delegated_account( + &DELEGATED_PDA_ID, + ), + false, + ), + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new_readonly(dlp_api::id(), false), + ], + data: vec![], + } +} + +#[derive(Clone, Copy)] +enum DelegatedAccountSetup { + OffCurvePda, + OnCurveKeypair, +} + +impl Default for DelegatedAccountSetup { + fn default() -> Self { + Self::OffCurvePda + } +} + +#[derive(Default)] +struct SetupConfig { + delegated_account: DelegatedAccountSetup, + with_request_wrapper: bool, + metadata_undelegatable: bool, + with_commit_accounts: bool, + with_owner_program: bool, + with_fee_accounts: bool, + with_request_account: bool, +} + +struct SetupContext { + banks: BanksClient, + payer: Keypair, + authority: Keypair, + delegated_on_curve: Keypair, + delegated_account: Pubkey, + blockhash: Hash, +} + +async fn setup_env(config: SetupConfig) -> SetupContext { + let mut program_test = ProgramTest::new("dlp", dlp_api::ID, None); + program_test.prefer_bpf(true); + if config.with_request_wrapper { + program_test.prefer_bpf(false); + program_test.add_program( + "request-wrapper", + DELEGATED_PDA_OWNER_ID, + processor!( + imaginary_program_processor_requesting_undelegation_through_cpi + ), + ); + program_test.prefer_bpf(true); + } + + let payer = Keypair::new(); + let authority = keypair_from_bytes(&TEST_AUTHORITY); + let delegated_on_curve = keypair_from_bytes(&ON_CURVE_KEYPAIR); + let delegated_account = match config.delegated_account { + DelegatedAccountSetup::OffCurvePda => DELEGATED_PDA_ID, + DelegatedAccountSetup::OnCurveKeypair => delegated_on_curve.pubkey(), + }; + let delegation_rent_payer = match config.delegated_account { + DelegatedAccountSetup::OffCurvePda => authority.pubkey(), + DelegatedAccountSetup::OnCurveKeypair => payer.pubkey(), + }; + + add_system_account(&mut program_test, payer.pubkey(), LAMPORTS_PER_SOL); + add_system_account(&mut program_test, authority.pubkey(), LAMPORTS_PER_SOL); + + add_delegated_account(&mut program_test, delegated_account); + match config.delegated_account { + DelegatedAccountSetup::OffCurvePda => { + add_delegation_accounts_with_metadata( + &mut program_test, + delegated_account, + authority.pubkey(), + delegation_rent_payer, + config.metadata_undelegatable, + false, + ); + } + DelegatedAccountSetup::OnCurveKeypair => { + add_delegation_accounts_with_metadata( + &mut program_test, + delegated_account, + delegated_on_curve.pubkey(), + delegation_rent_payer, + config.metadata_undelegatable, + true, + ); + } + } + + if config.with_commit_accounts { + add_commit_accounts( + &mut program_test, + delegated_account, + authority.pubkey(), + ); + } + if config.with_owner_program { + add_owner_program(&mut program_test); + } + if config.with_fee_accounts { + add_fee_accounts(&mut program_test, authority.pubkey()); + } + if config.with_request_account { + add_request_account(&mut program_test, delegated_account); + } + + let (banks, _, blockhash) = program_test.start().await; + SetupContext { + banks, + payer, + authority, + delegated_on_curve, + delegated_account, + blockhash, + } +} + +fn add_system_account( + program_test: &mut ProgramTest, + pubkey: Pubkey, + lamports: u64, +) { + program_test.add_account( + pubkey, + Account { + lamports, + data: vec![], + owner: system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); +} + +fn add_delegated_account(program_test: &mut ProgramTest, pubkey: Pubkey) { + program_test.add_account( + pubkey, + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); +} + +fn add_delegation_accounts_with_metadata( + program_test: &mut ProgramTest, + delegated_account: Pubkey, + authority: Pubkey, + rent_payer: Pubkey, + undelegatable: bool, + on_curve: bool, +) { + let delegation_record_data = if on_curve { + get_delegation_record_on_curve_data(authority, Some(LAMPORTS_PER_SOL)) + } else { + get_delegation_record_data(authority, None) + }; + program_test.add_account( + delegation_record_pda_from_delegated_account(&delegated_account), + Account { + lamports: Rent::default() + .minimum_balance(delegation_record_data.len()), + data: delegation_record_data, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + let delegation_metadata_data = if on_curve { + get_delegation_metadata_data_on_curve(rent_payer, Some(undelegatable)) + } else { + get_delegation_metadata_data(rent_payer, Some(undelegatable)) + }; + program_test.add_account( + delegation_metadata_pda_from_delegated_account(&delegated_account), + Account { + lamports: Rent::default() + .minimum_balance(delegation_metadata_data.len()), + data: delegation_metadata_data, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); +} + +fn add_commit_accounts( + program_test: &mut ProgramTest, + delegated_account: Pubkey, + authority: Pubkey, +) { + program_test.add_account( + commit_state_pda_from_delegated_account(&delegated_account), + Account { + lamports: LAMPORTS_PER_SOL, + data: COMMIT_NEW_STATE_ACCOUNT_DATA.into(), + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + let commit_record_data = get_commit_record_account_data(authority); + program_test.add_account( + commit_record_pda_from_delegated_account(&delegated_account), + Account { + lamports: Rent::default().minimum_balance(commit_record_data.len()), + data: commit_record_data, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); +} + +fn add_owner_program(program_test: &mut ProgramTest) { + let data = read_file("tests/buffers/test_delegation.so"); + program_test.add_account( + DELEGATED_PDA_OWNER_ID, + Account { + lamports: Rent::default().minimum_balance(data.len()), + data, + owner: solana_sdk::bpf_loader::id(), + executable: true, + rent_epoch: 0, + }, + ); +} + +fn add_fee_accounts(program_test: &mut ProgramTest, authority: Pubkey) { + program_test.add_account( + fees_vault_pda(), + Account { + lamports: Rent::default().minimum_balance(0), + data: vec![], + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + program_test.add_account( + validator_fees_vault_pda_from_validator(&authority), + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); +} + +fn add_request_account( + program_test: &mut ProgramTest, + delegated_account: Pubkey, +) { + let request_data = create_undelegation_request_data(delegated_account, 1); + program_test.add_account( + undelegation_request_pda_from_delegated_account(&delegated_account), + Account { + lamports: Rent::default().minimum_balance(request_data.len()), + data: request_data, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); +} diff --git a/tests/test_undelegate_with_rollback_after_timeout.rs b/tests/test_undelegate_with_rollback_after_timeout.rs new file mode 100644 index 00000000..37c5aeba --- /dev/null +++ b/tests/test_undelegate_with_rollback_after_timeout.rs @@ -0,0 +1,515 @@ +use dlp::solana_program; +use dlp_api::{ + consts::EXTERNAL_UNDELEGATE_DISCRIMINATOR, + error::DlpError, + pda::{ + commit_record_pda_from_delegated_account, + commit_state_pda_from_delegated_account, + delegation_metadata_pda_from_delegated_account, + delegation_record_pda_from_delegated_account, + undelegation_request_pda_from_delegated_account, + }, +}; +use solana_program::{ + account_info::AccountInfo, + entrypoint::ProgramResult, + hash::Hash, + instruction::{AccountMeta, Instruction}, + native_token::LAMPORTS_PER_SOL, + program::invoke_signed, + program_error::ProgramError, + pubkey::Pubkey, + rent::Rent, + sysvar::Sysvar, +}; +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 solana_system_interface::instruction as system_instruction; + +use crate::fixtures::{ + create_undelegation_request_data_with_expiry, + get_commit_record_account_data, + get_delegation_metadata_data_with_commit_id, get_delegation_record_data, + keypair_from_bytes, COMMIT_NEW_STATE_ACCOUNT_DATA, DELEGATED_PDA, + DELEGATED_PDA_ID, DELEGATED_PDA_OWNER_ID, TEST_AUTHORITY, +}; + +mod fixtures; + +const TEST_PDA_SEED: &[u8] = b"test-pda"; + +#[tokio::test] +async fn test_undelegate_with_rollback_after_timeout_after_expiry() { + let (banks, caller, delegation_rent_payer, _, blockhash) = + setup_request_timeout_env(false, 0).await; + + let request_pda = + undelegation_request_pda_from_delegated_account(&DELEGATED_PDA_ID); + let delegation_record_pda = + delegation_record_pda_from_delegated_account(&DELEGATED_PDA_ID); + let delegation_metadata_pda = + delegation_metadata_pda_from_delegated_account(&DELEGATED_PDA_ID); + + let request_lamports = banks + .get_account(request_pda) + .await + .unwrap() + .unwrap() + .lamports; + let delegation_record_lamports = banks + .get_account(delegation_record_pda) + .await + .unwrap() + .unwrap() + .lamports; + let delegation_metadata_lamports = banks + .get_account(delegation_metadata_pda) + .await + .unwrap() + .unwrap() + .lamports; + let delegation_payer_before = banks + .get_account(delegation_rent_payer.pubkey()) + .await + .unwrap() + .unwrap() + .lamports; + let delegated_before = + banks.get_account(DELEGATED_PDA_ID).await.unwrap().unwrap(); + + let ix = rollback_from_owner_program( + delegation_rent_payer.pubkey(), + delegation_rent_payer.pubkey(), + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&caller.pubkey()), + &[&caller], + blockhash, + ); + + banks.process_transaction(tx).await.unwrap(); + + assert!(banks.get_account(request_pda).await.unwrap().is_none()); + assert!(banks + .get_account(delegation_record_pda) + .await + .unwrap() + .is_none()); + assert!(banks + .get_account(delegation_metadata_pda) + .await + .unwrap() + .is_none()); + + let delegated_after = + banks.get_account(DELEGATED_PDA_ID).await.unwrap().unwrap(); + assert_eq!(delegated_after.owner, DELEGATED_PDA_OWNER_ID); + assert_eq!(delegated_after.data, delegated_before.data); + + let delegation_payer_after = banks + .get_account(delegation_rent_payer.pubkey()) + .await + .unwrap() + .unwrap() + .lamports; + assert_eq!( + delegation_payer_after, + delegation_payer_before + + request_lamports + + delegation_record_lamports + + delegation_metadata_lamports + ); +} + +#[tokio::test] +async fn test_undelegate_with_rollback_after_timeout_rejects_before_expiry() { + let (banks, caller, delegation_rent_payer, _, blockhash) = + setup_request_timeout_env(false, 1_000_000).await; + + let ix = rollback_from_owner_program( + delegation_rent_payer.pubkey(), + delegation_rent_payer.pubkey(), + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&caller.pubkey()), + &[&caller], + blockhash, + ); + + let err = banks.process_transaction(tx).await.unwrap_err(); + assert!( + matches!( + err, + BanksClientError::TransactionError( + TransactionError::InstructionError( + 0, + InstructionError::Custom(code), + ) + ) if code == DlpError::UndelegationRequestNotExpired as u32 + ), + "expected UndelegationRequestNotExpired, got {err:?}" + ); +} + +#[tokio::test] +async fn test_request_timeout_closes_pending_commit_without_applying_it() { + let (banks, caller, delegation_rent_payer, validator, blockhash) = + setup_request_timeout_env(true, 0).await; + + let commit_state_pda = + commit_state_pda_from_delegated_account(&DELEGATED_PDA_ID); + let commit_record_pda = + commit_record_pda_from_delegated_account(&DELEGATED_PDA_ID); + let commit_state_lamports = banks + .get_account(commit_state_pda) + .await + .unwrap() + .unwrap() + .lamports; + let commit_record_lamports = banks + .get_account(commit_record_pda) + .await + .unwrap() + .unwrap() + .lamports; + let validator_before = banks + .get_account(validator.pubkey()) + .await + .unwrap() + .unwrap() + .lamports; + + let ix = rollback_from_owner_program( + delegation_rent_payer.pubkey(), + validator.pubkey(), + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&caller.pubkey()), + &[&caller], + blockhash, + ); + + banks.process_transaction(tx).await.unwrap(); + + assert!(banks.get_account(commit_state_pda).await.unwrap().is_none()); + assert!(banks + .get_account(commit_record_pda) + .await + .unwrap() + .is_none()); + + let delegated_after = + banks.get_account(DELEGATED_PDA_ID).await.unwrap().unwrap(); + assert_eq!(delegated_after.owner, DELEGATED_PDA_OWNER_ID); + assert_eq!(delegated_after.data, DELEGATED_PDA); + assert_ne!(delegated_after.data, COMMIT_NEW_STATE_ACCOUNT_DATA); + + let validator_after = banks + .get_account(validator.pubkey()) + .await + .unwrap() + .unwrap() + .lamports; + assert_eq!( + validator_after, + validator_before + commit_state_lamports + commit_record_lamports + ); +} + +async fn setup_request_timeout_env( + with_pending_commit: bool, + expires_at_slot: u64, +) -> (BanksClient, Keypair, Keypair, Keypair, Hash) { + let mut program_test = ProgramTest::default(); + program_test.prefer_bpf(true); + program_test.add_program("dlp", dlp_api::ID, None); + program_test.prefer_bpf(false); + program_test.add_program( + "rollback-wrapper", + DELEGATED_PDA_OWNER_ID, + processor!(owner_program_processor), + ); + program_test.prefer_bpf(true); + + let caller = Keypair::new(); + let delegation_rent_payer = Keypair::new(); + let validator = keypair_from_bytes(&TEST_AUTHORITY); + + add_system_account(&mut program_test, caller.pubkey()); + add_system_account(&mut program_test, delegation_rent_payer.pubkey()); + add_system_account(&mut program_test, validator.pubkey()); + add_delegated_account(&mut program_test); + add_delegation_accounts(&mut program_test, delegation_rent_payer.pubkey()); + add_request_account(&mut program_test, expires_at_slot); + if with_pending_commit { + add_pending_commit_accounts(&mut program_test, validator.pubkey()); + } + + let (banks, _, blockhash) = program_test.start().await; + (banks, caller, delegation_rent_payer, validator, blockhash) +} + +fn add_system_account( + program_test: &mut ProgramTest, + pubkey: solana_program::pubkey::Pubkey, +) { + program_test.add_account( + pubkey, + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); +} + +fn add_delegated_account(program_test: &mut ProgramTest) { + program_test.add_account( + DELEGATED_PDA_ID, + Account { + lamports: LAMPORTS_PER_SOL, + data: DELEGATED_PDA.into(), + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); +} + +fn add_delegation_accounts( + program_test: &mut ProgramTest, + delegation_rent_payer: solana_program::pubkey::Pubkey, +) { + let delegation_record_data = get_delegation_record_data( + keypair_from_bytes(&TEST_AUTHORITY).pubkey(), + None, + ); + program_test.add_account( + delegation_record_pda_from_delegated_account(&DELEGATED_PDA_ID), + Account { + lamports: Rent::default() + .minimum_balance(delegation_record_data.len()), + data: delegation_record_data, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + let delegation_metadata_data = get_delegation_metadata_data_with_commit_id( + delegation_rent_payer, + Some(false), + 0, + ); + program_test.add_account( + delegation_metadata_pda_from_delegated_account(&DELEGATED_PDA_ID), + Account { + lamports: Rent::default() + .minimum_balance(delegation_metadata_data.len()), + data: delegation_metadata_data, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); +} + +fn add_request_account(program_test: &mut ProgramTest, expires_at_slot: u64) { + let request_data = create_undelegation_request_data_with_expiry( + DELEGATED_PDA_ID, + expires_at_slot, + ); + program_test.add_account( + undelegation_request_pda_from_delegated_account(&DELEGATED_PDA_ID), + Account { + lamports: Rent::default().minimum_balance(request_data.len()), + data: request_data, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); +} + +fn add_pending_commit_accounts( + program_test: &mut ProgramTest, + validator: solana_program::pubkey::Pubkey, +) { + program_test.add_account( + commit_state_pda_from_delegated_account(&DELEGATED_PDA_ID), + Account { + lamports: LAMPORTS_PER_SOL, + data: COMMIT_NEW_STATE_ACCOUNT_DATA.into(), + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); + + let commit_record_data = get_commit_record_account_data(validator); + program_test.add_account( + commit_record_pda_from_delegated_account(&DELEGATED_PDA_ID), + Account { + lamports: Rent::default().minimum_balance(commit_record_data.len()), + data: commit_record_data, + owner: dlp_api::id(), + executable: false, + rent_epoch: 0, + }, + ); +} + +fn rollback_from_owner_program( + delegation_rent_payer: Pubkey, + commit_reimbursement: Pubkey, +) -> Instruction { + Instruction { + program_id: DELEGATED_PDA_OWNER_ID, + accounts: vec![ + AccountMeta::new(DELEGATED_PDA_ID, false), + AccountMeta::new_readonly(DELEGATED_PDA_OWNER_ID, false), + AccountMeta::new( + undelegation_request_pda_from_delegated_account( + &DELEGATED_PDA_ID, + ), + false, + ), + AccountMeta::new( + delegation_record_pda_from_delegated_account(&DELEGATED_PDA_ID), + false, + ), + AccountMeta::new( + delegation_metadata_pda_from_delegated_account( + &DELEGATED_PDA_ID, + ), + false, + ), + AccountMeta::new(delegation_rent_payer, false), + AccountMeta::new( + commit_state_pda_from_delegated_account(&DELEGATED_PDA_ID), + false, + ), + AccountMeta::new( + commit_record_pda_from_delegated_account(&DELEGATED_PDA_ID), + false, + ), + AccountMeta::new(commit_reimbursement, false), + AccountMeta::new_readonly(dlp_api::id(), false), + ], + data: vec![0], + } +} + +fn owner_program_processor( + program_id: &Pubkey, + accounts: &[AccountInfo], + data: &[u8], +) -> ProgramResult { + if data.starts_with(&EXTERNAL_UNDELEGATE_DISCRIMINATOR) { + return process_external_undelegate(program_id, accounts); + } + + match data.first().copied() { + Some(0) => process_rollback(program_id, accounts), + _ => Err(ProgramError::InvalidInstructionData), + } +} + +fn process_rollback( + program_id: &Pubkey, + accounts: &[AccountInfo], +) -> ProgramResult { + let [delegated_account, owner_program, request_account, delegation_record_account, delegation_metadata_account, delegation_rent_payer, commit_state_account, commit_record_account, commit_reimbursement, dlp_program] = + accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + if owner_program.key != program_id { + return Err(ProgramError::IncorrectProgramId); + } + + let delegated_data = delegated_account.try_borrow_data()?.to_vec(); + + let ix = + dlp_api::instruction_builder::undelegate_with_rollback_after_timeout( + *delegated_account.key, + *owner_program.key, + *delegation_rent_payer.key, + *commit_reimbursement.key, + ); + let (_, bump) = Pubkey::find_program_address(&[TEST_PDA_SEED], program_id); + let bump_seed = [bump]; + invoke_signed( + &ix, + &[ + delegated_account.clone(), + owner_program.clone(), + request_account.clone(), + delegation_record_account.clone(), + delegation_metadata_account.clone(), + delegation_rent_payer.clone(), + commit_state_account.clone(), + commit_record_account.clone(), + commit_reimbursement.clone(), + dlp_program.clone(), + ], + &[&[TEST_PDA_SEED, &bump_seed]], + )?; + + delegated_account.resize(delegated_data.len())?; + delegated_account + .try_borrow_mut_data()? + .copy_from_slice(&delegated_data); + + Ok(()) +} + +fn process_external_undelegate( + program_id: &Pubkey, + accounts: &[AccountInfo], +) -> ProgramResult { + let [delegated_account, undelegate_buffer_account, payer, system_program_account] = + accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + let space = undelegate_buffer_account.data_len(); + let rent_lamports = Rent::get()?.minimum_balance(space); + let (_, bump) = Pubkey::find_program_address(&[TEST_PDA_SEED], program_id); + let bump_seed = [bump]; + invoke_signed( + &system_instruction::create_account( + payer.key, + delegated_account.key, + rent_lamports, + space as u64, + program_id, + ), + &[ + payer.clone(), + delegated_account.clone(), + system_program_account.clone(), + ], + &[&[TEST_PDA_SEED, &bump_seed]], + )?; + + let buffer_data = undelegate_buffer_account.try_borrow_data()?; + let mut delegated_data = delegated_account.try_borrow_mut_data()?; + delegated_data.copy_from_slice(&buffer_data); + Ok(()) +}