diff --git a/Cargo.lock b/Cargo.lock index a01ee42699..8d6f5cc45d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10724,6 +10724,7 @@ dependencies = [ "subspace-runtime-primitives", "subspace-test-client", "subspace-test-service", + "substrate-test-runtime-client", "tempfile", "thiserror", "tokio", diff --git a/crates/pallet-domains/src/lib.rs b/crates/pallet-domains/src/lib.rs index 1193b75a3f..08b207bb9c 100644 --- a/crates/pallet-domains/src/lib.rs +++ b/crates/pallet-domains/src/lib.rs @@ -35,7 +35,7 @@ pub mod weights; extern crate alloc; use crate::block_tree::verify_execution_receipt; -use crate::staking::{do_nominate_operator, Operator, OperatorStatus}; +use crate::staking::{do_nominate_operator, OperatorStatus}; use codec::{Decode, Encode}; use frame_support::ensure; use frame_support::traits::fungible::{Inspect, InspectHold}; @@ -45,17 +45,17 @@ use frame_system::pallet_prelude::*; pub use pallet::*; use scale_info::TypeInfo; use sp_core::H256; -use sp_domains::bundle_producer_election::{is_below_threshold, BundleProducerElectionParams}; +use sp_domains::bundle_producer_election::BundleProducerElectionParams; use sp_domains::{ DomainBlockLimit, DomainId, DomainInstanceData, ExecutionReceipt, OpaqueBundle, OperatorId, - OperatorPublicKey, ProofOfElection, RuntimeId, DOMAIN_EXTRINSICS_SHUFFLING_SEED_SUBJECT, - EMPTY_EXTRINSIC_ROOT, + OperatorPublicKey, RuntimeId, DOMAIN_EXTRINSICS_SHUFFLING_SEED_SUBJECT, EMPTY_EXTRINSIC_ROOT, }; use sp_domains_fraud_proof::fraud_proof::{ FraudProof, InvalidDomainBlockHashProof, InvalidTotalRewardsProof, }; use sp_domains_fraud_proof::verification::{ - verify_invalid_bundles_fraud_proof, verify_invalid_domain_block_hash_fraud_proof, + verify_bundle_equivocation_fraud_proof, verify_invalid_bundles_fraud_proof, + verify_invalid_domain_block_hash_fraud_proof, verify_invalid_domain_extrinsics_root_fraud_proof, verify_invalid_state_transition_fraud_proof, verify_invalid_total_rewards_fraud_proof, verify_valid_bundle_fraud_proof, }; @@ -65,6 +65,7 @@ use sp_std::boxed::Box; use sp_std::collections::btree_map::BTreeMap; use sp_std::vec::Vec; use subspace_core_primitives::U256; +use subspace_runtime_primitives::Balance; pub(crate) type BalanceOf = <::Currency as Inspect<::AccountId>>::Balance; @@ -151,6 +152,7 @@ mod pallet { use frame_support::{Identity, PalletError}; use frame_system::pallet_prelude::*; use sp_core::H256; + use sp_domains::bundle_producer_election::ProofOfElectionError; use sp_domains::{ BundleDigest, DomainId, EpochIndex, GenesisDomain, OperatorAllowList, OperatorId, RuntimeId, RuntimeType, @@ -627,6 +629,12 @@ mod pallet { InvalidBundleFraudProof, /// Bad/Invalid valid bundle fraud proof BadValidBundleFraudProof, + /// Missing operator. + MissingOperator, + /// Unexpected fraud proof. + UnexpectedFraudProof, + /// Bad/Invalid bundle equivocation fraud proof. + BadBundleEquivocationFraudProof, } impl From for Error { @@ -665,6 +673,15 @@ mod pallet { } } + impl From for BundleError { + fn from(err: ProofOfElectionError) -> Self { + match err { + ProofOfElectionError::BadVrfProof => Self::BadVrfSignature, + ProofOfElectionError::ThresholdUnsatisfied => Self::ThresholdUnsatisfied, + } + } + } + #[pallet::error] pub enum Error { /// Invalid fraud proof. @@ -737,7 +754,7 @@ mod pallet { }, FraudProofProcessed { domain_id: DomainId, - new_head_receipt_number: DomainBlockNumberFor, + new_head_receipt_number: Option>, }, DomainOperatorAllowListUpdated { domain_id: DomainId, @@ -889,61 +906,70 @@ mod pallet { ensure_none(origin)?; log::trace!(target: "runtime::domains", "Processing fraud proof: {fraud_proof:?}"); - + let mut operators_to_slash = BTreeSet::new(); let domain_id = fraud_proof.domain_id(); - let head_receipt_number = HeadReceiptNumber::::get(domain_id); - let bad_receipt_number = BlockTreeNodes::::get(fraud_proof.bad_receipt_hash()) - .ok_or::>(FraudProofError::BadReceiptNotFound.into())? - .execution_receipt - .domain_block_number; - // The `head_receipt_number` must greater than or equal to any existing receipt, including - // the bad receipt, otherwise the fraud proof should be rejected due to `BadReceiptNotFound`, - // double check here to make it more robust. - ensure!( - head_receipt_number >= bad_receipt_number, - Error::::from(FraudProofError::BadReceiptNotFound), - ); - // Starting from the head receipt, prune all ER between [bad_receipt_number..head_receipt_number] - let mut to_prune = head_receipt_number; - let mut operator_to_slash = BTreeSet::new(); - while to_prune >= bad_receipt_number { - let receipt_hash = BlockTree::::take(domain_id, to_prune) - .ok_or::>(FraudProofError::BadReceiptNotFound.into())?; + if let Some(bad_receipt_hash) = fraud_proof.targeted_bad_receipt_hash() { + let head_receipt_number = HeadReceiptNumber::::get(domain_id); + let bad_receipt_number = BlockTreeNodes::::get(bad_receipt_hash) + .ok_or::>(FraudProofError::BadReceiptNotFound.into())? + .execution_receipt + .domain_block_number; + // The `head_receipt_number` must greater than or equal to any existing receipt, including + // the bad receipt, otherwise the fraud proof should be rejected due to `BadReceiptNotFound`, + // double check here to make it more robust. + ensure!( + head_receipt_number >= bad_receipt_number, + Error::::from(FraudProofError::BadReceiptNotFound), + ); - let BlockTreeNode { - execution_receipt, - operator_ids, - } = BlockTreeNodes::::take(receipt_hash) - .ok_or::>(FraudProofError::BadReceiptNotFound.into())?; + // Starting from the head receipt, prune all ER between [bad_receipt_number..head_receipt_number] + let mut to_prune = head_receipt_number; - let _ = StateRoots::::take(( - domain_id, - execution_receipt.domain_block_number, - execution_receipt.domain_block_hash, - )); + while to_prune >= bad_receipt_number { + let receipt_hash = BlockTree::::take(domain_id, to_prune) + .ok_or::>(FraudProofError::BadReceiptNotFound.into())?; - // NOTE: the operator id will be deduplicated since we are using `BTreeSet` - operator_ids.into_iter().for_each(|id| { - operator_to_slash.insert(id); - }); + let BlockTreeNode { + execution_receipt, + operator_ids, + } = BlockTreeNodes::::take(receipt_hash) + .ok_or::>(FraudProofError::BadReceiptNotFound.into())?; - to_prune -= One::one(); - } + let _ = StateRoots::::take(( + domain_id, + execution_receipt.domain_block_number, + execution_receipt.domain_block_hash, + )); + + // NOTE: the operator id will be deduplicated since we are using `BTreeSet` + operator_ids.into_iter().for_each(|id| { + operators_to_slash.insert(id); + }); - // Update the head receipt number to `bad_receipt_number - 1` - let new_head_receipt_number = bad_receipt_number.saturating_sub(One::one()); - HeadReceiptNumber::::insert(domain_id, new_head_receipt_number); + to_prune -= One::one(); + } - // Slash operator who have submitted the pruned fraudulent ER - do_slash_operators::(operator_to_slash.into_iter()).map_err(Error::::from)?; + // Update the head receipt number to `bad_receipt_number - 1` + let new_head_receipt_number = bad_receipt_number.saturating_sub(One::one()); + HeadReceiptNumber::::insert(domain_id, new_head_receipt_number); - SuccessfulFraudProofs::::append(domain_id, fraud_proof.hash()); + Self::deposit_event(Event::FraudProofProcessed { + domain_id, + new_head_receipt_number: Some(new_head_receipt_number), + }); + } else if let Some(targeted_bad_operator) = fraud_proof.targeted_bad_operator() { + operators_to_slash.insert(targeted_bad_operator); + Self::deposit_event(Event::FraudProofProcessed { + domain_id, + new_head_receipt_number: None, + }); + } - Self::deposit_event(Event::FraudProofProcessed { - domain_id, - new_head_receipt_number, - }); + // Slash bad operators + do_slash_operators::(operators_to_slash.into_iter()).map_err(Error::::from)?; + + SuccessfulFraudProofs::::append(domain_id, fraud_proof.hash()); Ok(()) } @@ -1442,33 +1468,6 @@ impl Pallet { Ok(()) } - fn check_proof_of_election( - domain_id: DomainId, - operator_id: OperatorId, - operator: Operator, T::Share>, - bundle_slot_probability: (u64, u64), - proof_of_election: &ProofOfElection, - ) -> Result<(), BundleError> { - proof_of_election - .verify_vrf_signature(&operator.signing_key) - .map_err(|_| BundleError::BadVrfSignature)?; - - let (operator_stake, total_domain_stake) = - Self::fetch_operator_stake_info(domain_id, &operator_id)?; - - let threshold = sp_domains::bundle_producer_election::calculate_threshold( - operator_stake.saturated_into(), - total_domain_stake.saturated_into(), - bundle_slot_probability, - ); - - if !is_below_threshold(&proof_of_election.vrf_signature.output, threshold) { - return Err(BundleError::ThresholdUnsatisfied); - } - - Ok(()) - } - fn validate_bundle(opaque_bundle: &OpaqueBundleOf) -> Result<(), BundleError> { let domain_id = opaque_bundle.domain_id(); let operator_id = opaque_bundle.operator_id(); @@ -1501,12 +1500,15 @@ impl Pallet { Self::check_extrinsics_root(opaque_bundle)?; let proof_of_election = &sealed_header.header.proof_of_election; - Self::check_proof_of_election( - domain_id, - operator_id, - operator, + let (operator_stake, total_domain_stake) = + Self::fetch_operator_stake_info(domain_id, &operator_id)?; + + sp_domains::bundle_producer_election::check_proof_of_election( + &operator.signing_key, domain_config.bundle_slot_probability, proof_of_election, + operator_stake.saturated_into(), + total_domain_stake.saturated_into(), )?; let receipt = &sealed_header.header.receipt; @@ -1518,118 +1520,144 @@ impl Pallet { fn validate_fraud_proof( fraud_proof: &FraudProof, T::Hash, T::DomainHeader>, ) -> Result<(), FraudProofError> { - let bad_receipt = BlockTreeNodes::::get(fraud_proof.bad_receipt_hash()) - .ok_or(FraudProofError::BadReceiptNotFound)? - .execution_receipt; + if let Some(bad_receipt_hash) = fraud_proof.targeted_bad_receipt_hash() { + let bad_receipt = BlockTreeNodes::::get(bad_receipt_hash) + .ok_or(FraudProofError::BadReceiptNotFound)? + .execution_receipt; - ensure!( - !bad_receipt.domain_block_number.is_zero(), - FraudProofError::ChallengingGenesisReceipt - ); + ensure!( + !bad_receipt.domain_block_number.is_zero(), + FraudProofError::ChallengingGenesisReceipt + ); - match fraud_proof { - FraudProof::InvalidTotalRewards(InvalidTotalRewardsProof { storage_proof, .. }) => { - verify_invalid_total_rewards_fraud_proof::< + match fraud_proof { + FraudProof::InvalidTotalRewards(InvalidTotalRewardsProof { + storage_proof, .. + }) => { + verify_invalid_total_rewards_fraud_proof::< + T::Block, + DomainBlockNumberFor, + T::DomainHash, + BalanceOf, + DomainHashingFor, + >(bad_receipt, storage_proof) + .map_err(|err| { + log::error!( + target: "runtime::domains", + "Total rewards proof verification failed: {err:?}" + ); + FraudProofError::InvalidTotalRewardsFraudProof + })?; + } + FraudProof::InvalidDomainBlockHash(InvalidDomainBlockHashProof { + digest_storage_proof, + .. + }) => { + let parent_receipt = + BlockTreeNodes::::get(bad_receipt.parent_domain_block_receipt_hash) + .ok_or(FraudProofError::ParentReceiptNotFound)? + .execution_receipt; + verify_invalid_domain_block_hash_fraud_proof::< + T::Block, + BalanceOf, + T::DomainHeader, + >( + bad_receipt, + digest_storage_proof.clone(), + parent_receipt.domain_block_hash, + ) + .map_err(|err| { + log::error!( + target: "runtime::domains", + "Invalid Domain block hash proof verification failed: {err:?}" + ); + FraudProofError::InvalidDomainBlockHashFraudProof + })?; + } + FraudProof::InvalidExtrinsicsRoot(proof) => { + verify_invalid_domain_extrinsics_root_fraud_proof::< + T::Block, + BalanceOf, + T::Hashing, + T::DomainHeader, + >(bad_receipt, proof) + .map_err(|err| { + log::error!( + target: "runtime::domains", + "Invalid Domain extrinsic root proof verification failed: {err:?}" + ); + FraudProofError::InvalidExtrinsicRootFraudProof + })?; + } + FraudProof::InvalidStateTransition(proof) => { + let bad_receipt_parent = + BlockTreeNodes::::get(bad_receipt.parent_domain_block_receipt_hash) + .ok_or(FraudProofError::ParentReceiptNotFound)? + .execution_receipt; + + verify_invalid_state_transition_fraud_proof::< + T::Block, + T::DomainHeader, + BalanceOf, + >(bad_receipt, bad_receipt_parent, proof) + .map_err(|err| { + log::error!( + target: "runtime::domains", + "Invalid State transition proof verification failed: {err:?}" + ); + FraudProofError::InvalidStateTransitionFraudProof + })?; + } + FraudProof::InvalidBundles(invalid_bundles_fraud_proof) => { + verify_invalid_bundles_fraud_proof::>( + bad_receipt, + invalid_bundles_fraud_proof, + ) + .map_err(|err| { + log::error!( + target: "runtime::domains", + "Invalid Bundle proof verification failed: {err:?}" + ); + FraudProofError::InvalidBundleFraudProof + })?; + } + FraudProof::ValidBundle(proof) => verify_valid_bundle_fraud_proof::< T::Block, DomainBlockNumberFor, T::DomainHash, BalanceOf, - DomainHashingFor, - >(bad_receipt, storage_proof) - .map_err(|err| { - log::error!( - target: "runtime::domains", - "Total rewards proof verification failed: {err:?}" - ); - FraudProofError::InvalidTotalRewardsFraudProof - })?; - } - FraudProof::InvalidDomainBlockHash(InvalidDomainBlockHashProof { - digest_storage_proof, - .. - }) => { - let parent_receipt = - BlockTreeNodes::::get(bad_receipt.parent_domain_block_receipt_hash) - .ok_or(FraudProofError::ParentReceiptNotFound)? - .execution_receipt; - verify_invalid_domain_block_hash_fraud_proof::< - T::Block, - BalanceOf, - T::DomainHeader, - >( - bad_receipt, - digest_storage_proof.clone(), - parent_receipt.domain_block_hash, - ) - .map_err(|err| { - log::error!( - target: "runtime::domains", - "Invalid Domain block hash proof verification failed: {err:?}" - ); - FraudProofError::InvalidDomainBlockHashFraudProof - })?; - } - FraudProof::InvalidExtrinsicsRoot(proof) => { - verify_invalid_domain_extrinsics_root_fraud_proof::< - T::Block, - BalanceOf, - T::Hashing, - T::DomainHeader, >(bad_receipt, proof) .map_err(|err| { log::error!( target: "runtime::domains", - "Invalid Domain extrinsic root proof verification failed: {err:?}" + "Valid bundle proof verification failed: {err:?}" ); - FraudProofError::InvalidExtrinsicRootFraudProof - })?; + FraudProofError::BadValidBundleFraudProof + })?, + _ => return Err(FraudProofError::UnexpectedFraudProof), } - FraudProof::InvalidStateTransition(proof) => { - let bad_receipt_parent = - BlockTreeNodes::::get(bad_receipt.parent_domain_block_receipt_hash) - .ok_or(FraudProofError::ParentReceiptNotFound)? - .execution_receipt; + } else if let Some(bad_operator_id) = fraud_proof.targeted_bad_operator() { + let operator = + Operators::::get(bad_operator_id).ok_or(FraudProofError::MissingOperator)?; + match fraud_proof { + FraudProof::BundleEquivocation(proof) => { + let operator_signing_key = operator.signing_key; + verify_bundle_equivocation_fraud_proof::( + &operator_signing_key, + &proof.first_header, + &proof.second_header, + ) + .map_err(|err| { + log::error!( + target: "runtime::domains", + "Bundle equivocation proof verification failed: {err:?}" + ); + FraudProofError::BadBundleEquivocationFraudProof + })?; + } - verify_invalid_state_transition_fraud_proof::< - T::Block, - T::DomainHeader, - BalanceOf, - >(bad_receipt, bad_receipt_parent, proof) - .map_err(|err| { - log::error!( - target: "runtime::domains", - "Invalid State transition proof verification failed: {err:?}" - ); - FraudProofError::InvalidStateTransitionFraudProof - })?; + _ => return Err(FraudProofError::UnexpectedFraudProof), } - FraudProof::InvalidBundles(invalid_bundles_fraud_proof) => { - verify_invalid_bundles_fraud_proof::>( - bad_receipt, - invalid_bundles_fraud_proof, - ) - .map_err(|err| { - log::error!( - target: "runtime::domains", - "Invalid Bundle proof verification failed: {err:?}" - ); - FraudProofError::InvalidBundleFraudProof - })?; - } - FraudProof::ValidBundle(proof) => verify_valid_bundle_fraud_proof::< - T::Block, - DomainBlockNumberFor, - T::DomainHash, - BalanceOf, - >(bad_receipt, proof) - .map_err(|err| { - log::error!( - target: "runtime::domains", - "Valid bundle proof verification failed: {err:?}" - ); - FraudProofError::BadValidBundleFraudProof - })?, - _ => {} } Ok(()) diff --git a/crates/pallet-domains/src/tests.rs b/crates/pallet-domains/src/tests.rs index 9905c718d2..9a3fda0530 100644 --- a/crates/pallet-domains/src/tests.rs +++ b/crates/pallet-domains/src/tests.rs @@ -1,9 +1,10 @@ use crate::block_tree::BlockTreeNode; use crate::domain_registry::{DomainConfig, DomainObject}; +use crate::staking::Operator; use crate::{ self as pallet_domains, BalanceOf, BlockTree, BlockTreeNodes, BundleError, Config, ConsensusBlockHash, DomainBlockNumberFor, DomainHashingFor, DomainRegistry, ExecutionInbox, - ExecutionReceiptOf, FraudProofError, FungibleHoldId, HeadReceiptNumber, NextDomainId, Operator, + ExecutionReceiptOf, FraudProofError, FungibleHoldId, HeadReceiptNumber, NextDomainId, OperatorStatus, Operators, ReceiptHashFor, }; use codec::{Decode, Encode, MaxEncodedLen}; @@ -261,6 +262,9 @@ pub(crate) struct MockDomainFraudProofExtension { runtime_code: Vec, tx_range: bool, is_inherent: bool, + domain_total_stake: Balance, + bundle_slot_probability: (u64, u64), + operator_stake: Balance, } impl FraudProofHostFunctions for MockDomainFraudProofExtension { @@ -309,6 +313,15 @@ impl FraudProofHostFunctions for MockDomainFraudProofExtension { FraudProofVerificationInfoRequest::InherentExtrinsicCheck { .. } => { FraudProofVerificationInfoResponse::InherentExtrinsicCheck(self.is_inherent) } + FraudProofVerificationInfoRequest::DomainElectionParams { .. } => { + FraudProofVerificationInfoResponse::DomainElectionParams { + domain_total_stake: self.domain_total_stake, + bundle_slot_probability: self.bundle_slot_probability, + } + } + FraudProofVerificationInfoRequest::OperatorStake { .. } => { + FraudProofVerificationInfoResponse::OperatorStake(self.operator_stake) + } }; Some(response) @@ -995,6 +1008,9 @@ fn test_invalid_domain_extrinsic_root_proof() { runtime_code: vec![1, 2, 3, 4], tx_range: true, is_inherent: true, + domain_total_stake: 100 * SSC, + operator_stake: 10 * SSC, + bundle_slot_probability: (0, 0), })); ext.register_extension(fraud_proof_ext); @@ -1071,6 +1087,9 @@ fn test_true_invalid_bundles_inherent_extrinsic_proof() { tx_range: true, // return `true` indicating this is an inherent extrinsic is_inherent: true, + domain_total_stake: 100 * SSC, + operator_stake: 10 * SSC, + bundle_slot_probability: (0, 0), })); ext.register_extension(fraud_proof_ext); @@ -1133,6 +1152,9 @@ fn test_false_invalid_bundles_inherent_extrinsic_proof() { tx_range: true, // return `false` indicating this is not an inherent extrinsic is_inherent: false, + domain_total_stake: 100 * SSC, + operator_stake: 10 * SSC, + bundle_slot_probability: (0, 0), })); ext.register_extension(fraud_proof_ext); diff --git a/crates/sp-domains-fraud-proof/Cargo.toml b/crates/sp-domains-fraud-proof/Cargo.toml index 7caefc15fb..caf77bf6f4 100644 --- a/crates/sp-domains-fraud-proof/Cargo.toml +++ b/crates/sp-domains-fraud-proof/Cargo.toml @@ -48,6 +48,7 @@ sc-service = { version = "0.10.0-dev", git = "https://github.com/subspace/polkad subspace-test-client = { version = "0.1.0", path = "../../test/subspace-test-client" } subspace-test-service = { version = "0.1.0", path = "../../test/subspace-test-service" } subspace-runtime-primitives = { version = "0.1.0", path = "../../crates/subspace-runtime-primitives" } +substrate-test-runtime-client = { version = "2.0.0", git = "https://github.com/subspace/polkadot-sdk", rev = "892bf8e938c6bd2b893d3827d1093cd81baa59a1" } tempfile = "3.8.0" tokio = "1.32.0" diff --git a/crates/sp-domains-fraud-proof/src/bundle_equivocation.rs b/crates/sp-domains-fraud-proof/src/bundle_equivocation.rs new file mode 100644 index 0000000000..3d398de7c6 --- /dev/null +++ b/crates/sp-domains-fraud-proof/src/bundle_equivocation.rs @@ -0,0 +1,254 @@ +//! Module to check bundle equivocation and produce the Equivocation fraud proof. +//! This is mostly derived from the `sc_consensus_slots::aux_schema` with changes adapted +//! for Bundle headers instead of block headers + +use crate::fraud_proof::{BundleEquivocationProof, FraudProof}; +use codec::{Decode, Encode}; +use sc_client_api::backend::AuxStore; +use sp_api::{BlockT, HeaderT}; +use sp_blockchain::{Error as ClientError, Result as ClientResult}; +use sp_consensus_slots::Slot; +use sp_domains::SealedBundleHeader; +use sp_runtime::traits::NumberFor; +use std::sync::Arc; +use subspace_runtime_primitives::Balance; + +const SLOT_BUNDLE_HEADER_MAP_KEY: &[u8] = b"slot_bundle_header_map"; +const SLOT_BUNDLE_HEADER_START: &[u8] = b"slot_bundle_header_start"; + +// TODO: revisit these values when there more than 1000 domains. +/// We keep at least this number of slots in database. +const MAX_SLOT_CAPACITY: u64 = 1000; +/// We prune slots when they reach this number. +const PRUNING_BOUND: u64 = 2 * MAX_SLOT_CAPACITY; + +fn load_decode(client: &Arc, key: &[u8]) -> ClientResult> +where + CClient: AuxStore, + T: Decode, +{ + match client.get_aux(key)? { + None => Ok(None), + Some(t) => T::decode(&mut &t[..]) + .map_err(|e| { + ClientError::Backend(format!("Slots DB is corrupted. Decode error: {}", e)) + }) + .map(Some), + } +} + +pub type CheckEquivocationResult = + ClientResult>>; + +/// Checks if the header is an equivocation and returns the proof in that case. +/// +/// Note: it detects equivocations only when slot_now - slot <= MAX_SLOT_CAPACITY. +pub fn check_equivocation( + backend: &Arc, + slot_now: Slot, + bundle_header: SealedBundleHeader, CBlock::Hash, DomainHeader, Balance>, +) -> CheckEquivocationResult, CBlock::Hash, DomainHeader> +where + CClient: AuxStore, + CBlock: BlockT, + DomainHeader: HeaderT, +{ + let slot: Slot = bundle_header.header.proof_of_election.slot_number.into(); + + // We don't check equivocations for old headers out of our capacity. + if slot_now.saturating_sub(*slot) > MAX_SLOT_CAPACITY { + return Ok(None); + } + + // Key for this slot. + let mut curr_slot_key = SLOT_BUNDLE_HEADER_MAP_KEY.to_vec(); + slot.using_encoded(|s| curr_slot_key.extend(s)); + + // Get headers of this slot. + let mut headers_with_sig = load_decode::< + CClient, + Vec, CBlock::Hash, DomainHeader, Balance>>, + >(backend, &curr_slot_key[..])? + .unwrap_or_else(Vec::new); + + // Get first slot saved. + let slot_header_start = SLOT_BUNDLE_HEADER_START.to_vec(); + let first_saved_slot = load_decode::<_, Slot>(backend, &slot_header_start[..])?.unwrap_or(slot); + + if slot_now < first_saved_slot { + // The code below assumes that slots will be visited sequentially. + return Ok(None); + } + + for previous_bundle_header in headers_with_sig.iter() { + let operator_set_1 = ( + previous_bundle_header.header.proof_of_election.operator_id, + previous_bundle_header.header.proof_of_election.domain_id, + ); + let operator_set_2 = ( + bundle_header.header.proof_of_election.operator_id, + bundle_header.header.proof_of_election.domain_id, + ); + + // A proof of equivocation consists of two headers: + // 1) signed by the same operator for same domain + if operator_set_1 == operator_set_2 { + // 2) with different hash + return if bundle_header.hash() != previous_bundle_header.hash() { + Ok(Some(FraudProof::BundleEquivocation( + BundleEquivocationProof { + domain_id: bundle_header.header.proof_of_election.domain_id, + slot, + first_header: previous_bundle_header.clone(), + second_header: bundle_header, + }, + ))) + } else { + // We don't need to continue in case of duplicated header, + // since it's already saved and a possible equivocation + // would have been detected before. + Ok(None) + }; + } + } + + let mut keys_to_delete = vec![]; + let mut new_first_saved_slot = first_saved_slot; + + if *slot_now - *first_saved_slot >= PRUNING_BOUND { + let prefix = SLOT_BUNDLE_HEADER_MAP_KEY.to_vec(); + new_first_saved_slot = slot_now.saturating_sub(MAX_SLOT_CAPACITY); + + for s in u64::from(first_saved_slot)..new_first_saved_slot.into() { + let mut p = prefix.clone(); + s.using_encoded(|s| p.extend(s)); + keys_to_delete.push(p); + } + } + + headers_with_sig.push(bundle_header); + + backend.insert_aux( + &[ + (&curr_slot_key[..], headers_with_sig.encode().as_slice()), + ( + &slot_header_start[..], + new_first_saved_slot.encode().as_slice(), + ), + ], + &keys_to_delete + .iter() + .map(|k| &k[..]) + .collect::>()[..], + )?; + + Ok(None) +} + +#[cfg(test)] +mod test { + use super::{check_equivocation, MAX_SLOT_CAPACITY, PRUNING_BOUND}; + use domain_runtime_primitives::opaque::Header as DomainHeader; + use sp_core::crypto::UncheckedFrom; + use sp_domains::{ + BundleHeader, DomainId, ExecutionReceipt, OperatorId, OperatorSignature, ProofOfElection, + SealedBundleHeader, + }; + use std::sync::Arc; + use subspace_runtime_primitives::opaque::Block; + use subspace_runtime_primitives::{Balance, BlockNumber, Hash}; + + fn create_header( + number: BlockNumber, + slot_number: u64, + domain_id: DomainId, + operator_id: OperatorId, + ) -> SealedBundleHeader { + let mut poe = ProofOfElection::dummy(domain_id, operator_id); + poe.slot_number = slot_number; + SealedBundleHeader { + header: BundleHeader { + proof_of_election: poe, + receipt: ExecutionReceipt { + domain_block_number: number, + domain_block_hash: Default::default(), + domain_block_extrinsic_root: Default::default(), + parent_domain_block_receipt_hash: Default::default(), + consensus_block_number: number, + consensus_block_hash: Default::default(), + inboxed_bundles: vec![], + final_state_root: Default::default(), + execution_trace: vec![], + execution_trace_root: Default::default(), + total_rewards: 0, + }, + bundle_size: 0, + estimated_bundle_weight: Default::default(), + bundle_extrinsics_root: Default::default(), + }, + signature: OperatorSignature::unchecked_from([0u8; 64]), + } + } + + #[test] + fn test_check_equivocation() { + let client = Arc::new(substrate_test_runtime_client::new()); + let domain_id = DomainId::new(0); + let operator_id = 1; + + let header1 = create_header(1, 2, domain_id, operator_id); // @ slot 2 + let header2 = create_header(2, 2, domain_id, operator_id); // @ slot 2 + let header3 = create_header(2, 4, domain_id, operator_id); // @ slot 4 + let header4 = create_header(3, MAX_SLOT_CAPACITY + 4, domain_id, operator_id); // @ slot MAX_SLOT_CAPACITY + 4 + let header5 = create_header(4, MAX_SLOT_CAPACITY + 4, domain_id, operator_id); // @ slot MAX_SLOT_CAPACITY + 4 + let header6 = create_header(3, 4, domain_id, operator_id); // @ slot 4 + + // It's ok to sign same headers. + assert!( + check_equivocation::<_, Block, _>(&client, 2.into(), header1.clone()) + .unwrap() + .is_none(), + ); + + assert!( + check_equivocation::<_, Block, _>(&client, 3.into(), header1.clone()) + .unwrap() + .is_none(), + ); + + // But not two different headers at the same slot. + assert!( + check_equivocation::<_, Block, _>(&client, 4.into(), header2) + .unwrap() + .is_some(), + ); + + // Different slot is ok. + assert!( + check_equivocation::<_, Block, _>(&client, 5.into(), header3) + .unwrap() + .is_none(), + ); + + // Here we trigger pruning and save header 4. + assert!( + check_equivocation::<_, Block, _>(&client, (PRUNING_BOUND + 2).into(), header4,) + .unwrap() + .is_none(), + ); + + // This fails because header 5 is an equivocation of header 4. + assert!( + check_equivocation::<_, Block, _>(&client, (PRUNING_BOUND + 3).into(), header5,) + .unwrap() + .is_some(), + ); + + // This is ok because we pruned the corresponding header. Shows that we are pruning. + assert!( + check_equivocation::<_, Block, _>(&client, (PRUNING_BOUND + 4).into(), header6,) + .unwrap() + .is_none(), + ); + } +} diff --git a/crates/sp-domains-fraud-proof/src/fraud_proof.rs b/crates/sp-domains-fraud-proof/src/fraud_proof.rs index 34957769e7..9242d52e1b 100644 --- a/crates/sp-domains-fraud-proof/src/fraud_proof.rs +++ b/crates/sp-domains-fraud-proof/src/fraud_proof.rs @@ -1,3 +1,4 @@ +use crate::verification::InvalidBundleEquivocationError; use codec::{Decode, Encode}; use scale_info::TypeInfo; use sp_consensus_slots::Slot; @@ -6,13 +7,13 @@ use sp_domain_digests::AsPredigest; use sp_domains::proof_provider_and_verifier::StorageProofVerifier; use sp_domains::{ BundleValidity, DomainId, ExecutionReceiptFor, ExtrinsicDigest, HeaderHashFor, - HeaderHashingFor, InvalidBundleType, SealedBundleHeader, + HeaderHashingFor, InvalidBundleType, OperatorId, SealedBundleHeader, }; use sp_runtime::traits::{Block as BlockT, Hash as HashT, Header as HeaderT}; use sp_runtime::{Digest, DigestItem}; use sp_std::vec::Vec; use sp_trie::StorageProof; -use subspace_runtime_primitives::{AccountId, Balance}; +use subspace_runtime_primitives::Balance; /// A phase of a block's execution, carrying necessary information needed for verifying the /// invalid state transition proof. @@ -321,6 +322,18 @@ pub enum VerificationError { error("Failed to check if a given extrinsic is inherent or not") )] FailedToCheckInherentExtrinsic, + /// Invalid bundle equivocation fraud proof. + #[cfg_attr( + feature = "thiserror", + error("Invalid bundle equivocation fraud proof: {0}") + )] + InvalidBundleEquivocationFraudProof(InvalidBundleEquivocationError), +} + +impl From for VerificationError { + fn from(err: InvalidBundleEquivocationError) -> Self { + Self::InvalidBundleEquivocationFraudProof(err) + } } #[derive(Debug, Decode, Encode, TypeInfo, PartialEq, Eq, Clone)] @@ -397,24 +410,30 @@ impl FraudProof } } - pub fn bad_receipt_hash(&self) -> HeaderHashFor { + pub fn targeted_bad_receipt_hash(&self) -> Option> { match self { - Self::InvalidStateTransition(proof) => proof.bad_receipt_hash, - Self::InvalidTransaction(proof) => proof.bad_receipt_hash, - Self::ImproperTransactionSortition(proof) => proof.bad_receipt_hash, - // TODO: the `BundleEquivocation` fraud proof is different from other fraud proof, - // which target equivocate bundle instead of bad receipt, revisit this when fraud - // proof v2 is implemented. - Self::BundleEquivocation(_) => Default::default(), + Self::InvalidStateTransition(proof) => Some(proof.bad_receipt_hash), + Self::InvalidTransaction(proof) => Some(proof.bad_receipt_hash), + Self::ImproperTransactionSortition(proof) => Some(proof.bad_receipt_hash), + Self::BundleEquivocation(_) => None, #[cfg(any(feature = "std", feature = "runtime-benchmarks"))] Self::Dummy { bad_receipt_hash, .. - } => *bad_receipt_hash, - Self::InvalidExtrinsicsRoot(proof) => proof.bad_receipt_hash, - Self::InvalidTotalRewards(proof) => proof.bad_receipt_hash(), - Self::ValidBundle(proof) => proof.bad_receipt_hash, - Self::InvalidBundles(proof) => proof.bad_receipt_hash, - Self::InvalidDomainBlockHash(proof) => proof.bad_receipt_hash, + } => Some(*bad_receipt_hash), + Self::InvalidExtrinsicsRoot(proof) => Some(proof.bad_receipt_hash), + Self::InvalidTotalRewards(proof) => Some(proof.bad_receipt_hash()), + Self::ValidBundle(proof) => Some(proof.bad_receipt_hash), + Self::InvalidBundles(proof) => Some(proof.bad_receipt_hash), + Self::InvalidDomainBlockHash(proof) => Some(proof.bad_receipt_hash), + } + } + + pub fn targeted_bad_operator(&self) -> Option { + match self { + Self::BundleEquivocation(proof) => { + Some(proof.first_header.header.proof_of_election.operator_id) + } + _ => None, } } @@ -472,8 +491,6 @@ pub fn dummy_invalid_state_transition_proof( pub struct BundleEquivocationProof { /// The id of the domain this fraud proof targeted pub domain_id: DomainId, - /// The authority id of the equivocator. - pub offender: AccountId, /// The slot at which the equivocation happened. pub slot: Slot, // TODO: The generic type should be `` diff --git a/crates/sp-domains-fraud-proof/src/host_functions.rs b/crates/sp-domains-fraud-proof/src/host_functions.rs index bef967f70c..0026019b9f 100644 --- a/crates/sp-domains-fraud-proof/src/host_functions.rs +++ b/crates/sp-domains-fraud-proof/src/host_functions.rs @@ -13,7 +13,8 @@ use sp_api::{BlockT, HashT, ProvideRuntimeApi}; use sp_blockchain::HeaderBackend; use sp_core::traits::{CodeExecutor, FetchRuntimeCode, RuntimeCode}; use sp_core::H256; -use sp_domains::{DomainId, DomainsApi}; +use sp_domains::bundle_producer_election::BundleProducerElectionParams; +use sp_domains::{BundleProducerElectionApi, DomainId, DomainsApi, OperatorId}; use sp_runtime::traits::Header as HeaderT; use sp_runtime::OpaqueExtrinsic; use sp_std::vec::Vec; @@ -22,6 +23,7 @@ use std::borrow::Cow; use std::marker::PhantomData; use std::sync::Arc; use subspace_core_primitives::{Randomness, U256}; +use subspace_runtime_primitives::Balance; struct DomainRuntimeCodeFetcher(Vec); @@ -99,7 +101,7 @@ where Block::Hash: From, DomainBlock: BlockT, Client: BlockBackend + HeaderBackend + ProvideRuntimeApi, - Client::Api: DomainsApi, + Client::Api: DomainsApi + BundleProducerElectionApi, Executor: CodeExecutor + RuntimeVersionOf, { fn get_block_randomness(&self, consensus_block_hash: H256) -> Option { @@ -277,6 +279,32 @@ where &extrinsic ).ok() } + + fn get_domain_election_params( + &self, + consensus_block_hash: H256, + domain_id: DomainId, + ) -> Option> { + let runtime_api = self.consensus_client.runtime_api(); + let consensus_block_hash = consensus_block_hash.into(); + let election_params = runtime_api + .bundle_producer_election_params(consensus_block_hash, domain_id) + .ok()??; + Some(election_params) + } + + fn get_operator_stake( + &self, + consensus_block_hash: H256, + operator_id: OperatorId, + ) -> Option { + let runtime_api = self.consensus_client.runtime_api(); + let consensus_block_hash = consensus_block_hash.into(); + let (_, operator_stake) = runtime_api + .operator(consensus_block_hash, operator_id) + .ok()??; + Some(operator_stake) + } } impl FraudProofHostFunctions @@ -287,7 +315,7 @@ where DomainBlock: BlockT, DomainBlock::Hash: From + Into, Client: BlockBackend + HeaderBackend + ProvideRuntimeApi, - Client::Api: DomainsApi, + Client::Api: DomainsApi + BundleProducerElectionApi, Executor: CodeExecutor + RuntimeVersionOf, { fn get_fraud_proof_verification_info( @@ -350,6 +378,19 @@ where .map(|is_inherent| { FraudProofVerificationInfoResponse::InherentExtrinsicCheck(is_inherent) }), + FraudProofVerificationInfoRequest::DomainElectionParams { domain_id } => self + .get_domain_election_params(consensus_block_hash, domain_id) + .map(|domain_election_params| { + FraudProofVerificationInfoResponse::DomainElectionParams { + domain_total_stake: domain_election_params.total_domain_stake, + bundle_slot_probability: domain_election_params.bundle_slot_probability, + } + }), + FraudProofVerificationInfoRequest::OperatorStake { operator_id } => self + .get_operator_stake(consensus_block_hash, operator_id) + .map(|operator_stake| { + FraudProofVerificationInfoResponse::OperatorStake(operator_stake) + }), } } diff --git a/crates/sp-domains-fraud-proof/src/lib.rs b/crates/sp-domains-fraud-proof/src/lib.rs index 4fd969994d..33b545487d 100644 --- a/crates/sp-domains-fraud-proof/src/lib.rs +++ b/crates/sp-domains-fraud-proof/src/lib.rs @@ -17,6 +17,8 @@ //! Subspace fraud proof primitives for consensus chain. #![cfg_attr(not(feature = "std"), no_std)] +#[cfg(feature = "std")] +pub mod bundle_equivocation; #[cfg(feature = "std")] pub mod execution_prover; pub mod fraud_proof; @@ -37,19 +39,21 @@ pub use runtime_interface::fraud_proof_runtime_interface; #[cfg(feature = "std")] pub use runtime_interface::fraud_proof_runtime_interface::HostFunctions; use sp_api::scale_info::TypeInfo; -use sp_domains::DomainId; -use sp_runtime::traits::{Header as HeaderT, NumberFor}; +use sp_api::HeaderT; +use sp_domains::{DomainId, OperatorId}; +use sp_runtime::traits::NumberFor; use sp_runtime::transaction_validity::{InvalidTransaction, TransactionValidity}; use sp_runtime::OpaqueExtrinsic; use sp_runtime_interface::pass_by; use sp_runtime_interface::pass_by::PassBy; use sp_std::vec::Vec; use subspace_core_primitives::Randomness; +use subspace_runtime_primitives::Balance; /// Custom invalid validity code for the extrinsics in pallet-domains. #[repr(u8)] pub enum InvalidTransactionCode { - BundleEquivocationProof = 101, + BundleEquivocation = 101, TransactionProof = 102, ExecutionReceipt = 103, Bundle = 104, @@ -100,6 +104,10 @@ pub enum FraudProofVerificationInfoRequest { /// Extrinsic for which we need to if it is inherent or not. opaque_extrinsic: OpaqueExtrinsic, }, + /// Request to get Domain election params. + DomainElectionParams { domain_id: DomainId }, + /// Request to get Operator stake. + OperatorStake { operator_id: OperatorId }, } impl PassBy for FraudProofVerificationInfoRequest { @@ -132,6 +140,13 @@ pub enum FraudProofVerificationInfoResponse { TxRangeCheck(bool), /// If the particular extrinsic provided is either inherent or not. InherentExtrinsicCheck(bool), + /// Domain's total stake at a given Consensus hash. + DomainElectionParams { + domain_total_stake: Balance, + bundle_slot_probability: (u64, u64), + }, + /// Operators Stake at a given Consensus hash. + OperatorStake(Balance), } impl FraudProofVerificationInfoResponse { @@ -189,6 +204,23 @@ impl FraudProofVerificationInfoResponse { _ => None, } } + + pub fn into_domain_election_params(self) -> Option<(Balance, (u64, u64))> { + match self { + FraudProofVerificationInfoResponse::DomainElectionParams { + domain_total_stake, + bundle_slot_probability, + } => Some((domain_total_stake, bundle_slot_probability)), + _ => None, + } + } + + pub fn into_operator_stake(self) -> Option { + match self { + FraudProofVerificationInfoResponse::OperatorStake(stake) => Some(stake), + _ => None, + } + } } sp_api::decl_runtime_apis! { diff --git a/crates/sp-domains-fraud-proof/src/verification.rs b/crates/sp-domains-fraud-proof/src/verification.rs index e777830009..6e67630b6d 100644 --- a/crates/sp-domains-fraud-proof/src/verification.rs +++ b/crates/sp-domains-fraud-proof/src/verification.rs @@ -11,15 +11,17 @@ use codec::{Decode, Encode}; use hash_db::Hasher; use sp_core::storage::StorageKey; use sp_core::H256; +use sp_domains::bundle_producer_election::{check_proof_of_election, ProofOfElectionError}; use sp_domains::extrinsics::{deduplicate_and_shuffle_extrinsics, extrinsics_shuffling_seed}; use sp_domains::proof_provider_and_verifier::StorageProofVerifier; use sp_domains::valued_trie::valued_ordered_trie_root; use sp_domains::{ BundleValidity, ExecutionReceipt, ExtrinsicDigest, HeaderHashFor, HeaderHashingFor, - HeaderNumberFor, InboxedBundle, InvalidBundleType, + HeaderNumberFor, InboxedBundle, InvalidBundleType, OperatorPublicKey, SealedBundleHeader, }; use sp_runtime::generic::Digest; use sp_runtime::traits::{Block as BlockT, Hash, Header as HeaderT, NumberFor}; +use sp_runtime::{RuntimeAppPublic, SaturatedConversion}; use sp_std::vec::Vec; use sp_trie::{LayoutV1, StorageProof}; use subspace_core_primitives::Randomness; @@ -458,3 +460,113 @@ where _ => Err(VerificationError::InvalidProof), } } + +/// Represents error for invalid bundle equivocation proof. +#[derive(Debug)] +#[cfg_attr(feature = "thiserror", derive(thiserror::Error))] +pub enum InvalidBundleEquivocationError { + /// Bundle signature is invalid. + #[cfg_attr(feature = "thiserror", error("Invalid bundle signature."))] + BadBundleSignature, + /// Bundle slot mismatch. + #[cfg_attr(feature = "thiserror", error("Bundle slot mismatch."))] + BundleSlotMismatch, + /// Same bundle hash. + #[cfg_attr(feature = "thiserror", error("Same bundle hash."))] + SameBundleHash, + /// Invalid Proof of election. + #[cfg_attr(feature = "thiserror", error("Invalid Proof of Election: {0:?}"))] + InvalidProofOfElection(ProofOfElectionError), + /// Failed to get domain total stake. + #[cfg_attr(feature = "thiserror", error("Failed to get domain total stake."))] + FailedToGetDomainTotalStake, + /// Failed to get operator stake. + #[cfg_attr(feature = "thiserror", error("Failed to get operator stake"))] + FailedToGetOperatorStake, + /// Mismatched operatorId and Domain. + #[cfg_attr(feature = "thiserror", error("Mismatched operatorId and Domain."))] + MismatchedOperatorAndDomain, +} + +/// Verifies Bundle equivocation fraud proof. +pub fn verify_bundle_equivocation_fraud_proof( + operator_signing_key: &OperatorPublicKey, + header_1: &SealedBundleHeader, CBlock::Hash, DomainHeader, Balance>, + header_2: &SealedBundleHeader, CBlock::Hash, DomainHeader, Balance>, +) -> Result<(), InvalidBundleEquivocationError> +where + CBlock: BlockT, + DomainHeader: HeaderT, + Balance: Encode, +{ + if !operator_signing_key.verify(&header_1.pre_hash(), &header_1.signature) { + return Err(InvalidBundleEquivocationError::BadBundleSignature); + } + + if !operator_signing_key.verify(&header_2.pre_hash(), &header_2.signature) { + return Err(InvalidBundleEquivocationError::BadBundleSignature); + } + + let operator_set_1 = ( + header_1.header.proof_of_election.operator_id, + header_1.header.proof_of_election.domain_id, + ); + let operator_set_2 = ( + header_2.header.proof_of_election.operator_id, + header_2.header.proof_of_election.domain_id, + ); + + // Operator and the domain the proof of election targeted should be same + if operator_set_1 != operator_set_2 { + return Err(InvalidBundleEquivocationError::MismatchedOperatorAndDomain); + } + + let consensus_block_hash = header_1.header.proof_of_election.consensus_block_hash; + let domain_id = header_1.header.proof_of_election.domain_id; + let operator_id = header_1.header.proof_of_election.operator_id; + + let (domain_total_stake, bundle_slot_probability) = get_fraud_proof_verification_info( + H256::from_slice(consensus_block_hash.as_ref()), + FraudProofVerificationInfoRequest::DomainElectionParams { domain_id }, + ) + .and_then(|resp| resp.into_domain_election_params()) + .ok_or(InvalidBundleEquivocationError::FailedToGetDomainTotalStake)?; + + let operator_stake = get_fraud_proof_verification_info( + H256::from_slice(consensus_block_hash.as_ref()), + FraudProofVerificationInfoRequest::OperatorStake { operator_id }, + ) + .and_then(|resp| resp.into_operator_stake()) + .ok_or(InvalidBundleEquivocationError::FailedToGetOperatorStake)? + .saturated_into(); + + check_proof_of_election( + operator_signing_key, + bundle_slot_probability, + &header_1.header.proof_of_election, + operator_stake, + domain_total_stake, + ) + .map_err(InvalidBundleEquivocationError::InvalidProofOfElection)?; + + check_proof_of_election( + operator_signing_key, + bundle_slot_probability, + &header_2.header.proof_of_election, + operator_stake, + domain_total_stake, + ) + .map_err(InvalidBundleEquivocationError::InvalidProofOfElection)?; + + if header_1.header.proof_of_election.slot_number + != header_2.header.proof_of_election.slot_number + { + return Err(InvalidBundleEquivocationError::BundleSlotMismatch); + } + + if header_1.hash() == header_2.hash() { + return Err(InvalidBundleEquivocationError::SameBundleHash); + } + + Ok(()) +} diff --git a/crates/sp-domains/src/bundle_producer_election.rs b/crates/sp-domains/src/bundle_producer_election.rs index fc179a7978..f9e764c907 100644 --- a/crates/sp-domains/src/bundle_producer_election.rs +++ b/crates/sp-domains/src/bundle_producer_election.rs @@ -1,4 +1,4 @@ -use crate::{DomainId, OperatorId, OperatorPublicKey, StakeWeight}; +use crate::{DomainId, OperatorId, OperatorPublicKey, ProofOfElection, StakeWeight}; use parity_scale_codec::{Decode, Encode}; use scale_info::TypeInfo; use sp_core::crypto::{VrfPublic, Wraps}; @@ -60,9 +60,11 @@ pub struct BundleProducerElectionParams { } #[derive(Debug, Decode, Encode, TypeInfo, PartialEq, Eq, Clone)] -pub enum VrfProofError { +pub enum ProofOfElectionError { /// Invalid vrf proof. - BadProof, + BadVrfProof, + /// Threshold unsatisfied error. + ThresholdUnsatisfied, } /// Verify the vrf proof generated in the bundle election. @@ -71,12 +73,31 @@ pub(crate) fn verify_vrf_signature( public_key: &OperatorPublicKey, vrf_signature: &VrfSignature, global_challenge: &Blake3Hash, -) -> Result<(), VrfProofError> { +) -> Result<(), ProofOfElectionError> { if !public_key.as_inner_ref().vrf_verify( &make_transcript(domain_id, global_challenge).into(), vrf_signature, ) { - return Err(VrfProofError::BadProof); + return Err(ProofOfElectionError::BadVrfProof); + } + + Ok(()) +} + +pub fn check_proof_of_election( + operator_signing_key: &OperatorPublicKey, + bundle_slot_probability: (u64, u64), + proof_of_election: &ProofOfElection, + operator_stake: StakeWeight, + total_domain_stake: StakeWeight, +) -> Result<(), ProofOfElectionError> { + proof_of_election.verify_vrf_signature(operator_signing_key)?; + + let threshold = + calculate_threshold(operator_stake, total_domain_stake, bundle_slot_probability); + + if !is_below_threshold(&proof_of_election.vrf_signature.output, threshold) { + return Err(ProofOfElectionError::ThresholdUnsatisfied); } Ok(()) diff --git a/crates/sp-domains/src/lib.rs b/crates/sp-domains/src/lib.rs index 12acc90b72..4a37013635 100644 --- a/crates/sp-domains/src/lib.rs +++ b/crates/sp-domains/src/lib.rs @@ -30,7 +30,7 @@ extern crate alloc; use crate::storage::{RawGenesis, StorageKey}; use alloc::string::String; -use bundle_producer_election::{BundleProducerElectionParams, VrfProofError}; +use bundle_producer_election::{BundleProducerElectionParams, ProofOfElectionError}; use hexlit::hex; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; @@ -185,7 +185,7 @@ impl PassBy for DomainId { #[derive(Debug, Decode, Encode, TypeInfo, PartialEq, Eq, Clone)] pub struct BundleHeader { /// Proof of bundle producer election. - pub proof_of_election: ProofOfElection, + pub proof_of_election: ProofOfElection, /// Execution receipt that should extend the receipt chain or add confirmations /// to the head receipt. pub receipt: ExecutionReceipt< @@ -342,7 +342,12 @@ impl } #[cfg(any(feature = "std", feature = "runtime-benchmarks"))] -pub fn dummy_opaque_bundle( +pub fn dummy_opaque_bundle< + Number: Encode, + Hash: Default + Encode, + DomainHeader: HeaderT, + Balance: Encode, +>( domain_id: DomainId, operator_id: OperatorId, receipt: ExecutionReceipt< @@ -527,7 +532,7 @@ impl< } #[derive(Debug, Decode, Encode, TypeInfo, PartialEq, Eq, Clone)] -pub struct ProofOfElection { +pub struct ProofOfElection { /// Domain id. pub domain_id: DomainId, /// The slot number. @@ -538,13 +543,15 @@ pub struct ProofOfElection { pub vrf_signature: VrfSignature, /// Operator index in the OperatorRegistry. pub operator_id: OperatorId, + /// Consensus block hash at which proof of election was derived. + pub consensus_block_hash: CHash, } -impl ProofOfElection { +impl ProofOfElection { pub fn verify_vrf_signature( &self, operator_signing_key: &OperatorPublicKey, - ) -> Result<(), VrfProofError> { + ) -> Result<(), ProofOfElectionError> { let global_challenge = self .global_randomness .derive_global_challenge(self.slot_number); @@ -564,7 +571,7 @@ impl ProofOfElection { } } -impl ProofOfElection { +impl ProofOfElection { #[cfg(any(feature = "std", feature = "runtime-benchmarks"))] pub fn dummy(domain_id: DomainId, operator_id: OperatorId) -> Self { let output_bytes = sp_std::vec![0u8; VrfOutput::max_encoded_len()]; @@ -579,6 +586,7 @@ impl ProofOfElection { global_randomness: Randomness::default(), vrf_signature, operator_id, + consensus_block_hash: Default::default(), } } } @@ -924,6 +932,10 @@ sp_api::decl_runtime_apis! { extrinsics: Vec, ) -> OpaqueBundles; + /// Extract bundle from the extrinsic if the extrinsic is `submit_bundle`. + #[api_version(2)] + fn extract_bundle(extrinsic: Block::Extrinsic) -> Option, Block::Hash, DomainHeader, Balance>>; + /// Extract the execution receipt stored successfully from the given extrinsics. #[api_version(2)] fn extract_receipts( diff --git a/crates/subspace-runtime/src/domains.rs b/crates/subspace-runtime/src/domains.rs index 42ebee19ce..003d21a044 100644 --- a/crates/subspace-runtime/src/domains.rs +++ b/crates/subspace-runtime/src/domains.rs @@ -1,8 +1,8 @@ use crate::{Balance, Block, Domains, RuntimeCall, UncheckedExtrinsic}; use domain_runtime_primitives::opaque::Header as DomainHeader; +use sp_api::{BlockT, NumberFor}; use sp_domains::DomainId; use sp_domains_fraud_proof::fraud_proof::FraudProof; -use sp_runtime::traits::{Block as BlockT, NumberFor}; use sp_std::vec::Vec; pub(crate) fn extract_successful_bundles( @@ -24,6 +24,19 @@ pub(crate) fn extract_successful_bundles( .collect() } +pub(crate) fn extract_bundle( + extrinsic: UncheckedExtrinsic, +) -> Option< + sp_domains::OpaqueBundle, ::Hash, DomainHeader, Balance>, +> { + match extrinsic.function { + RuntimeCall::Domains(pallet_domains::Call::submit_bundle { opaque_bundle }) => { + Some(opaque_bundle) + } + _ => None, + } +} + pub(crate) fn extract_fraud_proofs( domain_id: DomainId, extrinsics: Vec, diff --git a/crates/subspace-runtime/src/lib.rs b/crates/subspace-runtime/src/lib.rs index bb5f4f719e..b601a63691 100644 --- a/crates/subspace-runtime/src/lib.rs +++ b/crates/subspace-runtime/src/lib.rs @@ -58,8 +58,8 @@ use sp_core::storage::StateVersion; use sp_core::{OpaqueMetadata, H256}; use sp_domains::bundle_producer_election::BundleProducerElectionParams; use sp_domains::{ - DomainId, DomainInstanceData, DomainsHoldIdentifier, ExecutionReceiptFor, OperatorId, - OperatorPublicKey, StakingHoldIdentifier, + DomainId, DomainInstanceData, DomainsHoldIdentifier, ExecutionReceiptFor, OpaqueBundle, + OperatorId, OperatorPublicKey, StakingHoldIdentifier, }; use sp_domains_fraud_proof::fraud_proof::FraudProof; use sp_messenger::endpoint::{Endpoint, EndpointHandler as EndpointHandlerT, EndpointId}; @@ -988,6 +988,12 @@ impl_runtime_apis! { crate::domains::extract_successful_bundles(domain_id, extrinsics) } + fn extract_bundle( + extrinsic: ::Extrinsic + ) -> Option, ::Hash, DomainHeader, Balance>> { + crate::domains::extract_bundle(extrinsic) + } + fn extract_receipts( domain_id: DomainId, extrinsics: Vec<::Extrinsic>, diff --git a/crates/subspace-service/src/lib.rs b/crates/subspace-service/src/lib.rs index db3aab9914..b13e35e9f1 100644 --- a/crates/subspace-service/src/lib.rs +++ b/crates/subspace-service/src/lib.rs @@ -28,9 +28,11 @@ pub mod dsn; mod metrics; pub mod rpc; mod sync_from_dsn; +pub mod transaction_pool; use crate::dsn::{create_dsn_instance, DsnConfigurationError}; use crate::metrics::NodeMetrics; +use crate::transaction_pool::FullPool; use core::sync::atomic::{AtomicU32, Ordering}; use cross_domain_message_gossip::cdm_gossip_peers_set_config; use domain_runtime_primitives::opaque::{Block as DomainBlock, Header as DomainHeader}; @@ -44,7 +46,9 @@ use parking_lot::Mutex; use prometheus_client::registry::Registry; use sc_basic_authorship::ProposerFactory; use sc_client_api::execution_extensions::ExtensionsFactory; -use sc_client_api::{Backend, BlockBackend, BlockchainEvents, ExecutorProvider, HeaderBackend}; +use sc_client_api::{ + AuxStore, Backend, BlockBackend, BlockchainEvents, ExecutorProvider, HeaderBackend, +}; use sc_consensus::{BasicQueue, DefaultImportQueue, ImportQueue, SharedBlockImport}; use sc_consensus_slots::SlotProportion; use sc_consensus_subspace::archiver::{create_subspace_archiver, SegmentHeadersStore}; @@ -65,7 +69,6 @@ use sc_subspace_block_relay::{ build_consensus_relay, BlockRelayConfigurationError, NetworkWrapper, }; use sc_telemetry::{Telemetry, TelemetryWorker}; -use sc_transaction_pool::FullPool; use sc_transaction_pool_api::OffchainTransactionPoolFactory; use sp_api::{ApiExt, ConstructRuntimeApi, Metadata, ProvideRuntimeApi}; use sp_block_builder::BlockBuilder; @@ -77,8 +80,8 @@ use sp_consensus_subspace::{ }; use sp_core::traits::{CodeExecutor, SpawnEssentialNamed}; use sp_core::H256; -use sp_domains::DomainsApi; -use sp_domains_fraud_proof::{FraudProofExtension, FraudProofHostFunctionsImpl}; +use sp_domains::{BundleProducerElectionApi, DomainsApi}; +use sp_domains_fraud_proof::{FraudProofApi, FraudProofExtension, FraudProofHostFunctionsImpl}; use sp_externalities::Extensions; use sp_objects::ObjectsApi; use sp_offchain::OffchainWorkerApi; @@ -221,7 +224,9 @@ where + Send + Sync + 'static, - Client::Api: SubspaceApi + DomainsApi, + Client::Api: SubspaceApi + + DomainsApi + + BundleProducerElectionApi, ExecutorDispatch: CodeExecutor + sc_executor::RuntimeVersionOf, { fn extensions_for( @@ -377,7 +382,7 @@ type PartialComponents = sc_service::PartialCompon FullBackend, FullSelectChain, DefaultImportQueue, - FullPool>, + FullPool, Block, DomainHeader>, OtherPartialComponents, >; @@ -401,6 +406,8 @@ where + TaggedTransactionQueue + SubspaceApi + DomainsApi + + FraudProofApi + + BundleProducerElectionApi + ObjectsApi, ExecutorDispatch: NativeExecutionDispatch + 'static, { @@ -454,14 +461,6 @@ where let select_chain = sc_consensus::LongestChain::new(backend.clone()); - let transaction_pool = sc_transaction_pool::BasicPool::new_full( - config.transaction_pool.clone(), - config.role.is_authority().into(), - config.prometheus_registry(), - task_manager.spawn_essential_handle(), - client.clone(), - ); - let segment_headers_store = SegmentHeadersStore::new(client.clone()) .map_err(|error| ServiceError::Application(error.into()))?; @@ -509,6 +508,13 @@ where )?; let sync_target_block_number = Arc::new(AtomicU32::new(0)); + let transaction_pool = crate::transaction_pool::new_full( + config, + &task_manager, + client.clone(), + sync_target_block_number.clone(), + )?; + let verifier = SubspaceVerifier::::new(SubspaceVerifierOptions { client: client.clone(), kzg, @@ -555,12 +561,16 @@ where pub struct NewFull where Client: ProvideRuntimeApi + + AuxStore + BlockBackend + BlockIdTo + HeaderBackend + HeaderMetadata + 'static, - Client::Api: TaggedTransactionQueue + DomainsApi, + Client::Api: TaggedTransactionQueue + + DomainsApi + + FraudProofApi + + SubspaceApi, { /// Task manager. pub task_manager: TaskManager, @@ -589,7 +599,7 @@ where /// Network starter. pub network_starter: NetworkStarter, /// Transaction pool. - pub transaction_pool: Arc>, + pub transaction_pool: Arc>, } type FullNode = NewFull>; @@ -617,6 +627,7 @@ where + TransactionPaymentApi + SubspaceApi + DomainsApi + + FraudProofApi + ObjectsApi, ExecutorDispatch: NativeExecutionDispatch + 'static, { diff --git a/crates/subspace-service/src/transaction_pool.rs b/crates/subspace-service/src/transaction_pool.rs new file mode 100644 index 0000000000..58b601cac3 --- /dev/null +++ b/crates/subspace-service/src/transaction_pool.rs @@ -0,0 +1,559 @@ +use async_trait::async_trait; +use futures::future::{Future, FutureExt, Ready}; +use sc_client_api::blockchain::HeaderBackend; +use sc_client_api::{AuxStore, BlockBackend, ExecutorProvider, UsageProvider}; +use sc_service::{Configuration, TaskManager}; +use sc_transaction_pool::error::{Error as TxPoolError, Result as TxPoolResult}; +use sc_transaction_pool::{ + BasicPool, ChainApi, FullChainApi, Pool, RevalidationType, Transaction, ValidatedTransaction, +}; +use sc_transaction_pool_api::{ + ChainEvent, ImportNotificationStream, LocalTransactionPool, MaintainedTransactionPool, + OffchainTransactionPoolFactory, PoolFuture, PoolStatus, ReadyTransactions, TransactionFor, + TransactionPool, TransactionSource, TransactionStatusStreamFor, TxHash, +}; +use sp_api::{ApiExt, HeaderT, ProvideRuntimeApi}; +use sp_blockchain::{HeaderMetadata, TreeRoute}; +use sp_consensus_slots::Slot; +use sp_consensus_subspace::{ChainConstants, FarmerPublicKey, SubspaceApi}; +use sp_core::traits::SpawnEssentialNamed; +use sp_domains::DomainsApi; +use sp_domains_fraud_proof::bundle_equivocation::check_equivocation; +use sp_domains_fraud_proof::fraud_proof::FraudProof; +use sp_domains_fraud_proof::{FraudProofApi, InvalidTransactionCode}; +use sp_runtime::generic::BlockId; +use sp_runtime::traits::{Block as BlockT, BlockIdTo, Header, NumberFor}; +use sp_runtime::transaction_validity::{TransactionValidity, TransactionValidityError}; +use sp_runtime::SaturatedConversion; +use sp_transaction_pool::runtime_api::TaggedTransactionQueue; +use std::collections::HashMap; +use std::marker::PhantomData; +use std::pin::Pin; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; +use substrate_prometheus_endpoint::Registry as PrometheusRegistry; +use tokio::sync::mpsc; +use tokio::sync::mpsc::UnboundedSender; +use tracing::log::error; + +/// Block hash type for a pool. +type BlockHash = <::Block as BlockT>::Hash; + +/// Extrinsic hash type for a pool. +type ExtrinsicHash = <::Block as BlockT>::Hash; + +/// Extrinsic type for a pool. +type ExtrinsicFor = <::Block as BlockT>::Extrinsic; + +/// A transaction pool for a full node. +pub type FullPool = + BasicPoolWrapper>; + +type BoxedReadyIterator = + Box>> + Send>; + +type ReadyIteratorFor = BoxedReadyIterator, ExtrinsicFor>; + +type PolledIterator = Pin> + Send>>; + +pub type BlockExtrinsicOf = ::Extrinsic; + +#[derive(Clone)] +pub struct FullChainApiWrapper { + inner: Arc>, + client: Arc, + sync_target_block_number: Arc, + chain_constants: ChainConstants, + fraud_proof_submit_sink: + UnboundedSender, Block::Hash, DomainHeader>>, + marker: PhantomData, +} + +impl FullChainApiWrapper +where + Block: BlockT, + Client: ProvideRuntimeApi + + BlockBackend + + BlockIdTo + + HeaderBackend + + HeaderMetadata + + Send + + Sync + + 'static, + Client::Api: TaggedTransactionQueue + SubspaceApi, + DomainHeader: HeaderT, +{ + fn new( + client: Arc, + prometheus: Option<&PrometheusRegistry>, + task_manager: &TaskManager, + sync_target_block_number: Arc, + fraud_proof_submit_sink: UnboundedSender< + FraudProof, Block::Hash, DomainHeader>, + >, + ) -> sp_blockchain::Result { + let chain_constants = client + .runtime_api() + .chain_constants(client.info().best_hash)?; + Ok(Self { + inner: Arc::new(FullChainApi::new( + client.clone(), + prometheus, + &task_manager.spawn_essential_handle(), + )), + client, + sync_target_block_number, + chain_constants, + fraud_proof_submit_sink, + marker: Default::default(), + }) + } + + fn validate_transaction_blocking( + &self, + at: &BlockId, + source: TransactionSource, + uxt: BlockExtrinsicOf, + ) -> TxPoolResult { + self.inner.validate_transaction_blocking(at, source, uxt) + } +} + +pub type ValidationFuture = Pin> + Send>>; + +impl ChainApi for FullChainApiWrapper +where + Block: BlockT, + <<::Header as HeaderT>::Number as TryInto>::Error: std::fmt::Debug, + Client: ProvideRuntimeApi + + AuxStore + + BlockBackend + + BlockIdTo + + HeaderBackend + + HeaderMetadata + + Send + + Sync + + 'static, + DomainHeader: Header, + Client::Api: TaggedTransactionQueue + + SubspaceApi + + DomainsApi, +{ + type Block = Block; + type Error = sc_transaction_pool::error::Error; + type ValidationFuture = ValidationFuture; + type BodyFuture = Ready::Extrinsic>>>>; + + fn validate_transaction( + &self, + at: &BlockId, + source: TransactionSource, + uxt: ExtrinsicFor, + ) -> Self::ValidationFuture { + let at = match self.client.block_hash_from_id(at) { + Ok(Some(at)) => at, + Ok(None) => { + let error = sc_transaction_pool::error::Error::BlockIdConversion(format!( + "Failed to convert block id {at} to hash: block not found" + )); + return Box::pin(async move { Err(error) }); + } + Err(error) => { + let error = sc_transaction_pool::error::Error::BlockIdConversion(format!( + "Failed to convert block id {at} to hash: {error}" + )); + return Box::pin(async move { Err(error) }); + } + }; + + let chain_api = self.inner.clone(); + let client = self.client.clone(); + let best_block_number = TryInto::::try_into(client.info().best_number) + .expect("Block number will always fit into u32; qed"); + let diff_in_blocks = self + .sync_target_block_number + .load(Ordering::Relaxed) + .saturating_sub(best_block_number); + let slot_probability = self.chain_constants.slot_probability(); + let fraud_proof_submit_sink = self.fraud_proof_submit_sink.clone(); + async move { + let uxt_validity = chain_api + .validate_transaction(&BlockId::Hash(at), source, uxt.clone()) + .await?; + + if uxt_validity.is_ok() { + // Transaction is successfully validated. + // If the transaction is `submit_bundle`, then extract the bundle + // and check for equivocation. + let runtime_api = client.runtime_api(); + let domains_api_version = runtime_api + .api_version::>(at) + .map_err(|err| { + TxPoolError::RuntimeApi(format!( + "Failed to get `DomainsApi` version for block {at:?}: {err:?}." + )) + })? + // safe to return default version as 1 since there will always be version 1. + .unwrap_or(1); + // TODO: this is keep gemini-3g compatible. Remove before a new network launch. + if domains_api_version >= 2 { + let maybe_opaque_bundle = runtime_api + .extract_bundle(at, uxt) + .map_err(|err| TxPoolError::RuntimeApi(err.to_string()))?; + if let Some(opaque_bundle) = maybe_opaque_bundle { + let slot = opaque_bundle + .sealed_header + .header + .proof_of_election + .slot_number + .into(); + + let slot_now = if diff_in_blocks > 0 { + slot + Slot::from( + u64::from(diff_in_blocks) * slot_probability.1 / slot_probability.0, + ) + } else { + slot + }; + + let maybe_equivocation_fraud_proof = check_equivocation::<_, Block, _>( + &client, + slot_now, + opaque_bundle.sealed_header, + )?; + + if let Some(equivocation_fraud_proof) = maybe_equivocation_fraud_proof { + let sent_result = + fraud_proof_submit_sink.send(equivocation_fraud_proof); + if let Err(err) = sent_result { + error!( + target: "consensus-fraud-proof-sender", + "failed to send fraud proof to be submitted: {err:?}" + ); + } + + return Err(TxPoolError::Pool( + sc_transaction_pool_api::error::Error::InvalidTransaction( + InvalidTransactionCode::BundleEquivocation.into(), + ), + )); + } + } + } + } + + Ok(uxt_validity) + } + .boxed() + } + + fn block_id_to_number( + &self, + at: &BlockId, + ) -> TxPoolResult>> { + self.inner.block_id_to_number(at) + } + + fn block_id_to_hash(&self, at: &BlockId) -> TxPoolResult>> { + self.inner.block_id_to_hash(at) + } + + fn hash_and_length(&self, ex: &ExtrinsicFor) -> (ExtrinsicHash, usize) { + self.inner.hash_and_length(ex) + } + + fn block_body(&self, id: ::Hash) -> Self::BodyFuture { + self.inner.block_body(id) + } + + fn block_header( + &self, + hash: ::Hash, + ) -> Result::Header>, Self::Error> { + self.inner.block_header(hash) + } + + fn tree_route( + &self, + from: ::Hash, + to: ::Hash, + ) -> Result, Self::Error> { + sp_blockchain::tree_route::(&*self.client, from, to).map_err(Into::into) + } +} + +pub struct BasicPoolWrapper +where + Block: BlockT, + PoolApi: ChainApi, +{ + inner: BasicPool, +} + +impl BasicPoolWrapper +where + Block: BlockT, + PoolApi: ChainApi + 'static, +{ + fn with_revalidation_type( + config: &Configuration, + pool_api: Arc, + prometheus: Option<&PrometheusRegistry>, + spawner: Spawn, + client: Arc, + ) -> Self + where + Client: UsageProvider, + Spawn: SpawnEssentialNamed, + { + let basic_pool = BasicPool::with_revalidation_type( + config.transaction_pool.clone(), + config.role.is_authority().into(), + pool_api, + prometheus, + RevalidationType::Full, + spawner, + client.usage_info().chain.best_number, + client.usage_info().chain.best_hash, + client.usage_info().chain.finalized_hash, + ); + + Self { inner: basic_pool } + } + + /// Gets shared reference to the underlying pool. + pub fn pool(&self) -> &Arc> { + self.inner.pool() + } + + pub fn api(&self) -> &PoolApi { + self.inner.api() + } +} + +impl LocalTransactionPool + for BasicPoolWrapper> +where + Block: BlockT, + <<::Header as HeaderT>::Number as TryInto>::Error: std::fmt::Debug, + DomainHeader: HeaderT, + Client: ProvideRuntimeApi + + AuxStore + + BlockBackend + + BlockIdTo + + HeaderBackend + + HeaderMetadata + + Send + + Sync + + 'static, + Client::Api: TaggedTransactionQueue + + SubspaceApi + + FraudProofApi + + DomainsApi, +{ + type Block = Block; + type Hash = ExtrinsicHash>; + type Error = as ChainApi>::Error; + + fn submit_local( + &self, + at: Block::Hash, + xt: sc_transaction_pool_api::LocalTransactionFor, + ) -> Result { + let at = BlockId::Hash(at); + let validity = self + .api() + .validate_transaction_blocking(&at, TransactionSource::Local, xt.clone())? + .map_err(|e| { + Self::Error::Pool(match e { + TransactionValidityError::Invalid(i) => { + sc_transaction_pool_api::error::Error::InvalidTransaction(i) + } + TransactionValidityError::Unknown(u) => { + sc_transaction_pool_api::error::Error::UnknownTransaction(u) + } + }) + })?; + let (hash, bytes) = self.pool().validated_pool().api().hash_and_length(&xt); + let block_number = self + .api() + .block_id_to_number(&at)? + .ok_or_else(|| sc_transaction_pool::error::Error::BlockIdConversion(at.to_string()))?; + let validated = ValidatedTransaction::valid_at( + block_number.saturated_into::(), + hash, + TransactionSource::Local, + xt, + bytes, + validity, + ); + self.pool() + .validated_pool() + .submit(vec![validated]) + .remove(0) + } +} + +impl TransactionPool for BasicPoolWrapper +where + Block: BlockT, + PoolApi: ChainApi + 'static, +{ + type Block = Block; + type Hash = ExtrinsicHash; + type InPoolTransaction = Transaction, TransactionFor>; + type Error = PoolApi::Error; + + fn submit_at( + &self, + at: &BlockId, + source: TransactionSource, + xts: Vec>, + ) -> PoolFuture, Self::Error>>, Self::Error> { + self.inner.submit_at(at, source, xts) + } + + fn submit_one( + &self, + at: &BlockId, + source: TransactionSource, + xt: TransactionFor, + ) -> PoolFuture, Self::Error> { + self.inner.submit_one(at, source, xt) + } + + fn submit_and_watch( + &self, + at: &BlockId, + source: TransactionSource, + xt: TransactionFor, + ) -> PoolFuture>>, Self::Error> { + self.inner.submit_and_watch(at, source, xt) + } + + fn ready_at(&self, at: NumberFor) -> PolledIterator { + self.inner.ready_at(at) + } + + fn ready(&self) -> ReadyIteratorFor { + self.inner.ready() + } + + fn remove_invalid(&self, hashes: &[TxHash]) -> Vec> { + self.inner.remove_invalid(hashes) + } + + fn status(&self) -> PoolStatus { + self.inner.status() + } + + fn futures(&self) -> Vec { + self.inner.futures() + } + + fn import_notification_stream(&self) -> ImportNotificationStream> { + self.inner.import_notification_stream() + } + + fn on_broadcasted(&self, propagations: HashMap, Vec>) { + self.inner.on_broadcasted(propagations) + } + + fn hash_of(&self, xt: &TransactionFor) -> TxHash { + self.inner.hash_of(xt) + } + + fn ready_transaction(&self, hash: &TxHash) -> Option> { + self.inner.ready_transaction(hash) + } +} + +#[async_trait] +impl MaintainedTransactionPool for BasicPoolWrapper +where + Block: BlockT, + PoolApi: ChainApi + 'static, +{ + async fn maintain(&self, event: ChainEvent) { + self.inner.maintain(event).await + } +} + +pub fn new_full( + config: &Configuration, + task_manager: &TaskManager, + client: Arc, + sync_target_block_number: Arc, +) -> sp_blockchain::Result>> +where + Block: BlockT, + <<::Header as HeaderT>::Number as TryInto>::Error: std::fmt::Debug, + Client: ProvideRuntimeApi + + AuxStore + + BlockBackend + + HeaderBackend + + HeaderMetadata + + ExecutorProvider + + UsageProvider + + BlockIdTo + + Send + + Sync + + 'static, + DomainHeader: HeaderT, + Client::Api: TaggedTransactionQueue + + SubspaceApi + + FraudProofApi + + DomainsApi, +{ + let prometheus = config.prometheus_registry(); + let (fraud_proof_submit_sink, mut fraud_proof_submit_stream) = mpsc::unbounded_channel(); + let pool_api = Arc::new(FullChainApiWrapper::new( + client.clone(), + prometheus, + task_manager, + sync_target_block_number, + fraud_proof_submit_sink, + )?); + + let basic_pool = Arc::new(BasicPoolWrapper::with_revalidation_type( + config, + pool_api, + prometheus, + task_manager.spawn_essential_handle(), + client.clone(), + )); + + let offchain_tx_pool_factory = OffchainTransactionPoolFactory::new(basic_pool.clone()); + + // run a separate task to submit fraud proof since chain api cannot depend on Basic pool since + // Basic pool would require chain api to be instantiated first. + // Ofcourse, there are other approaches that inject this into chain Api but it feels more like + // a hack and prefer to run a separate task that submits the fraud proof when equivocation is detected + task_manager + .spawn_essential_handle() + .spawn_essential_blocking( + "consensus-fraud-proof-submitter", + None, + Box::pin(async move { + loop { + if let Some(fraud_proof) = fraud_proof_submit_stream.recv().await { + let mut runtime_api = client.runtime_api(); + let best_hash = client.info().best_hash; + runtime_api.register_extension( + offchain_tx_pool_factory.offchain_transaction_pool(best_hash), + ); + let result = + runtime_api.submit_fraud_proof_unsigned(best_hash, fraud_proof); + if let Err(err) = result { + error!( + target: "consensus-fraud-proof-submitter", + "failed to submit fraud proof: {err:?}" + ); + } + } + } + }), + ); + + Ok(basic_pool) +} diff --git a/domains/client/domain-operator/src/bundle_producer_election_solver.rs b/domains/client/domain-operator/src/bundle_producer_election_solver.rs index 9973fee423..a1fc8ca1ca 100644 --- a/domains/client/domain-operator/src/bundle_producer_election_solver.rs +++ b/domains/client/domain-operator/src/bundle_producer_election_solver.rs @@ -49,7 +49,7 @@ where consensus_block_hash: CBlock::Hash, domain_id: DomainId, global_randomness: Randomness, - ) -> sp_blockchain::Result> { + ) -> sp_blockchain::Result, OperatorPublicKey)>> { let BundleProducerElectionParams { current_operators, total_domain_stake, @@ -94,6 +94,7 @@ where global_randomness, vrf_signature, operator_id, + consensus_block_hash, }; return Ok(Some((proof_of_election, operator_signing_key))); } diff --git a/domains/client/domain-operator/src/domain_block_processor.rs b/domains/client/domain-operator/src/domain_block_processor.rs index e4b36b3a38..58e8d9a56a 100644 --- a/domains/client/domain-operator/src/domain_block_processor.rs +++ b/domains/client/domain-operator/src/domain_block_processor.rs @@ -869,23 +869,24 @@ where }; let mut bad_receipts_to_delete = vec![]; for fraud_proof in fraud_proofs { - let bad_receipt_hash = fraud_proof.bad_receipt_hash(); - if let Some(bad_receipt) = self - .consensus_client - .runtime_api() - .execution_receipt(consensus_parent_hash, bad_receipt_hash)? - { - // In order to not delete a receipt which was just inserted, accumulate the write&delete operations - // in case the bad receipt and corresponding fraud proof are included in the same block. - if let Some(index) = bad_receipts_to_write - .iter() - .map(|(_, receipt_hash, _)| receipt_hash) - .position(|v| *v == bad_receipt_hash) + if let Some(bad_receipt_hash) = fraud_proof.targeted_bad_receipt_hash() { + if let Some(bad_receipt) = self + .consensus_client + .runtime_api() + .execution_receipt(consensus_parent_hash, bad_receipt_hash)? { - bad_receipts_to_write.swap_remove(index); - } else { - bad_receipts_to_delete - .push((bad_receipt.consensus_block_number, bad_receipt_hash)); + // In order to not delete a receipt which was just inserted, accumulate the write&delete operations + // in case the bad receipt and corresponding fraud proof are included in the same block. + if let Some(index) = bad_receipts_to_write + .iter() + .map(|(_, receipt_hash, _)| receipt_hash) + .position(|v| *v == bad_receipt_hash) + { + bad_receipts_to_write.swap_remove(index); + } else { + bad_receipts_to_delete + .push((bad_receipt.consensus_block_number, bad_receipt_hash)); + } } } } diff --git a/domains/client/domain-operator/src/domain_bundle_proposer.rs b/domains/client/domain-operator/src/domain_bundle_proposer.rs index 00ca068e1b..ba68669734 100644 --- a/domains/client/domain-operator/src/domain_bundle_proposer.rs +++ b/domains/client/domain-operator/src/domain_bundle_proposer.rs @@ -75,7 +75,7 @@ where pub(crate) async fn propose_bundle_at( &self, - proof_of_election: ProofOfElection, + proof_of_election: ProofOfElection, tx_range: U256, ) -> sp_blockchain::Result> { let parent_number = self.client.info().best_number; diff --git a/domains/client/domain-operator/src/tests.rs b/domains/client/domain-operator/src/tests.rs index 37df55a4e0..142743e593 100644 --- a/domains/client/domain-operator/src/tests.rs +++ b/domains/client/domain-operator/src/tests.rs @@ -1604,6 +1604,114 @@ async fn test_invalid_domain_extrinsics_root_proof_creation() { .is_none()); } +#[tokio::test(flavor = "multi_thread")] +async fn test_bundle_equivocation_fraud_proof() { + let directory = TempDir::new().expect("Must be able to create temporary directory"); + + let mut builder = sc_cli::LoggerBuilder::new(""); + builder.with_colors(false); + let _ = builder.init(); + + let tokio_handle = tokio::runtime::Handle::current(); + + // Start Ferdie + let mut ferdie = MockConsensusNode::run( + tokio_handle.clone(), + Ferdie, + BasePath::new(directory.path().join("ferdie")), + ); + + // Run Alice (a evm domain authority node) + let alice = domain_test_service::DomainNodeBuilder::new( + tokio_handle.clone(), + Alice, + BasePath::new(directory.path().join("alice")), + ) + .build_evm_node(Role::Authority, GENESIS_DOMAIN_ID, &mut ferdie) + .await; + + produce_blocks!(ferdie, alice, 3).await.unwrap(); + + let bundle_to_tx = |opaque_bundle| { + subspace_test_runtime::UncheckedExtrinsic::new_unsigned( + pallet_domains::Call::submit_bundle { opaque_bundle }.into(), + ) + .into() + }; + + let (slot, bundle) = ferdie.produce_slot_and_wait_for_bundle_submission().await; + let original_submit_bundle_tx = bundle_to_tx(bundle.clone().unwrap()); + + // Remove the original bundle submission and resubmit it again. + // This is done since when the bundle is submitted through offchain transaction submission + // the validation is skipped for local transactions and this bundle slot is not stored in the Aux storage + // so when we resubmit the transaction through `submit_transaction`, it will go through validation + // process and the first bundle after validating will go through the validation and Aux storage + // updated. When the equivocated bundle is submitted next, the Aux storage is used to check equivocation. + // + // In the production behaviour will not cause any issues, since we trust the local transactions + // and will only check equivocations for the transactions coming from the network. + ferdie + .prune_tx_from_pool(&original_submit_bundle_tx) + .await + .unwrap(); + assert!(ferdie.get_bundle_from_tx_pool(slot.into()).is_none()); + + ferdie + .submit_transaction(original_submit_bundle_tx) + .await + .unwrap(); + + // change the bundle contents such that we derive a new bundle + // with same slot and proof of election such that this leads to bundle equivocation. + let equivocated_bundle_tx = { + let mut opaque_bundle = bundle.unwrap(); + let receipt = &mut opaque_bundle.sealed_header.header.receipt; + receipt.domain_block_extrinsic_root = Default::default(); + opaque_bundle.sealed_header.signature = Sr25519Keyring::Alice + .pair() + .sign(opaque_bundle.sealed_header.pre_hash().as_ref()) + .into(); + bundle_to_tx(opaque_bundle) + }; + + let mut import_tx_stream = ferdie.transaction_pool.import_notification_stream(); + + match ferdie + .submit_transaction(equivocated_bundle_tx) + .await + .unwrap_err() + { + sc_transaction_pool::error::Error::Pool( + sc_transaction_pool_api::error::Error::InvalidTransaction(_), + ) => {} + e => panic!("Unexpected error while submitting fraud proof: {e}"), + } + + while let Some(ready_tx_hash) = import_tx_stream.next().await { + let ready_tx = ferdie + .transaction_pool + .ready_transaction(&ready_tx_hash) + .unwrap(); + let ext = subspace_test_runtime::UncheckedExtrinsic::decode( + &mut ready_tx.data.encode().as_slice(), + ) + .unwrap(); + if let subspace_test_runtime::RuntimeCall::Domains( + pallet_domains::Call::submit_fraud_proof { fraud_proof }, + ) = ext.function + { + if let FraudProof::BundleEquivocation(_) = *fraud_proof { + break; + } + } + } + + // Produce a consensus block that contains the fraud proof, the fraud proof wil be verified on + // on the runtime itself + ferdie.produce_blocks(1).await.unwrap(); +} + #[tokio::test(flavor = "multi_thread")] async fn test_valid_bundle_proof_generation_and_verification() { let directory = TempDir::new().expect("Must be able to create temporary directory"); diff --git a/test/subspace-test-runtime/src/lib.rs b/test/subspace-test-runtime/src/lib.rs index 7153cb90bc..4068aa673d 100644 --- a/test/subspace-test-runtime/src/lib.rs +++ b/test/subspace-test-runtime/src/lib.rs @@ -936,6 +936,19 @@ fn extract_successful_bundles( .collect() } +fn extract_bundle( + extrinsic: UncheckedExtrinsic, +) -> Option< + sp_domains::OpaqueBundle, ::Hash, DomainHeader, Balance>, +> { + match extrinsic.function { + RuntimeCall::Domains(pallet_domains::Call::submit_bundle { opaque_bundle }) => { + Some(opaque_bundle) + } + _ => None, + } +} + pub(crate) fn extract_fraud_proofs( domain_id: DomainId, extrinsics: Vec, @@ -1156,6 +1169,13 @@ impl_runtime_apis! { extract_successful_bundles(domain_id, extrinsics) } + fn extract_bundle( + extrinsic: ::Extrinsic + ) -> Option, ::Hash, DomainHeader, Balance>> { + extract_bundle(extrinsic) + } + + fn extract_receipts( domain_id: DomainId, extrinsics: Vec<::Extrinsic>, diff --git a/test/subspace-test-service/src/lib.rs b/test/subspace-test-service/src/lib.rs index 7365bd8078..aaa5faa8cd 100644 --- a/test/subspace-test-service/src/lib.rs +++ b/test/subspace-test-service/src/lib.rs @@ -45,7 +45,6 @@ use sc_service::{ BasePath, BlocksPruning, Configuration, NetworkStarter, Role, SpawnTasksParams, TaskManager, }; use sc_transaction_pool::error::Error as PoolError; -use sc_transaction_pool::FullPool; use sc_transaction_pool_api::{InPoolTransaction, TransactionPool, TransactionSource}; use sc_utils::mpsc::{tracing_unbounded, TracingUnboundedReceiver, TracingUnboundedSender}; use sp_api::{ApiExt, HashT, HeaderT, ProvideRuntimeApi}; @@ -57,7 +56,7 @@ use sp_consensus_subspace::digests::{CompatibleDigestItem, PreDigest, PreDigestP use sp_consensus_subspace::FarmerPublicKey; use sp_core::traits::{CodeExecutor, SpawnEssentialNamed}; use sp_core::H256; -use sp_domains::{DomainsApi, OpaqueBundle}; +use sp_domains::{BundleProducerElectionApi, DomainsApi, OpaqueBundle}; use sp_domains_fraud_proof::{FraudProofExtension, FraudProofHostFunctionsImpl}; use sp_externalities::Extensions; use sp_inherents::{InherentData, InherentDataProvider}; @@ -68,11 +67,13 @@ use sp_runtime::{DigestItem, OpaqueExtrinsic}; use sp_timestamp::Timestamp; use std::error::Error; use std::marker::PhantomData; +use std::sync::atomic::AtomicU32; use std::sync::Arc; use std::time; use subspace_core_primitives::{Randomness, Solution}; use subspace_runtime_primitives::opaque::Block; use subspace_runtime_primitives::{AccountId, Balance, Hash}; +use subspace_service::transaction_pool::FullPool; use subspace_service::FullSelectChain; use subspace_test_client::{chain_spec, Backend, Client, TestExecutorDispatch}; use subspace_test_runtime::{RuntimeApi, RuntimeCall, UncheckedExtrinsic, SLOT_DURATION}; @@ -191,7 +192,7 @@ where DomainBlock: BlockT, DomainBlock::Hash: Into + From, Client: BlockBackend + HeaderBackend + ProvideRuntimeApi + 'static, - Client::Api: DomainsApi, + Client::Api: DomainsApi + BundleProducerElectionApi, Executor: CodeExecutor + sc_executor::RuntimeVersionOf, { fn extensions_for( @@ -220,7 +221,7 @@ pub struct MockConsensusNode { /// Code executor. pub executor: NativeElseWasmExecutor, /// Transaction pool. - pub transaction_pool: Arc>, + pub transaction_pool: Arc>, /// The SelectChain Strategy pub select_chain: FullSelectChain, /// Network service. @@ -286,13 +287,14 @@ impl MockConsensusNode { let select_chain = sc_consensus::LongestChain::new(backend.clone()); - let transaction_pool = sc_transaction_pool::BasicPool::new_full( - config.transaction_pool.clone(), - config.role.is_authority().into(), - config.prometheus_registry(), - task_manager.spawn_essential_handle(), + let sync_target_block_number = Arc::new(AtomicU32::new(0)); + let transaction_pool = subspace_service::transaction_pool::new_full( + &config, + &task_manager, client.clone(), - ); + sync_target_block_number.clone(), + ) + .expect("failed to create transaction pool"); let block_import = MockBlockImport::<_, _>::new(client.clone());