From a1b695c1e3833be13c0ac6e79180817da3cf8db8 Mon Sep 17 00:00:00 2001 From: Sarfaraz Nawaz Date: Thu, 19 Feb 2026 17:23:31 +0530 Subject: [PATCH] feat: Add DelegateWithActions --- .github/workflows/run-tests.yml | 7 +- Cargo.toml | 2 +- src/args/delegate.rs | 5 +- src/args/delegate_with_actions.rs | 48 +++ src/args/mod.rs | 2 + src/compact/account_meta.rs | 60 ++++ src/compact/instruction.rs | 10 + src/compact/mod.rs | 5 + src/discriminator.rs | 3 + .../delegate_with_actions.rs | 322 ++++++++++++++++++ src/instruction_builder/mod.rs | 2 + src/lib.rs | 6 + src/processor/fast/delegate_with_actions.rs | 303 ++++++++++++++++ src/processor/fast/mod.rs | 2 + src/processor/fast/utils/requires.rs | 28 ++ src/state/utils/try_from_bytes.rs | 10 +- tests/test_delegate_with_actions.rs | 157 +++++++++ 17 files changed, 965 insertions(+), 7 deletions(-) create mode 100644 src/args/delegate_with_actions.rs create mode 100644 src/compact/account_meta.rs create mode 100644 src/compact/instruction.rs create mode 100644 src/compact/mod.rs create mode 100644 src/instruction_builder/delegate_with_actions.rs create mode 100644 src/processor/fast/delegate_with_actions.rs create mode 100644 tests/test_delegate_with_actions.rs diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 02e477a0..86f11420 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -80,7 +80,12 @@ jobs: run: | cargo build - - name: run tests + - name: run tests (cargo test) + run: | + export PATH="/home/runner/.local/share/solana/install/active_release/bin:$PATH" + cargo test + + - name: run tests (cargo test-sbf) run: | export PATH="/home/runner/.local/share/solana/install/active_release/bin:$PATH" cargo test-sbf --features unit_test_config diff --git a/Cargo.toml b/Cargo.toml index ce1ed002..83f6121f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,7 @@ pinocchio-associated-token-account = { version = "0.3.0" , optional = true } solana-address = { version = "2.0", features = ["bytemuck", "decode", "syscalls", "curve25519", "std"] } # manually resolves the conflict with a pinned version of serde -serde = "=1.0.226" +serde = { version = "=1.0.226", features = ["derive"] } [dev-dependencies] assertables = "9.8.2" diff --git a/src/args/delegate.rs b/src/args/delegate.rs index 305b5e51..c438625d 100644 --- a/src/args/delegate.rs +++ b/src/args/delegate.rs @@ -1,7 +1,10 @@ use borsh::{BorshDeserialize, BorshSerialize}; +use serde::{Deserialize, Serialize}; use solana_program::pubkey::Pubkey; -#[derive(Default, Debug, BorshSerialize, BorshDeserialize)] +#[derive( + Default, Debug, BorshSerialize, BorshDeserialize, Serialize, Deserialize, +)] pub struct DelegateArgs { /// The frequency at which the validator should commit the account data /// if no commit is triggered by the owning program diff --git a/src/args/delegate_with_actions.rs b/src/args/delegate_with_actions.rs new file mode 100644 index 00000000..ae9aff41 --- /dev/null +++ b/src/args/delegate_with_actions.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; +use solana_program::{instruction::Instruction, pubkey::Pubkey}; + +use super::DelegateArgs; +use crate::compact; + +#[derive(Debug, Serialize, Deserialize)] +pub struct DelegateWithActionsArgs { + /// Standard delegation parameters. + pub delegate: DelegateArgs, + + /// Compact post-delegation actions. + pub actions: PostDelegationActions, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PostDelegationActions { + /// Number of signer pubkeys in the `pubkeys` prefix. + /// First `signer_count` entries of `pubkeys` are required signers. + pub signer_count: u8, + + /// Shared pubkey table. Account metas and program IDs reference this table by index. + pub pubkeys: Vec, + + /// Instruction payload in compact cleartext or encrypted bytes. + pub instructions: Instructions, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum Instructions { + /// Compact cleartext instructions. + ClearText { + instructions: Vec, + }, + + /// Encrypted compact instruction bytes. + Encrypted { instructions: Vec }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DecryptedInstructions { + /// Sender-provided nonce/salt to randomize ciphertext so identical + /// plaintext does not always map to identical encrypted bytes. + pub random_salt: u64, + + /// Decrypted instructions ready for execution. + pub instructions: Vec, +} diff --git a/src/args/mod.rs b/src/args/mod.rs index 89853b11..492f02f4 100644 --- a/src/args/mod.rs +++ b/src/args/mod.rs @@ -2,6 +2,7 @@ mod call_handler; mod commit_state; mod delegate; mod delegate_ephemeral_balance; +mod delegate_with_actions; mod top_up_ephemeral_balance; mod types; mod validator_claim_fees; @@ -11,6 +12,7 @@ pub use call_handler::*; pub use commit_state::*; pub use delegate::*; pub use delegate_ephemeral_balance::*; +pub use delegate_with_actions::*; pub use top_up_ephemeral_balance::*; pub use types::*; pub use validator_claim_fees::*; diff --git a/src/compact/account_meta.rs b/src/compact/account_meta.rs new file mode 100644 index 00000000..2470387a --- /dev/null +++ b/src/compact/account_meta.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; + +const ACCOUNT_INDEX_MASK: u8 = 0b0011_1111; +const SIGNER_MASK: u8 = 0b0100_0000; +const WRITABLE_MASK: u8 = 0b1000_0000; + +/// +/// MAX_PUBKEYS = 64 +/// +pub const MAX_PUBKEYS: usize = ACCOUNT_INDEX_MASK as usize + 1; + +/// Compact account meta packed into one byte. +/// Bits `0..=5` encode the pubkey-table index (`0..MAX_PUBKEYS-1`). +/// Bit `6` is `is_signer`, and bit `7` is `is_writable`. +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub struct AccountMeta(pub u8); + +impl AccountMeta { + pub fn new(index: usize, is_signer: bool) -> Self { + Self::try_new(index, is_signer, true).expect("index is out of range") + } + pub fn new_readonly(index: usize, is_signer: bool) -> Self { + Self::try_new(index, is_signer, false).expect("index is out of range") + } + + pub fn try_new( + index: usize, + is_signer: bool, + is_writable: bool, + ) -> Option { + if index >= MAX_PUBKEYS { + return None; + } + let mut packed = index as u8; + if is_signer { + packed |= SIGNER_MASK; + } + if is_writable { + packed |= WRITABLE_MASK; + } + Some(Self(packed)) + } + + pub fn index(self) -> usize { + (self.0 & ACCOUNT_INDEX_MASK) as usize + } + + pub fn is_signer(self) -> bool { + (self.0 & SIGNER_MASK) != 0 + } + + pub fn is_writable(self) -> bool { + (self.0 & WRITABLE_MASK) != 0 + } + + pub fn set_index(&mut self, new_index: usize) { + *self = Self::try_new(new_index, self.is_signer(), self.is_writable()) + .expect("index is out of range"); + } +} diff --git a/src/compact/instruction.rs b/src/compact/instruction.rs new file mode 100644 index 00000000..5de5344a --- /dev/null +++ b/src/compact/instruction.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +use crate::compact; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Instruction { + pub program_id: u8, + pub accounts: Vec, + pub data: Vec, +} diff --git a/src/compact/mod.rs b/src/compact/mod.rs new file mode 100644 index 00000000..a33bfc45 --- /dev/null +++ b/src/compact/mod.rs @@ -0,0 +1,5 @@ +mod account_meta; +mod instruction; + +pub use account_meta::*; +pub use instruction::*; diff --git a/src/discriminator.rs b/src/discriminator.rs index 1af1d58d..c18410de 100644 --- a/src/discriminator.rs +++ b/src/discriminator.rs @@ -53,6 +53,9 @@ pub enum DlpDiscriminator { /// See [crate::processor::process_commit_finalize_from_buffer] for docs. CommitFinalizeFromBuffer = 22, + + /// See [crate::processor::process_delegate_with_actions] for docs. + DelegateWithActions = 23, } impl DlpDiscriminator { diff --git a/src/instruction_builder/delegate_with_actions.rs b/src/instruction_builder/delegate_with_actions.rs new file mode 100644 index 00000000..5ee114b8 --- /dev/null +++ b/src/instruction_builder/delegate_with_actions.rs @@ -0,0 +1,322 @@ +use solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, + system_program, +}; + +use crate::{ + args::{ + DelegateArgs, DelegateWithActionsArgs, Instructions, + PostDelegationActions, + }, + compact::{self}, + discriminator::DlpDiscriminator, + pda::{ + delegate_buffer_pda_from_delegated_account_and_owner_program, + delegation_metadata_pda_from_delegated_account, + delegation_record_pda_from_delegated_account, + }, +}; + +/// +/// Encrypt serialized bytes into serialized-encrypted bytes. +/// +pub type EncryptFn = fn(&[u8]) -> Vec; + +/// +/// Dencrypt serialized-encrypted bytes into serialized-decrypted bytes. +/// +pub type DecryptFn = fn(&[u8]) -> Vec; + +/// Builds a delegate instruction that stores an actions payload. +/// See [crate::processor::process_delegate_with_actions] for docs. +pub fn delegate_with_actions( + payer: Pubkey, + delegated_account: Pubkey, + owner: Option, + delegate: DelegateArgs, + actions: Vec, + private: Option, +) -> Instruction { + let actions = compact_post_delegation_actions(actions, private); + + Instruction { + program_id: crate::id(), + + accounts: { + let owner = owner.unwrap_or(system_program::id()); + let delegate_buffer_pda = + delegate_buffer_pda_from_delegated_account_and_owner_program( + &delegated_account, + &owner, + ); + let delegation_record_pda = + delegation_record_pda_from_delegated_account( + &delegated_account, + ); + let delegation_metadata_pda = + delegation_metadata_pda_from_delegated_account( + &delegated_account, + ); + + [ + vec![ + AccountMeta::new(payer, true), + AccountMeta::new(delegated_account, true), + AccountMeta::new_readonly(owner, false), + AccountMeta::new(delegate_buffer_pda, false), + AccountMeta::new(delegation_record_pda, false), + AccountMeta::new(delegation_metadata_pda, false), + AccountMeta::new_readonly(system_program::id(), false), + ], + actions + .pubkeys + .iter() + .take(actions.signer_count as usize) + .map(|signer| AccountMeta::new_readonly(*signer, true)) + .collect(), + ] + .concat() + }, + + data: { + let args = DelegateWithActionsArgs { delegate, actions }; + let mut data = DlpDiscriminator::DelegateWithActions.to_vec(); + data.extend_from_slice(&bincode::serialize(&args).unwrap()); + data + }, + } +} + +fn compact_post_delegation_actions( + instructions: Vec, + private: Option, +) -> PostDelegationActions { + let mut pubkeys = Vec::new(); // Vec of (pubkey, index, signer) + + let compact_instructions = instructions + .into_iter() + .map(|ix| compact::Instruction { + program_id: upsert_pubkey(&mut pubkeys, ix.program_id, false) as u8, + + accounts: ix + .accounts + .iter() + .map(|meta| { + let account_index = upsert_pubkey( + &mut pubkeys, + meta.pubkey, + meta.is_signer, + ); + compact::AccountMeta::try_new( + account_index, + meta.is_signer, + meta.is_writable, + ) + .expect("compact account index must fit in 6 bits") + }) + .collect(), + + data: ix.data, + }) + .collect(); + + let (pubkeys, compact_instructions, signer_count) = + reorder_signers_first(pubkeys, compact_instructions); + + let compact_payload = if let Some(encrypt) = private { + Instructions::Encrypted { + // first serialize the compact-instructions, then encrypt it, + // note that the final serialized-encrypted bytes will be + // serialized again later by the caller. + instructions: encrypt( + &bincode::serialize(&compact_instructions).expect( + "compact instruction serialization should not fail", + ), + ), + } + } else { + Instructions::ClearText { + instructions: compact_instructions.clone(), + } + }; + + PostDelegationActions { + signer_count, + pubkeys, + instructions: compact_payload, + } +} + +fn upsert_pubkey( + pubkeys: &mut Vec<(Pubkey, usize, bool)>, + key: Pubkey, + signer: bool, +) -> usize { + if let Some(index) = + pubkeys.iter().position(|(existing, _, _)| *existing == key) + { + pubkeys[index].2 |= signer; + return index; + } + assert!( + pubkeys.len() < compact::MAX_PUBKEYS, + "delegate_with_actions supports at most {} unique pubkeys", + compact::MAX_PUBKEYS + ); + pubkeys.push((key, pubkeys.len(), signer)); + pubkeys.len() - 1 +} + +fn reorder_signers_first( + mut pubkeys: Vec<(Pubkey, usize, bool)>, + mut instructions: Vec, +) -> (Vec, Vec, u8) { + if pubkeys.is_empty() { + return (Vec::new(), instructions, 0); + } + + let signer_count = partition(&mut pubkeys, |(_, _, signer)| *signer); + + let new_index = |old_index: u8| -> u8 { + pubkeys + .iter() + .position(|(_, index, _)| *index == old_index as usize) + .unwrap() as u8 + }; + + for ix in instructions.iter_mut() { + ix.program_id = new_index(ix.program_id); + for meta in ix.accounts.iter_mut() { + meta.set_index(new_index(meta.index() as u8) as usize); + } + } + + let pubkeys = pubkeys.into_iter().map(|(key, _, _)| key).collect(); + (pubkeys, instructions, signer_count as u8) +} + +/// +/// It's a C++ equivalent of std::partition() +/// ref: https://en.cppreference.com/w/cpp/algorithm/partition.html +/// +/// Returns the size of first group (good elements) which can also be used as the +/// first index of the second group. +/// +fn partition(v: &mut [T], mut pred: F) -> usize +where + F: FnMut(&T) -> bool, +{ + let mut good = 0; // number of good elements + + for i in 0..v.len() { + if pred(&v[i]) { + v.swap(good, i); + good += 1; + } + } + + good +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_reorder_signers_first_remaps_and_prefixes_signers() { + let a = Pubkey::new_from_array([1; 32]); // 0: signer + let b = Pubkey::new_from_array([2; 32]); // 1: non-signer + let c = Pubkey::new_from_array([3; 32]); // 2: signer + let d = Pubkey::new_from_array([4; 32]); // 3: non-signer + let e = Pubkey::new_from_array([5; 32]); // 4: signer + + // (pubkey, old_index, is_signer) + let pubkeys = vec![ + (a, 0, true), + (b, 1, false), + (c, 2, true), + (d, 3, false), + (e, 4, true), + ]; + let instructions = vec![compact::Instruction { + program_id: 3, // old index of d + accounts: vec![ + compact::AccountMeta::new_readonly(0, true), // a + compact::AccountMeta::new(2, true), // c + compact::AccountMeta::new_readonly(1, false), // b + compact::AccountMeta::new_readonly(4, true), // e + compact::AccountMeta::new(3, false), // d + ], + data: vec![9], + }]; + + let (reordered_pubkeys, ixs, signer_count) = + reorder_signers_first(pubkeys, instructions); + + // reordered: a, c, e, d, b + // 0, 1, 2, 3, 4 + + assert_eq!(signer_count, 3); + assert_eq!(reordered_pubkeys[0], a); // signer + assert_eq!(reordered_pubkeys[1], c); // signer + assert_eq!(reordered_pubkeys[2], e); // signer + assert_eq!(reordered_pubkeys[3], d); // non-signer + assert_eq!(reordered_pubkeys[4], b); // non-signer + + // old->new mapping: a(0)->0, b(1)->4, c(2)->1, d(3)->3, e(4)->2 + // + assert_eq!(ixs[0].program_id, 3); // d + assert_eq!(ixs[0].accounts[0].index(), 0); // a + assert_eq!(ixs[0].accounts[1].index(), 1); // c + assert_eq!(ixs[0].accounts[2].index(), 4); // b + assert_eq!(ixs[0].accounts[3].index(), 2); // e + assert_eq!(ixs[0].accounts[4].index(), 3); // d + } + + #[test] + fn test_compact_post_delegation_actions() { + let a = Pubkey::new_from_array([1; 32]); // 0: signer + let b = Pubkey::new_from_array([2; 32]); // 1: non-signer + let c = Pubkey::new_from_array([3; 32]); // 2: signer + let d = Pubkey::new_from_array([4; 32]); // 3: non-signer + let e = Pubkey::new_from_array([5; 32]); // 4: signer + + let instructions = vec![Instruction { + program_id: d, + accounts: vec![ + AccountMeta::new_readonly(a, true), // a + AccountMeta::new(c, true), // c + AccountMeta::new_readonly(b, false), // b + AccountMeta::new_readonly(e, true), // e + AccountMeta::new(d, false), // d + ], + data: vec![9], + }]; + + let actions = compact_post_delegation_actions(instructions, None); + + // reordered: a, c, e, d, b + // 0, 1, 2, 3, 4 + + assert_eq!(actions.signer_count, 3); + assert_eq!(actions.pubkeys[0], a); // signer + assert_eq!(actions.pubkeys[1], c); // signer + assert_eq!(actions.pubkeys[2], e); // signer + assert_eq!(actions.pubkeys[3], b); // non-signer + assert_eq!(actions.pubkeys[4], d); // non-signer + + // old->new mapping: a(0)->0, b(1)->4, c(2)->1, d(3)->3, e(4)->2 + let Instructions::ClearText { instructions: ixs } = actions.instructions + else { + panic!(); + }; + + assert_eq!(ixs[0].program_id, 4); // d + assert_eq!(ixs[0].accounts[0].index(), 0); // a + assert_eq!(ixs[0].accounts[1].index(), 1); // c + assert_eq!(ixs[0].accounts[2].index(), 3); // b + assert_eq!(ixs[0].accounts[3].index(), 2); // e + assert_eq!(ixs[0].accounts[4].index(), 4); // d + } +} diff --git a/src/instruction_builder/mod.rs b/src/instruction_builder/mod.rs index 66eef03b..8334b77c 100644 --- a/src/instruction_builder/mod.rs +++ b/src/instruction_builder/mod.rs @@ -10,6 +10,7 @@ mod commit_state; mod commit_state_from_buffer; mod delegate; mod delegate_ephemeral_balance; +mod delegate_with_actions; mod finalize; mod init_protocol_fees_vault; mod init_validator_fees_vault; @@ -32,6 +33,7 @@ pub use commit_state::*; pub use commit_state_from_buffer::*; pub use delegate::*; pub use delegate_ephemeral_balance::*; +pub use delegate_with_actions::*; pub use finalize::*; pub use init_protocol_fees_vault::*; pub use init_validator_fees_vault::*; diff --git a/src/lib.rs b/src/lib.rs index 3e94e488..f2cc3147 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,7 @@ use { }; pub mod args; +pub mod compact; pub mod consts; mod discriminator; pub mod error; @@ -110,6 +111,11 @@ pub fn fast_process_instruction( program_id, accounts, data, )) } + DlpDiscriminator::DelegateWithActions => { + Some(processor::fast::process_delegate_with_actions( + program_id, accounts, data, + )) + } DlpDiscriminator::CommitState => Some( processor::fast::process_commit_state(program_id, accounts, data), ), diff --git a/src/processor/fast/delegate_with_actions.rs b/src/processor/fast/delegate_with_actions.rs new file mode 100644 index 00000000..77cc01c6 --- /dev/null +++ b/src/processor/fast/delegate_with_actions.rs @@ -0,0 +1,303 @@ +use pinocchio::{ + address::address_eq, + cpi::{Seed, Signer}, + error::ProgramError, + sysvars::{clock::Clock, Sysvar}, + AccountView, Address, ProgramResult, +}; +use pinocchio_log::log; +use pinocchio_system::instructions as system; + +use crate::{ + args::{DelegateWithActionsArgs, Instructions}, + consts::{DEFAULT_VALIDATOR_IDENTITY, RENT_EXCEPTION_ZERO_BYTES_LAMPORTS}, + error::DlpError, + pda, + processor::{ + fast::{ + to_pinocchio_program_error, + utils::{ + pda::create_pda, + requires::{ + require_owned_pda, require_pda, require_signer, + require_uninitialized_pda, DelegationMetadataCtx, + DelegationRecordCtx, + }, + }, + }, + utils::curve::is_on_curve_fast, + }, + require_n_accounts_with_optionals, + state::{DelegationMetadata, DelegationRecord}, +}; + +/// Delegates an account and stores an actions payload. +pub fn process_delegate_with_actions( + _program_id: &Address, + accounts: &[AccountView], + data: &[u8], +) -> ProgramResult { + let ( + [ + payer, // force multi-line + delegated_account, + owner_program, + delegate_buffer_account, + delegation_record_account, + delegation_metadata_account, + _system_program, + ], + remaining_accounts, + ) = require_n_accounts_with_optionals!(accounts, 7); + + require_owned_pda( + delegated_account, + &crate::fast::ID, + "delegated account", + )?; + + // Check that payer and delegated_account are signers, this ensures the instruction is being called from CPI + require_signer(payer, "payer")?; + require_signer(delegated_account, "delegated account")?; + + // Check that the buffer PDA is initialized and derived correctly from the PDA + require_pda( + delegate_buffer_account, + &[ + pda::DELEGATE_BUFFER_TAG, + delegated_account.address().as_ref(), + ], + owner_program.address(), + true, + "delegate buffer", + )?; + + // Check that the delegation record PDA is uninitialized + let delegation_record_bump = require_uninitialized_pda( + delegation_record_account, + &[ + pda::DELEGATION_RECORD_TAG, + delegated_account.address().as_ref(), + ], + &crate::fast::ID, + true, + DelegationRecordCtx, + )?; + + // Check that the delegation metadata PDA is uninitialized + let delegation_metadata_bump = require_uninitialized_pda( + delegation_metadata_account, + &[ + pda::DELEGATION_METADATA_TAG, + delegated_account.address().as_ref(), + ], + &crate::fast::ID, + true, + DelegationMetadataCtx, + )?; + + // Validate instruction payload shape up-front. This confirms delegate args + // and actions envelope format, while encrypted bytes remain opaque. + let args: DelegateWithActionsArgs = bincode::deserialize(data) + .map_err(|_| ProgramError::InvalidInstructionData)?; + + if args.actions.signer_count as usize > args.actions.pubkeys.len() { + return Err(ProgramError::InvalidInstructionData); + } + + // Validate compact indices for cleartext payload. + if let Instructions::ClearText { instructions } = &args.actions.instructions + { + for compact_ix in instructions { + if compact_ix.program_id as usize >= args.actions.pubkeys.len() { + return Err(ProgramError::InvalidInstructionData); + } + for compact_meta in &compact_ix.accounts { + let pubkey_index = compact_meta.index(); + if pubkey_index >= args.actions.pubkeys.len() { + return Err(ProgramError::InvalidInstructionData); + } + if compact_meta.is_signer() + && pubkey_index >= args.actions.signer_count as usize + { + return Err(ProgramError::InvalidInstructionData); + } + } + } + } + + // Enforce required signers from the pubkey-table prefix. + for signer in &args.actions.pubkeys[..args.actions.signer_count as usize] { + let account = remaining_accounts + .iter() + .find(|account| account.address().to_bytes() == signer.to_bytes()) + .ok_or(ProgramError::NotEnoughAccountKeys)?; + if !account.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + } + + if let Some(validator) = args.delegate.validator { + if validator.to_bytes() == pinocchio_system::ID.to_bytes() { + return Err(DlpError::DelegationToSystemProgramNotAllowed.into()); + } + } + + // Validate seeds if the delegate account is not on curve, i.e. is a PDA + // If the owner is the system program, we check if the account is derived from the delegation program, + // allowing delegation of escrow accounts + if !is_on_curve_fast(delegated_account.address()) { + let program_id = + if address_eq(owner_program.address(), &pinocchio_system::ID) { + &crate::fast::ID + } else { + owner_program.address() + }; + let seeds_to_validate: &[&[u8]] = match args.delegate.seeds.len() { + 1 => &[&args.delegate.seeds[0]], + 2 => &[&args.delegate.seeds[0], &args.delegate.seeds[1]], + 3 => &[ + &args.delegate.seeds[0], + &args.delegate.seeds[1], + &args.delegate.seeds[2], + ], + 4 => &[ + &args.delegate.seeds[0], + &args.delegate.seeds[1], + &args.delegate.seeds[2], + &args.delegate.seeds[3], + ], + 5 => &[ + &args.delegate.seeds[0], + &args.delegate.seeds[1], + &args.delegate.seeds[2], + &args.delegate.seeds[3], + &args.delegate.seeds[4], + ], + 6 => &[ + &args.delegate.seeds[0], + &args.delegate.seeds[1], + &args.delegate.seeds[2], + &args.delegate.seeds[3], + &args.delegate.seeds[4], + &args.delegate.seeds[5], + ], + 7 => &[ + &args.delegate.seeds[0], + &args.delegate.seeds[1], + &args.delegate.seeds[2], + &args.delegate.seeds[3], + &args.delegate.seeds[4], + &args.delegate.seeds[5], + &args.delegate.seeds[6], + ], + 8 => &[ + &args.delegate.seeds[0], + &args.delegate.seeds[1], + &args.delegate.seeds[2], + &args.delegate.seeds[3], + &args.delegate.seeds[4], + &args.delegate.seeds[5], + &args.delegate.seeds[6], + &args.delegate.seeds[7], + ], + _ => return Err(DlpError::TooManySeeds.into()), + }; + let derived_pda = + Address::find_program_address(seeds_to_validate, program_id).0; + + if !address_eq(&derived_pda, delegated_account.address()) { + log!("Expected delegated PDA to be: "); + derived_pda.log(); + log!("but got: "); + delegated_account.address().log(); + return Err(ProgramError::InvalidSeeds); + } + } + + let action_data = bincode::serialize(&args.actions) + .map_err(|_| ProgramError::InvalidInstructionData)?; + + create_pda( + delegation_record_account, + &crate::fast::ID, + DelegationRecord::size_with_discriminator() + action_data.len(), + &[Signer::from(&[ + Seed::from(pda::DELEGATION_RECORD_TAG), + Seed::from(delegated_account.address().as_ref()), + Seed::from(&[delegation_record_bump]), + ])], + payer, + )?; + + // Initialize the delegation record + let delegation_record = DelegationRecord { + owner: owner_program.address().to_bytes().into(), + authority: args + .delegate + .validator + .unwrap_or(DEFAULT_VALIDATOR_IDENTITY), + commit_frequency_ms: args.delegate.commit_frequency_ms as u64, + delegation_slot: Clock::get()?.slot, + lamports: delegated_account.lamports(), + }; + + let mut delegation_record_data = + delegation_record_account.try_borrow_mut()?; + let record_size = DelegationRecord::size_with_discriminator(); + if delegation_record_data.len() != record_size + action_data.len() { + return Err(DlpError::InvalidDataLength.into()); + } + let (record_bytes, action_bytes) = + delegation_record_data.split_at_mut(record_size); + delegation_record + .to_bytes_with_discriminator(record_bytes) + .map_err(to_pinocchio_program_error)?; + action_bytes.copy_from_slice(&action_data); + + let delegation_metadata = DelegationMetadata { + seeds: args.delegate.seeds, + last_update_nonce: 0, + is_undelegatable: false, + rent_payer: payer.address().to_bytes().into(), + }; + + // Initialize the delegation metadata PDA + create_pda( + delegation_metadata_account, + &crate::fast::ID, + delegation_metadata.serialized_size(), + &[Signer::from(&[ + Seed::from(pda::DELEGATION_METADATA_TAG), + Seed::from(delegated_account.address().as_ref()), + Seed::from(&[delegation_metadata_bump]), + ])], + payer, + )?; + + // Copy the seeds to the delegated metadata PDA + let mut delegation_metadata_data = + delegation_metadata_account.try_borrow_mut()?; + delegation_metadata + .to_bytes_with_discriminator(&mut delegation_metadata_data.as_mut()) + .map_err(to_pinocchio_program_error)?; + + // Copy the data from the buffer into the original account + if !delegate_buffer_account.is_data_empty() { + let mut delegated_data = delegated_account.try_borrow_mut()?; + let delegate_buffer_data = delegate_buffer_account.try_borrow()?; + (*delegated_data).copy_from_slice(&delegate_buffer_data); + } + + // Make the account rent exempt if it's not + if delegated_account.lamports() == 0 && delegated_account.data_len() == 0 { + system::Transfer { + from: payer, + to: delegated_account, + lamports: RENT_EXCEPTION_ZERO_BYTES_LAMPORTS, + } + .invoke()?; + } + + Ok(()) +} diff --git a/src/processor/fast/mod.rs b/src/processor/fast/mod.rs index 8413fdcb..2e975201 100644 --- a/src/processor/fast/mod.rs +++ b/src/processor/fast/mod.rs @@ -5,6 +5,7 @@ mod commit_finalize_from_buffer; mod commit_state; mod commit_state_from_buffer; mod delegate; +mod delegate_with_actions; mod finalize; mod undelegate; mod undelegate_confined_account; @@ -19,6 +20,7 @@ pub use commit_finalize_from_buffer::*; pub use commit_state::*; pub use commit_state_from_buffer::*; pub use delegate::*; +pub use delegate_with_actions::*; pub use finalize::*; pub use undelegate::*; pub use undelegate_confined_account::*; diff --git a/src/processor/fast/utils/requires.rs b/src/processor/fast/utils/requires.rs index 3bee15df..e7a88a0c 100644 --- a/src/processor/fast/utils/requires.rs +++ b/src/processor/fast/utils/requires.rs @@ -169,6 +169,34 @@ macro_rules! require_n_accounts { }}; } +#[macro_export] +macro_rules! require_n_accounts_with_optionals { + ( $accounts:expr, $n:literal) => {{ + match $accounts.len().cmp(&$n) { + core::cmp::Ordering::Less => { + pinocchio_log::log!( + "Need {} accounts, but got less ({}) accounts", + $n, + $accounts.len() + ); + return Err( + pinocchio::error::ProgramError::NotEnoughAccountKeys, + ); + } + _ => { + let (exact, optionals) = $accounts.split_at($n); + + ( + TryInto::<&[_; $n]>::try_into(exact).map_err(|_| { + $crate::error::DlpError::InfallibleError + })?, + optionals, + ) + } + } + }}; +} + #[macro_export] macro_rules! require_some { ($option:expr, $error:expr) => {{ diff --git a/src/state/utils/try_from_bytes.rs b/src/state/utils/try_from_bytes.rs index 964f22b0..f3060746 100644 --- a/src/state/utils/try_from_bytes.rs +++ b/src/state/utils/try_from_bytes.rs @@ -5,26 +5,28 @@ macro_rules! impl_try_from_bytes_with_discriminator_zero_copy { pub fn try_from_bytes_with_discriminator( data: &[u8], ) -> Result<&Self, ::solana_program::program_error::ProgramError> { - if data.len() < 8 { + let expected_len = 8 + ::std::mem::size_of::(); + if data.len() < expected_len { return Err($crate::error::DlpError::InvalidDataLength.into()); } if Self::discriminator().to_bytes().ne(&data[..8]) { return Err($crate::error::DlpError::InvalidDiscriminator.into()); } - bytemuck::try_from_bytes::(&data[8..]).or(Err( + bytemuck::try_from_bytes::(&data[8..expected_len]).or(Err( $crate::error::DlpError::InvalidDelegationRecordData.into(), )) } pub fn try_from_bytes_with_discriminator_mut( data: &mut [u8], ) -> Result<&mut Self, ::solana_program::program_error::ProgramError> { - if data.len() < 8 { + let expected_len = 8 + ::std::mem::size_of::(); + if data.len() < expected_len { return Err($crate::error::DlpError::InvalidDataLength.into()); } if Self::discriminator().to_bytes().ne(&data[..8]) { return Err($crate::error::DlpError::InvalidDiscriminator.into()); } - bytemuck::try_from_bytes_mut::(&mut data[8..]).or(Err( + bytemuck::try_from_bytes_mut::(&mut data[8..expected_len]).or(Err( $crate::error::DlpError::InvalidDelegationRecordData.into(), )) } diff --git a/tests/test_delegate_with_actions.rs b/tests/test_delegate_with_actions.rs new file mode 100644 index 00000000..8e7a9d30 --- /dev/null +++ b/tests/test_delegate_with_actions.rs @@ -0,0 +1,157 @@ +use dlp::{ + args::{DelegateArgs, DelegateWithActionsArgs, Instructions}, + compact, + instruction_builder::delegate_with_actions, +}; +use solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; + +fn test_encrypt(serialized: &[u8]) -> Vec { + serialized.to_vec() +} + +#[test] +fn test_compact_account_meta_bit_packing() { + let packed = compact::AccountMeta::new_readonly(42, true); + assert_eq!(packed.index(), 42); + assert!(packed.is_signer()); + assert!(!packed.is_writable()); + + let packed = compact::AccountMeta::new(63, false); + assert_eq!(packed.index(), 63); + assert!(!packed.is_signer()); + assert!(packed.is_writable()); + + assert!(compact::AccountMeta::try_new(64, true, true).is_none()); +} + +#[test] +fn test_delegate_with_actions_bincode_roundtrip_compact_payload() { + let payer = Pubkey::new_unique(); + let signer = Pubkey::new_unique(); + + let instructions = vec![ + Instruction { + program_id: Pubkey::new_unique(), + accounts: vec![ + AccountMeta::new_readonly(payer, true), + AccountMeta::new(Pubkey::new_unique(), false), + ], + data: vec![1, 2, 3], + }, + Instruction { + program_id: Pubkey::new_unique(), + accounts: vec![AccountMeta::new_readonly(signer, true)], + data: vec![9, 9], + }, + ]; + + let ix = delegate_with_actions( + payer, + Pubkey::new_unique(), + Some(Pubkey::new_unique()), + DelegateArgs { + commit_frequency_ms: 500, + seeds: vec![b"seed-a".to_vec()], + validator: Some(Pubkey::new_unique()), + }, + instructions, + None, + ); + + let args: DelegateWithActionsArgs = + bincode::deserialize(&ix.data[8..]).unwrap(); + assert_eq!(args.delegate.commit_frequency_ms, 500); + assert_eq!(args.actions.signer_count, 2); + match args.actions.instructions { + Instructions::ClearText { instructions } => { + assert_eq!(instructions.len(), 2); + } + Instructions::Encrypted { .. } => { + panic!("expected cleartext compact instructions"); + } + } + assert!(args.actions.pubkeys.len() <= compact::MAX_PUBKEYS); +} + +#[test] +fn test_delegate_with_actions_builder_adds_compact_signers_to_remaining_accounts( +) { + let payer = Pubkey::new_unique(); + let delegated_account = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let signer_a = Pubkey::new_unique(); + let signer_b = Pubkey::new_unique(); + + let instructions = vec![ + Instruction { + program_id: Pubkey::new_unique(), + accounts: vec![ + AccountMeta::new_readonly(signer_a, true), + AccountMeta::new_readonly(signer_b, true), + ], + data: vec![7, 7], + }, + Instruction { + program_id: Pubkey::new_unique(), + accounts: vec![ + AccountMeta::new_readonly(signer_a, true), + AccountMeta::new(Pubkey::new_unique(), false), + ], + data: vec![8, 8], + }, + ]; + + let ix = delegate_with_actions( + payer, + delegated_account, + Some(owner), + DelegateArgs::default(), + instructions, + None, + ); + + // first 7 are the required delegate_with_actions accounts + let remaining = &ix.accounts[7..]; + assert_eq!(remaining.len(), 2); + assert!(remaining.iter().all(|a| a.is_signer && !a.is_writable)); + assert!(remaining.iter().any(|a| a.pubkey == signer_a)); + assert!(remaining.iter().any(|a| a.pubkey == signer_b)); +} + +#[test] +fn test_delegate_with_actions_builder_private_sets_encrypted_payload() { + let payer = Pubkey::new_unique(); + let signer = Pubkey::new_unique(); + let instructions = vec![Instruction { + program_id: Pubkey::new_unique(), + accounts: vec![AccountMeta::new_readonly(signer, true)], + data: vec![4, 2], + }]; + + let ix = delegate_with_actions( + payer, + Pubkey::new_unique(), + Some(Pubkey::new_unique()), + DelegateArgs::default(), + instructions, + Some(test_encrypt), + ); + + let args: DelegateWithActionsArgs = + bincode::deserialize(&ix.data[8..]).unwrap(); + assert_eq!(args.actions.signer_count, 1); + match args.actions.instructions { + Instructions::Encrypted { instructions } => { + let decoded: Vec = + bincode::deserialize(&instructions).unwrap(); + assert_eq!(decoded.len(), 1); + assert_eq!(decoded[0].data, vec![4, 2]); + } + Instructions::ClearText { .. } => { + panic!("expected encrypted compact instructions"); + } + } +}