diff --git a/interface/src/args/commit_finalize.rs b/interface/src/args/commit_finalize.rs new file mode 100644 index 00000000..27acb57c --- /dev/null +++ b/interface/src/args/commit_finalize.rs @@ -0,0 +1,42 @@ +use wheels::variable_offset_layout; + +use crate::compat::Pubkey; + +#[derive(Clone, Debug, PartialEq, Eq)] +#[variable_offset_layout(buffer_offset = 1)] +pub struct CommitFinalizeArgs { + /// Next commit sequence number for this delegated account. + pub commit_seq: u32, + + /// Account lamports on the ER after this commit. + pub lamports: u64, + + /// Account owner committed by the ER. + pub owner: Pubkey, + + /// Whether the validator requests undelegation after this commit. + pub allow_undelegation: bool, + + /// Stored as `CommitPayloadKind::value()`. + pub payload_kind: u8, + + /// Keeps the inline payload four-byte aligned for zero-copy diff decoding. + pub payload_alignment_padding: u8, + + /// Complete account data or an encoded diff, according to `payload_kind`. + #[flexible = 4] + pub payload: Vec, +} + +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommitPayloadKind { + FullData = 0, + Diff = 1, +} + +impl CommitPayloadKind { + pub const fn value(self) -> u8 { + self as u8 + } +} diff --git a/interface/src/args/mod.rs b/interface/src/args/mod.rs index 127da0fc..c7c35e5f 100644 --- a/interface/src/args/mod.rs +++ b/interface/src/args/mod.rs @@ -2,6 +2,7 @@ // instruction tag, so v2 instruction args use `buffer_offset = 1`. mod challenger_reveal; +mod commit_finalize; mod delegate; mod init_delegation_cluster; mod propose_commitment; @@ -14,6 +15,7 @@ mod update_verifier_registry; mod write_state_buffer; pub use challenger_reveal::*; +pub use commit_finalize::*; pub use delegate::*; pub use init_delegation_cluster::*; pub use propose_commitment::*; diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs index e0a65cb9..bae5e411 100644 --- a/interface/src/instruction.rs +++ b/interface/src/instruction.rs @@ -35,6 +35,8 @@ pub enum DlpV2Instruction { ResolveDispute = 111, /// Delegates an account to one delegation cluster. Delegate = 112, + /// Commits and immediately applies inline state for a Direct cluster. + CommitFinalize = 113, } impl DlpV2Instruction { diff --git a/interface/src/instruction_builder/commit_finalize.rs b/interface/src/instruction_builder/commit_finalize.rs new file mode 100644 index 00000000..ce636540 --- /dev/null +++ b/interface/src/instruction_builder/commit_finalize.rs @@ -0,0 +1,37 @@ +use solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; +use solana_sdk_ids::system_program; +use wheels::{layout::Encodable, DataLayoutError}; + +use crate::{ + compat::{Compatize, Modernize}, + pda::delegation_state_pda, + CommitFinalizeArgs, DlpV2Instruction, +}; + +/// Builds a direct-cluster commit that carries its payload inline. +pub fn commit_finalize( + current_validator: Pubkey, + delegation_cluster: Pubkey, + delegated_account: Pubkey, + args: CommitFinalizeArgs, +) -> Result { + Ok(Instruction { + program_id: crate::id().modernize(), + accounts: vec![ + AccountMeta::new(current_validator, true), + AccountMeta::new(delegated_account, false), + AccountMeta::new( + delegation_state_pda(&delegated_account.compatize()) + .modernize(), + false, + ), + AccountMeta::new_readonly(delegation_cluster, false), + AccountMeta::new_readonly(system_program::id(), false), + ], + data: [DlpV2Instruction::CommitFinalize.to_vec(), args.encode()?] + .concat(), + }) +} diff --git a/interface/src/instruction_builder/mod.rs b/interface/src/instruction_builder/mod.rs index 2a4b5eb0..db2640ae 100644 --- a/interface/src/instruction_builder/mod.rs +++ b/interface/src/instruction_builder/mod.rs @@ -1,5 +1,6 @@ mod approve_commitment; mod challenger_reveal; +mod commit_finalize; mod delegate; mod finalize_commitment; mod init_delegation_cluster; @@ -14,6 +15,7 @@ mod write_state_buffer; pub use approve_commitment::*; pub use challenger_reveal::*; +pub use commit_finalize::*; pub use delegate::*; pub use finalize_commitment::*; pub use init_delegation_cluster::*; diff --git a/program/src/processor/direct/commit_finalize.rs b/program/src/processor/direct/commit_finalize.rs new file mode 100644 index 00000000..b785e4f2 --- /dev/null +++ b/program/src/processor/direct/commit_finalize.rs @@ -0,0 +1,62 @@ +use delegation_program_interface as dpi; +use dpi::{CommitFinalizeArgs, CommitPayloadKind}; +use pinocchio::{error::ProgramError, AccountView, ProgramResult}; +use wheels::{layout::Decodable, require_eq, require_n_accounts}; + +use crate::processor::internal::{ + apply_commit_finalize, CommitFinalizeInput, CommitStateUpdate, +}; + +/// Commit and immediately apply one state update for a Direct cluster. +/// +/// Accounts: +/// 0: `[signer, writable]` current validator and lamport settlement account +/// 1: `[writable]` delegated account +/// 2: `[writable]` DelegationState PDA +/// 3: `[]` DelegationCluster PDA +/// 4: `[]` system program, required by system CPI +#[inline(never)] +pub fn process_commit_finalize( + accounts: &[AccountView], + data: &[u8], +) -> ProgramResult { + let [ + current_validator, // force multi-line + delegated_account, + delegation_state, + delegation_cluster, + _system_program, + ] = require_n_accounts!(accounts, 5); + + let args = CommitFinalizeArgs::decode(data)?; + require_eq!( + args.payload_alignment_padding(), + 0, + ProgramError::InvalidInstructionData + ); + let state_update = match args.payload_kind() { + value if value == CommitPayloadKind::FullData.value() => { + CommitStateUpdate::FullData(args.payload()) + } + value if value == CommitPayloadKind::Diff.value() => { + CommitStateUpdate::Diff(dpi::diff::DiffSet::try_new( + args.payload(), + )?) + } + _ => return Err(ProgramError::InvalidInstructionData), + }; + + apply_commit_finalize( + current_validator, + delegated_account, + delegation_state, + delegation_cluster, + CommitFinalizeInput { + commit_seq: args.commit_seq(), + lamports: args.lamports(), + owner: args.owner(), + allow_undelegation: args.allow_undelegation(), + state_update, + }, + ) +} diff --git a/program/src/processor/direct/mod.rs b/program/src/processor/direct/mod.rs new file mode 100644 index 00000000..64a66c1e --- /dev/null +++ b/program/src/processor/direct/mod.rs @@ -0,0 +1,5 @@ +//! Processors for clusters that finalize without fraud proofs. + +mod commit_finalize; + +pub use commit_finalize::*; diff --git a/program/src/processor/internal/commit_finalize.rs b/program/src/processor/internal/commit_finalize.rs new file mode 100644 index 00000000..ccc7b4ff --- /dev/null +++ b/program/src/processor/internal/commit_finalize.rs @@ -0,0 +1,215 @@ +use delegation_program_interface as dpi; +use dpi::{ + error::DlpError, + pda::{DELEGATION_CLUSTER_SEED, DELEGATION_STATE_SEED}, + DelegationCluster, DelegationState, UndelegationRequester, +}; +use pinocchio::{ + address::address_eq, + error::ProgramError, + sysvars::{rent::Rent, Sysvar}, + AccountView, ProgramResult, +}; +use pinocchio_system::instructions as system; +use wheels::{ + layout::Decodable, require, require_eq, require_eq_keys, require_ge, + require_owned_by, require_signer, +}; + +use crate::{ + apply_diff_in_place, legacy::processor::fast::utils::LamportsOperation, +}; + +pub(crate) enum CommitStateUpdate<'a> { + FullData(&'a [u8]), + Diff(dpi::diff::DiffSet<'a>), +} + +impl CommitStateUpdate<'_> { + fn data_len(&self) -> usize { + match self { + Self::FullData(data) => data.len(), + Self::Diff(diff) => diff.changed_len(), + } + } +} + +pub(crate) struct CommitFinalizeInput<'a> { + pub(crate) commit_seq: u32, + pub(crate) lamports: u64, + pub(crate) owner: &'a dpi::compat::Pubkey, + pub(crate) allow_undelegation: bool, + pub(crate) state_update: CommitStateUpdate<'a>, +} + +pub(crate) fn apply_commit_finalize( + current_validator: &AccountView, + delegated_account: &AccountView, + delegation_state: &AccountView, + delegation_cluster: &AccountView, + input: CommitFinalizeInput<'_>, +) -> ProgramResult { + require_signer!(current_validator); + require!(current_validator.is_writable(), ProgramError::Immutable); + require_owned_by!(delegated_account, &crate::fast::ID); + require!(delegated_account.is_writable(), ProgramError::Immutable); + + let cluster_data = delegation_cluster.try_borrow()?; + let cluster = DelegationCluster::decode(cluster_data.as_ref())?; + let cluster_bump = [cluster.bump()]; + dpi::require_initialized_pda!( + delegation_cluster, + &[ + DELEGATION_CLUSTER_SEED, + cluster.cluster_id().as_ref(), + &cluster_bump, + ], + &crate::fast::ID, + false + ); + require_eq!( + &cluster.discriminator(), + &DelegationCluster::DISCRIMINATOR, + ProgramError::InvalidAccountData + ); + require!(!cluster.paused(), ProgramError::InvalidAccountData); + require_eq!( + cluster.challenge_config().is_none(), + true, + ProgramError::InvalidInstructionData + ); + require_eq_keys!( + cluster.current_validator(), + current_validator.address(), + DlpError::InvalidAuthority + ); + drop(cluster_data); + + let state_data = delegation_state.try_borrow()?; + let state = DelegationState::decode(state_data.as_ref())?; + let state_bump = [state.bump()]; + dpi::require_initialized_pda!( + delegation_state, + &[ + DELEGATION_STATE_SEED, + delegated_account.address().as_ref(), + &state_bump, + ], + &crate::fast::ID, + true + ); + require_eq!( + &state.discriminator(), + &DelegationState::DISCRIMINATOR, + ProgramError::InvalidAccountData + ); + require_eq_keys!( + state.delegated_account(), + delegated_account.address(), + DlpError::InvalidDelegatedAccount + ); + require_eq_keys!( + state.delegation_cluster(), + delegation_cluster.address(), + ProgramError::InvalidAccountData + ); + require_eq_keys!( + state.original_owner(), + input.owner, + ProgramError::InvalidAccountData + ); + require_eq!( + input.commit_seq, + state + .last_commit_seq() + .checked_add(1) + .ok_or(DlpError::Overflow)?, + DlpError::NonceOutOfOrder + ); + + let next_undelegation_requester = match state.undelegation_requester() { + value if value == UndelegationRequester::None.value() => { + if input.allow_undelegation { + UndelegationRequester::Validator.value() + } else { + value + } + } + value if value == UndelegationRequester::Validator.value() => { + return Err(DlpError::AlreadyUndelegated.into()); + } + value if value == UndelegationRequester::OwnerProgram.value() => { + require!( + input.allow_undelegation, + DlpError::OwnerRequestedUndelegation + ); + value + } + _ => return Err(ProgramError::InvalidAccountData), + }; + let previous_lamports = state.lamports(); + drop(state_data); + + require_ge!( + delegated_account.lamports(), + previous_lamports, + DlpError::InvalidDelegatedState + ); + + delegated_account.resize(input.state_update.data_len())?; + settle_lamports( + current_validator, + delegated_account, + previous_lamports, + input.lamports, + )?; + require_ge!( + delegated_account.lamports(), + Rent::get()?.try_minimum_balance(delegated_account.data_len())?, + DlpError::InsufficientRent + ); + + let mut account_data = delegated_account.try_borrow_mut()?; + match input.state_update { + CommitStateUpdate::FullData(data) => { + account_data.as_mut().copy_from_slice(data); + } + CommitStateUpdate::Diff(diff) => { + apply_diff_in_place(account_data.as_mut(), &diff)?; + } + } + drop(account_data); + + let mut state = DelegationState::decode_mut(delegation_state)?; + state.lamports_mut()?.set(input.lamports)?; + state.last_commit_seq_mut()?.set(input.commit_seq)?; + *state.undelegation_requester_mut()? = next_undelegation_requester; + + Ok(()) +} + +fn settle_lamports( + current_validator: &AccountView, + delegated_account: &AccountView, + previous_lamports: u64, + commit_lamports: u64, +) -> ProgramResult { + match commit_lamports.cmp(&previous_lamports) { + core::cmp::Ordering::Greater => system::Transfer { + from: current_validator, + to: delegated_account, + lamports: commit_lamports + .checked_sub(previous_lamports) + .ok_or(DlpError::Overflow)?, + } + .invoke(), + core::cmp::Ordering::Less => { + let delta = previous_lamports + .checked_sub(commit_lamports) + .ok_or(DlpError::Overflow)?; + delegated_account.lamports_decrement_by(delta)?; + current_validator.lamports_increment_by(delta) + } + core::cmp::Ordering::Equal => Ok(()), + } +} diff --git a/program/src/processor/internal/mod.rs b/program/src/processor/internal/mod.rs new file mode 100644 index 00000000..fe84489c --- /dev/null +++ b/program/src/processor/internal/mod.rs @@ -0,0 +1,3 @@ +mod commit_finalize; + +pub(crate) use commit_finalize::*; diff --git a/program/src/processor/mod.rs b/program/src/processor/mod.rs index 11fbba97..81cb2afa 100644 --- a/program/src/processor/mod.rs +++ b/program/src/processor/mod.rs @@ -1,10 +1,13 @@ use delegation_program_interface as dpi; mod bootstrap; mod delegate; +mod direct; mod fraud_proofs; +pub(crate) mod internal; pub use bootstrap::*; pub use delegate::*; +pub use direct::*; use dpi::DlpV2Instruction; pub use fraud_proofs::*; use pinocchio::{AccountView, ProgramResult}; @@ -53,5 +56,8 @@ pub fn process_instruction( process_resolve_dispute(accounts, data) } DlpV2Instruction::Delegate => process_delegate(accounts, data), + DlpV2Instruction::CommitFinalize => { + process_commit_finalize(accounts, data) + } } } diff --git a/program/tests/test_primary_commit_finalize.rs b/program/tests/test_primary_commit_finalize.rs new file mode 100644 index 00000000..793bfd7b --- /dev/null +++ b/program/tests/test_primary_commit_finalize.rs @@ -0,0 +1,366 @@ +use delegation_program::solana_program; +use delegation_program_interface as dpi; +use dpi::{ + diff::compute_diff, + instruction_builder::commit_finalize, + pda::{delegation_cluster_pda, delegation_state_pda}, + CommitFinalizeArgs, CommitPayloadKind, DelegationCluster, DelegationState, + UndelegationRequester, +}; +use solana_program::{ + native_token::LAMPORTS_PER_SOL, pubkey::Pubkey, rent::Rent, +}; +use solana_program_test::{BanksClient, BanksClientError, ProgramTest}; +use solana_sdk::{ + account::Account, + instruction::InstructionError, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, +}; +use solana_sdk_ids::system_program; +use wheels::layout::{Decodable, Encodable}; + +const INITIAL_DATA: &[u8] = b"initial account data"; +const FULL_DATA: &[u8] = b"complete committed account data"; +const INITIAL_LAMPORTS: u64 = LAMPORTS_PER_SOL; + +struct CommitFinalizeTestContext { + banks: BanksClient, + payer: Keypair, + current_validator: Keypair, + delegated_account: Pubkey, + delegation_state: Pubkey, + delegation_cluster: Pubkey, + original_owner: Pubkey, +} + +#[tokio::test] +async fn test_commit_finalize_applies_full_data_and_diff() { + let context = + setup_commit_finalize_test(false, UndelegationRequester::None).await; + + submit_commit( + &context, + &context.current_validator, + CommitFinalizeArgs { + commit_seq: 1, + lamports: INITIAL_LAMPORTS + 500, + owner: context.original_owner, + allow_undelegation: false, + payload_kind: CommitPayloadKind::FullData.value(), + payload_alignment_padding: 0, + payload: FULL_DATA.to_vec(), + }, + ) + .await + .unwrap(); + + { + let account = context + .banks + .get_account(context.delegated_account) + .await + .unwrap() + .unwrap(); + let state_account = context + .banks + .get_account(context.delegation_state) + .await + .unwrap() + .unwrap(); + let state = DelegationState::decode(&state_account.data).unwrap(); + + assert_eq!(account.data, FULL_DATA); + assert_eq!(account.lamports, INITIAL_LAMPORTS + 500); + assert_eq!(state.last_commit_seq(), 1); + assert_eq!(state.lamports(), INITIAL_LAMPORTS + 500); + assert_eq!( + state.undelegation_requester(), + UndelegationRequester::None.value() + ); + } + + let changed_data = b"complete changed account data and tail"; + submit_commit( + &context, + &context.current_validator, + CommitFinalizeArgs { + commit_seq: 2, + lamports: INITIAL_LAMPORTS + 300, + owner: context.original_owner, + allow_undelegation: true, + payload_kind: CommitPayloadKind::Diff.value(), + payload_alignment_padding: 0, + payload: compute_diff(FULL_DATA, changed_data).to_vec(), + }, + ) + .await + .unwrap(); + + let account = context + .banks + .get_account(context.delegated_account) + .await + .unwrap() + .unwrap(); + let state_account = context + .banks + .get_account(context.delegation_state) + .await + .unwrap() + .unwrap(); + let state = DelegationState::decode(&state_account.data).unwrap(); + + assert_eq!(account.data, changed_data); + assert_eq!(account.lamports, INITIAL_LAMPORTS + 300); + assert_eq!(state.last_commit_seq(), 2); + assert_eq!(state.lamports(), INITIAL_LAMPORTS + 300); + assert_eq!( + state.undelegation_requester(), + UndelegationRequester::Validator.value() + ); +} + +#[tokio::test] +async fn test_commit_finalize_rejects_out_of_order_commit() { + let context = + setup_commit_finalize_test(false, UndelegationRequester::None).await; + let result = submit_commit( + &context, + &context.current_validator, + full_data_args(&context, 2), + ) + .await; + + assert_custom_error( + result.unwrap_err(), + dpi::error::DlpError::NonceOutOfOrder, + ); +} + +#[tokio::test] +async fn test_commit_finalize_rejects_challenge_cluster() { + let context = + setup_commit_finalize_test(true, UndelegationRequester::None).await; + + assert!(submit_commit( + &context, + &context.current_validator, + full_data_args(&context, 1), + ) + .await + .is_err()); +} + +#[tokio::test] +async fn test_commit_finalize_rejects_wrong_validator_and_owner() { + let context = + setup_commit_finalize_test(false, UndelegationRequester::None).await; + + assert_custom_error( + submit_commit(&context, &context.payer, full_data_args(&context, 1)) + .await + .unwrap_err(), + dpi::error::DlpError::InvalidAuthority, + ); + + let mut args = full_data_args(&context, 1); + args.owner = Pubkey::new_unique(); + assert!(submit_commit(&context, &context.current_validator, args) + .await + .is_err()); +} + +#[tokio::test] +async fn test_commit_finalize_requires_owner_undelegation_to_be_allowed() { + let context = + setup_commit_finalize_test(false, UndelegationRequester::OwnerProgram) + .await; + + assert_custom_error( + submit_commit( + &context, + &context.current_validator, + full_data_args(&context, 1), + ) + .await + .unwrap_err(), + dpi::error::DlpError::OwnerRequestedUndelegation, + ); +} + +fn full_data_args( + context: &CommitFinalizeTestContext, + commit_seq: u32, +) -> CommitFinalizeArgs { + CommitFinalizeArgs { + commit_seq, + lamports: INITIAL_LAMPORTS, + owner: context.original_owner, + allow_undelegation: false, + payload_kind: CommitPayloadKind::FullData.value(), + payload_alignment_padding: 0, + payload: FULL_DATA.to_vec(), + } +} + +async fn submit_commit( + context: &CommitFinalizeTestContext, + validator: &Keypair, + args: CommitFinalizeArgs, +) -> Result<(), BanksClientError> { + let instruction = commit_finalize( + validator.pubkey(), + context.delegation_cluster, + context.delegated_account, + args, + ) + .unwrap(); + let tx = Transaction::new_signed_with_payer( + &[instruction], + Some(&context.payer.pubkey()), + &[&context.payer, validator], + context.banks.get_latest_blockhash().await.unwrap(), + ); + + context.banks.process_transaction(tx).await +} + +async fn setup_commit_finalize_test( + challenge_enabled: bool, + undelegation_requester: UndelegationRequester, +) -> CommitFinalizeTestContext { + let mut program_test = ProgramTest::default(); + program_test.prefer_bpf(true); + program_test.add_program("delegation_program", dpi::ID, None); + + let current_validator = Keypair::new(); + let delegated_account = Pubkey::new_unique(); + let original_owner = Pubkey::new_unique(); + let cluster_id = Pubkey::new_unique(); + let delegation_cluster = delegation_cluster_pda(&cluster_id); + let (_, cluster_bump) = Pubkey::find_program_address( + &[dpi::pda::DELEGATION_CLUSTER_SEED, cluster_id.as_ref()], + &dpi::id(), + ); + let delegation_state = delegation_state_pda(&delegated_account); + let (_, state_bump) = Pubkey::find_program_address( + &[dpi::pda::DELEGATION_STATE_SEED, delegated_account.as_ref()], + &dpi::id(), + ); + + program_test.add_account( + current_validator.pubkey(), + Account { + lamports: 10 * LAMPORTS_PER_SOL, + data: vec![], + owner: system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); + program_test.add_account( + delegated_account, + Account { + lamports: INITIAL_LAMPORTS, + data: INITIAL_DATA.to_vec(), + owner: dpi::id(), + executable: false, + rent_epoch: 0, + }, + ); + + let cluster_data = DelegationCluster { + discriminator: DelegationCluster::DISCRIMINATOR, + bump: cluster_bump, + cluster_id, + config_authority: Pubkey::new_unique(), + validator_rotation_authority: Pubkey::new_unique(), + current_validator: current_validator.pubkey(), + paused: false, + protocol_fee_vault: Pubkey::new_unique(), + challenge_config: challenge_enabled.then(|| dpi::ChallengeConfig { + resolver: Pubkey::new_unique(), + min_operator_bond: 1, + min_verifier_bond: 1, + min_challenger_stake: 1, + challenge_window_slots: 1, + operator_response_timeout_slots: 1, + challenger_reveal_timeout_slots: 1, + payout_timelock_slots: 1, + verifiers_per_commitment: 1, + approval_threshold: 1, + max_window_extensions: 1, + match_penalty_bps: 1, + }), + } + .encode() + .unwrap(); + program_test.add_account( + delegation_cluster, + Account { + lamports: Rent::default().minimum_balance(cluster_data.len()), + data: cluster_data, + owner: dpi::id(), + executable: false, + rent_epoch: 0, + }, + ); + + let state_data = DelegationState { + discriminator: DelegationState::DISCRIMINATOR, + bump: state_bump, + delegated_account, + delegation_cluster, + original_owner, + rent_payer: Pubkey::new_unique(), + delegation_slot: 0, + lamports: INITIAL_LAMPORTS, + last_commit_seq: 0, + undelegation_requester: undelegation_requester.value(), + delegated_account_bump: 0, + seed_count: 0, + encoded_seeds: vec![], + } + .encode() + .unwrap(); + program_test.add_account( + delegation_state, + Account { + lamports: Rent::default().minimum_balance(state_data.len()), + data: state_data, + owner: dpi::id(), + executable: false, + rent_epoch: 0, + }, + ); + + let (banks, payer, _) = program_test.start().await; + CommitFinalizeTestContext { + banks, + payer, + current_validator, + delegated_account, + delegation_state, + delegation_cluster, + original_owner, + } +} + +fn assert_custom_error( + error: BanksClientError, + expected: dpi::error::DlpError, +) { + assert!( + matches!( + error, + BanksClientError::TransactionError( + TransactionError::InstructionError( + 0, + InstructionError::Custom(code), + ), + ) if code == expected as u32 + ), + "unexpected error: {error:?}" + ); +}