diff --git a/src/args/call_handler.rs b/src/args/call_handler.rs new file mode 100644 index 00000000..2c9c03ed --- /dev/null +++ b/src/args/call_handler.rs @@ -0,0 +1,15 @@ +use borsh::{BorshDeserialize, BorshSerialize}; + +#[derive(BorshSerialize, BorshDeserialize, Clone, Copy)] +pub enum Context { + Commit, + Undelegate, + Standalone, +} + +#[derive(BorshSerialize, BorshDeserialize)] +pub struct CallHandlerArgs { + pub escrow_index: u8, + pub data: Vec, + pub context: Context, +} diff --git a/src/args/commit_state.rs b/src/args/commit_state.rs index a10f0e27..00bf914c 100644 --- a/src/args/commit_state.rs +++ b/src/args/commit_state.rs @@ -2,8 +2,9 @@ use borsh::{BorshDeserialize, BorshSerialize}; #[derive(Default, Debug, BorshSerialize, BorshDeserialize)] pub struct CommitStateArgs { - /// The ephemeral slot at which the account data is committed - pub slot: u64, + /// "Nonce" of an account. Updates are submitted historically and nonce incremented by 1 + /// Deprecated: The ephemeral slot at which the account data is committed + pub nonce: u64, /// The lamports that the account holds in the ephemeral validator pub lamports: u64, /// Whether the account can be undelegated after the commit completes @@ -14,8 +15,9 @@ pub struct CommitStateArgs { #[derive(Default, Debug, BorshSerialize, BorshDeserialize)] pub struct CommitStateFromBufferArgs { - /// The ephemeral slot at which the account data is committed - pub slot: u64, + /// "Nonce" of an account. Updates are submitted historically and nonce incremented by 1 + /// Deprecated: The ephemeral slot at which the account data is committed + pub nonce: u64, /// The lamports that the account holds in the ephemeral validator pub lamports: u64, /// Whether the account can be undelegated after the commit completes diff --git a/src/args/mod.rs b/src/args/mod.rs index 0d131795..456d44d7 100644 --- a/src/args/mod.rs +++ b/src/args/mod.rs @@ -1,3 +1,4 @@ +mod call_handler; mod commit_state; mod delegate; mod delegate_ephemeral_balance; @@ -5,6 +6,7 @@ mod top_up_ephemeral_balance; mod validator_claim_fees; mod whitelist_validator_for_program; +pub use call_handler::*; pub use commit_state::*; pub use delegate::*; pub use delegate_ephemeral_balance::*; diff --git a/src/consts.rs b/src/consts.rs index a60e6402..c8bdca41 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -9,5 +9,9 @@ pub const PROTOCOL_FEES_PERCENTAGE: u8 = 10; /// The discriminator for the external undelegate instruction. pub const EXTERNAL_UNDELEGATE_DISCRIMINATOR: [u8; 8] = [196, 28, 41, 206, 48, 37, 51, 167]; +/// The discriminator for the external hook after finalization is complete +/// For anchor: corresponds to function/instruction name delegation_program_call_handler +pub const EXTERNAL_CALL_HANDLER_DISCRIMINATOR: [u8; 8] = [157, 197, 228, 30, 0, 80, 121, 135]; + /// The program ID of the delegation program. pub const DELEGATION_PROGRAM_ID: Pubkey = crate::id(); diff --git a/src/discriminator.rs b/src/discriminator.rs index e896fa2f..08b14b09 100644 --- a/src/discriminator.rs +++ b/src/discriminator.rs @@ -33,6 +33,8 @@ pub enum DlpDiscriminator { CommitStateFromBuffer = 13, /// See [crate::processor::process_close_validator_fees_vault] for docs. CloseValidatorFeesVault = 14, + /// See [crate::processor::process_call_handler] for docs. + CallHandler = 15, } impl DlpDiscriminator { @@ -60,6 +62,7 @@ impl TryFrom<[u8; 8]> for DlpDiscriminator { 0xc => Ok(DlpDiscriminator::ProtocolClaimFees), 0xd => Ok(DlpDiscriminator::CommitStateFromBuffer), 0xe => Ok(DlpDiscriminator::CloseValidatorFeesVault), + 0xf => Ok(DlpDiscriminator::CallHandler), _ => Err(ProgramError::InvalidInstructionData), } } diff --git a/src/error.rs b/src/error.rs index f0b69ee3..bd62497f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -29,7 +29,7 @@ pub enum DlpError { InvalidWhitelistProgramConfig = 10, #[error("Account already undelegated")] AlreadyUndelegated = 11, - #[error("Committed state slot is outdated")] + #[error("Commit is out of order")] OutdatedSlot = 12, #[error("Computation overflow detected")] Overflow = 13, diff --git a/src/instruction_builder/call_handler.rs b/src/instruction_builder/call_handler.rs new file mode 100644 index 00000000..6f895486 --- /dev/null +++ b/src/instruction_builder/call_handler.rs @@ -0,0 +1,40 @@ +use crate::args::CallHandlerArgs; +use crate::discriminator::DlpDiscriminator; +use crate::pda::{ephemeral_balance_pda_from_payer, validator_fees_vault_pda_from_validator}; +use borsh::to_vec; +use solana_program::instruction::Instruction; +use solana_program::{instruction::AccountMeta, pubkey::Pubkey}; + +/// Builds a call handler instruction. +/// See [crate::processor::call_handler] for docs. +pub fn call_handler( + validator: Pubkey, + destination_program: Pubkey, + escrow_authority: Pubkey, + other_accounts: Vec, + args: CallHandlerArgs, +) -> Instruction { + let validator_fees_vault_pda = validator_fees_vault_pda_from_validator(&validator); + + // handler accounts + let escrow_account = ephemeral_balance_pda_from_payer(&escrow_authority, args.escrow_index); + let mut accounts = vec![ + AccountMeta::new(validator, true), + AccountMeta::new(validator_fees_vault_pda, false), + AccountMeta::new_readonly(destination_program, false), + AccountMeta::new(escrow_authority, false), + AccountMeta::new(escrow_account, false), + ]; + // append other accounts at the end + accounts.extend(other_accounts); + + Instruction { + program_id: crate::id(), + accounts, + data: [ + DlpDiscriminator::CallHandler.to_vec(), + to_vec(&args).unwrap(), + ] + .concat(), + } +} diff --git a/src/instruction_builder/mod.rs b/src/instruction_builder/mod.rs index e0cae79b..139f52de 100644 --- a/src/instruction_builder/mod.rs +++ b/src/instruction_builder/mod.rs @@ -1,7 +1,7 @@ +mod call_handler; mod close_ephemeral_balance; -mod commit_state; - mod close_validator_fees_vault; +mod commit_state; mod commit_state_from_buffer; mod delegate; mod delegate_ephemeral_balance; @@ -14,6 +14,7 @@ mod undelegate; mod validator_claim_fees; mod whitelist_validator_for_program; +pub use call_handler::*; pub use close_ephemeral_balance::*; pub use close_validator_fees_vault::*; pub use commit_state::*; diff --git a/src/lib.rs b/src/lib.rs index 4b1eca80..946aef92 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ #![allow(unexpected_cfgs)] // silence clippy for target_os solana and other solana program custom features +use crate::processor::process_call_handler; use solana_program::{ account_info::AccountInfo, declare_id, entrypoint::ProgramResult, msg, program_error::ProgramError, pubkey::Pubkey, @@ -93,6 +94,9 @@ pub fn process_instruction( discriminator::DlpDiscriminator::CloseValidatorFeesVault => { processor::process_close_validator_fees_vault(program_id, accounts, data)? } + discriminator::DlpDiscriminator::CallHandler => { + process_call_handler(program_id, accounts, data)? + } } Ok(()) } diff --git a/src/processor/call_handler.rs b/src/processor/call_handler.rs new file mode 100644 index 00000000..a2beb537 --- /dev/null +++ b/src/processor/call_handler.rs @@ -0,0 +1,126 @@ +use crate::args::CallHandlerArgs; +use crate::consts::EXTERNAL_CALL_HANDLER_DISCRIMINATOR; +use crate::ephemeral_balance_seeds_from_payer; +use crate::processor::utils::loaders::{ + load_initialized_validator_fees_vault, load_owned_pda, load_pda, load_signer, +}; + +use borsh::BorshDeserialize; +use solana_program::account_info::AccountInfo; +use solana_program::entrypoint::ProgramResult; +use solana_program::instruction::{AccountMeta, Instruction}; +use solana_program::program::invoke_signed; +use solana_program::program_error::ProgramError; +use solana_program::pubkey::Pubkey; +use solana_program::{msg, system_program}; + +pub const INVALID_ESCROW_PDA: &str = "invalid escrow pda in CallHandler"; +pub const INVALID_ESCROW_OWNER: &str = "escrow can not be delegated in CallHandler"; + +/// Calls a handler on user specified program +/// +/// Accounts: +/// 0: `[signer]` validator +/// 1: `[]` validator fee vault to verify its registration +/// 2: `[]` destination program of an action +/// 3: `[]` escrow authority account which created escrow account +/// 4: `[writable]` non delegated escrow pda created from 3 +/// 5: `[readonly/writable]` other accounts needed for action +/// 6: `[readonly/writable]` other accounts needed for action +/// 7: ... +/// +/// Requirements: +/// +/// - escrow account initialized +/// - escrow account not delegated +/// - validator as a caller +/// +/// Steps: +/// 1. Verify that signer is a valid registered validator +/// 2. Verify escrow pda exists and not delegated +/// 3. Invoke signed on behalf of escrow pda user specified action +/// +/// Usage: +/// +/// This instruction is meant to be called via CPI with the owning program signing for the +/// delegated account. +pub fn process_call_handler( + _program_id: &Pubkey, + accounts: &[AccountInfo], + data: &[u8], +) -> ProgramResult { + const OTHER_ACCOUNTS_OFFSET: usize = 5; + + if accounts.len() < OTHER_ACCOUNTS_OFFSET { + return Err(ProgramError::NotEnoughAccountKeys); + } + + let ( + [validator, validator_fees_vault, destination_program, escrow_authority_account, escrow_account], + other_accounts, + ) = accounts.split_at(OTHER_ACCOUNTS_OFFSET) + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + + let args = CallHandlerArgs::try_from_slice(data)?; + + // verify account is a signer + load_signer(validator, "validator")?; + // verify signer is a registered validator + load_initialized_validator_fees_vault(validator, validator_fees_vault, true)?; + // Check if destination program is executable + if !destination_program.executable { + msg!( + "{} program is not executable: destination program", + destination_program.key + ); + return Err(ProgramError::InvalidAccountData); + } + + // verify passed escrow_account derived from escrow authority + let escrow_seeds: &[&[u8]] = + ephemeral_balance_seeds_from_payer!(escrow_authority_account.key, args.escrow_index); + let escrow_bump = load_pda( + escrow_account, + escrow_seeds, + &crate::id(), + true, + INVALID_ESCROW_PDA, + )?; + load_owned_pda(escrow_account, &system_program::id(), INVALID_ESCROW_OWNER)?; + + // deduce necessary accounts for CPI + let (accounts_meta, handler_accounts): (Vec, Vec) = + [escrow_authority_account, escrow_account] + .into_iter() + .chain(other_accounts) + .filter(|account| account.key != validator.key) + .map(|account| { + ( + // We enable only escrow to be a signer + AccountMeta { + pubkey: *account.key, + is_writable: account.is_writable, + is_signer: account.key == escrow_account.key, + }, + account.clone(), + ) + }) + .collect(); + + let data = [EXTERNAL_CALL_HANDLER_DISCRIMINATOR.to_vec(), data.to_vec()].concat(); + let handler_instruction = Instruction { + program_id: *destination_program.key, + data, + accounts: accounts_meta, + }; + let bump_slice = &[escrow_bump]; + let escrow_signer_seeds = [escrow_seeds, &[bump_slice]].concat(); + + invoke_signed( + &handler_instruction, + &handler_accounts, + &[&escrow_signer_seeds], + ) +} diff --git a/src/processor/commit_state.rs b/src/processor/commit_state.rs index e20fd4b2..252ac5fe 100644 --- a/src/processor/commit_state.rs +++ b/src/processor/commit_state.rs @@ -56,7 +56,7 @@ pub fn process_commit_state( let commit_state_bytes: &[u8] = args.data.as_ref(); let commit_record_lamports = args.lamports; - let commit_record_slot = args.slot; + let commit_record_nonce = args.nonce; let allow_undelegation = args.allow_undelegation; let [validator, delegated_account, commit_state_account, commit_record_account, delegation_record_account, delegation_metadata_account, validator_fees_vault, program_config_account, system_program] = @@ -68,7 +68,7 @@ pub fn process_commit_state( let commit_args = CommitStateInternalArgs { commit_state_bytes, commit_record_lamports, - commit_record_slot, + commit_record_nonce, allow_undelegation, validator, delegated_account, @@ -88,7 +88,7 @@ pub fn process_commit_state( pub(crate) struct CommitStateInternalArgs<'a, 'info> { pub(crate) commit_state_bytes: &'a [u8], pub(crate) commit_record_lamports: u64, - pub(crate) commit_record_slot: u64, + pub(crate) commit_record_nonce: u64, pub(crate) allow_undelegation: bool, pub(crate) validator: &'a AccountInfo<'info>, pub(crate) delegated_account: &'a AccountInfo<'info>, @@ -126,17 +126,14 @@ pub(crate) fn process_commit_state_internal( let mut delegation_metadata = DelegationMetadata::try_from_bytes_with_discriminator(&delegation_metadata_data)?; - // If the commit slot is greater or equal than the last update slot, we can proceed. - // If the slot is less, we simply do not commit. - // Since commit instructions are typically bundled, we return without error - // so that correct commits are executed. - if args.commit_record_slot < delegation_metadata.last_update_external_slot { + // To preserve correct history of account updates we require sequential commits + if args.commit_record_nonce != delegation_metadata.last_update_nonce + 1 { msg!( - "Slot {} is outdated, previous slot is {}. Skipping commit", - args.commit_record_slot, - delegation_metadata.last_update_external_slot + "Slot {} is outdated, previous slot is {}. Rejecting commit", + args.commit_record_nonce, + delegation_metadata.last_update_nonce ); - return Ok(()); + return Err(DlpError::OutdatedSlot.into()); } // Once the account is marked as undelegatable, any subsequent commit should fail @@ -250,7 +247,7 @@ pub(crate) fn process_commit_state_internal( let commit_record = CommitRecord { identity: *args.validator.key, account: *args.delegated_account.key, - slot: args.commit_record_slot, + nonce: args.commit_record_nonce, lamports: args.commit_record_lamports, }; let mut commit_record_data = args.commit_record_account.try_borrow_mut_data()?; diff --git a/src/processor/commit_state_from_buffer.rs b/src/processor/commit_state_from_buffer.rs index 2f05f3d1..6b328c0d 100644 --- a/src/processor/commit_state_from_buffer.rs +++ b/src/processor/commit_state_from_buffer.rs @@ -46,7 +46,7 @@ pub fn process_commit_state_from_buffer( let args = CommitStateFromBufferArgs::try_from_slice(data)?; let commit_record_lamports = args.lamports; - let commit_record_slot = args.slot; + let commit_record_nonce = args.nonce; let allow_undelegation = args.allow_undelegation; let [validator, delegated_account, commit_state_account, commit_record_account, delegation_record_account, delegation_metadata_account, state_buffer_account, validator_fees_vault, program_config_account, system_program] = @@ -60,7 +60,7 @@ pub fn process_commit_state_from_buffer( let commit_args = CommitStateInternalArgs { commit_state_bytes, commit_record_lamports, - commit_record_slot, + commit_record_nonce, allow_undelegation, validator, delegated_account, diff --git a/src/processor/delegate.rs b/src/processor/delegate.rs index 444f632a..7d9c10fa 100644 --- a/src/processor/delegate.rs +++ b/src/processor/delegate.rs @@ -145,7 +145,7 @@ pub fn process_delegate( let mut delegation_metadata_bytes = vec![]; let delegation_metadata = DelegationMetadata { seeds: args.seeds, - last_update_external_slot: 0, + last_update_nonce: 0, is_undelegatable: false, rent_payer: *payer.key, }; diff --git a/src/processor/finalize.rs b/src/processor/finalize.rs index ccc88277..0f5808e8 100644 --- a/src/processor/finalize.rs +++ b/src/processor/finalize.rs @@ -111,7 +111,7 @@ pub fn process_finalize( )?; // Update the delegation metadata - delegation_metadata.last_update_external_slot = commit_record.slot; + delegation_metadata.last_update_nonce = commit_record.nonce; delegation_metadata.to_bytes_with_discriminator(&mut delegation_metadata_data.as_mut())?; // Update the delegation record diff --git a/src/processor/mod.rs b/src/processor/mod.rs index e9b25db0..9c4f9155 100644 --- a/src/processor/mod.rs +++ b/src/processor/mod.rs @@ -1,3 +1,4 @@ +mod call_handler; mod close_ephemeral_balance; mod close_validator_fees_vault; mod commit_state; @@ -14,6 +15,7 @@ mod utils; mod validator_claim_fees; mod whitelist_validator_for_program; +pub use call_handler::*; pub use close_ephemeral_balance::*; pub use close_validator_fees_vault::*; pub use commit_state::*; diff --git a/src/processor/utils/loaders.rs b/src/processor/utils/loaders.rs index 980d393c..ade1e1d9 100644 --- a/src/processor/utils/loaders.rs +++ b/src/processor/utils/loaders.rs @@ -287,9 +287,10 @@ pub fn load_program_config( let pda = program_config_from_program_id(&program); if !pda.eq(program_config.key) { msg!( - "Invalid program config PDA, expected {} but got {}", + "Invalid program config PDA, expected {} but got {}. program: {}", pda, - program_config.key + program_config.key, + program ); return Err(InvalidAuthority.into()); } diff --git a/src/state/commit_record.rs b/src/state/commit_record.rs index b0bc5fbd..7bd5ef77 100644 --- a/src/state/commit_record.rs +++ b/src/state/commit_record.rs @@ -19,8 +19,8 @@ pub struct CommitRecord { /// The account for which the state is committed pub account: Pubkey, - /// The external slot of the commit. This is used to enforce sequential commits - pub slot: u64, + /// The external nonce of the commit. This is used to enforce sequential commits + pub nonce: u64, /// The account committed lamports pub lamports: u64, diff --git a/src/state/delegation_metadata.rs b/src/state/delegation_metadata.rs index 139c3e10..669fc774 100644 --- a/src/state/delegation_metadata.rs +++ b/src/state/delegation_metadata.rs @@ -9,8 +9,9 @@ use super::discriminator::{AccountDiscriminator, AccountWithDiscriminator}; /// * Everything necessary at cloning time is instead stored in the delegation record. #[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq)] pub struct DelegationMetadata { - /// The last slot at which the delegation was updated - pub last_update_external_slot: u64, + /// 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, /// The seeds of the account, used to reopen it on undelegation @@ -45,7 +46,7 @@ mod tests { ], ], is_undelegatable: false, - last_update_external_slot: 0, + last_update_nonce: 0, rent_payer: Pubkey::default(), }; diff --git a/tests/buffers/test_delegation.so b/tests/buffers/test_delegation.so index 6a76628d..4bbbeb3d 100755 Binary files a/tests/buffers/test_delegation.so and b/tests/buffers/test_delegation.so differ diff --git a/tests/fixtures/accounts.rs b/tests/fixtures/accounts.rs index 077d1043..6a4c8d68 100644 --- a/tests/fixtures/accounts.rs +++ b/tests/fixtures/accounts.rs @@ -110,7 +110,7 @@ pub fn create_delegation_metadata_data( is_undelegatable: bool, ) -> Vec { let delegation_metadata = DelegationMetadata { - last_update_external_slot: DEFAULT_LAST_UPDATE_EXTERNAL_SLOT, + last_update_nonce: DEFAULT_LAST_UPDATE_EXTERNAL_SLOT, is_undelegatable, seeds: seeds.iter().map(|s| s.to_vec()).collect(), rent_payer, @@ -125,7 +125,7 @@ pub fn create_delegation_metadata_data( #[allow(dead_code)] pub fn get_commit_record_account_data(authority: Pubkey) -> Vec { let commit_record = CommitRecord { - slot: 100, + nonce: 100, identity: authority, account: DELEGATED_PDA_ID, lamports: LAMPORTS_PER_SOL, diff --git a/tests/integration/programs/test-delegation/src/lib.rs b/tests/integration/programs/test-delegation/src/lib.rs index 5dbe30bb..45467f0e 100644 --- a/tests/integration/programs/test-delegation/src/lib.rs +++ b/tests/integration/programs/test-delegation/src/lib.rs @@ -1,6 +1,7 @@ use anchor_lang::prelude::*; use ephemeral_rollups_sdk::anchor::{delegate, ephemeral}; use ephemeral_rollups_sdk::cpi::DelegateConfig; +use ephemeral_rollups_sdk::pda::ephemeral_balance_pda_from_payer; declare_id!("3vAK9JQiDsKoQNwmcfeEng4Cnv22pYuj1ASfso7U4ukF"); @@ -10,6 +11,7 @@ pub const TEST_PDA_SEED_OTHER: &[u8] = b"test-pda-other"; #[ephemeral] #[program] pub mod test_delegation { + use anchor_lang::system_program::{transfer, Transfer}; use super::*; pub fn initialize(ctx: Context) -> Result<()> { @@ -54,6 +56,88 @@ pub mod test_delegation { )?; Ok(()) } + + /// Delegation program call handler + pub fn delegation_program_call_handler( + ctx: Context, + hook_args: delegation_program_utils::CallHandlerArgs, + ) -> Result<()> { + let expected = ephemeral_balance_pda_from_payer( + ctx.accounts.escrow_authority.key, + hook_args.escrow_index, + ); + if &expected != ctx.accounts.escrow_account.key { + Err(ProgramError::InvalidAccountData) + } else { + Ok(()) + }?; + + if !ctx.accounts.escrow_account.is_signer { + Err(ProgramError::MissingRequiredSignature) + } else { + Ok(()) + }?; + + match hook_args.context { + delegation_program_utils::Context::Commit => { + msg!("commit context"); + let amount = u64::try_from_slice(&hook_args.data)?; + let transfer_ctx = CpiContext::new( + ctx.accounts.system_program.to_account_info(), + Transfer { + from: ctx.accounts.escrow_account.to_account_info(), + to: ctx.accounts.destination_account.to_account_info(), + }, + ); + transfer(transfer_ctx, amount)?; + }, + delegation_program_utils::Context::Undelegate => { + msg!("undelegate context"); + let amount = u64::try_from_slice(&hook_args.data)?; + let transfer_ctx = CpiContext::new( + ctx.accounts.system_program.to_account_info(), + Transfer { + from: ctx.accounts.escrow_account.to_account_info(), + to: ctx.accounts.destination_account.to_account_info(), + }, + ); + transfer(transfer_ctx, amount)?; + + let counter_data = &mut ctx.accounts.counter.try_borrow_mut_data()?; + let mut counter = Counter::try_from_slice(&counter_data)?; + counter.count += 1; + + counter_data.copy_from_slice(&counter.try_to_vec()?); + } + delegation_program_utils::Context::Standalone => msg!("standalone context"), + } + + Ok(()) + } +} + +pub fn transfer_from_undelegated( + undelegated_pda: &UncheckedAccount, + destination_pda: &AccountInfo, + amount: u64, +) -> Result<()> { + if undelegated_pda.owner != &ID { + return Err(ProgramError::IllegalOwner.into()); + } + + **undelegated_pda + .try_borrow_mut_lamports()? = undelegated_pda + .lamports() + .checked_sub(amount) + .ok_or(ProgramError::InsufficientFunds)?; + + **destination_pda + .try_borrow_mut_lamports()? = destination_pda + .lamports() + .checked_add(amount) + .ok_or(ProgramError::ArithmeticOverflow)?; + + Ok(()) } #[delegate] @@ -101,7 +185,45 @@ pub struct Increment<'info> { pub counter: Account<'info, Counter>, } +#[derive(Accounts)] +#[instruction(hook_args: delegation_program_utils::CallHandlerArgs)] +pub struct DelegationProgramCallHandler<'info> { + pub escrow_authority: UncheckedAccount<'info>, + #[account( + mut, + seeds = [b"balance", &escrow_authority.key().as_ref(), &[hook_args.escrow_index]], + seeds::program = delegation_program_utils::ID, + bump + )] + pub escrow_account: Signer<'info>, + #[account(mut)] + pub destination_account: AccountInfo<'info>, + // CHECK: fails in finalize stage due to ownership by dlp + pub counter: UncheckedAccount<'info>, + pub system_program: Program<'info, System>, +} + #[account] pub struct Counter { pub count: u64, } + +mod delegation_program_utils { + use anchor_lang::prelude::*; + + declare_id!("DELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh"); + + #[derive(AnchorSerialize, AnchorDeserialize)] + pub enum Context { + Commit, + Undelegate, + Standalone, + } + + #[derive(AnchorSerialize, AnchorDeserialize)] + pub struct CallHandlerArgs { + pub escrow_index: u8, + pub data: Vec, + pub context: Context, + } +} diff --git a/tests/test_call_handler.rs b/tests/test_call_handler.rs new file mode 100644 index 00000000..5b92652e --- /dev/null +++ b/tests/test_call_handler.rs @@ -0,0 +1,473 @@ +use crate::fixtures::{ + create_delegation_metadata_data, create_delegation_record_data, get_commit_record_account_data, + get_delegation_metadata_data, get_delegation_record_data, DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, TEST_AUTHORITY, +}; +use borsh::{to_vec, BorshDeserialize, BorshSerialize}; +use dlp::args::CallHandlerArgs; +use dlp::ephemeral_balance_seeds_from_payer; +use dlp::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, + ephemeral_balance_pda_from_payer, fees_vault_pda, validator_fees_vault_pda_from_validator, +}; +use solana_program::instruction::AccountMeta; +use solana_program::rent::Rent; +use solana_program::{hash::Hash, native_token::LAMPORTS_PER_SOL, system_program}; +use solana_program_test::{processor, read_file, BanksClient, ProgramTest}; +use solana_sdk::pubkey::Pubkey; +use solana_sdk::{ + account::Account, + signature::{Keypair, Signer}, + transaction::Transaction, +}; + +mod fixtures; + +// Mimic counter from test_delegation program +#[derive(BorshSerialize, BorshDeserialize)] +pub struct Counter { + pub count: u64, +} + +async fn setup_delegated_pda(program_test: &mut ProgramTest, authority_pubkey: &Pubkey) { + let state = to_vec(&Counter { count: 100 }).unwrap(); + // Setup a delegated PDA + program_test.add_account( + DELEGATED_PDA_ID, + Account { + lamports: LAMPORTS_PER_SOL, + data: state, + owner: dlp::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup the delegation record PDA + let delegation_record_data = get_delegation_record_data(*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.clone(), + owner: dlp::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup the delegated account metadata PDA + let delegation_metadata_data = get_delegation_metadata_data(*authority_pubkey, Some(true)); + 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::id(), + executable: false, + rent_epoch: 0, + }, + ); +} + +async fn setup_commit_state(program_test: &mut ProgramTest, authority_pubkey: &Pubkey) { + // Setup the commit state PDA + let commit_state = to_vec(&Counter { count: 101 }).unwrap(); + program_test.add_account( + commit_state_pda_from_delegated_account(&DELEGATED_PDA_ID), + Account { + lamports: LAMPORTS_PER_SOL, + data: commit_state, + owner: dlp::id(), + executable: false, + rent_epoch: 0, + }, + ); + + let commit_record_data = get_commit_record_account_data(*authority_pubkey); + 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::id(), + executable: false, + rent_epoch: 0, + }, + ); +} + +async fn setup_invalid_escrow_account(program_test: &mut ProgramTest, authority_pubkey: &Pubkey) { + let ephemeral_balance_pda = ephemeral_balance_pda_from_payer(&DELEGATED_PDA_ID, 0); + + // Setup the delegated account PDA + program_test.add_account( + ephemeral_balance_pda, + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: dlp::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup the delegated record PDA + let delegation_record_data = + create_delegation_record_data(*authority_pubkey, dlp::id(), Some(LAMPORTS_PER_SOL)); + program_test.add_account( + delegation_record_pda_from_delegated_account(&ephemeral_balance_pda), + Account { + lamports: Rent::default().minimum_balance(delegation_record_data.len()), + data: delegation_record_data, + owner: dlp::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup the delegated account metadata PDA + let delegation_metadata_data = create_delegation_metadata_data( + *authority_pubkey, + ephemeral_balance_seeds_from_payer!(DELEGATED_PDA_ID, 0), + true, + ); + program_test.add_account( + delegation_metadata_pda_from_delegated_account(&ephemeral_balance_pda), + Account { + lamports: Rent::default().minimum_balance(delegation_metadata_data.len()), + data: delegation_metadata_data, + owner: dlp::id(), + executable: false, + rent_epoch: 0, + }, + ); +} + +async fn setup_delegated_ephemeral_balance( + program_test: &mut ProgramTest, + validator: &Keypair, + payer: &Keypair, +) { + let ephemeral_balance_pda = ephemeral_balance_pda_from_payer(&payer.pubkey(), 1); + + // Setup the delegated account PDA + program_test.add_account( + ephemeral_balance_pda, + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: dlp::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup the delegated record PDA + let delegation_record_data = create_delegation_record_data( + validator.pubkey(), + system_program::id(), + Some(LAMPORTS_PER_SOL), + ); + program_test.add_account( + delegation_record_pda_from_delegated_account(&ephemeral_balance_pda), + Account { + lamports: Rent::default().minimum_balance(delegation_record_data.len()), + data: delegation_record_data, + owner: dlp::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup the delegated account metadata PDA + let delegation_metadata_data = create_delegation_metadata_data( + validator.pubkey(), + ephemeral_balance_seeds_from_payer!(payer.pubkey(), 0), + true, + ); + program_test.add_account( + delegation_metadata_pda_from_delegated_account(&ephemeral_balance_pda), + Account { + lamports: Rent::default().minimum_balance(delegation_metadata_data.len()), + data: delegation_metadata_data, + owner: dlp::id(), + executable: false, + rent_epoch: 0, + }, + ); +} + +async fn setup_ephemeral_balance(program_test: &mut ProgramTest, payer: &Keypair) { + let ephemeral_balance_pda = ephemeral_balance_pda_from_payer(&payer.pubkey(), 2); + + // Setup the delegated account PDA + program_test.add_account( + ephemeral_balance_pda, + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); +} + +async fn setup_program_test_env() -> (BanksClient, Keypair, Keypair, Hash) { + let mut program_test = ProgramTest::new("dlp", dlp::ID, processor!(dlp::process_instruction)); + program_test.prefer_bpf(true); + + let payer = Keypair::new(); + let validator = Keypair::from_bytes(&TEST_AUTHORITY).unwrap(); + + // Setup authority + program_test.add_account( + validator.pubkey(), + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup necessary accounts + setup_delegated_pda(&mut program_test, &validator.pubkey()).await; + setup_commit_state(&mut program_test, &validator.pubkey()).await; + setup_invalid_escrow_account(&mut program_test, &validator.pubkey()).await; + setup_delegated_ephemeral_balance(&mut program_test, &validator, &payer).await; + setup_ephemeral_balance(&mut program_test, &payer).await; + + // Setup the protocol fees vault + program_test.add_account( + fees_vault_pda(), + Account { + lamports: Rent::default().minimum_balance(0), + data: vec![], + owner: dlp::id(), + executable: false, + rent_epoch: 0, + }, + ); + // Setup the validator fees vault + program_test.add_account( + validator_fees_vault_pda_from_validator(&validator.pubkey()), + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: dlp::id(), + executable: false, + rent_epoch: 0, + }, + ); + + // Setup program to test delegation + 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()).max(1), + data, + owner: solana_sdk::bpf_loader::id(), + executable: true, + rent_epoch: 0, + }, + ); + + let (banks, _, blockhash) = program_test.start().await; + (banks, payer, validator, blockhash) +} + +/// Test call_handler in finalize context +#[tokio::test] +async fn test_finalize_call_handler() { + const PRIZE: u64 = LAMPORTS_PER_SOL / 1000; + + let (banks, payer, validator, blockhash) = setup_program_test_env().await; + + let transfer_destination = Keypair::new(); + let finalize_ix = dlp::instruction_builder::finalize(validator.pubkey(), DELEGATED_PDA_ID); + let call_handler_ix = dlp::instruction_builder::call_handler( + validator.pubkey(), + DELEGATED_PDA_OWNER_ID, // destination program + payer.pubkey(), // escrow authority + vec![ + AccountMeta::new(transfer_destination.pubkey(), false), + AccountMeta::new(DELEGATED_PDA_ID, false), + AccountMeta::new_readonly(system_program::id(), false), + ], + CallHandlerArgs { + escrow_index: 2, // undelegated escrow index, + data: to_vec(&PRIZE).unwrap(), + context: dlp::args::Context::Commit, + }, + ); + + let tx = Transaction::new_signed_with_payer( + &[finalize_ix, call_handler_ix], + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res.is_ok()); + + // Prize transferred + let transfer_destination = banks + .get_account(transfer_destination.pubkey()) + .await + .unwrap() + .unwrap(); + assert_eq!(transfer_destination.lamports, PRIZE); +} + +/// Test call_handler in finalize context +#[tokio::test] +async fn test_undelegate_call_handler() { + const PRIZE: u64 = LAMPORTS_PER_SOL / 1000; + + let (banks, payer, validator, blockhash) = setup_program_test_env().await; + + let transfer_destination = Keypair::new(); + let finalize_ix = dlp::instruction_builder::finalize(validator.pubkey(), DELEGATED_PDA_ID); + let undelegate_ix = dlp::instruction_builder::undelegate( + validator.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + validator.pubkey(), + ); + let call_handler_ix = dlp::instruction_builder::call_handler( + validator.pubkey(), + DELEGATED_PDA_OWNER_ID, // destination program + payer.pubkey(), // escrow authority + vec![ + AccountMeta::new(transfer_destination.pubkey(), false), + AccountMeta::new(DELEGATED_PDA_ID, false), + AccountMeta::new_readonly(system_program::id(), false), + ], + CallHandlerArgs { + escrow_index: 2, // undelegated escrow index, + data: to_vec(&PRIZE).unwrap(), + context: dlp::args::Context::Undelegate, + }, + ); + + let tx = Transaction::new_signed_with_payer( + &[finalize_ix, undelegate_ix, call_handler_ix], + Some(&validator.pubkey()), + &[&validator], + blockhash, + ); + + let counter_before = banks.get_account(DELEGATED_PDA_ID).await.unwrap().unwrap(); + println!("counter before: {:?}", counter_before.data); + let counter_before = Counter::try_from_slice(&counter_before.data).unwrap(); + println!("counter before: {}", counter_before.count); + let res = banks.process_transaction(tx).await; + assert!(res.is_ok()); + + // Prize transferred + let transfer_destination = banks + .get_account(transfer_destination.pubkey()) + .await + .unwrap() + .unwrap(); + assert_eq!(transfer_destination.lamports, PRIZE); + + let counter_after = banks.get_account(DELEGATED_PDA_ID).await.unwrap().unwrap(); + let counter_after = Counter::try_from_slice(&counter_after.data).unwrap(); + // Committing state from count 100 to 101, and then increasing in handler on 1 + assert_eq!(counter_before.count + 2, counter_after.count); +} + +/// Testing call_handler in finalize context with invalid escrow +#[tokio::test] +async fn test_finalize_invalid_escrow_call_handler() { + // Setup + let (banks, _, authority, blockhash) = setup_program_test_env().await; + + // Submit the finalize with handler tx + let transfer_destination = Keypair::new(); + let finalize_ix = dlp::instruction_builder::finalize(authority.pubkey(), DELEGATED_PDA_ID); + let call_handler_ix = dlp::instruction_builder::call_handler( + authority.pubkey(), + DELEGATED_PDA_OWNER_ID, // destination program + DELEGATED_PDA_ID, + vec![AccountMeta::new(transfer_destination.pubkey(), false)], + CallHandlerArgs { + escrow_index: 0, + data: vec![], + context: dlp::args::Context::Commit, + }, + ); + let tx = Transaction::new_signed_with_payer( + &[finalize_ix, call_handler_ix], + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res + .unwrap_err() + .to_string() + .contains("Invalid account owner")); +} + +#[tokio::test] +async fn test_undelegate_invalid_escow_call_handler() { + const PRIZE: u64 = LAMPORTS_PER_SOL / 1000; + + let (banks, _, authority, blockhash) = setup_program_test_env().await; + + // Submit the finalize with handler tx + let destination = Keypair::new(); + let finalize_ix = dlp::instruction_builder::finalize(authority.pubkey(), DELEGATED_PDA_ID); + let finalize_call_handler_ix = dlp::instruction_builder::call_handler( + authority.pubkey(), + DELEGATED_PDA_OWNER_ID, // handler program + DELEGATED_PDA_ID, + vec![AccountMeta::new(destination.pubkey(), false)], + CallHandlerArgs { + escrow_index: 0, + data: vec![], + context: dlp::args::Context::Commit, + }, + ); + + let undelegate_ix = dlp::instruction_builder::undelegate( + authority.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + authority.pubkey(), + ); + let undelegate_call_handler_ix = dlp::instruction_builder::call_handler( + authority.pubkey(), + DELEGATED_PDA_OWNER_ID, // handler program + DELEGATED_PDA_ID, + vec![AccountMeta::new(destination.pubkey(), false)], + CallHandlerArgs { + escrow_index: 0, + data: to_vec(&PRIZE).unwrap(), + context: dlp::args::Context::Undelegate, + }, + ); + let tx = Transaction::new_signed_with_payer( + &[ + finalize_ix, + finalize_call_handler_ix, + undelegate_ix, + undelegate_call_handler_ix, + ], + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert!(res + .unwrap_err() + .to_string() + .contains("Invalid account owner")); +} diff --git a/tests/test_commit_on_curve.rs b/tests/test_commit_on_curve.rs index a55f5ace..b66f685d 100644 --- a/tests/test_commit_on_curve.rs +++ b/tests/test_commit_on_curve.rs @@ -29,7 +29,7 @@ async fn test_commit_on_curve() { let new_account_balance = 1_000_000; let commit_args = CommitStateArgs { data: vec![], - slot: 100, + nonce: 1, allow_undelegation: true, lamports: new_account_balance, }; @@ -63,7 +63,7 @@ async fn test_commit_on_curve() { CommitRecord::try_from_bytes_with_discriminator(&commit_record_account.data).unwrap(); assert_eq!(commit_record.account, payer_delegated.pubkey()); assert_eq!(commit_record.identity, validator.pubkey()); - assert_eq!(commit_record.slot, 100); + assert_eq!(commit_record.nonce, 1); let delegation_metadata_pda = delegation_metadata_pda_from_delegated_account(&payer_delegated.pubkey()); diff --git a/tests/test_commit_state.rs b/tests/test_commit_state.rs index aa0d2c8f..50e9d418 100644 --- a/tests/test_commit_state.rs +++ b/tests/test_commit_state.rs @@ -30,7 +30,7 @@ async fn test_commit_new_state() { let new_account_balance = 1_000_000; let commit_args = CommitStateArgs { data: new_state.clone(), - slot: 100, + nonce: 1, allow_undelegation: true, lamports: new_account_balance, }; @@ -68,7 +68,7 @@ async fn test_commit_new_state() { CommitRecord::try_from_bytes_with_discriminator(&commit_record_account.data).unwrap(); assert_eq!(commit_record.account, DELEGATED_PDA_ID); assert_eq!(commit_record.identity, authority.pubkey()); - assert_eq!(commit_record.slot, 100); + assert_eq!(commit_record.nonce, 1); let delegation_metadata_pda = delegation_metadata_pda_from_delegated_account(&DELEGATED_PDA_ID); let delegation_metadata_account = banks @@ -82,6 +82,43 @@ async fn test_commit_new_state() { assert!(delegation_metadata.is_undelegatable); } +#[tokio::test] +async fn test_commit_out_of_order() { + const OUTDATED_SLOT_ERR_MSG: &str = + "transport transaction error: Error processing Instruction 0: custom program error: 0xc"; + + // Setup + let (banks, _, authority, blockhash) = setup_program_test_env().await; + let new_state = vec![0, 1, 2, 9, 9, 9, 6, 7, 8, 9]; + + let new_account_balance = 1_000_000; + let commit_args = CommitStateArgs { + data: new_state.clone(), + nonce: 101, + allow_undelegation: true, + lamports: new_account_balance, + }; + + // Commit the state for the delegated account + let ix = dlp::instruction_builder::commit_state( + authority.pubkey(), + DELEGATED_PDA_ID, + DELEGATED_PDA_OWNER_ID, + commit_args, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&authority.pubkey()), + &[&authority], + blockhash, + ); + let res = banks.process_transaction(tx).await; + assert_eq!( + res.unwrap_err().to_string(), + OUTDATED_SLOT_ERR_MSG.to_string() + ); +} + async fn setup_program_test_env() -> (BanksClient, Keypair, Keypair, Hash) { let mut program_test = ProgramTest::new("dlp", dlp::ID, processor!(dlp::process_instruction)); program_test.prefer_bpf(true); diff --git a/tests/test_commit_state_from_buffer.rs b/tests/test_commit_state_from_buffer.rs index f557a9a9..d2047dd8 100644 --- a/tests/test_commit_state_from_buffer.rs +++ b/tests/test_commit_state_from_buffer.rs @@ -31,7 +31,7 @@ async fn test_commit_new_state_from_buffer() { let state_buffer_pda = Pubkey::find_program_address(&[b"state_buffer"], &authority.pubkey()).0; let commit_args = CommitStateFromBufferArgs { - slot: 100, + nonce: 1, allow_undelegation: true, lamports: new_account_balance, }; @@ -70,7 +70,7 @@ async fn test_commit_new_state_from_buffer() { CommitRecord::try_from_bytes_with_discriminator(&commit_record_account.data).unwrap(); assert_eq!(commit_record.account, DELEGATED_PDA_ID); assert_eq!(commit_record.identity, authority.pubkey()); - assert_eq!(commit_record.slot, 100); + assert_eq!(commit_record.nonce, 1); let delegation_metadata_pda = delegation_metadata_pda_from_delegated_account(&DELEGATED_PDA_ID); let delegation_metadata_account = banks diff --git a/tests/test_commit_state_with_program_config.rs b/tests/test_commit_state_with_program_config.rs index 1e42504d..889e8a0a 100644 --- a/tests/test_commit_state_with_program_config.rs +++ b/tests/test_commit_state_with_program_config.rs @@ -40,7 +40,7 @@ async fn test_commit_new_state(valid_config: bool) { let new_account_balance = 1_000_000; let commit_args = CommitStateArgs { data: new_state.clone(), - slot: 100, + nonce: 1, allow_undelegation: true, lamports: new_account_balance, }; @@ -81,7 +81,7 @@ async fn test_commit_new_state(valid_config: bool) { CommitRecord::try_from_bytes_with_discriminator(&commit_record_account.data).unwrap(); assert_eq!(commit_record.account, DELEGATED_PDA_ID); assert_eq!(commit_record.identity, authority.pubkey()); - assert_eq!(commit_record.slot, 100); + assert_eq!(commit_record.nonce, 1); let delegation_metadata_pda = delegation_metadata_pda_from_delegated_account(&DELEGATED_PDA_ID); diff --git a/tests/test_finalize.rs b/tests/test_finalize.rs index 20ebc620..fcfe5a31 100644 --- a/tests/test_finalize.rs +++ b/tests/test_finalize.rs @@ -79,10 +79,7 @@ async fn test_finalize() { let delegation_metadata = DelegationMetadata::try_from_bytes_with_discriminator(&delegation_metadata_account.data) .unwrap(); - assert_eq!( - commit_record.slot, - delegation_metadata.last_update_external_slot - ); + assert_eq!(commit_record.nonce, delegation_metadata.last_update_nonce); } 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 0f4e485e..46ec383f 100644 --- a/tests/test_lamports_settlement.rs +++ b/tests/test_lamports_settlement.rs @@ -447,7 +447,7 @@ async fn commit_new_state(args: CommitNewStateArgs<'_>) { }; let commit_args = CommitStateArgs { data: data.clone(), - slot: 100, + nonce: 1, allow_undelegation: true, lamports: args.new_delegated_account_lamports, }; @@ -503,7 +503,7 @@ async fn commit_new_state(args: CommitNewStateArgs<'_>) { CommitRecord::try_from_bytes_with_discriminator(&commit_record_account.data).unwrap(); assert_eq!(commit_record.account, args.delegated_account); assert_eq!(commit_record.identity, args.authority.pubkey()); - assert_eq!(commit_record.slot, 100); + assert_eq!(commit_record.nonce, 1); let delegation_metadata_pda = delegation_metadata_pda_from_delegated_account(&args.delegated_account);