diff --git a/Cargo.toml b/Cargo.toml index 30a1dc5b..36e1f8d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ name = "dlp" no-entrypoint = [] default = ["solana-security-txt"] unit_test_config = [] +log-cost = [] [dependencies] borsh = { version = "1.5.3", features = [ "derive" ] } diff --git a/src/cu.rs b/src/cu.rs new file mode 100644 index 00000000..264c6e87 --- /dev/null +++ b/src/cu.rs @@ -0,0 +1,41 @@ +use pinocchio::syscalls::sol_remaining_compute_units; +use pinocchio_log::log; + +pub struct BenchmarkComputeUnit { + name: &'static str, + remaining_at_start: u64, +} + +impl BenchmarkComputeUnit { + pub fn start(name: &'static str) -> BenchmarkComputeUnit { + log!("BENCHMARK BEGIN: [{}]", name); + Self { + name, + remaining_at_start: Self::remaining_cu(), + } + } + + fn remaining_cu() -> u64 { + unsafe { sol_remaining_compute_units() } + } +} + +impl Drop for BenchmarkComputeUnit { + fn drop(&mut self) { + let consumed = self.remaining_at_start - Self::remaining_cu(); + log!( + "BENCHMARK END: [{}] consumed {} of {} compute units.", + self.name, + consumed, + self.remaining_at_start + ) + } +} + +#[macro_export] +macro_rules! compute { + ($msg:expr=> $($tt:tt)*) => { + let _log = $crate::cu::BenchmarkComputeUnit::start($msg); + $($tt)* + }; +} diff --git a/src/lib.rs b/src/lib.rs index 9f5bc2cd..73aa1b8d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,9 @@ pub mod pda; mod processor; pub mod state; +#[cfg(feature = "log-cost")] +mod cu; + declare_id!("DELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh"); pub mod fast { @@ -106,6 +109,9 @@ pub fn fast_process_instruction( discriminator::DlpDiscriminator::Delegate => Some(processor::fast::process_delegate( program_id, accounts, data, )), + discriminator::DlpDiscriminator::Undelegate => Some(processor::fast::process_undelegate( + program_id, accounts, data, + )), _ => None, } } diff --git a/src/processor/fast/mod.rs b/src/processor/fast/mod.rs index e40bc425..a7bdba61 100644 --- a/src/processor/fast/mod.rs +++ b/src/processor/fast/mod.rs @@ -1,7 +1,9 @@ mod delegate; +mod undelegate; mod utils; pub use delegate::*; +pub use undelegate::*; pub fn to_pinocchio_program_error( error: solana_program::program_error::ProgramError, diff --git a/src/processor/fast/undelegate.rs b/src/processor/fast/undelegate.rs new file mode 100644 index 00000000..7fae2e08 --- /dev/null +++ b/src/processor/fast/undelegate.rs @@ -0,0 +1,311 @@ +use pinocchio::{ + account_info::AccountInfo, + cpi::invoke_signed, + instruction::{AccountMeta, Instruction, Signer}, + program_error::ProgramError, + pubkey::{pubkey_eq, Pubkey}, + sysvars::{rent::Rent, Sysvar}, + ProgramResult, +}; +use pinocchio::{pubkey, seeds}; +use pinocchio_log::log; +use pinocchio_system::instructions as system; + +use crate::consts::{EXTERNAL_UNDELEGATE_DISCRIMINATOR, RENT_FEES_PERCENTAGE}; +use crate::error::DlpError; +use crate::pda; +use crate::processor::fast::utils::{ + pda::{close_pda, close_pda_with_fees, create_pda}, + requires::require_uninitialized_pda, +}; +use crate::state::{DelegationMetadata, DelegationRecord}; + +#[cfg(feature = "log-cost")] +use crate::compute; + +use super::{ + to_pinocchio_program_error, + utils::requires::{ + require_initialized_delegation_metadata, require_initialized_delegation_record, + require_initialized_protocol_fees_vault, require_initialized_validator_fees_vault, + require_owned_pda, require_program, require_signer, + }, +}; + +pub fn process_undelegate( + _program_id: &Pubkey, + accounts: &[AccountInfo], + _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); + }; + + // Check accounts + require_signer(validator, "validator")?; + require_owned_pda(delegated_account, &crate::fast::ID, "delegated account")?; + require_initialized_delegation_record(delegated_account, delegation_record_account, true)?; + require_initialized_delegation_metadata(delegated_account, delegation_metadata_account, true)?; + require_initialized_protocol_fees_vault(fees_vault, true)?; + require_initialized_validator_fees_vault(validator, validator_fees_vault, true)?; + require_program(system_program, &pinocchio_system::ID, "system program")?; + + // Make sure there is no pending commits to be finalized before this call + require_uninitialized_pda( + commit_state_account, + &[pda::COMMIT_STATE_TAG, delegated_account.key()], + &crate::fast::ID, + false, + "commit state", + )?; + require_uninitialized_pda( + commit_record_account, + &[pda::COMMIT_RECORD_TAG, delegated_account.key()], + &crate::fast::ID, + false, + "commit record", + )?; + + // Load delegation record + let delegation_record_data = delegation_record_account.try_borrow_data()?; + let delegation_record = + DelegationRecord::try_from_bytes_with_discriminator(&delegation_record_data) + .map_err(to_pinocchio_program_error)?; + + // Check passed owner and owner stored in the delegation record match + if !pubkey_eq(delegation_record.owner.as_array(), owner_program.key()) { + log!("Expected delegation record owner to be : "); + pubkey::log(delegation_record.owner.as_array()); + log!("but got : "); + pubkey::log(owner_program.key()); + return Err(ProgramError::InvalidAccountOwner); + } + + // Load delegated account metadata + let delegation_metadata_data = delegation_metadata_account.try_borrow_data()?; + let delegation_metadata = + DelegationMetadata::try_from_bytes_with_discriminator(&delegation_metadata_data) + .map_err(to_pinocchio_program_error)?; + + // Check if the delegated account is undelegatable + if !delegation_metadata.is_undelegatable { + log!("delegation metadata indicates the account is not undelegatable : "); + pubkey::log(delegation_metadata_account.key()); + return Err(DlpError::NotUndelegatable.into()); + } + + // Check if the rent payer is correct + if !pubkey_eq( + delegation_metadata.rent_payer.as_array(), + rent_reimbursement.key(), + ) { + log!("Expected rent payer to be : "); + pubkey::log(delegation_metadata.rent_payer.as_array()); + log!("but got : "); + pubkey::log(rent_reimbursement.key()); + return Err(DlpError::InvalidReimbursementAddressForDelegationRent.into()); + } + + // Dropping delegation references + drop(delegation_record_data); + drop(delegation_metadata_data); + + // If there is no program to call CPI to, we can just assign the owner back and we're done + if delegated_account.data_is_empty() { + // TODO - we could also do this fast-path if the data was non-empty but zeroed-out + unsafe { + delegated_account.assign(owner_program.key()); + } + process_delegation_cleanup( + delegation_record_account, + delegation_metadata_account, + rent_reimbursement, + fees_vault, + validator_fees_vault, + )?; + return Ok(()); + } + + // Initialize the undelegation buffer PDA + + let undelegate_buffer_bump: u8 = require_uninitialized_pda( + undelegate_buffer_account, + &[pda::UNDELEGATE_BUFFER_TAG, delegated_account.key()], + &crate::fast::ID, + true, + "undelegate buffer", + )?; + + create_pda( + undelegate_buffer_account, + &crate::fast::ID, + delegated_account.data_len(), + &[Signer::from(&seeds!( + pda::UNDELEGATE_BUFFER_TAG, + delegated_account.key(), + &[undelegate_buffer_bump] + ))], + validator, + )?; + + // Copy data in the undelegation buffer PDA + (*undelegate_buffer_account.try_borrow_mut_data()?) + .copy_from_slice(&delegated_account.try_borrow_data()?); + + // Call a CPI to the owner program to give it back the new state + process_undelegation_with_cpi( + validator, + delegated_account, + owner_program, + undelegate_buffer_account, + &[Signer::from(&seeds!( + pda::UNDELEGATE_BUFFER_TAG, + delegated_account.key(), + &[undelegate_buffer_bump] + ))], + delegation_metadata, + system_program, + )?; + + // Done, close undelegation buffer + close_pda(undelegate_buffer_account, validator)?; + + // Closing delegation accounts + process_delegation_cleanup( + delegation_record_account, + delegation_metadata_account, + rent_reimbursement, + fees_vault, + validator_fees_vault, + )?; + Ok(()) +} + +/// 1. Close the delegated account +/// 2. CPI to the owner program +/// 3. Check state +/// 4. Settle lamports balance +#[allow(clippy::too_many_arguments)] +fn process_undelegation_with_cpi( + validator: &AccountInfo, + delegated_account: &AccountInfo, + owner_program: &AccountInfo, + undelegate_buffer_account: &AccountInfo, + undelegate_buffer_signer_seeds: &[Signer], + delegation_metadata: DelegationMetadata, + system_program: &AccountInfo, +) -> ProgramResult { + let delegated_account_lamports_before_close = delegated_account.lamports(); + close_pda(delegated_account, validator)?; + + // Invoke the owner program's post-undelegation IX, to give the state back to the original program + let validator_lamports_before_cpi = validator.lamports(); + + cpi_external_undelegate( + validator, + delegated_account, + undelegate_buffer_account, + undelegate_buffer_signer_seeds, + system_program, + owner_program.key(), + delegation_metadata, + )?; + + let validator_lamports_after_cpi = validator.lamports(); + + // Check that the validator lamports are exactly as expected + let delegated_account_min_rent = Rent::get()?.minimum_balance(delegated_account.data_len()); + if validator_lamports_before_cpi + != validator_lamports_after_cpi + .checked_add(delegated_account_min_rent) + .ok_or(DlpError::Overflow)? + { + return Err(DlpError::InvalidValidatorBalanceAfterCPI.into()); + } + + // Check that the owner program properly moved the state back into the original account during CPI + if delegated_account.try_borrow_data()?.as_ref() + != undelegate_buffer_account.try_borrow_data()?.as_ref() + { + return Err(DlpError::InvalidAccountDataAfterCPI.into()); + } + + // Return the extra lamports to the delegated account + let delegated_account_extra_lamports = delegated_account_lamports_before_close + .checked_sub(delegated_account_min_rent) + .ok_or(DlpError::Overflow)?; + + system::Transfer { + from: validator, + to: delegated_account, + lamports: delegated_account_extra_lamports, + } + .invoke()?; + Ok(()) +} + +/// CPI to the original owner program to re-open the PDA with the new state +fn cpi_external_undelegate( + payer: &AccountInfo, + delegated_account: &AccountInfo, + undelegate_buffer_account: &AccountInfo, + undelegate_buffer_signer_seeds: &[Signer], + system_program: &AccountInfo, + owner_program_id: &Pubkey, + delegation_metadata: DelegationMetadata, +) -> ProgramResult { + let data = { + // GAIN: 299 (42075 => 41776) + let mut data = Vec::with_capacity(32); + data.extend_from_slice(&EXTERNAL_UNDELEGATE_DISCRIMINATOR); + borsh::to_writer(&mut data, &delegation_metadata.seeds) + .map_err(|_| ProgramError::BorshIoError)?; + data + }; + + let external_undelegate_instruction = Instruction { + program_id: owner_program_id, + data: &data, + accounts: &[ + AccountMeta::new(delegated_account.key(), true, false), + AccountMeta::new(undelegate_buffer_account.key(), true, true), + AccountMeta::new(payer.key(), true, true), + AccountMeta::new(system_program.key(), false, false), + ], + }; + + invoke_signed( + &external_undelegate_instruction, + &[ + delegated_account, + undelegate_buffer_account, + payer, + system_program, + ], + undelegate_buffer_signer_seeds, + ) +} + +fn process_delegation_cleanup( + delegation_record_account: &AccountInfo, + delegation_metadata_account: &AccountInfo, + rent_reimbursement: &AccountInfo, + fees_vault: &AccountInfo, + validator_fees_vault: &AccountInfo, +) -> ProgramResult { + close_pda_with_fees( + delegation_record_account, + rent_reimbursement, + &[validator_fees_vault, fees_vault], + RENT_FEES_PERCENTAGE, + )?; + close_pda_with_fees( + delegation_metadata_account, + rent_reimbursement, + &[validator_fees_vault, fees_vault], + RENT_FEES_PERCENTAGE, + )?; + Ok(()) +} diff --git a/src/processor/fast/utils/pda.rs b/src/processor/fast/utils/pda.rs index 95da4f12..33abe23a 100644 --- a/src/processor/fast/utils/pda.rs +++ b/src/processor/fast/utils/pda.rs @@ -1,5 +1,6 @@ use pinocchio::account_info::AccountInfo; use pinocchio::instruction::Signer; +use pinocchio::program_error::ProgramError; use pinocchio::pubkey::Pubkey; use pinocchio::sysvars::rent::Rent; use pinocchio::sysvars::Sysvar; @@ -59,3 +60,78 @@ pub(crate) fn create_pda( .invoke_signed(pda_signers) } } + +/// Close PDA +#[inline(always)] +pub(crate) fn close_pda(target_account: &AccountInfo, destination: &AccountInfo) -> ProgramResult { + // Transfer tokens from the account to the destination. + unsafe { + *destination.borrow_mut_lamports_unchecked() = destination + .lamports() + .checked_add(target_account.lamports()) + .ok_or(ProgramError::ArithmeticOverflow)?; + + *target_account.borrow_mut_lamports_unchecked() = 0; + + target_account.assign(&pinocchio_system::ID); + } + + target_account.resize(0).map_err(Into::into) +} + +/// Close PDA with fees, distributing the fees to the specified addresses in sequence +/// The total fees are calculated as `fee_percentage` of the total lamports in the PDA +/// Each fee address receives fee_percentage % of the previous fee address's amount +pub(crate) fn close_pda_with_fees( + target_account: &AccountInfo, + destination: &AccountInfo, + fees_addresses: &[&AccountInfo], + fee_percentage: u8, +) -> ProgramResult { + if fees_addresses.is_empty() || fee_percentage > 100 { + return Err(ProgramError::InvalidArgument); + } + + let init_lamports = target_account.lamports(); + let total_fee_amount = target_account + .lamports() + .checked_mul(fee_percentage as u64) + .and_then(|v| v.checked_div(100)) + .ok_or(ProgramError::InsufficientFunds)?; + + let mut fees: Vec = vec![total_fee_amount; fees_addresses.len()]; + + let mut fee_amount = total_fee_amount; + for fee in fees.iter_mut().take(fees_addresses.len()).skip(1) { + fee_amount = fee_amount + .checked_mul(fee_percentage as u64) + .and_then(|v| v.checked_div(100)) + .ok_or(ProgramError::InsufficientFunds)?; + *fee = fee_amount; + } + + for i in 0..fees.len() - 1 { + fees[i] -= fees[i + 1]; + } + + for (i, &fee_address) in fees_addresses.iter().enumerate() { + unsafe { + *fee_address.borrow_mut_lamports_unchecked() = fee_address + .lamports() + .checked_add(fees[i]) + .ok_or(ProgramError::InsufficientFunds)?; + } + } + + unsafe { + *destination.borrow_mut_lamports_unchecked() = destination + .lamports() + .checked_add(init_lamports - total_fee_amount) + .ok_or(ProgramError::InsufficientFunds)?; + + *target_account.borrow_mut_lamports_unchecked() = 0; + + target_account.assign(&pinocchio_system::ID); + } + target_account.resize(0).map_err(Into::into) +} diff --git a/src/processor/fast/utils/requires.rs b/src/processor/fast/utils/requires.rs index 3eab1921..da002a26 100644 --- a/src/processor/fast/utils/requires.rs +++ b/src/processor/fast/utils/requires.rs @@ -1,8 +1,32 @@ use pinocchio::account_info::AccountInfo; use pinocchio::program_error::ProgramError; -use pinocchio::pubkey::{self, pubkey_eq, Pubkey}; +use pinocchio::pubkey::{pubkey_eq, Pubkey}; use pinocchio_log::log; +#[cfg(not(feature = "log-cost"))] +use pinocchio::pubkey; + +#[cfg(feature = "log-cost")] +mod pubkey { + pub use pinocchio::pubkey::log; + + use pinocchio::pubkey::{self, Pubkey}; + use pinocchio::syscalls::sol_remaining_compute_units; + use pinocchio_log::log; + + #[inline(always)] + pub fn find_program_address(seeds: &[&[u8]], program_id: &Pubkey) -> (Pubkey, u8) { + let prev = unsafe { sol_remaining_compute_units() }; + let rv = pubkey::find_program_address(seeds, program_id); + let curr = unsafe { sol_remaining_compute_units() }; + log!(">> find_program_address => {} CU", prev - curr); + rv + } +} + +use crate::error::DlpError; +use crate::pda::{self, validator_fees_vault_pda_from_validator}; + /// Errors if: /// - Account is not owned by expected program. #[inline(always)] @@ -122,6 +146,35 @@ pub fn require_uninitialized_pda( Ok(pda.1) } +/// Errors if: +/// - Address does not match PDA derived from provided seeds. +/// - Owner is not the expected program. +/// - Account is not writable if set to writable. +pub fn require_initialized_pda( + info: &AccountInfo, + seeds: &[&[u8]], + program_id: &Pubkey, + is_writable: bool, + label: &str, +) -> Result { + let pda = pubkey::find_program_address(seeds, program_id); + if !pubkey_eq(info.key(), &pda.0) { + log!("Invalid seeds for account: "); + pubkey::log(info.key()); + return Err(ProgramError::InvalidSeeds); + } + + require_owned_pda(info, program_id, label)?; + + if is_writable && !info.is_writable() { + log!("Account is not writable: "); + pubkey::log(info.key()); + return Err(ProgramError::InvalidAccountData); + } + + Ok(pda.1) +} + /// Errors if: /// - Address does not match the expected value. /// - Account is not executable. @@ -142,3 +195,79 @@ pub fn require_program(info: &AccountInfo, key: &Pubkey, label: &str) -> Result< Ok(()) } + +/// Load fee vault PDA +/// - Protocol fees vault PDA +pub fn require_initialized_protocol_fees_vault( + fees_vault: &AccountInfo, + is_writable: bool, +) -> Result<(), ProgramError> { + require_initialized_pda( + fees_vault, + &[b"fees-vault"], + &crate::fast::ID, + is_writable, + "protocol fees vault", + )?; + Ok(()) +} + +/// Load validator fee vault PDA +/// - Validator fees vault PDA must be derived from the validator pubkey +/// - Validator fees vault PDA must be initialized with the expected seeds and owner +pub fn require_initialized_validator_fees_vault( + validator: &AccountInfo, + validator_fees_vault: &AccountInfo, + is_writable: bool, +) -> Result<(), ProgramError> { + let pda = validator_fees_vault_pda_from_validator(&(*validator.key()).into()); + if !pubkey_eq(validator_fees_vault.key(), pda.as_array()) { + log!("Invalid validator fees vault PDA, expected: "); + pubkey::log(pda.as_array()); + log!("but got: "); + pubkey::log(validator_fees_vault.key()); + return Err(DlpError::InvalidAuthority.into()); + } + require_initialized_pda( + validator_fees_vault, + &[pda::VALIDATOR_FEES_VAULT_TAG, validator.key()], + &crate::fast::ID, + is_writable, + "validator fees vault", + )?; + Ok(()) +} + +/// Load initialized delegation record +/// - Delegation record must be derived from the delegated account +pub fn require_initialized_delegation_record( + delegated_account: &AccountInfo, + delegation_record: &AccountInfo, + is_writable: bool, +) -> Result<(), ProgramError> { + require_initialized_pda( + delegation_record, + &[pda::DELEGATION_RECORD_TAG, delegated_account.key()], + &crate::fast::ID, + is_writable, + "delegation record", + )?; + Ok(()) +} + +/// Load initialized delegation metadata +/// - Delegation metadata must be derived from the delegated account +pub fn require_initialized_delegation_metadata( + delegated_account: &AccountInfo, + delegation_metadata: &AccountInfo, + is_writable: bool, +) -> Result<(), ProgramError> { + require_initialized_pda( + delegation_metadata, + &[pda::DELEGATION_METADATA_TAG, delegated_account.key()], + &crate::fast::ID, + is_writable, + "delegation metadata", + )?; + Ok(()) +} diff --git a/tests/integration/tests/test-delegation.ts b/tests/integration/tests/test-delegation.ts index 5143693e..e149a301 100644 --- a/tests/integration/tests/test-delegation.ts +++ b/tests/integration/tests/test-delegation.ts @@ -270,6 +270,19 @@ describe("TestDelegation", () => { ); const txId = await processInstruction(ix); console.log("Undelegate signature", txId); + + const tx = await fetchTransaction(txId); + console.log(tx.meta.logMessages); + + const consumedLog = tx.meta.logMessages.find((m) => + m.includes("DELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh consumed") + ); + + assert.isAtMost( + parseInt(consumedLog.split(" ").at(3)), + 45000, + "undelegate instruction must consume less than 18500" + ); }); it("Whitelist a validator for a program", async () => {