diff --git a/program/src/address.rs b/program/src/address.rs new file mode 100644 index 0000000..f128ce7 --- /dev/null +++ b/program/src/address.rs @@ -0,0 +1,45 @@ +//! Helper struct to derive the seeds for the report PDA + +use { + crate::{id, state::ViolationReport}, + solana_program::pubkey::Pubkey, +}; + +pub(crate) struct ViolationReportAddress<'a> { + address: Pubkey, + pubkey_seed: &'a [u8], + slot_seed: &'a [u8; 8], + violation_seed: [u8; 1], + bump_seed: [u8; 1], +} + +impl<'a> ViolationReportAddress<'a> { + pub(crate) fn new(report: &'a ViolationReport) -> ViolationReportAddress<'a> { + let pubkey_seed = report.pubkey.as_ref(); + let slot_seed = &report.slot.0; + let violation_seed = [report.violation_type]; + let (pda, bump) = + Pubkey::find_program_address(&[pubkey_seed, slot_seed, &violation_seed], &id()); + let bump_seed = [bump]; + Self { + address: pda, + pubkey_seed, + slot_seed, + violation_seed, + bump_seed, + } + } + + pub(crate) fn key(&self) -> &Pubkey { + &self.address + } + + pub(crate) fn seeds(&self) -> [&[u8]; 4] { + [ + self.pubkey_seed, + self.slot_seed, + &self.violation_seed, + &self.bump_seed, + ] + } +} diff --git a/program/src/error.rs b/program/src/error.rs index 4b07aa9..a8a09e7 100644 --- a/program/src/error.rs +++ b/program/src/error.rs @@ -9,6 +9,15 @@ use { /// Errors that may be returned by the program. #[derive(Clone, Copy, Debug, Eq, Error, FromPrimitive, PartialEq)] pub enum SlashingError { + /// Attempting to close a violation report before 3 + /// epochs have passed + #[error("Closing violation report too soon")] + CloseViolationReportTooSoon, + + /// Destination address is the report account itself + #[error("Destination address is the report account")] + DestinationAddressIsReportAccount, + /// Violation has already been reported #[error("Duplicate report")] DuplicateReport, @@ -17,6 +26,10 @@ pub enum SlashingError { #[error("Exceeds statute of limitations")] ExceedsStatuteOfLimitations, + /// Destination account does not match the key on the report + #[error("Invalid destination account")] + InvalidDestinationAccount, + /// Invalid shred variant #[error("Invalid shred variant")] InvalidShredVariant, diff --git a/program/src/instruction.rs b/program/src/instruction.rs index 8577a7d..a5e635b 100644 --- a/program/src/instruction.rs +++ b/program/src/instruction.rs @@ -29,6 +29,20 @@ use { #[repr(u8)] #[derive(Clone, Copy, Debug, PartialEq, TryFromPrimitive, IntoPrimitive)] pub enum SlashingInstruction { + /// Close the report account that was created after successfully submitting + /// a slashing proof. To ensure that the runtime and indexers have seen the + /// report, we require that at least 3 epochs have passed since creation. + /// + /// After closing the account, we credit the lamports to the destination + /// address denoted in the report. + /// + /// Accounts expected by this instruction: + /// 0. `[WRITE]` PDA where the violation report is stored, see + /// `[get_violation_report_address]` for the address derivation. + /// 1. `[WRITE]` Destination account which will be credited the lamports + /// from the PDA. + CloseViolationReport, + /// Submit a slashable violation proof for `node_pubkey`, which indicates /// that they submitted a duplicate block to the network /// @@ -36,7 +50,8 @@ pub enum SlashingInstruction { /// Accounts expected by this instruction: /// 0. `[]` Proof account, must be previously initialized with the proof /// data. - /// 1. `[WRITE]` PDA to store the violation report + /// 1. `[WRITE]` PDA to store the violation report, see + /// `[get_violation_report_address]` for the address derivation. /// 2. `[]` Instructions sysvar /// 3. `[]` System program /// @@ -141,6 +156,20 @@ pub(crate) fn decode_instruction_data(input_with_type: &[u8]) -> Result< } } +/// Create a `SlashingInstruction::CloseViolationReport` instruction +/// Callers can use `[get_violation_report_address]` to derive the report +/// account address +pub fn close_violation_report( + report_account: &Pubkey, + destination_account: &Pubkey, +) -> Instruction { + let accounts = vec![ + AccountMeta::new(*report_account, false), + AccountMeta::new(*destination_account, false), + ]; + encode_instruction(accounts, SlashingInstruction::CloseViolationReport, &()) +} + /// Create a `SlashingInstruction::DuplicateBlockProof` instruction pub fn duplicate_block_proof( proof_account: &Pubkey, @@ -286,7 +315,7 @@ mod tests { shred_2_signature, }; let instruction = duplicate_block_proof(&Pubkey::new_unique(), &instruction_data); - let mut expected = vec![0]; + let mut expected = vec![1]; expected.extend_from_slice(&offset.to_le_bytes()); expected.extend_from_slice(&slot.to_le_bytes()); expected.extend_from_slice(&node_pubkey.to_bytes()); @@ -316,6 +345,16 @@ mod tests { assert_eq!(instruction_data.shred_2_signature, shred_2_signature); } + #[test] + fn serialize_close_violation_report() { + let instruction = close_violation_report(&Pubkey::new_unique(), &Pubkey::new_unique()); + + assert_eq!( + SlashingInstruction::CloseViolationReport, + decode_instruction_type(&instruction.data).unwrap() + ); + } + #[test] fn deserialize_invalid_instruction() { let mut expected = vec![12]; diff --git a/program/src/lib.rs b/program/src/lib.rs index 799384f..deec1fc 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -1,6 +1,7 @@ //! Slashing program #![deny(missing_docs)] +mod address; pub mod duplicate_block_proof; mod entrypoint; pub mod error; diff --git a/program/src/processor.rs b/program/src/processor.rs index b152e83..cf1fb25 100644 --- a/program/src/processor.rs +++ b/program/src/processor.rs @@ -2,6 +2,7 @@ use { crate::{ + check_id, duplicate_block_proof::DuplicateBlockProofData, error::SlashingError, instruction::{ @@ -9,12 +10,12 @@ use { SlashingInstruction, }, state::{ - store_violation_report, PodEpoch, ProofType, SlashingAccounts, SlashingProofData, - ViolationReport, + close_violation_report, store_violation_report, PodEpoch, ProofType, SlashingAccounts, + SlashingProofData, ViolationReport, }, }, solana_program::{ - account_info::AccountInfo, + account_info::{next_account_info, AccountInfo}, entrypoint::ProgramResult, msg, program_error::ProgramError, @@ -65,9 +66,21 @@ pub fn process_instruction( ) -> ProgramResult { let instruction_type = decode_instruction_type(input)?; let account_info_iter = &mut accounts.iter(); - let accounts = SlashingAccounts::new(account_info_iter)?; match instruction_type { + SlashingInstruction::CloseViolationReport => { + let report_account = next_account_info(account_info_iter)?; + let destination_account = next_account_info(account_info_iter)?; + + if !check_id(report_account.owner) || report_account.data_is_empty() { + return Err(ProgramError::from( + SlashingError::InvalidViolationReportAcccount, + )); + } + + close_violation_report(report_account, destination_account)?; + } SlashingInstruction::DuplicateBlockProof => { + let accounts = SlashingAccounts::new(account_info_iter)?; let data = decode_instruction_data::(input)?; let proof_data = &accounts.proof_account.try_borrow_data()?[data.offset()?..]; let violation_report = ViolationReport { @@ -86,9 +99,9 @@ pub fn process_instruction( proof_data, input, )?; - Ok(()) } } + Ok(()) } #[cfg(test)] diff --git a/program/src/state.rs b/program/src/state.rs index f4d8144..97fa2d9 100644 --- a/program/src/state.rs +++ b/program/src/state.rs @@ -1,10 +1,13 @@ //! Program state use { - crate::{check_id, duplicate_block_proof::DuplicateBlockProofData, error::SlashingError, id}, + crate::{ + address::ViolationReportAddress, check_id, duplicate_block_proof::DuplicateBlockProofData, + error::SlashingError, id, + }, bytemuck::{Pod, Zeroable}, solana_program::{ account_info::{next_account_info, AccountInfo}, - clock::Slot, + clock::{Epoch, Slot}, msg, program::invoke_signed, program_error::ProgramError, @@ -13,7 +16,7 @@ use { system_instruction, system_program, sysvar::{self, Sysvar}, }, - spl_pod::primitives::PodU64, + spl_pod::{bytemuck::pod_from_bytes, primitives::PodU64}, }; const PACKET_DATA_SIZE: usize = 1232; @@ -222,16 +225,15 @@ pub(crate) fn store_violation_report<'a, 'b, T>( where T: SlashingProofData<'a>, { - let slot = report.slot; - let pubkey_seed = report.pubkey.as_ref(); - let slot_seed = slot.0; - let violation_seed = [report.violation_type]; - let mut seeds: Vec<&[u8]> = vec![&pubkey_seed, &slot_seed, &violation_seed]; - let (pda, bump) = Pubkey::find_program_address(&seeds, &id()); - let bump_seed = [bump]; - seeds.push(&bump_seed); - - if pda != *accounts.violation_account() { + let report_address = ViolationReportAddress::new(&report); + let report_key = report_address.key(); + let seeds = report_address.seeds(); + let cpi_accounts = [ + accounts.violation_pda_account.clone(), + accounts.system_program_account.clone(), + ]; + + if *report_key != *accounts.violation_account() { return Err(ProgramError::from( SlashingError::InvalidViolationReportAcccount, )); @@ -242,11 +244,17 @@ where msg!( "{} violation verified in slot {} however the violation has already been reported", T::PROOF_TYPE.violation_str(), - u64::from(slot), + u64::from(report.slot), ); return Err(ProgramError::from(SlashingError::DuplicateReport)); } + if *report_key == report.destination { + return Err(ProgramError::from( + SlashingError::DestinationAddressIsReportAccount, + )); + } + // Check if the account has been prefunded to store the report let data_len = ViolationReport::packed_len(&proof_data); let lamports = Rent::get()?.minimum_balance(data_len); @@ -255,15 +263,8 @@ where } // Assign the slashing program as the owner - let assign_instruction = system_instruction::assign(&pda, &id()); - invoke_signed( - &assign_instruction, - &[ - accounts.violation_pda_account.clone(), - accounts.system_program_account.clone(), - ], - &[&seeds], - )?; + let assign_instruction = system_instruction::assign(report_key, &id()); + invoke_signed(&assign_instruction, &cpi_accounts, &[&seeds])?; // Allocate enough space for the report accounts.violation_pda_account.realloc(data_len, false)?; @@ -282,6 +283,50 @@ where accounts.write_violation_report(report, proof_data) } +pub(crate) fn close_violation_report<'a, 'b>( + report_account: &'a AccountInfo<'b>, + destination_account: &'a AccountInfo<'b>, +) -> Result<(), ProgramError> { + let report_data = report_account.try_borrow_data()?; + let report: &ViolationReport = + pod_from_bytes(&report_data[0..std::mem::size_of::()])?; + let destination = report.destination; + + if Epoch::from(report.epoch).saturating_add(3) > sysvar::clock::Clock::get()?.epoch { + return Err(ProgramError::from( + SlashingError::CloseViolationReportTooSoon, + )); + } + + if destination != *destination_account.key { + return Err(ProgramError::from(SlashingError::InvalidDestinationAccount)); + } + + // Drop the report account to close it + drop(report_data); + + // Reallocate the account to 0 bytes + report_account.realloc(0, false)?; + + // Assign the system program as the owner + report_account.assign(&system_program::id()); + + // Transfer the lamports to the destination address + let report_lamports = report_account.lamports(); + **report_account.try_borrow_mut_lamports()? = 0; + + let destination_lamports = destination_account.lamports(); + **(destination_account.try_borrow_mut_lamports()?) = destination_lamports + .checked_add(report_lamports) + .ok_or(ProgramError::ArithmeticOverflow)?; + + msg!( + "Closed violation report and credited {} lamports to the destination address", + report_lamports + ); + Ok(()) +} + #[cfg(test)] mod tests { use crate::state::PACKET_DATA_SIZE; diff --git a/program/tests/duplicate_block_proof.rs b/program/tests/duplicate_block_proof.rs index 5a7ec9c..1eb308c 100644 --- a/program/tests/duplicate_block_proof.rs +++ b/program/tests/duplicate_block_proof.rs @@ -10,7 +10,7 @@ use { solana_program::pubkey::Pubkey, solana_program_test::*, solana_sdk::{ - clock::{Clock, Slot}, + clock::{Clock, Epoch, Slot}, decode_error::DecodeError, ed25519_instruction::SIGNATURE_OFFSETS_START, hash::{Hash, HASH_BYTES}, @@ -31,7 +31,8 @@ use { error::SlashingError, id, instruction::{ - duplicate_block_proof_with_sigverify_and_prefund, DuplicateBlockProofInstructionData, + close_violation_report, duplicate_block_proof_with_sigverify_and_prefund, + DuplicateBlockProofInstructionData, }, processor::process_instruction, state::{ProofType, SlashingProofData, ViolationReport}, @@ -40,7 +41,7 @@ use { }; const SLOT: Slot = 53084024; -const EPOCH: Slot = 42; +const EPOCH: Epoch = 42; fn program_test() -> ProgramTest { let mut program_test = ProgramTest::new("spl_slashing", id(), processor!(process_instruction)); @@ -124,6 +125,56 @@ async fn write_proof( } } +async fn close_report( + context: &mut ProgramTestContext, + report_key: Pubkey, + destination: Pubkey, +) -> Result<(), BanksClientError> { + let initial_lamports = context + .banks_client + .get_account(report_key) + .await + .unwrap() + .unwrap() + .lamports; + assert!(context + .banks_client + .get_account(destination) + .await + .unwrap() + .is_none()); + + let transaction = Transaction::new_signed_with_payer( + &[close_violation_report(&report_key, &destination)], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await?; + + let new_lamports = context + .banks_client + .get_account(destination) + .await + .unwrap() + .unwrap() + .lamports; + + assert!(context + .banks_client + .get_account(report_key) + .await + .unwrap() + .is_none()); + assert_eq!(new_lamports, initial_lamports); + + Ok(()) +} + fn slashing_instructions( reporter: &Pubkey, destination: &Pubkey, @@ -281,7 +332,7 @@ async fn valid_proof_data() { .unwrap(); // Verify that the report was written - let (report_account, _) = Pubkey::find_program_address( + let (report_key, _) = Pubkey::find_program_address( &[ &leader.pubkey().to_bytes(), &slot.to_le_bytes(), @@ -291,7 +342,7 @@ async fn valid_proof_data() { ); let report_account = context .banks_client - .get_account(report_account) + .get_account(report_key) .await .unwrap() .unwrap(); @@ -315,6 +366,12 @@ async fn valid_proof_data() { DuplicateBlockProofData::unpack_proof(&report_account.data[violation_report_size..]) .unwrap(); assert_eq!(duplicate_proof, proof); + + // Close the report + context.warp_to_epoch(EPOCH + 3).unwrap(); + close_report(&mut context, report_key, destination) + .await + .unwrap(); } #[tokio::test] @@ -373,7 +430,7 @@ async fn valid_proof_coding() { .unwrap(); // Verify that the report was written - let (report_account, _) = Pubkey::find_program_address( + let (report_key, _) = Pubkey::find_program_address( &[ &leader.pubkey().to_bytes(), &slot.to_le_bytes(), @@ -383,7 +440,7 @@ async fn valid_proof_coding() { ); let report_account = context .banks_client - .get_account(report_account) + .get_account(report_key) .await .unwrap() .unwrap(); @@ -408,6 +465,12 @@ async fn valid_proof_coding() { DuplicateBlockProofData::unpack_proof(&report_account.data[violation_report_size..]) .unwrap(); assert_eq!(duplicate_proof, proof); + + // Close the report + context.warp_to_epoch(EPOCH + 3).unwrap(); + close_report(&mut context, report_key, destination) + .await + .unwrap(); } #[tokio::test] @@ -869,3 +932,176 @@ async fn double_report() { assert_eq!(*violation_report, expected_violation_report); } + +#[tokio::test] +async fn close_report_destination_and_early() { + let mut context = program_test().start_with_context().await; + setup_clock(&mut context).await; + + let authority = Keypair::new(); + let account = Keypair::new(); + let reporter = context.payer.pubkey(); + let destination = Pubkey::new_unique(); + + let mut rng = rand::rng(); + let leader = Arc::new(Keypair::new()); + let (slot, parent_slot, reference_tick, version) = (SLOT, 53084023, 0, 0); + let shredder = Shredder::new(slot, parent_slot, reference_tick, version).unwrap(); + let next_shred_index = rng.random_range(0..32_000); + let shred1 = new_rand_data_shred(&mut rng, next_shred_index, &shredder, &leader, true); + let shred2 = new_rand_data_shred(&mut rng, next_shred_index, &shredder, &leader, true); + + assert_ne!( + shred1.merkle_root().unwrap(), + shred2.merkle_root().unwrap(), + "Expecting merkle root conflict", + ); + + let duplicate_proof = DuplicateBlockProofData { + shred1: shred1.payload().as_ref(), + shred2: shred2.payload().as_ref(), + }; + let data = duplicate_proof.pack_proof(); + + initialize_duplicate_proof_account(&mut context, &authority, &account).await; + write_proof(&mut context, &authority, &account, &data).await; + + let (report_key, _) = Pubkey::find_program_address( + &[ + &leader.pubkey().to_bytes(), + &slot.to_le_bytes(), + &[u8::from(ProofType::DuplicateBlockProof)], + ], + &spl_slashing::id(), + ); + + // Trying to create an account with the destination set to the report account + // should fail + let transaction = Transaction::new_signed_with_payer( + &slashing_instructions( + &reporter, + &report_key, + &account.pubkey(), + slot, + leader.pubkey(), + &shred1, + &shred2, + ), + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + let err = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + let TransactionError::InstructionError(2, InstructionError::Custom(code)) = err else { + panic!("Invalid error {err:?}"); + }; + let err: SlashingError = SlashingError::decode_custom_error_to_enum(code).unwrap(); + assert_eq!(err, SlashingError::DestinationAddressIsReportAccount); + + // Use a proper destination account + let transaction = Transaction::new_signed_with_payer( + &slashing_instructions( + &reporter, + &destination, + &account.pubkey(), + slot, + leader.pubkey(), + &shred1, + &shred2, + ), + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // Verify that the report was written + let report_account = context + .banks_client + .get_account(report_key) + .await + .unwrap() + .unwrap(); + let violation_report_size = std::mem::size_of::(); + let violation_report: &ViolationReport = + pod_from_bytes(&report_account.data[0..violation_report_size]).unwrap(); + + let expected_violation_report = ViolationReport { + version: ViolationReport::VERSION, + reporter, + destination, + epoch: PodU64::from(EPOCH), + pubkey: leader.pubkey(), + slot: PodU64::from(slot), + violation_type: ProofType::DuplicateBlockProof.into(), + proof_account: account.pubkey(), + }; + assert_eq!(*violation_report, expected_violation_report); + + // Verify that the proof was also serialized to the account + let proof = + DuplicateBlockProofData::unpack_proof(&report_account.data[violation_report_size..]) + .unwrap(); + assert_eq!(duplicate_proof, proof); + + // Close the report should fail as only 0 epochs have passed + let err = close_report(&mut context, report_key, destination) + .await + .unwrap_err() + .unwrap(); + let TransactionError::InstructionError(0, InstructionError::Custom(code)) = err else { + panic!("Invalid error {err:?}"); + }; + let err: SlashingError = SlashingError::decode_custom_error_to_enum(code).unwrap(); + assert_eq!(err, SlashingError::CloseViolationReportTooSoon); + + // Close the report should fail as only 1 epochs have passed + context.warp_to_epoch(EPOCH + 1).unwrap(); + let err = close_report(&mut context, report_key, destination) + .await + .unwrap_err() + .unwrap(); + let TransactionError::InstructionError(0, InstructionError::Custom(code)) = err else { + panic!("Invalid error {err:?}"); + }; + let err: SlashingError = SlashingError::decode_custom_error_to_enum(code).unwrap(); + assert_eq!(err, SlashingError::CloseViolationReportTooSoon); + + // Close the report should fail as only 2 epochs have passed + context.warp_to_epoch(EPOCH + 2).unwrap(); + let err = close_report(&mut context, report_key, destination) + .await + .unwrap_err() + .unwrap(); + let TransactionError::InstructionError(0, InstructionError::Custom(code)) = err else { + panic!("Invalid error {err:?}"); + }; + let err: SlashingError = SlashingError::decode_custom_error_to_enum(code).unwrap(); + assert_eq!(err, SlashingError::CloseViolationReportTooSoon); + + // Close report should fail with invalid destination account + context.warp_to_epoch(EPOCH + 3).unwrap(); + let err = close_report(&mut context, report_key, Pubkey::new_unique()) + .await + .unwrap_err() + .unwrap(); + let TransactionError::InstructionError(0, InstructionError::Custom(code)) = err else { + panic!("Invalid error {err:?}"); + }; + let err: SlashingError = SlashingError::decode_custom_error_to_enum(code).unwrap(); + assert_eq!(err, SlashingError::InvalidDestinationAccount); + + // Close report should succeed with 3+ epochs + close_report(&mut context, report_key, destination) + .await + .unwrap() +}