diff --git a/dlp-api/src/v2/args/mod.rs b/dlp-api/src/v2/args/mod.rs index d399fbcc..dfbcbe54 100644 --- a/dlp-api/src/v2/args/mod.rs +++ b/dlp-api/src/v2/args/mod.rs @@ -2,5 +2,7 @@ // instruction tag, so v2 instruction args use `buffer_offset = 1`. mod init_protocol_config; +mod register_operator; pub use init_protocol_config::*; +pub use register_operator::*; diff --git a/dlp-api/src/v2/args/register_operator.rs b/dlp-api/src/v2/args/register_operator.rs new file mode 100644 index 00000000..cfa8fc70 --- /dev/null +++ b/dlp-api/src/v2/args/register_operator.rs @@ -0,0 +1,7 @@ +use wheels::variable_offset_layout; + +#[derive(Clone, Debug, PartialEq, Eq)] +#[variable_offset_layout(buffer_offset = 1)] +pub struct RegisterOperatorArgs { + pub stake_lamports: u64, +} diff --git a/dlp-api/src/v2/instruction.rs b/dlp-api/src/v2/instruction.rs index fc1751ac..1cbbc2c3 100644 --- a/dlp-api/src/v2/instruction.rs +++ b/dlp-api/src/v2/instruction.rs @@ -7,6 +7,8 @@ use strum::IntoStaticStr; pub enum DlpV2Instruction { /// Creates the global v2 protocol config and verifier registry accounts. InitProtocolConfig = 100, + /// Registers one operator and deposits its initial stake. + RegisterOperator = 101, } impl DlpV2Instruction { diff --git a/dlp-api/src/v2/instruction_builder/mod.rs b/dlp-api/src/v2/instruction_builder/mod.rs index 13cc48ad..4d7ec5b2 100644 --- a/dlp-api/src/v2/instruction_builder/mod.rs +++ b/dlp-api/src/v2/instruction_builder/mod.rs @@ -1,3 +1,5 @@ mod init_protocol_config; +mod register_operator; pub use init_protocol_config::*; +pub use register_operator::*; diff --git a/dlp-api/src/v2/instruction_builder/register_operator.rs b/dlp-api/src/v2/instruction_builder/register_operator.rs new file mode 100644 index 00000000..406aa104 --- /dev/null +++ b/dlp-api/src/v2/instruction_builder/register_operator.rs @@ -0,0 +1,40 @@ +use solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, +}; +use solana_sdk_ids::system_program; +use wheels::layout::Encodable; + +use crate::{ + compat::{Compatize, Modernize}, + v2::{ + pda::{operator_bond_pda, protocol_config_pda}, + DlpV2Instruction, RegisterOperatorArgs, + }, +}; + +/// Builds the instruction that registers one operator for v2 commitments. +pub fn register_operator( + operator: Pubkey, + authority: Pubkey, + args: RegisterOperatorArgs, +) -> Instruction { + Instruction { + program_id: crate::id().modernize(), + accounts: vec![ + AccountMeta::new(operator, true), + AccountMeta::new_readonly(authority, true), + AccountMeta::new( + operator_bond_pda(&operator.compatize()).modernize(), + false, + ), + AccountMeta::new_readonly(protocol_config_pda().modernize(), false), + AccountMeta::new_readonly(system_program::id(), false), + ], + data: [ + DlpV2Instruction::RegisterOperator.to_vec(), + args.encode().unwrap(), + ] + .concat(), + } +} diff --git a/dlp-api/src/v2/pda.rs b/dlp-api/src/v2/pda.rs index b1f2ad9f..eef0929e 100644 --- a/dlp-api/src/v2/pda.rs +++ b/dlp-api/src/v2/pda.rs @@ -1,6 +1,7 @@ use crate::compat::Pubkey; pub const PROTOCOL_CONFIG_SEED: &[u8] = b"protocol-config"; +pub const OPERATOR_BOND_SEED: &[u8] = b"operator-bond"; pub const VERIFIER_REGISTRY_SEED: &[u8] = b"verifier-registry"; // TODO (snawaz): Precompute these addresses if PDA derivation becomes const-safe. @@ -12,3 +13,11 @@ pub fn protocol_config_pda() -> Pubkey { pub fn verifier_registry_pda() -> Pubkey { Pubkey::find_program_address(&[VERIFIER_REGISTRY_SEED], &crate::id()).0 } + +pub fn operator_bond_pda(operator: &Pubkey) -> Pubkey { + Pubkey::find_program_address( + &[OPERATOR_BOND_SEED, operator.as_ref()], + &crate::id(), + ) + .0 +} diff --git a/dlp-api/src/v2/state/mod.rs b/dlp-api/src/v2/state/mod.rs index 31678d9d..a8504d35 100644 --- a/dlp-api/src/v2/state/mod.rs +++ b/dlp-api/src/v2/state/mod.rs @@ -1,5 +1,7 @@ +mod operator_bond; mod protocol_config; mod verifier_registry; +pub use operator_bond::*; pub use protocol_config::*; pub use verifier_registry::*; diff --git a/dlp-api/src/v2/state/operator_bond.rs b/dlp-api/src/v2/state/operator_bond.rs new file mode 100644 index 00000000..1f9d1ff6 --- /dev/null +++ b/dlp-api/src/v2/state/operator_bond.rs @@ -0,0 +1,51 @@ +use wheels::fixed_offset_layout; + +use crate::compat::Pubkey; + +#[derive(Clone, Debug, PartialEq, Eq)] +#[fixed_offset_layout(buffer_offset = 0)] +pub struct OperatorBond { + /// Account type marker. + pub discriminator: [u8; 8], + + /// Canonical PDA bump for this account. + pub bump: u8, + + /// Operator identity allowed to post commitments through this bond. + pub operator_identity: Pubkey, + + /// Slashable operator stake held in this account. + /// CHECKPOINT: the staking asset is SOL or BLOCK? + /// If this changes to BLOCK, this field will need to point at token-account + /// accounting instead of native lamports. + pub stake_lamports: u64, + + /// Stake reserved by active commitments. + /// CHECKPOINT: the staking asset is SOL or BLOCK? + pub locked_lamports: u64, + + /// Current operator lifecycle state, stored as `OperatorStatus::value()`. + pub status: u8, + + /// Slot when withdrawal was requested, if the operator is exiting. + pub withdraw_requested_slot: Option, +} + +impl OperatorBond { + pub const DISCRIMINATOR: [u8; 8] = *b"v2opbond"; +} + +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OperatorStatus { + Active = 1, + Exiting = 2, + Slashed = 3, + Jailed = 4, +} + +impl OperatorStatus { + pub const fn value(self) -> u8 { + self as u8 + } +} diff --git a/src/v2/processor/bootstrap/mod.rs b/src/v2/processor/bootstrap/mod.rs index 13cc48ad..4d7ec5b2 100644 --- a/src/v2/processor/bootstrap/mod.rs +++ b/src/v2/processor/bootstrap/mod.rs @@ -1,3 +1,5 @@ mod init_protocol_config; +mod register_operator; pub use init_protocol_config::*; +pub use register_operator::*; diff --git a/src/v2/processor/bootstrap/register_operator.rs b/src/v2/processor/bootstrap/register_operator.rs new file mode 100644 index 00000000..98d0a54b --- /dev/null +++ b/src/v2/processor/bootstrap/register_operator.rs @@ -0,0 +1,119 @@ +use dlp_api::{ + error::DlpError, + v2::{ + pda::{OPERATOR_BOND_SEED, PROTOCOL_CONFIG_SEED}, + OperatorBond, OperatorStatus, ProtocolConfig, RegisterOperatorArgs, + }, +}; +use pinocchio::{ + cpi::{Seed, Signer}, + error::ProgramError, + AccountView, ProgramResult, +}; +use pinocchio_system::instructions as system; +use wheels::{ + layout::{Decodable, Encodable}, + require, require_eq_keys, require_ge, require_n_accounts, require_signer, +}; + +use crate::{ + processor::fast::utils::pda::create_pda, + requires::{ + require_initialized_pda, require_uninitialized_pda, StandardCtx, + }, +}; + +/// Register one operator for v2 commitments. +/// +/// Accounts: +/// 0: `[signer, writable]` operator identity and stake payer +/// 1: `[signer]` protocol authority that admits the operator +/// 2: `[writable]` OperatorBond PDA +/// 3: `[]` ProtocolConfig PDA +/// 4: `[]` system program, required by system CPI +#[inline(never)] +pub fn process_register_operator( + accounts: &[AccountView], + data: &[u8], +) -> ProgramResult { + let [ + operator, // force multi-line + authority, + operator_bond, + protocol_config, + _system_program, + ] = require_n_accounts!(accounts, 5); + + require_signer!(operator); + require_signer!(authority); + + let args = RegisterOperatorArgs::decode(data)?; + + require_initialized_pda( + protocol_config, + &[PROTOCOL_CONFIG_SEED], + &crate::fast::ID, + false, + "protocol config", + )?; + let protocol_config_data = protocol_config.try_borrow()?; + let protocol_config_state = + ProtocolConfig::decode(protocol_config_data.as_ref())?; + require!( + protocol_config_state.discriminator() == ProtocolConfig::DISCRIMINATOR, + ProgramError::InvalidAccountData + ); + + require_eq_keys!( + protocol_config_state.authority(), + authority.address(), + DlpError::InvalidAuthority + ); + require_ge!( + args.stake_lamports(), + protocol_config_state.min_operator_bond(), + ProgramError::InvalidInstructionData + ); + + drop(protocol_config_data); + + let operator_bond_bump = require_uninitialized_pda( + operator_bond, + &[OPERATOR_BOND_SEED, operator.address().as_ref()], + &crate::fast::ID, + true, + StandardCtx::new("operator bond"), + )?; + + create_pda( + operator_bond, + &crate::fast::ID, + OperatorBond::DATA_LEN, + &[Signer::from(&[ + Seed::from(OPERATOR_BOND_SEED), + Seed::from(operator.address().as_ref()), + Seed::from(&[operator_bond_bump]), + ])], + operator, + )?; + + system::Transfer { + from: operator, + to: operator_bond, + lamports: args.stake_lamports(), + } + .invoke()?; + + OperatorBond { + discriminator: OperatorBond::DISCRIMINATOR, + bump: operator_bond_bump, + operator_identity: operator.address().to_bytes().into(), + stake_lamports: args.stake_lamports(), + locked_lamports: 0, + status: OperatorStatus::Active.value(), + withdraw_requested_slot: None, + } + .encode_to(operator_bond.try_borrow_mut()?.as_mut())?; + + Ok(()) +} diff --git a/src/v2/processor/mod.rs b/src/v2/processor/mod.rs index 666462a0..4cdbf83e 100644 --- a/src/v2/processor/mod.rs +++ b/src/v2/processor/mod.rs @@ -16,5 +16,8 @@ pub fn process_instruction( DlpV2Instruction::InitProtocolConfig => { process_init_protocol_config(accounts, data) } + DlpV2Instruction::RegisterOperator => { + process_register_operator(accounts, data) + } } } diff --git a/tests/fixtures/mod.rs b/tests/fixtures/mod.rs index 5903b91f..3ef9b7c5 100644 --- a/tests/fixtures/mod.rs +++ b/tests/fixtures/mod.rs @@ -1,4 +1,5 @@ pub mod accounts; +pub mod v2; #[allow(unused_imports)] pub(crate) use accounts::*; diff --git a/tests/fixtures/v2.rs b/tests/fixtures/v2.rs new file mode 100644 index 00000000..d7641cb0 --- /dev/null +++ b/tests/fixtures/v2.rs @@ -0,0 +1,69 @@ +use dlp_api::v2::{ + instruction_builder::init_protocol_config, InitProtocolConfigArgs, +}; +use solana_program::{ + hash::Hash, native_token::LAMPORTS_PER_SOL, pubkey::Pubkey, +}; +use solana_program_test::{BanksClient, ProgramTest}; +use solana_sdk::{ + account::Account, + signature::{Keypair, Signer}, + transaction::Transaction, +}; +use solana_sdk_ids::system_program; + +pub fn valid_protocol_config_args() -> InitProtocolConfigArgs { + InitProtocolConfigArgs { + resolver: Pubkey::new_unique(), + min_operator_bond: 1, + min_verifier_bond: 1, + min_challenger_stake: 1, + challenge_window_slots: 10, + operator_response_timeout_slots: 10, + challenger_reveal_timeout_slots: 10, + payout_timelock_slots: 10, + verifiers_per_commitment: 1, + approval_threshold: 1, + max_window_extensions: 1, + match_penalty_bps: 500, + } +} + +#[allow(dead_code)] +pub async fn initialize_protocol_config( + banks: &BanksClient, + payer: &Keypair, + authority: &Keypair, + blockhash: Hash, + args: InitProtocolConfigArgs, +) { + let ix = init_protocol_config(authority.pubkey(), args); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[payer, authority], + blockhash, + ); + + banks.process_transaction(tx).await.unwrap(); +} + +pub async fn setup_program_test_env() -> (BanksClient, Keypair, Keypair, Hash) { + let mut program_test = ProgramTest::new("dlp", dlp_api::ID, None); + program_test.prefer_bpf(true); + + let authority = Keypair::new(); + program_test.add_account( + authority.pubkey(), + Account { + lamports: LAMPORTS_PER_SOL, + data: vec![], + owner: system_program::id(), + executable: false, + rent_epoch: 0, + }, + ); + + let (banks, payer, blockhash) = program_test.start().await; + (banks, payer, authority, blockhash) +} diff --git a/tests/test_v2_init_protocol_config.rs b/tests/test_v2_init_protocol_config.rs index 10787960..4469cd35 100644 --- a/tests/test_v2_init_protocol_config.rs +++ b/tests/test_v2_init_protocol_config.rs @@ -1,4 +1,3 @@ -use dlp::solana_program; use dlp_api::{ pda::fees_vault_pda, v2::{ @@ -7,22 +6,16 @@ use dlp_api::{ protocol_config_pda, verifier_registry_pda, PROTOCOL_CONFIG_SEED, VERIFIER_REGISTRY_SEED, }, - InitProtocolConfigArgs, ProtocolConfig, VerifierRegistry, + ProtocolConfig, VerifierRegistry, }, }; -use solana_program::{hash::Hash, native_token::LAMPORTS_PER_SOL}; -use solana_program_test::{BanksClient, ProgramTest}; -use solana_sdk::{ - account::Account, - pubkey::Pubkey, - signature::{Keypair, Signer}, - transaction::Transaction, -}; -use solana_sdk_ids::system_program; +use solana_sdk::{pubkey::Pubkey, signature::Signer, transaction::Transaction}; use wheels::layout::Decodable; mod fixtures; +use crate::fixtures::v2::{setup_program_test_env, valid_protocol_config_args}; + #[tokio::test] async fn test_init_protocol_config() { let (banks, payer, authority, blockhash) = setup_program_test_env().await; @@ -145,7 +138,6 @@ async fn test_init_protocol_config_fails_with_wrong_protocol_config_pda() { let mut ix = init_protocol_config(authority.pubkey(), valid_protocol_config_args()); - ix.accounts[1].pubkey = Pubkey::new_unique(); let tx = Transaction::new_signed_with_payer( @@ -164,7 +156,6 @@ async fn test_init_protocol_config_fails_with_wrong_verifier_registry_pda() { let mut ix = init_protocol_config(authority.pubkey(), valid_protocol_config_args()); - ix.accounts[2].pubkey = Pubkey::new_unique(); let tx = Transaction::new_signed_with_payer( @@ -183,7 +174,6 @@ async fn test_init_protocol_config_fails_without_authority_signature() { let mut ix = init_protocol_config(authority.pubkey(), valid_protocol_config_args()); - ix.accounts[0].is_signer = false; let tx = Transaction::new_signed_with_payer( @@ -201,7 +191,6 @@ async fn test_init_protocol_config_fails_with_invalid_args() { let (banks, payer, authority, blockhash) = setup_program_test_env().await; let mut args = valid_protocol_config_args(); - args.approval_threshold = args.verifiers_per_commitment + 1; let ix = init_protocol_config(authority.pubkey(), args); @@ -220,7 +209,6 @@ async fn test_init_protocol_config_fails_with_zero_verifier_cap() { let (banks, payer, authority, blockhash) = setup_program_test_env().await; let mut args = valid_protocol_config_args(); - args.verifiers_per_commitment = 0; let ix = init_protocol_config(authority.pubkey(), args); @@ -239,7 +227,6 @@ async fn test_init_protocol_config_fails_with_zero_approval_threshold() { let (banks, payer, authority, blockhash) = setup_program_test_env().await; let mut args = valid_protocol_config_args(); - args.approval_threshold = 0; let ix = init_protocol_config(authority.pubkey(), args); @@ -252,40 +239,3 @@ async fn test_init_protocol_config_fails_with_zero_approval_threshold() { assert!(banks.process_transaction(tx).await.is_err()); } - -fn valid_protocol_config_args() -> InitProtocolConfigArgs { - InitProtocolConfigArgs { - resolver: Pubkey::new_unique(), - min_operator_bond: 1, - min_verifier_bond: 1, - min_challenger_stake: 1, - challenge_window_slots: 10, - operator_response_timeout_slots: 10, - challenger_reveal_timeout_slots: 10, - payout_timelock_slots: 10, - verifiers_per_commitment: 1, - approval_threshold: 1, - max_window_extensions: 1, - match_penalty_bps: 500, - } -} - -async fn setup_program_test_env() -> (BanksClient, Keypair, Keypair, Hash) { - let mut program_test = ProgramTest::new("dlp", dlp_api::ID, None); - program_test.prefer_bpf(true); - - let authority = Keypair::new(); - program_test.add_account( - authority.pubkey(), - Account { - lamports: LAMPORTS_PER_SOL, - data: vec![], - owner: system_program::id(), - executable: false, - rent_epoch: 0, - }, - ); - - let (banks, payer, blockhash) = program_test.start().await; - (banks, payer, authority, blockhash) -} diff --git a/tests/test_v2_register_operator.rs b/tests/test_v2_register_operator.rs new file mode 100644 index 00000000..d5d46021 --- /dev/null +++ b/tests/test_v2_register_operator.rs @@ -0,0 +1,237 @@ +use dlp_api::v2::{ + instruction_builder::register_operator, pda::OPERATOR_BOND_SEED, + OperatorBond, OperatorStatus, RegisterOperatorArgs, +}; +use solana_program::{ + native_token::LAMPORTS_PER_SOL, pubkey::Pubkey, rent::Rent, +}; +use solana_program_test::ProgramTestBanksClientExt; +use solana_sdk::{ + signature::{Keypair, Signer}, + transaction::Transaction, +}; +use solana_system_interface::instruction as system_instruction; +use wheels::layout::Decodable; + +mod fixtures; + +use crate::fixtures::v2::{ + initialize_protocol_config, setup_program_test_env, + valid_protocol_config_args, +}; + +#[tokio::test] +async fn test_register_operator() { + let (banks, payer, authority, blockhash) = setup_program_test_env().await; + let config_args = valid_protocol_config_args(); + let operator = Keypair::new(); + let (operator_bond_address, expected_operator_bond_bump) = + Pubkey::find_program_address( + &[OPERATOR_BOND_SEED, operator.pubkey().as_ref()], + &dlp_api::id(), + ); + + initialize_protocol_config( + &banks, + &payer, + &authority, + blockhash, + config_args.clone(), + ) + .await; + fund_operator(&banks, &payer, &operator).await; + + let blockhash = banks.get_latest_blockhash().await.unwrap(); + let ix = register_operator( + operator.pubkey(), + authority.pubkey(), + RegisterOperatorArgs { + stake_lamports: config_args.min_operator_bond, + }, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer, &operator, &authority], + blockhash, + ); + + assert!(banks.process_transaction(tx).await.is_ok()); + + let operator_bond_account = banks + .get_account(operator_bond_address) + .await + .unwrap() + .unwrap(); + let operator_bond = + OperatorBond::decode(&operator_bond_account.data).unwrap(); + let expected_operator_bond_lamports = Rent::default() + .minimum_balance(OperatorBond::DATA_LEN) + + config_args.min_operator_bond; + + assert_eq!(operator_bond.discriminator(), OperatorBond::DISCRIMINATOR); + assert_eq!(operator_bond.bump(), expected_operator_bond_bump); + assert_eq!(*operator_bond.operator_identity(), operator.pubkey()); + assert_eq!( + operator_bond.stake_lamports(), + config_args.min_operator_bond + ); + assert_eq!(operator_bond.locked_lamports(), 0); + assert_eq!(operator_bond.status(), OperatorStatus::Active.value()); + assert_eq!(operator_bond.withdraw_requested_slot(), None); + assert_eq!( + operator_bond_account.lamports, + expected_operator_bond_lamports + ); +} + +#[tokio::test] +async fn test_register_operator_fails_with_wrong_authority() { + let (banks, payer, authority, blockhash) = setup_program_test_env().await; + let config_args = valid_protocol_config_args(); + let operator = Keypair::new(); + + initialize_protocol_config( + &banks, + &payer, + &authority, + blockhash, + config_args.clone(), + ) + .await; + fund_operator(&banks, &payer, &operator).await; + + let wrong_authority = Keypair::new(); + let blockhash = banks.get_latest_blockhash().await.unwrap(); + let ix = register_operator( + operator.pubkey(), + wrong_authority.pubkey(), + RegisterOperatorArgs { + stake_lamports: config_args.min_operator_bond, + }, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer, &operator, &wrong_authority], + blockhash, + ); + + assert!(banks.process_transaction(tx).await.is_err()); +} + +#[tokio::test] +async fn test_register_operator_fails_with_low_stake() { + let (banks, payer, authority, blockhash) = setup_program_test_env().await; + let config_args = valid_protocol_config_args(); + let operator = Keypair::new(); + + initialize_protocol_config( + &banks, + &payer, + &authority, + blockhash, + config_args, + ) + .await; + fund_operator(&banks, &payer, &operator).await; + + let blockhash = banks.get_latest_blockhash().await.unwrap(); + let ix = register_operator( + operator.pubkey(), + authority.pubkey(), + RegisterOperatorArgs { stake_lamports: 0 }, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer, &operator, &authority], + blockhash, + ); + + assert!(banks.process_transaction(tx).await.is_err()); +} + +#[tokio::test] +async fn test_register_operator_fails_twice() { + let (mut banks, payer, authority, blockhash) = + setup_program_test_env().await; + let config_args = valid_protocol_config_args(); + let operator = Keypair::new(); + + initialize_protocol_config( + &banks, + &payer, + &authority, + blockhash, + config_args.clone(), + ) + .await; + fund_operator(&banks, &payer, &operator).await; + + { + let ix = register_operator( + operator.pubkey(), + authority.pubkey(), + RegisterOperatorArgs { + stake_lamports: config_args.min_operator_bond, + }, + ); + let latest_blockhash = banks.get_latest_blockhash().await.unwrap(); + let blockhash = banks + .get_new_latest_blockhash(&latest_blockhash) + .await + .unwrap(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer, &operator, &authority], + blockhash, + ); + banks.process_transaction(tx).await.unwrap(); + } + + { + let ix = register_operator( + operator.pubkey(), + authority.pubkey(), + RegisterOperatorArgs { + stake_lamports: config_args.min_operator_bond, + }, + ); + let latest_blockhash = banks.get_latest_blockhash().await.unwrap(); + let blockhash = banks + .get_new_latest_blockhash(&latest_blockhash) + .await + .unwrap(); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[&payer, &operator, &authority], + blockhash, + ); + + assert!(banks.process_transaction(tx).await.is_err()); + } +} + +async fn fund_operator( + banks: &solana_program_test::BanksClient, + payer: &Keypair, + operator: &Keypair, +) { + let blockhash = banks.get_latest_blockhash().await.unwrap(); + let ix = system_instruction::transfer( + &payer.pubkey(), + &operator.pubkey(), + LAMPORTS_PER_SOL, + ); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[payer], + blockhash, + ); + + banks.process_transaction(tx).await.unwrap(); +}