From 63a24581f7f4c42642e8eb5812dd222b4f680a61 Mon Sep 17 00:00:00 2001 From: Adrian Catangiu Date: Fri, 17 Feb 2023 11:45:00 +0200 Subject: [PATCH] BEEFY: implement equivocations detection, reporting and slashing (#13121) * client/beefy: simplify self_vote logic * client/beefy: migrate to new state version * client/beefy: detect equivocated votes * fix typos * sp-beefy: add equivocation primitives * client/beefy: refactor vote processing * fix version migration for new rounds struct * client/beefy: track equivocations and create proofs * client/beefy: adjust tests for new voting logic * sp-beefy: fix commitment ordering and equality * client/beefy: simplify handle_vote() a bit * client/beefy: add simple equivocation test * client/beefy: submit equivocation proof - WIP * frame/beefy: add equivocation report runtime api - part 1 * frame/beefy: report equivocation logic - part 2 * frame/beefy: add pluggable Equivocation handler - part 3 * frame/beefy: impl ValidateUnsigned for equivocations reporting * client/beefy: submit report equivocation unsigned extrinsic * primitives/beefy: fix tests * frame/beefy: add default weights * frame/beefy: fix tests * client/beefy: fix tests * frame/beefy-mmr: fix tests * frame/beefy: cross-check session index with equivocation report * sp-beefy: make test Keyring useable in pallet * frame/beefy: add basic equivocation test * frame/beefy: test verify equivocation results in slashing * frame/beefy: test report_equivocation_old_set * frame/beefy: add more equivocation tests * sp-beefy: fix docs * beefy: simplify equivocations and fix tests * client/beefy: address review comments * frame/beefy: add ValidateUnsigned to test/mock runtime * client/beefy: fixes after merge master * fix missed merge damage * client/beefy: add test for reporting equivocations Also validated there's no unexpected equivocations reported in the other tests. Signed-off-by: acatangiu * sp-beefy: move test utils to their own file * client/beefy: add negative test for equivocation reports * sp-beefy: move back MmrRootProvider - used in polkadot-service * impl review suggestions * client/beefy: add equivocation metrics --------- Signed-off-by: acatangiu Co-authored-by: parity-processbot <> --- Cargo.lock | 8 + client/beefy/src/communication/gossip.rs | 4 +- client/beefy/src/error.rs | 18 +- client/beefy/src/justification.rs | 3 +- client/beefy/src/keystore.rs | 2 +- client/beefy/src/lib.rs | 3 +- client/beefy/src/metrics.rs | 47 +- client/beefy/src/round.rs | 52 +- client/beefy/src/tests.rs | 127 +++-- client/beefy/src/worker.rs | 157 +++++- client/consensus/babe/src/lib.rs | 2 +- frame/beefy-mmr/src/lib.rs | 4 +- frame/beefy-mmr/src/mock.rs | 13 +- frame/beefy/Cargo.toml | 12 + frame/beefy/src/default_weights.rs | 54 ++ frame/beefy/src/equivocation.rs | 385 ++++++++++++++ frame/beefy/src/lib.rs | 252 ++++++++- frame/beefy/src/mock.rs | 231 +++++++- frame/beefy/src/tests.rs | 647 ++++++++++++++++++++++- primitives/beefy/src/lib.rs | 208 +++++--- primitives/beefy/src/test_utils.rs | 110 ++++ test-utils/runtime/src/lib.rs | 14 + 22 files changed, 2140 insertions(+), 213 deletions(-) create mode 100644 frame/beefy/src/default_weights.rs create mode 100644 frame/beefy/src/equivocation.rs create mode 100644 primitives/beefy/src/test_utils.rs diff --git a/Cargo.lock b/Cargo.lock index ef320a85f05a6..b33f7627f230e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5500,9 +5500,16 @@ dependencies = [ name = "pallet-beefy" version = "4.0.0-dev" dependencies = [ + "frame-election-provider-support", "frame-support", "frame-system", + "pallet-authorship", + "pallet-balances", + "pallet-offences", "pallet-session", + "pallet-staking", + "pallet-staking-reward-curve", + "pallet-timestamp", "parity-scale-codec", "scale-info", "serde", @@ -5510,6 +5517,7 @@ dependencies = [ "sp-core", "sp-io", "sp-runtime", + "sp-session", "sp-staking", "sp-std", ] diff --git a/client/beefy/src/communication/gossip.rs b/client/beefy/src/communication/gossip.rs index 5d51610214610..7e60eb11ea731 100644 --- a/client/beefy/src/communication/gossip.rs +++ b/client/beefy/src/communication/gossip.rs @@ -244,8 +244,8 @@ mod tests { use crate::keystore::BeefyKeystore; use beefy_primitives::{ - crypto::Signature, keyring::Keyring, known_payloads, Commitment, MmrRootHash, Payload, - VoteMessage, KEY_TYPE, + crypto::Signature, known_payloads, Commitment, Keyring, MmrRootHash, Payload, VoteMessage, + KEY_TYPE, }; use super::*; diff --git a/client/beefy/src/error.rs b/client/beefy/src/error.rs index dd5fd649d52ce..1b44e3801e303 100644 --- a/client/beefy/src/error.rs +++ b/client/beefy/src/error.rs @@ -22,14 +22,30 @@ use std::fmt::Debug; -#[derive(Debug, thiserror::Error, PartialEq)] +#[derive(Debug, thiserror::Error)] pub enum Error { #[error("Backend: {0}")] Backend(String), #[error("Keystore error: {0}")] Keystore(String), + #[error("Runtime api error: {0}")] + RuntimeApi(sp_api::ApiError), #[error("Signature error: {0}")] Signature(String), #[error("Session uninitialized")] UninitSession, } + +#[cfg(test)] +impl PartialEq for Error { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Error::Backend(s1), Error::Backend(s2)) => s1 == s2, + (Error::Keystore(s1), Error::Keystore(s2)) => s1 == s2, + (Error::RuntimeApi(_), Error::RuntimeApi(_)) => true, + (Error::Signature(s1), Error::Signature(s2)) => s1 == s2, + (Error::UninitSession, Error::UninitSession) => true, + _ => false, + } + } +} diff --git a/client/beefy/src/justification.rs b/client/beefy/src/justification.rs index 6f869015ba763..9f433aa6ad1d7 100644 --- a/client/beefy/src/justification.rs +++ b/client/beefy/src/justification.rs @@ -81,8 +81,7 @@ fn verify_with_validator_set( #[cfg(test)] pub(crate) mod tests { use beefy_primitives::{ - keyring::Keyring, known_payloads, Commitment, Payload, SignedCommitment, - VersionedFinalityProof, + known_payloads, Commitment, Keyring, Payload, SignedCommitment, VersionedFinalityProof, }; use substrate_test_runtime_client::runtime::Block; diff --git a/client/beefy/src/keystore.rs b/client/beefy/src/keystore.rs index 73788a31b7fdc..29436e36e65bb 100644 --- a/client/beefy/src/keystore.rs +++ b/client/beefy/src/keystore.rs @@ -123,7 +123,7 @@ pub mod tests { use sc_keystore::LocalKeystore; use sp_core::{ecdsa, Pair}; - use beefy_primitives::{crypto, keyring::Keyring}; + use beefy_primitives::{crypto, Keyring}; use super::*; use crate::error::Error; diff --git a/client/beefy/src/lib.rs b/client/beefy/src/lib.rs index 5f74b052e99fe..4031b52970283 100644 --- a/client/beefy/src/lib.rs +++ b/client/beefy/src/lib.rs @@ -282,6 +282,7 @@ where let worker_params = worker::WorkerParams { backend, payload_provider, + runtime, network, key_store: key_store.into(), gossip_engine, @@ -292,7 +293,7 @@ where persisted_state, }; - let worker = worker::BeefyWorker::<_, _, _, _>::new(worker_params); + let worker = worker::BeefyWorker::<_, _, _, _, _>::new(worker_params); futures::future::join( worker.run(block_import_justif, finality_notifications), diff --git a/client/beefy/src/metrics.rs b/client/beefy/src/metrics.rs index e3b48e6ec8d54..c74fb420d13f4 100644 --- a/client/beefy/src/metrics.rs +++ b/client/beefy/src/metrics.rs @@ -46,10 +46,16 @@ pub struct VoterMetrics { pub beefy_no_authority_found_in_store: Counter, /// Number of currently buffered votes pub beefy_buffered_votes: Gauge, - /// Number of valid but stale votes received - pub beefy_stale_votes: Counter, /// Number of votes dropped due to full buffers pub beefy_buffered_votes_dropped: Counter, + /// Number of good votes successfully handled + pub beefy_good_votes_processed: Counter, + /// Number of equivocation votes received + pub beefy_equivocation_votes: Counter, + /// Number of invalid votes received + pub beefy_invalid_votes: Counter, + /// Number of valid but stale votes received + pub beefy_stale_votes: Counter, /// Number of currently buffered justifications pub beefy_buffered_justifications: Gauge, /// Number of valid but stale justifications received @@ -60,8 +66,6 @@ pub struct VoterMetrics { pub beefy_buffered_justifications_dropped: Counter, /// Trying to set Best Beefy block to old block pub beefy_best_block_set_last_failure: Gauge, - /// Number of Successful handled votes - pub beefy_successful_handled_votes: Counter, } impl PrometheusRegister for VoterMetrics { @@ -109,17 +113,35 @@ impl PrometheusRegister for VoterMetrics { Gauge::new("substrate_beefy_buffered_votes", "Number of currently buffered votes")?, registry, )?, - beefy_stale_votes: register( + beefy_buffered_votes_dropped: register( + Counter::new( + "substrate_beefy_buffered_votes_dropped", + "Number of votes dropped due to full buffers", + )?, + registry, + )?, + beefy_good_votes_processed: register( + Counter::new( + "substrate_beefy_successful_handled_votes", + "Number of good votes successfully handled", + )?, + registry, + )?, + beefy_equivocation_votes: register( Counter::new( "substrate_beefy_stale_votes", - "Number of valid but stale votes received", + "Number of equivocation votes received", )?, registry, )?, - beefy_buffered_votes_dropped: register( + beefy_invalid_votes: register( + Counter::new("substrate_beefy_stale_votes", "Number of invalid votes received")?, + registry, + )?, + beefy_stale_votes: register( Counter::new( - "substrate_beefy_buffered_votes_dropped", - "Number of votes dropped due to full buffers", + "substrate_beefy_stale_votes", + "Number of valid but stale votes received", )?, registry, )?, @@ -158,13 +180,6 @@ impl PrometheusRegister for VoterMetrics { )?, registry, )?, - beefy_successful_handled_votes: register( - Counter::new( - "substrate_beefy_successful_handled_votes", - "Number of Successful handled votes", - )?, - registry, - )?, }) } } diff --git a/client/beefy/src/round.rs b/client/beefy/src/round.rs index 9ad4e5d0232bf..b2c4e97a420fe 100644 --- a/client/beefy/src/round.rs +++ b/client/beefy/src/round.rs @@ -20,7 +20,7 @@ use crate::LOG_TARGET; use beefy_primitives::{ crypto::{AuthorityId, Public, Signature}, - Commitment, SignedCommitment, ValidatorSet, ValidatorSetId, VoteMessage, + Commitment, EquivocationProof, SignedCommitment, ValidatorSet, ValidatorSetId, VoteMessage, }; use codec::{Decode, Encode}; use log::debug; @@ -61,7 +61,7 @@ pub fn threshold(authorities: usize) -> usize { pub enum VoteImportResult { Ok, RoundConcluded(SignedCommitment, Signature>), - Equivocation, /* TODO: (EquivocationProof, Public, Signature>) */ + Equivocation(EquivocationProof, Public, Signature>), Invalid, Stale, } @@ -149,8 +149,10 @@ where target: LOG_TARGET, "🥩 detected equivocated vote: 1st: {:?}, 2nd: {:?}", previous_vote, vote ); - // TODO: build `EquivocationProof` and return it here. - return VoteImportResult::Equivocation + return VoteImportResult::Equivocation(EquivocationProof { + first: previous_vote.clone(), + second: vote, + }) } } else { // this is the first vote sent by `id` for `num`, all good @@ -197,8 +199,8 @@ mod tests { use sc_network_test::Block; use beefy_primitives::{ - crypto::Public, keyring::Keyring, known_payloads::MMR_ROOT_ID, Commitment, Payload, - SignedCommitment, ValidatorSet, VoteMessage, + crypto::Public, known_payloads::MMR_ROOT_ID, Commitment, EquivocationProof, Keyring, + Payload, SignedCommitment, ValidatorSet, VoteMessage, }; use super::{threshold, Block as BlockT, RoundTracker, Rounds}; @@ -452,4 +454,42 @@ mod tests { rounds.conclude(3); assert!(rounds.previous_votes.is_empty()); } + + #[test] + fn should_provide_equivocation_proof() { + sp_tracing::try_init_simple(); + + let validators = ValidatorSet::::new( + vec![Keyring::Alice.public(), Keyring::Bob.public()], + Default::default(), + ) + .unwrap(); + let validator_set_id = validators.id(); + let session_start = 1u64.into(); + let mut rounds = Rounds::::new(session_start, validators); + + let payload1 = Payload::from_single_entry(MMR_ROOT_ID, vec![1, 1, 1, 1]); + let payload2 = Payload::from_single_entry(MMR_ROOT_ID, vec![2, 2, 2, 2]); + let commitment1 = Commitment { block_number: 1, payload: payload1, validator_set_id }; + let commitment2 = Commitment { block_number: 1, payload: payload2, validator_set_id }; + + let alice_vote1 = VoteMessage { + id: Keyring::Alice.public(), + commitment: commitment1, + signature: Keyring::Alice.sign(b"I am committed"), + }; + let mut alice_vote2 = alice_vote1.clone(); + alice_vote2.commitment = commitment2; + + let expected_result = VoteImportResult::Equivocation(EquivocationProof { + first: alice_vote1.clone(), + second: alice_vote2.clone(), + }); + + // vote on one payload - ok + assert_eq!(rounds.add_vote(alice_vote1), VoteImportResult::Ok); + + // vote on _another_ commitment/payload -> expected equivocation proof + assert_eq!(rounds.add_vote(alice_vote2), expected_result); + } } diff --git a/client/beefy/src/tests.rs b/client/beefy/src/tests.rs index 007c57e4269cf..42f56226ce5af 100644 --- a/client/beefy/src/tests.rs +++ b/client/beefy/src/tests.rs @@ -31,10 +31,10 @@ use crate::{ }; use beefy_primitives::{ crypto::{AuthorityId, Signature}, - keyring::Keyring as BeefyKeyring, known_payloads, mmr::MmrRootProvider, - BeefyApi, Commitment, ConsensusLog, MmrRootHash, Payload, SignedCommitment, ValidatorSet, + BeefyApi, Commitment, ConsensusLog, EquivocationProof, Keyring as BeefyKeyring, MmrRootHash, + OpaqueKeyOwnershipProof, Payload, SignedCommitment, ValidatorSet, ValidatorSetId, VersionedFinalityProof, BEEFY_ENGINE_ID, KEY_TYPE as BeefyKeyType, }; use futures::{future, stream::FuturesUnordered, Future, StreamExt}; @@ -55,7 +55,7 @@ use sp_api::{ApiRef, ProvideRuntimeApi}; use sp_consensus::BlockOrigin; use sp_core::H256; use sp_keystore::{testing::KeyStore as TestKeystore, SyncCryptoStore, SyncCryptoStorePtr}; -use sp_mmr_primitives::{EncodableOpaqueLeaf, Error as MmrError, MmrApi, Proof}; +use sp_mmr_primitives::{Error as MmrError, MmrApi}; use sp_runtime::{ codec::Encode, generic::BlockId, @@ -73,6 +73,7 @@ fn beefy_gossip_proto_name() -> ProtocolName { const GOOD_MMR_ROOT: MmrRootHash = MmrRootHash::repeat_byte(0xbf); const BAD_MMR_ROOT: MmrRootHash = MmrRootHash::repeat_byte(0x42); +const ALTERNATE_BAD_MMR_ROOT: MmrRootHash = MmrRootHash::repeat_byte(0x13); type BeefyBlockImport = crate::BeefyBlockImport< Block, @@ -236,6 +237,8 @@ pub(crate) struct TestApi { pub beefy_genesis: u64, pub validator_set: BeefyValidatorSet, pub mmr_root_hash: MmrRootHash, + pub reported_equivocations: + Option, AuthorityId, Signature>>>>>, } impl TestApi { @@ -244,7 +247,12 @@ impl TestApi { validator_set: &BeefyValidatorSet, mmr_root_hash: MmrRootHash, ) -> Self { - TestApi { beefy_genesis, validator_set: validator_set.clone(), mmr_root_hash } + TestApi { + beefy_genesis, + validator_set: validator_set.clone(), + mmr_root_hash, + reported_equivocations: None, + } } pub fn with_validator_set(validator_set: &BeefyValidatorSet) -> Self { @@ -252,8 +260,13 @@ impl TestApi { beefy_genesis: 1, validator_set: validator_set.clone(), mmr_root_hash: GOOD_MMR_ROOT, + reported_equivocations: None, } } + + pub fn allow_equivocations(&mut self) { + self.reported_equivocations = Some(Arc::new(Mutex::new(vec![]))); + } } // compiler gets confused and warns us about unused inner @@ -277,31 +290,29 @@ sp_api::mock_impl_runtime_apis! { fn validator_set() -> Option { Some(self.inner.validator_set.clone()) } + + fn submit_report_equivocation_unsigned_extrinsic( + proof: EquivocationProof, AuthorityId, Signature>, + _dummy: OpaqueKeyOwnershipProof, + ) -> Option<()> { + if let Some(equivocations_buf) = self.inner.reported_equivocations.as_ref() { + equivocations_buf.lock().push(proof); + None + } else { + panic!("Equivocations not expected, but following proof was reported: {:?}", proof); + } + } + + fn generate_key_ownership_proof( + _dummy1: ValidatorSetId, + _dummy2: AuthorityId, + ) -> Option { Some(OpaqueKeyOwnershipProof::new(vec![])) } } impl MmrApi> for RuntimeApi { fn mmr_root() -> Result { Ok(self.inner.mmr_root_hash) } - - fn generate_proof( - _block_numbers: Vec, - _best_known_block_number: Option - ) -> Result<(Vec, Proof), MmrError> { - unimplemented!() - } - - fn verify_proof(_leaves: Vec, _proof: Proof) -> Result<(), MmrError> { - unimplemented!() - } - - fn verify_proof_stateless( - _root: MmrRootHash, - _leaves: Vec, - _proof: Proof - ) -> Result<(), MmrError> { - unimplemented!() - } } } @@ -330,7 +341,7 @@ pub(crate) fn create_beefy_keystore(authority: BeefyKeyring) -> SyncCryptoStoreP keystore } -fn voter_init_setup( +async fn voter_init_setup( net: &mut BeefyTestNet, finality: &mut futures::stream::Fuse>, api: &TestApi, @@ -345,9 +356,7 @@ fn voter_init_setup( gossip_validator, None, ); - let best_grandpa = - futures::executor::block_on(wait_for_runtime_pallet(api, &mut gossip_engine, finality)) - .unwrap(); + let best_grandpa = wait_for_runtime_pallet(api, &mut gossip_engine, finality).await.unwrap(); load_or_init_voter_state(&*backend, api, best_grandpa, 1) } @@ -980,7 +989,7 @@ async fn should_initialize_voter_at_genesis() { let api = TestApi::with_validator_set(&validator_set); // load persistent state - nothing in DB, should init at genesis - let persisted_state = voter_init_setup(&mut net, &mut finality, &api).unwrap(); + let persisted_state = voter_init_setup(&mut net, &mut finality, &api).await.unwrap(); // Test initialization at session boundary. // verify voter initialized with two sessions starting at blocks 1 and 10 @@ -1029,7 +1038,7 @@ async fn should_initialize_voter_at_custom_genesis() { net.peer(0).client().as_client().finalize_block(hashes[8], None).unwrap(); // load persistent state - nothing in DB, should init at genesis - let persisted_state = voter_init_setup(&mut net, &mut finality, &api).unwrap(); + let persisted_state = voter_init_setup(&mut net, &mut finality, &api).await.unwrap(); // Test initialization at session boundary. // verify voter initialized with single session starting at block `custom_pallet_genesis` (7) @@ -1090,7 +1099,7 @@ async fn should_initialize_voter_when_last_final_is_session_boundary() { let api = TestApi::with_validator_set(&validator_set); // load persistent state - nothing in DB, should init at session boundary - let persisted_state = voter_init_setup(&mut net, &mut finality, &api).unwrap(); + let persisted_state = voter_init_setup(&mut net, &mut finality, &api).await.unwrap(); // verify voter initialized with single session starting at block 10 assert_eq!(persisted_state.voting_oracle().sessions().len(), 1); @@ -1148,7 +1157,7 @@ async fn should_initialize_voter_at_latest_finalized() { let api = TestApi::with_validator_set(&validator_set); // load persistent state - nothing in DB, should init at last BEEFY finalized - let persisted_state = voter_init_setup(&mut net, &mut finality, &api).unwrap(); + let persisted_state = voter_init_setup(&mut net, &mut finality, &api).await.unwrap(); // verify voter initialized with single session starting at block 12 assert_eq!(persisted_state.voting_oracle().sessions().len(), 1); @@ -1205,3 +1214,59 @@ async fn beefy_finalizing_after_pallet_genesis() { // GRANDPA finalize #21 -> BEEFY finalize #20 (mandatory) and #21 finalize_block_and_wait_for_beefy(&net, peers.clone(), &[hashes[21]], &[20, 21]).await; } + +#[tokio::test] +async fn beefy_reports_equivocations() { + sp_tracing::try_init_simple(); + + let peers = [BeefyKeyring::Alice, BeefyKeyring::Bob, BeefyKeyring::Charlie]; + let validator_set = ValidatorSet::new(make_beefy_ids(&peers), 0).unwrap(); + let session_len = 10; + let min_block_delta = 4; + + let mut net = BeefyTestNet::new(3); + + // Alice votes on good MMR roots, equivocations are allowed/expected. + let mut api_alice = TestApi::with_validator_set(&validator_set); + api_alice.allow_equivocations(); + let api_alice = Arc::new(api_alice); + let alice = (0, &peers[0], api_alice.clone()); + tokio::spawn(initialize_beefy(&mut net, vec![alice], min_block_delta)); + + // Bob votes on bad MMR roots, equivocations are allowed/expected. + let mut api_bob = TestApi::new(1, &validator_set, BAD_MMR_ROOT); + api_bob.allow_equivocations(); + let api_bob = Arc::new(api_bob); + let bob = (1, &peers[1], api_bob.clone()); + tokio::spawn(initialize_beefy(&mut net, vec![bob], min_block_delta)); + + // We spawn another node voting with Bob key, on alternate bad MMR roots (equivocating). + // Equivocations are allowed/expected. + let mut api_bob_prime = TestApi::new(1, &validator_set, ALTERNATE_BAD_MMR_ROOT); + api_bob_prime.allow_equivocations(); + let api_bob_prime = Arc::new(api_bob_prime); + let bob_prime = (2, &BeefyKeyring::Bob, api_bob_prime.clone()); + tokio::spawn(initialize_beefy(&mut net, vec![bob_prime], min_block_delta)); + + // push 42 blocks including `AuthorityChange` digests every 10 blocks. + let hashes = net.generate_blocks_and_sync(42, session_len, &validator_set, false).await; + + let net = Arc::new(Mutex::new(net)); + + // Minimum BEEFY block delta is 4. + + let peers = peers.into_iter().enumerate(); + // finalize block #1 -> BEEFY should not finalize anything (each node votes on different MMR). + finalize_block_and_wait_for_beefy(&net, peers, &[hashes[1]], &[]).await; + + // Verify neither Bob or Bob_Prime report themselves as equivocating. + assert!(api_bob.reported_equivocations.as_ref().unwrap().lock().is_empty()); + assert!(api_bob_prime.reported_equivocations.as_ref().unwrap().lock().is_empty()); + + // Verify Alice reports Bob/Bob_Prime equivocation. + let alice_reported_equivocations = api_alice.reported_equivocations.as_ref().unwrap().lock(); + assert_eq!(alice_reported_equivocations.len(), 1); + let equivocation_proof = alice_reported_equivocations.get(0).unwrap(); + assert_eq!(equivocation_proof.first.id, BeefyKeyring::Bob.public()); + assert_eq!(equivocation_proof.first.commitment.block_number, 1); +} diff --git a/client/beefy/src/worker.rs b/client/beefy/src/worker.rs index 6528aef12508a..8783997cc6b22 100644 --- a/client/beefy/src/worker.rs +++ b/client/beefy/src/worker.rs @@ -23,16 +23,17 @@ use crate::{ }, error::Error, justification::BeefyVersionedFinalityProof, - keystore::BeefyKeystore, + keystore::{BeefyKeystore, BeefySignatureHasher}, metric_get, metric_inc, metric_set, metrics::VoterMetrics, round::{Rounds, VoteImportResult}, BeefyVoterLinks, LOG_TARGET, }; use beefy_primitives::{ + check_equivocation_proof, crypto::{AuthorityId, Signature}, - Commitment, ConsensusLog, PayloadProvider, ValidatorSet, VersionedFinalityProof, VoteMessage, - BEEFY_ENGINE_ID, + BeefyApi, Commitment, ConsensusLog, EquivocationProof, PayloadProvider, ValidatorSet, + VersionedFinalityProof, VoteMessage, BEEFY_ENGINE_ID, }; use codec::{Codec, Decode, Encode}; use futures::{stream::Fuse, FutureExt, StreamExt}; @@ -41,7 +42,7 @@ use sc_client_api::{Backend, FinalityNotification, FinalityNotifications, Header use sc_network_common::service::{NetworkEventStream, NetworkRequest}; use sc_network_gossip::GossipEngine; use sc_utils::notification::NotificationReceiver; -use sp_api::BlockId; +use sp_api::{BlockId, ProvideRuntimeApi}; use sp_arithmetic::traits::{AtLeast32Bit, Saturating}; use sp_consensus::SyncOracle; use sp_runtime::{ @@ -243,9 +244,10 @@ impl VoterOracle { } } -pub(crate) struct WorkerParams { +pub(crate) struct WorkerParams { pub backend: Arc, pub payload_provider: P, + pub runtime: Arc, pub network: N, pub key_store: BeefyKeystore, pub gossip_engine: GossipEngine, @@ -294,10 +296,11 @@ impl PersistedState { } /// A BEEFY worker plays the BEEFY protocol -pub(crate) struct BeefyWorker { +pub(crate) struct BeefyWorker { // utilities backend: Arc, payload_provider: P, + runtime: Arc, network: N, key_store: BeefyKeystore, @@ -327,11 +330,13 @@ pub(crate) struct BeefyWorker { persisted_state: PersistedState, } -impl BeefyWorker +impl BeefyWorker where B: Block + Codec, BE: Backend, P: PayloadProvider, + R: ProvideRuntimeApi, + R::Api: BeefyApi, N: NetworkEventStream + NetworkRequest + SyncOracle + Send + Sync + Clone + 'static, { /// Return a new BEEFY worker instance. @@ -340,10 +345,11 @@ where /// BEEFY pallet has been deployed on-chain. /// /// The BEEFY pallet is needed in order to keep track of the BEEFY authority set. - pub(crate) fn new(worker_params: WorkerParams) -> Self { + pub(crate) fn new(worker_params: WorkerParams) -> Self { let WorkerParams { backend, payload_provider, + runtime, key_store, network, gossip_engine, @@ -357,6 +363,7 @@ where BeefyWorker { backend, payload_provider, + runtime, network, key_store, gossip_engine, @@ -571,6 +578,7 @@ where // We created the `finality_proof` and know to be valid. // New state is persisted after finalization. self.finalize(finality_proof)?; + metric_inc!(self, beefy_good_votes_processed); }, VoteImportResult::Ok => { // Persist state after handling mandatory block vote. @@ -583,14 +591,15 @@ where crate::aux_schema::write_voter_state(&*self.backend, &self.persisted_state) .map_err(|e| Error::Backend(e.to_string()))?; } + metric_inc!(self, beefy_good_votes_processed); }, - VoteImportResult::Equivocation => { - // TODO: report returned `EquivocationProof` to chain through `pallet-beefy`. - () + VoteImportResult::Equivocation(proof) => { + metric_inc!(self, beefy_equivocation_votes); + self.report_equivocation(proof)?; }, - VoteImportResult::Invalid | VoteImportResult::Stale => (), + VoteImportResult::Invalid => metric_inc!(self, beefy_invalid_votes), + VoteImportResult::Stale => metric_inc!(self, beefy_stale_votes), }; - metric_inc!(self, beefy_successful_handled_votes); Ok(()) } @@ -928,6 +937,60 @@ where } } } + + /// Report the given equivocation to the BEEFY runtime module. This method + /// generates a session membership proof of the offender and then submits an + /// extrinsic to report the equivocation. In particular, the session membership + /// proof must be generated at the block at which the given set was active which + /// isn't necessarily the best block if there are pending authority set changes. + pub(crate) fn report_equivocation( + &self, + proof: EquivocationProof, AuthorityId, Signature>, + ) -> Result<(), Error> { + let rounds = + self.persisted_state.voting_oracle.active_rounds().ok_or(Error::UninitSession)?; + let (validators, validator_set_id) = (rounds.validators(), rounds.validator_set_id()); + let offender_id = proof.offender_id().clone(); + + if !check_equivocation_proof::<_, _, BeefySignatureHasher>(&proof) { + debug!(target: LOG_TARGET, "🥩 Skip report for bad equivocation {:?}", proof); + return Ok(()) + } else if let Some(local_id) = self.key_store.authority_id(validators) { + if offender_id == local_id { + debug!(target: LOG_TARGET, "🥩 Skip equivocation report for own equivocation"); + return Ok(()) + } + } + + let number = *proof.round_number(); + let runtime_api = self.runtime.runtime_api(); + // generate key ownership proof at that block + let key_owner_proof = match runtime_api + .generate_key_ownership_proof(&BlockId::Number(number), validator_set_id, offender_id) + .map_err(Error::RuntimeApi)? + { + Some(proof) => proof, + None => { + debug!( + target: LOG_TARGET, + "🥩 Equivocation offender not part of the authority set." + ); + return Ok(()) + }, + }; + + // submit equivocation report at **best** block + let best_block_hash = self.backend.blockchain().info().best_hash; + runtime_api + .submit_report_equivocation_unsigned_extrinsic( + &BlockId::Hash(best_block_hash), + proof, + key_owner_proof, + ) + .map_err(Error::RuntimeApi)?; + + Ok(()) + } } /// Scan the `header` digest log for a BEEFY validator set change. Return either the new @@ -993,7 +1056,8 @@ pub(crate) mod tests { BeefyRPCLinks, KnownPeers, }; use beefy_primitives::{ - keyring::Keyring, known_payloads, mmr::MmrRootProvider, Payload, SignedCommitment, + generate_equivocation_proof, known_payloads, known_payloads::MMR_ROOT_ID, + mmr::MmrRootProvider, Keyring, Payload, SignedCommitment, }; use futures::{future::poll_fn, task::Poll}; use parking_lot::Mutex; @@ -1041,6 +1105,7 @@ pub(crate) mod tests { Block, Backend, MmrRootProvider, + TestApi, Arc>, > { let keystore = create_beefy_keystore(*key); @@ -1091,6 +1156,7 @@ pub(crate) mod tests { let worker_params = crate::worker::WorkerParams { backend, payload_provider, + runtime: api, key_store: Some(keystore).into(), links, gossip_engine, @@ -1100,7 +1166,7 @@ pub(crate) mod tests { on_demand_justifications, persisted_state, }; - BeefyWorker::<_, _, _, _>::new(worker_params) + BeefyWorker::<_, _, _, _, _>::new(worker_params) } #[test] @@ -1546,4 +1612,65 @@ pub(crate) mod tests { assert_eq!(votes.next().unwrap().first().unwrap().commitment.block_number, 21); assert_eq!(votes.next().unwrap().first().unwrap().commitment.block_number, 22); } + + #[tokio::test] + async fn should_not_report_bad_old_or_self_equivocations() { + let block_num = 1; + let set_id = 1; + let keys = [Keyring::Alice]; + let validator_set = ValidatorSet::new(make_beefy_ids(&keys), set_id).unwrap(); + // Alice votes on good MMR roots, equivocations are allowed/expected + let mut api_alice = TestApi::with_validator_set(&validator_set); + api_alice.allow_equivocations(); + let api_alice = Arc::new(api_alice); + + let mut net = BeefyTestNet::new(1); + let mut worker = create_beefy_worker(&net.peer(0), &keys[0], 1, validator_set.clone()); + worker.runtime = api_alice.clone(); + + let payload1 = Payload::from_single_entry(MMR_ROOT_ID, vec![42]); + let payload2 = Payload::from_single_entry(MMR_ROOT_ID, vec![128]); + + // generate an equivocation proof, with Bob as perpetrator + let good_proof = generate_equivocation_proof( + (block_num, payload1.clone(), set_id, &Keyring::Bob), + (block_num, payload2.clone(), set_id, &Keyring::Bob), + ); + { + // expect voter (Alice) to successfully report it + assert_eq!(worker.report_equivocation(good_proof.clone()), Ok(())); + // verify Alice reports Bob equivocation to runtime + let reported = api_alice.reported_equivocations.as_ref().unwrap().lock(); + assert_eq!(reported.len(), 1); + assert_eq!(*reported.get(0).unwrap(), good_proof); + } + api_alice.reported_equivocations.as_ref().unwrap().lock().clear(); + + // now let's try with a bad proof + let mut bad_proof = good_proof.clone(); + bad_proof.first.id = Keyring::Charlie.public(); + // bad proofs are simply ignored + assert_eq!(worker.report_equivocation(bad_proof), Ok(())); + // verify nothing reported to runtime + assert!(api_alice.reported_equivocations.as_ref().unwrap().lock().is_empty()); + + // now let's try with old set it + let mut old_proof = good_proof.clone(); + old_proof.first.commitment.validator_set_id = 0; + old_proof.second.commitment.validator_set_id = 0; + // old proofs are simply ignored + assert_eq!(worker.report_equivocation(old_proof), Ok(())); + // verify nothing reported to runtime + assert!(api_alice.reported_equivocations.as_ref().unwrap().lock().is_empty()); + + // now let's try reporting a self-equivocation + let self_proof = generate_equivocation_proof( + (block_num, payload1.clone(), set_id, &Keyring::Alice), + (block_num, payload2.clone(), set_id, &Keyring::Alice), + ); + // equivocations done by 'self' are simply ignored (not reported) + assert_eq!(worker.report_equivocation(self_proof), Ok(())); + // verify nothing reported to runtime + assert!(api_alice.reported_equivocations.as_ref().unwrap().lock().is_empty()); + } } diff --git a/client/consensus/babe/src/lib.rs b/client/consensus/babe/src/lib.rs index 99b29986ec8fc..403792249e8df 100644 --- a/client/consensus/babe/src/lib.rs +++ b/client/consensus/babe/src/lib.rs @@ -1086,7 +1086,7 @@ where .map_err(|e| Error::Client(e.into()))?; // generate a key ownership proof. we start by trying to generate the - // key owernship proof at the parent of the equivocating header, this + // key ownership proof at the parent of the equivocating header, this // will make sure that proof generation is successful since it happens // during the on-going session (i.e. session keys are available in the // state to be able to generate the proof). this might fail if the diff --git a/frame/beefy-mmr/src/lib.rs b/frame/beefy-mmr/src/lib.rs index e5506ecd01d6e..eb030b71ddec6 100644 --- a/frame/beefy-mmr/src/lib.rs +++ b/frame/beefy-mmr/src/lib.rs @@ -23,8 +23,8 @@ //! While both BEEFY and Merkle Mountain Range (MMR) can be used separately, //! these tools were designed to work together in unison. //! -//! The pallet provides a standardized MMR Leaf format that is can be used -//! to bridge BEEFY+MMR-based networks (both standalone and polkadot-like). +//! The pallet provides a standardized MMR Leaf format that can be used +//! to bridge BEEFY+MMR-based networks (both standalone and Polkadot-like). //! //! The MMR leaf contains: //! 1. Block number and parent block hash. diff --git a/frame/beefy-mmr/src/mock.rs b/frame/beefy-mmr/src/mock.rs index 2de71cd5b320a..de6fa135286b9 100644 --- a/frame/beefy-mmr/src/mock.rs +++ b/frame/beefy-mmr/src/mock.rs @@ -22,10 +22,10 @@ use codec::Encode; use frame_support::{ construct_runtime, parameter_types, sp_io::TestExternalities, - traits::{ConstU16, ConstU32, ConstU64, GenesisBuild}, + traits::{ConstU16, ConstU32, ConstU64, GenesisBuild, KeyOwnerProofSystem}, BasicExternalities, }; -use sp_core::{Hasher, H256}; +use sp_core::{crypto::KeyTypeId, Hasher, H256}; use sp_runtime::{ app_crypto::ecdsa::Public, impl_opaque_keys, @@ -124,8 +124,17 @@ impl pallet_mmr::Config for Test { impl pallet_beefy::Config for Test { type BeefyId = BeefyId; + type KeyOwnerProofSystem = (); + type KeyOwnerProof = + >::Proof; + type KeyOwnerIdentification = >::IdentificationTuple; + type HandleEquivocation = (); type MaxAuthorities = ConstU32<100>; type OnNewValidatorSet = BeefyMmr; + type WeightInfo = (); } parameter_types! { diff --git a/frame/beefy/Cargo.toml b/frame/beefy/Cargo.toml index 4db8c05682840..b2d6635377571 100644 --- a/frame/beefy/Cargo.toml +++ b/frame/beefy/Cargo.toml @@ -15,11 +15,20 @@ serde = { version = "1.0.136", optional = true } beefy-primitives = { version = "4.0.0-dev", default-features = false, path = "../../primitives/beefy", package = "sp-beefy" } frame-support = { version = "4.0.0-dev", default-features = false, path = "../support" } frame-system = { version = "4.0.0-dev", default-features = false, path = "../system" } +pallet-authorship = { version = "4.0.0-dev", default-features = false, path = "../authorship" } pallet-session = { version = "4.0.0-dev", default-features = false, path = "../session" } sp-runtime = { version = "7.0.0", default-features = false, path = "../../primitives/runtime" } +sp-session = { version = "4.0.0-dev", default-features = false, path = "../../primitives/session" } +sp-staking = { version = "4.0.0-dev", default-features = false, path = "../../primitives/staking" } sp-std = { version = "5.0.0", default-features = false, path = "../../primitives/std" } [dev-dependencies] +frame-election-provider-support = { version = "4.0.0-dev", path = "../election-provider-support" } +pallet-balances = { version = "4.0.0-dev", path = "../balances" } +pallet-offences = { version = "4.0.0-dev", path = "../offences" } +pallet-staking = { version = "4.0.0-dev", path = "../staking" } +pallet-staking-reward-curve = { version = "4.0.0-dev", path = "../staking/reward-curve" } +pallet-timestamp = { version = "4.0.0-dev", path = "../timestamp" } sp-core = { version = "7.0.0", path = "../../primitives/core" } sp-io = { version = "7.0.0", path = "../../primitives/io" } sp-staking = { version = "4.0.0-dev", path = "../../primitives/staking" } @@ -31,10 +40,13 @@ std = [ "codec/std", "frame-support/std", "frame-system/std", + "pallet-authorship/std", "pallet-session/std", "scale-info/std", "serde", "sp-runtime/std", + "sp-session/std", + "sp-staking/std", "sp-std/std", ] try-runtime = ["frame-support/try-runtime"] diff --git a/frame/beefy/src/default_weights.rs b/frame/beefy/src/default_weights.rs new file mode 100644 index 0000000000000..0b642cb45e9ce --- /dev/null +++ b/frame/beefy/src/default_weights.rs @@ -0,0 +1,54 @@ +// This file is part of Substrate. + +// Copyright (C) 2020-2023 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Default weights for the BEEFY Pallet +//! This file was not auto-generated. + +use frame_support::weights::{ + constants::{RocksDbWeight as DbWeight, WEIGHT_REF_TIME_PER_MICROS, WEIGHT_REF_TIME_PER_NANOS}, + Weight, +}; + +impl crate::WeightInfo for () { + fn report_equivocation(validator_count: u32) -> Weight { + // we take the validator set count from the membership proof to + // calculate the weight but we set a floor of 100 validators. + let validator_count = validator_count.max(100) as u64; + + // worst case we are considering is that the given offender is backed by 200 nominators + const MAX_NOMINATORS: u64 = 200; + + // checking membership proof + Weight::from_ref_time(35u64 * WEIGHT_REF_TIME_PER_MICROS) + .saturating_add( + Weight::from_ref_time(175u64 * WEIGHT_REF_TIME_PER_NANOS) + .saturating_mul(validator_count), + ) + .saturating_add(DbWeight::get().reads(5)) + // check equivocation proof + .saturating_add(Weight::from_ref_time(95u64 * WEIGHT_REF_TIME_PER_MICROS)) + // report offence + .saturating_add(Weight::from_ref_time(110u64 * WEIGHT_REF_TIME_PER_MICROS)) + .saturating_add(Weight::from_ref_time( + 25u64 * WEIGHT_REF_TIME_PER_MICROS * MAX_NOMINATORS, + )) + .saturating_add(DbWeight::get().reads(14 + 3 * MAX_NOMINATORS)) + .saturating_add(DbWeight::get().writes(10 + 3 * MAX_NOMINATORS)) + // fetching set id -> session index mappings + .saturating_add(DbWeight::get().reads(2)) + } +} diff --git a/frame/beefy/src/equivocation.rs b/frame/beefy/src/equivocation.rs new file mode 100644 index 0000000000000..ea6a9f9122351 --- /dev/null +++ b/frame/beefy/src/equivocation.rs @@ -0,0 +1,385 @@ +// This file is part of Substrate. + +// Copyright (C) 2017-2023 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! An opt-in utility module for reporting equivocations. +//! +//! This module defines an offence type for BEEFY equivocations +//! and some utility traits to wire together: +//! - a key ownership proof system (e.g. to prove that a given authority was part of a session); +//! - a system for reporting offences; +//! - a system for signing and submitting transactions; +//! - a way to get the current block author; +//! +//! These can be used in an offchain context in order to submit equivocation +//! reporting extrinsics (from the client that's running the BEEFY protocol). +//! And in a runtime context, so that the BEEFY pallet can validate the +//! equivocation proofs in the extrinsic and report the offences. +//! +//! IMPORTANT: +//! When using this module for enabling equivocation reporting it is required +//! that the `ValidateUnsigned` for the BEEFY pallet is used in the runtime +//! definition. + +use sp_std::prelude::*; + +use beefy_primitives::{EquivocationProof, ValidatorSetId}; +use codec::{self as codec, Decode, Encode}; +use frame_support::{ + log, + traits::{Get, KeyOwnerProofSystem}, +}; +use frame_system::pallet_prelude::BlockNumberFor; +use sp_runtime::{ + transaction_validity::{ + InvalidTransaction, TransactionPriority, TransactionSource, TransactionValidity, + TransactionValidityError, ValidTransaction, + }, + DispatchResult, Perbill, RuntimeAppPublic, +}; +use sp_staking::{ + offence::{Kind, Offence, OffenceError, ReportOffence}, + SessionIndex, +}; + +use super::{Call, Config, Pallet, LOG_TARGET}; + +/// A trait with utility methods for handling equivocation reports in BEEFY. +/// The offence type is generic, and the trait provides, reporting an offence +/// triggered by a valid equivocation report, and also for creating and +/// submitting equivocation report extrinsics (useful only in offchain context). +pub trait HandleEquivocation { + /// The offence type used for reporting offences on valid equivocation reports. + type Offence: BeefyOffence, T::KeyOwnerIdentification>; + + /// The longevity, in blocks, that the equivocation report is valid for. When using the staking + /// pallet this should be equal to the bonding duration (in blocks, not eras). + type ReportLongevity: Get; + + /// Report an offence proved by the given reporters. + fn report_offence( + reporters: Vec, + offence: Self::Offence, + ) -> Result<(), OffenceError>; + + /// Returns true if all of the offenders at the given time slot have already been reported. + fn is_known_offence( + offenders: &[T::KeyOwnerIdentification], + time_slot: &>::TimeSlot, + ) -> bool; + + /// Create and dispatch an equivocation report extrinsic. + fn submit_unsigned_equivocation_report( + equivocation_proof: EquivocationProof< + BlockNumberFor, + T::BeefyId, + ::Signature, + >, + key_owner_proof: T::KeyOwnerProof, + ) -> DispatchResult; + + /// Fetch the current block author id, if defined. + fn block_author() -> Option; +} + +impl HandleEquivocation for () { + type Offence = BeefyEquivocationOffence, T::KeyOwnerIdentification>; + type ReportLongevity = (); + + fn report_offence( + _reporters: Vec, + _offence: BeefyEquivocationOffence, T::KeyOwnerIdentification>, + ) -> Result<(), OffenceError> { + Ok(()) + } + + fn is_known_offence( + _offenders: &[T::KeyOwnerIdentification], + _time_slot: &BeefyTimeSlot>, + ) -> bool { + true + } + + fn submit_unsigned_equivocation_report( + _equivocation_proof: EquivocationProof< + BlockNumberFor, + T::BeefyId, + ::Signature, + >, + _key_owner_proof: T::KeyOwnerProof, + ) -> DispatchResult { + Ok(()) + } + + fn block_author() -> Option { + None + } +} + +/// Generic equivocation handler. This type implements `HandleEquivocation` +/// using existing subsystems that are part of frame (type bounds described +/// below) and will dispatch to them directly, it's only purpose is to wire all +/// subsystems together. +pub struct EquivocationHandler> { + _phantom: sp_std::marker::PhantomData<(N, I, R, L, O)>, +} + +impl Default for EquivocationHandler { + fn default() -> Self { + Self { _phantom: Default::default() } + } +} + +impl HandleEquivocation + for EquivocationHandler, T::KeyOwnerIdentification, R, L, O> +where + // We use the authorship pallet to fetch the current block author and use + // `offchain::SendTransactionTypes` for unsigned extrinsic creation and + // submission. + T: Config + pallet_authorship::Config + frame_system::offchain::SendTransactionTypes>, + // A system for reporting offences after valid equivocation reports are + // processed. + R: ReportOffence, + // The longevity (in blocks) that the equivocation report is valid for. When using the staking + // pallet this should be the bonding duration. + L: Get, + // The offence type that should be used when reporting. + O: BeefyOffence, T::KeyOwnerIdentification>, +{ + type Offence = O; + type ReportLongevity = L; + + fn report_offence(reporters: Vec, offence: O) -> Result<(), OffenceError> { + R::report_offence(reporters, offence) + } + + fn is_known_offence(offenders: &[T::KeyOwnerIdentification], time_slot: &O::TimeSlot) -> bool { + R::is_known_offence(offenders, time_slot) + } + + fn submit_unsigned_equivocation_report( + equivocation_proof: EquivocationProof< + BlockNumberFor, + T::BeefyId, + ::Signature, + >, + key_owner_proof: T::KeyOwnerProof, + ) -> DispatchResult { + use frame_system::offchain::SubmitTransaction; + + let call = Call::report_equivocation_unsigned { + equivocation_proof: Box::new(equivocation_proof), + key_owner_proof, + }; + + match SubmitTransaction::>::submit_unsigned_transaction(call.into()) { + Ok(()) => log::info!(target: LOG_TARGET, "Submitted BEEFY equivocation report.",), + Err(e) => + log::error!(target: LOG_TARGET, "Error submitting equivocation report: {:?}", e,), + } + + Ok(()) + } + + fn block_author() -> Option { + >::author() + } +} + +/// A round number and set id which point on the time of an offence. +#[derive(Copy, Clone, PartialOrd, Ord, Eq, PartialEq, Encode, Decode)] +pub struct BeefyTimeSlot { + // The order of these matters for `derive(Ord)`. + /// BEEFY Set ID. + pub set_id: ValidatorSetId, + /// Round number. + pub round: N, +} + +/// Methods for the `ValidateUnsigned` implementation: +/// It restricts calls to `report_equivocation_unsigned` to local calls (i.e. extrinsics generated +/// on this node) or that already in a block. This guarantees that only block authors can include +/// unsigned equivocation reports. +impl Pallet { + pub fn validate_unsigned(source: TransactionSource, call: &Call) -> TransactionValidity { + if let Call::report_equivocation_unsigned { equivocation_proof, key_owner_proof } = call { + // discard equivocation report not coming from the local node + match source { + TransactionSource::Local | TransactionSource::InBlock => { /* allowed */ }, + _ => { + log::warn!( + target: LOG_TARGET, + "rejecting unsigned report equivocation transaction because it is not local/in-block." + ); + return InvalidTransaction::Call.into() + }, + } + + // check report staleness + is_known_offence::(equivocation_proof, key_owner_proof)?; + + let longevity = + >::ReportLongevity::get(); + + ValidTransaction::with_tag_prefix("BeefyEquivocation") + // We assign the maximum priority for any equivocation report. + .priority(TransactionPriority::MAX) + // Only one equivocation report for the same offender at the same slot. + .and_provides(( + equivocation_proof.offender_id().clone(), + equivocation_proof.set_id(), + *equivocation_proof.round_number(), + )) + .longevity(longevity) + // We don't propagate this. This can never be included on a remote node. + .propagate(false) + .build() + } else { + InvalidTransaction::Call.into() + } + } + + pub fn pre_dispatch(call: &Call) -> Result<(), TransactionValidityError> { + if let Call::report_equivocation_unsigned { equivocation_proof, key_owner_proof } = call { + is_known_offence::(equivocation_proof, key_owner_proof) + } else { + Err(InvalidTransaction::Call.into()) + } + } +} + +fn is_known_offence( + equivocation_proof: &EquivocationProof< + BlockNumberFor, + T::BeefyId, + ::Signature, + >, + key_owner_proof: &T::KeyOwnerProof, +) -> Result<(), TransactionValidityError> { + // check the membership proof to extract the offender's id, + // equivocation validity will be fully checked during the call. + let key = (beefy_primitives::KEY_TYPE, equivocation_proof.offender_id().clone()); + + let offender = T::KeyOwnerProofSystem::check_proof(key, key_owner_proof.clone()) + .ok_or(InvalidTransaction::BadProof)?; + + // check if the offence has already been reported, + // and if so then we can discard the report. + let time_slot = >::Offence::new_time_slot( + equivocation_proof.set_id(), + *equivocation_proof.round_number(), + ); + + let is_known_offence = T::HandleEquivocation::is_known_offence(&[offender], &time_slot); + + if is_known_offence { + Err(InvalidTransaction::Stale.into()) + } else { + Ok(()) + } +} + +/// A BEEFY equivocation offence report. +pub struct BeefyEquivocationOffence +where + N: Copy + Clone + PartialOrd + Ord + Eq + PartialEq + Encode + Decode, +{ + /// Time slot at which this incident happened. + pub time_slot: BeefyTimeSlot, + /// The session index in which the incident happened. + pub session_index: SessionIndex, + /// The size of the validator set at the time of the offence. + pub validator_set_count: u32, + /// The authority which produced this equivocation. + pub offender: FullIdentification, +} + +/// An interface for types that will be used as BEEFY offences and must also +/// implement the `Offence` trait. This trait provides a constructor that is +/// provided all available data during processing of BEEFY equivocations. +pub trait BeefyOffence: Offence +where + N: Copy + Clone + PartialOrd + Ord + Eq + PartialEq + Encode + Decode, +{ + /// Create a new BEEFY offence using the given equivocation details. + fn new( + session_index: SessionIndex, + validator_set_count: u32, + offender: FullIdentification, + set_id: ValidatorSetId, + round: N, + ) -> Self; + + /// Create a new BEEFY offence time slot. + fn new_time_slot(set_id: ValidatorSetId, round: N) -> Self::TimeSlot; +} + +impl BeefyOffence + for BeefyEquivocationOffence +where + N: Copy + Clone + PartialOrd + Ord + Eq + PartialEq + Encode + Decode, +{ + fn new( + session_index: SessionIndex, + validator_set_count: u32, + offender: FullIdentification, + set_id: ValidatorSetId, + round: N, + ) -> Self { + BeefyEquivocationOffence { + session_index, + validator_set_count, + offender, + time_slot: BeefyTimeSlot { set_id, round }, + } + } + + fn new_time_slot(set_id: ValidatorSetId, round: N) -> Self::TimeSlot { + BeefyTimeSlot { set_id, round } + } +} + +impl Offence + for BeefyEquivocationOffence +where + N: Copy + Clone + PartialOrd + Ord + Eq + PartialEq + Encode + Decode, +{ + const ID: Kind = *b"beefy:equivocati"; + type TimeSlot = BeefyTimeSlot; + + fn offenders(&self) -> Vec { + vec![self.offender.clone()] + } + + fn session_index(&self) -> SessionIndex { + self.session_index + } + + fn validator_set_count(&self) -> u32 { + self.validator_set_count + } + + fn time_slot(&self) -> Self::TimeSlot { + self.time_slot + } + + fn slash_fraction(&self, offenders_count: u32) -> Perbill { + // the formula is min((3k / n)^2, 1) + let x = Perbill::from_rational(3 * offenders_count, self.validator_set_count); + // _ ^ 2 + x.square() + } +} diff --git a/frame/beefy/src/lib.rs b/frame/beefy/src/lib.rs index 86c8763a51dd3..d29440457f5bf 100644 --- a/frame/beefy/src/lib.rs +++ b/frame/beefy/src/lib.rs @@ -20,35 +20,48 @@ use codec::{Encode, MaxEncodedLen}; use frame_support::{ + dispatch::{DispatchResultWithPostInfo, Pays}, log, - traits::{Get, OneSessionHandler}, + pallet_prelude::*, + traits::{Get, KeyOwnerProofSystem, OneSessionHandler}, + weights::Weight, BoundedSlice, BoundedVec, Parameter, }; - +use frame_system::{ + ensure_none, ensure_signed, + pallet_prelude::{BlockNumberFor, OriginFor}, +}; use sp_runtime::{ generic::DigestItem, traits::{IsMember, Member}, - RuntimeAppPublic, + KeyTypeId, RuntimeAppPublic, }; +use sp_session::{GetSessionNumber, GetValidatorCount}; +use sp_staking::SessionIndex; use sp_std::prelude::*; use beefy_primitives::{ - AuthorityIndex, ConsensusLog, OnNewValidatorSet, ValidatorSet, BEEFY_ENGINE_ID, - GENESIS_AUTHORITY_SET_ID, + AuthorityIndex, BeefyAuthorityId, ConsensusLog, EquivocationProof, OnNewValidatorSet, + ValidatorSet, BEEFY_ENGINE_ID, GENESIS_AUTHORITY_SET_ID, }; +mod default_weights; +mod equivocation; #[cfg(test)] mod mock; - #[cfg(test)] mod tests; +pub use crate::equivocation::{ + BeefyEquivocationOffence, BeefyOffence, BeefyTimeSlot, EquivocationHandler, HandleEquivocation, +}; pub use pallet::*; +const LOG_TARGET: &str = "runtime::beefy"; + #[frame_support::pallet] pub mod pallet { use super::*; - use frame_support::pallet_prelude::*; use frame_system::pallet_prelude::BlockNumberFor; #[pallet::config] @@ -56,10 +69,35 @@ pub mod pallet { /// Authority identifier type type BeefyId: Member + Parameter - + RuntimeAppPublic + // todo: use custom signature hashing type instead of hardcoded `Keccak256` + + BeefyAuthorityId + MaybeSerializeDeserialize + MaxEncodedLen; + /// A system for proving ownership of keys, i.e. that a given key was part + /// of a validator set, needed for validating equivocation reports. + type KeyOwnerProofSystem: KeyOwnerProofSystem< + (KeyTypeId, Self::BeefyId), + Proof = Self::KeyOwnerProof, + IdentificationTuple = Self::KeyOwnerIdentification, + >; + + /// The proof of key ownership, used for validating equivocation reports + /// The proof must include the session index and validator count of the + /// session at which the equivocation occurred. + type KeyOwnerProof: Parameter + GetSessionNumber + GetValidatorCount; + + /// The identification of a key owner, used when reporting equivocations. + type KeyOwnerIdentification: Parameter; + + /// The equivocation handling subsystem, defines methods to report an + /// offence (after the equivocation has been validated) and for submitting a + /// transaction to report an equivocation (from an offchain context). + /// NOTE: when enabling equivocation handling (i.e. this type isn't set to + /// `()`) you must use this pallet's `ValidateUnsigned` in the runtime + /// definition. + type HandleEquivocation: HandleEquivocation; + /// The maximum number of authorities that can be added. type MaxAuthorities: Get; @@ -69,6 +107,9 @@ pub mod pallet { /// externally apart from having it in the storage. For instance you might cache a light /// weight MMR root over validators and make it available for Light Clients. type OnNewValidatorSet: OnNewValidatorSet<::BeefyId>; + + /// Weights for this pallet. + type WeightInfo: WeightInfo; } #[pallet::pallet] @@ -92,6 +133,15 @@ pub mod pallet { pub(super) type NextAuthorities = StorageValue<_, BoundedVec, ValueQuery>; + /// A mapping from BEEFY set ID to the index of the *most recent* session for which its + /// members were responsible. + /// + /// TWOX-NOTE: `ValidatorSetId` is not under user control. + #[pallet::storage] + #[pallet::getter(fn session_for_set)] + pub(super) type SetIdSession = + StorageMap<_, Twox64Concat, beefy_primitives::ValidatorSetId, SessionIndex>; + /// Block number where BEEFY consensus is enabled/started. /// If changing this, make sure `Self::ValidatorSetId` is also reset to /// `GENESIS_AUTHORITY_SET_ID` in the state of the new block number configured here. @@ -124,13 +174,90 @@ pub mod pallet { #[pallet::genesis_build] impl GenesisBuild for GenesisConfig { fn build(&self) { - Pallet::::initialize_authorities(&self.authorities) + Pallet::::initialize(&self.authorities) // we panic here as runtime maintainers can simply reconfigure genesis and restart // the chain easily .expect("Authorities vec too big"); >::put(&self.genesis_block); } } + + #[pallet::error] + pub enum Error { + /// A key ownership proof provided as part of an equivocation report is invalid. + InvalidKeyOwnershipProof, + /// An equivocation proof provided as part of an equivocation report is invalid. + InvalidEquivocationProof, + /// A given equivocation report is valid but already previously reported. + DuplicateOffenceReport, + } + + #[pallet::call] + impl Pallet { + /// Report voter equivocation/misbehavior. This method will verify the + /// equivocation proof and validate the given key ownership proof + /// against the extracted offender. If both are valid, the offence + /// will be reported. + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::report_equivocation(key_owner_proof.validator_count()))] + pub fn report_equivocation( + origin: OriginFor, + equivocation_proof: Box< + EquivocationProof< + BlockNumberFor, + T::BeefyId, + ::Signature, + >, + >, + key_owner_proof: T::KeyOwnerProof, + ) -> DispatchResultWithPostInfo { + let reporter = ensure_signed(origin)?; + + Self::do_report_equivocation(Some(reporter), *equivocation_proof, key_owner_proof) + } + + /// Report voter equivocation/misbehavior. This method will verify the + /// equivocation proof and validate the given key ownership proof + /// against the extracted offender. If both are valid, the offence + /// will be reported. + /// + /// This extrinsic must be called unsigned and it is expected that only + /// block authors will call it (validated in `ValidateUnsigned`), as such + /// if the block author is defined it will be defined as the equivocation + /// reporter. + #[pallet::call_index(1)] + #[pallet::weight(T::WeightInfo::report_equivocation(key_owner_proof.validator_count()))] + pub fn report_equivocation_unsigned( + origin: OriginFor, + equivocation_proof: Box< + EquivocationProof< + BlockNumberFor, + T::BeefyId, + ::Signature, + >, + >, + key_owner_proof: T::KeyOwnerProof, + ) -> DispatchResultWithPostInfo { + ensure_none(origin)?; + + Self::do_report_equivocation( + T::HandleEquivocation::block_author(), + *equivocation_proof, + key_owner_proof, + ) + } + } + + #[pallet::validate_unsigned] + impl ValidateUnsigned for Pallet { + type Call = Call; + fn pre_dispatch(call: &Self::Call) -> Result<(), TransactionValidityError> { + Self::pre_dispatch(call) + } + fn validate_unsigned(source: TransactionSource, call: &Self::Call) -> TransactionValidity { + Self::validate_unsigned(source, call) + } + } } impl Pallet { @@ -141,6 +268,24 @@ impl Pallet { ValidatorSet::::new(validators, id) } + /// Submits an extrinsic to report an equivocation. This method will create + /// an unsigned extrinsic with a call to `report_equivocation_unsigned` and + /// will push the transaction to the pool. Only useful in an offchain context. + pub fn submit_unsigned_equivocation_report( + equivocation_proof: EquivocationProof< + BlockNumberFor, + T::BeefyId, + ::Signature, + >, + key_owner_proof: T::KeyOwnerProof, + ) -> Option<()> { + T::HandleEquivocation::submit_unsigned_equivocation_report( + equivocation_proof, + key_owner_proof, + ) + .ok() + } + fn change_authorities( new: BoundedVec, queued: BoundedVec, @@ -169,7 +314,7 @@ impl Pallet { } } - fn initialize_authorities(authorities: &Vec) -> Result<(), ()> { + fn initialize(authorities: &Vec) -> Result<(), ()> { if authorities.is_empty() { return Ok(()) } @@ -199,15 +344,80 @@ impl Pallet { ); } } + + // NOTE: initialize first session of first set. this is necessary for + // the genesis set and session since we only update the set -> session + // mapping whenever a new session starts, i.e. through `on_new_session`. + SetIdSession::::insert(0, 0); + Ok(()) } + + fn do_report_equivocation( + reporter: Option, + equivocation_proof: EquivocationProof< + BlockNumberFor, + T::BeefyId, + ::Signature, + >, + key_owner_proof: T::KeyOwnerProof, + ) -> DispatchResultWithPostInfo { + // We check the equivocation within the context of its set id (and + // associated session) and round. We also need to know the validator + // set count at the time of the offence since it is required to calculate + // the slash amount. + let set_id = equivocation_proof.set_id(); + let round = *equivocation_proof.round_number(); + let offender_id = equivocation_proof.offender_id().clone(); + let session_index = key_owner_proof.session(); + let validator_count = key_owner_proof.validator_count(); + + // validate the key ownership proof extracting the id of the offender. + let offender = T::KeyOwnerProofSystem::check_proof( + (beefy_primitives::KEY_TYPE, offender_id), + key_owner_proof, + ) + .ok_or(Error::::InvalidKeyOwnershipProof)?; + + // validate equivocation proof (check votes are different and signatures are valid). + if !beefy_primitives::check_equivocation_proof(&equivocation_proof) { + return Err(Error::::InvalidEquivocationProof.into()) + } + + // check that the session id for the membership proof is within the + // bounds of the set id reported in the equivocation. + let set_id_session_index = + Self::session_for_set(set_id).ok_or(Error::::InvalidEquivocationProof)?; + if session_index != set_id_session_index { + return Err(Error::::InvalidEquivocationProof.into()) + } + + // report to the offences module rewarding the sender. + T::HandleEquivocation::report_offence( + reporter.into_iter().collect(), + >::Offence::new( + session_index, + validator_count, + offender, + set_id, + round, + ), + ) + .map_err(|_| Error::::DuplicateOffenceReport)?; + + // waive the fee since the report is valid and beneficial + Ok(Pays::No.into()) + } } impl sp_runtime::BoundToRuntimeAppPublic for Pallet { type Public = T::BeefyId; } -impl OneSessionHandler for Pallet { +impl OneSessionHandler for Pallet +where + T: pallet_session::Config, +{ type Key = T::BeefyId; fn on_genesis_session<'a, I: 'a>(validators: I) @@ -217,7 +427,7 @@ impl OneSessionHandler for Pallet { let authorities = validators.map(|(_, k)| k).collect::>(); // we panic here as runtime maintainers can simply reconfigure genesis and restart the // chain easily - Self::initialize_authorities(&authorities).expect("Authorities vec too big"); + Self::initialize(&authorities).expect("Authorities vec too big"); } fn on_new_session<'a, I: 'a>(_changed: bool, validators: I, queued_validators: I) @@ -227,9 +437,10 @@ impl OneSessionHandler for Pallet { let next_authorities = validators.map(|(_, k)| k).collect::>(); if next_authorities.len() as u32 > T::MaxAuthorities::get() { log::error!( - target: "runtime::beefy", + target: LOG_TARGET, "authorities list {:?} truncated to length {}", - next_authorities, T::MaxAuthorities::get(), + next_authorities, + T::MaxAuthorities::get(), ); } let bounded_next_authorities = @@ -238,9 +449,10 @@ impl OneSessionHandler for Pallet { let next_queued_authorities = queued_validators.map(|(_, k)| k).collect::>(); if next_queued_authorities.len() as u32 > T::MaxAuthorities::get() { log::error!( - target: "runtime::beefy", + target: LOG_TARGET, "queued authorities list {:?} truncated to length {}", - next_queued_authorities, T::MaxAuthorities::get(), + next_queued_authorities, + T::MaxAuthorities::get(), ); } let bounded_next_queued_authorities = @@ -249,6 +461,10 @@ impl OneSessionHandler for Pallet { // Always issue a change on each `session`, even if validator set hasn't changed. // We want to have at least one BEEFY mandatory block per session. Self::change_authorities(bounded_next_authorities, bounded_next_queued_authorities); + + // Update the mapping for the new set id that corresponds to the latest session (i.e. now). + let session_index = >::current_index(); + SetIdSession::::insert(Self::validator_set_id(), &session_index); } fn on_disabled(i: u32) { @@ -266,3 +482,7 @@ impl IsMember for Pallet { Self::authorities().iter().any(|id| id == authority_id) } } + +pub trait WeightInfo { + fn report_equivocation(validator_count: u32) -> Weight; +} diff --git a/frame/beefy/src/mock.rs b/frame/beefy/src/mock.rs index ad3a672333dd5..8d8b831950d8e 100644 --- a/frame/beefy/src/mock.rs +++ b/frame/beefy/src/mock.rs @@ -17,24 +17,33 @@ use std::vec; +use frame_election_provider_support::{onchain, SequentialPhragmen}; use frame_support::{ construct_runtime, parameter_types, sp_io::TestExternalities, - traits::{ConstU16, ConstU32, ConstU64, GenesisBuild}, + traits::{ + ConstU16, ConstU32, ConstU64, GenesisBuild, KeyOwnerProofSystem, OnFinalize, OnInitialize, + }, BasicExternalities, }; -use sp_core::H256; +use pallet_session::historical as pallet_session_historical; +use sp_core::{crypto::KeyTypeId, ConstU128, H256}; use sp_runtime::{ app_crypto::ecdsa::Public, + curve::PiecewiseLinear, impl_opaque_keys, - testing::Header, - traits::{BlakeTwo256, ConvertInto, IdentityLookup, OpaqueKeys}, + testing::{Header, TestXt}, + traits::{BlakeTwo256, IdentityLookup, OpaqueKeys}, Perbill, }; +use sp_staking::{EraIndex, SessionIndex}; use crate as pallet_beefy; -pub use beefy_primitives::{crypto::AuthorityId as BeefyId, ConsensusLog, BEEFY_ENGINE_ID}; +pub use beefy_primitives::{ + crypto::{AuthorityId as BeefyId, AuthoritySignature as BeefySignature}, + ConsensusLog, EquivocationProof, BEEFY_ENGINE_ID, +}; impl_opaque_keys! { pub struct MockSessionKeys { @@ -51,9 +60,15 @@ construct_runtime!( NodeBlock = Block, UncheckedExtrinsic = UncheckedExtrinsic, { - System: frame_system::{Pallet, Call, Config, Storage, Event}, - Beefy: pallet_beefy::{Pallet, Config, Storage}, - Session: pallet_session::{Pallet, Call, Storage, Event, Config}, + System: frame_system, + Authorship: pallet_authorship, + Timestamp: pallet_timestamp, + Balances: pallet_balances, + Beefy: pallet_beefy, + Staking: pallet_staking, + Session: pallet_session, + Offences: pallet_offences, + Historical: pallet_session_historical, } ); @@ -75,7 +90,7 @@ impl frame_system::Config for Test { type BlockHashCount = ConstU64<250>; type Version = (); type PalletInfo = PalletInfo; - type AccountData = (); + type AccountData = pallet_balances::AccountData; type OnNewAccount = (); type OnKilledAccount = (); type SystemWeightInfo = (); @@ -84,10 +99,34 @@ impl frame_system::Config for Test { type MaxConsumers = ConstU32<16>; } +impl frame_system::offchain::SendTransactionTypes for Test +where + RuntimeCall: From, +{ + type OverarchingCall = RuntimeCall; + type Extrinsic = TestXt; +} + +parameter_types! { + pub const Period: u64 = 1; + pub const ReportLongevity: u64 = + BondingDuration::get() as u64 * SessionsPerEra::get() as u64 * Period::get(); +} + impl pallet_beefy::Config for Test { type BeefyId = BeefyId; + type KeyOwnerProofSystem = Historical; + type KeyOwnerProof = + >::Proof; + type KeyOwnerIdentification = >::IdentificationTuple; + type HandleEquivocation = + super::EquivocationHandler; type MaxAuthorities = ConstU32<100>; type OnNewValidatorSet = (); + type WeightInfo = (); } parameter_types! { @@ -97,29 +136,107 @@ parameter_types! { impl pallet_session::Config for Test { type RuntimeEvent = RuntimeEvent; type ValidatorId = u64; - type ValidatorIdOf = ConvertInto; + type ValidatorIdOf = pallet_staking::StashOf; type ShouldEndSession = pallet_session::PeriodicSessions, ConstU64<0>>; type NextSessionRotation = pallet_session::PeriodicSessions, ConstU64<0>>; - type SessionManager = MockSessionManager; + type SessionManager = pallet_session::historical::NoteHistoricalRoot; type SessionHandler = ::KeyTypeIdProviders; type Keys = MockSessionKeys; type WeightInfo = (); } -pub struct MockSessionManager; +impl pallet_session::historical::Config for Test { + type FullIdentification = pallet_staking::Exposure; + type FullIdentificationOf = pallet_staking::ExposureOf; +} -impl pallet_session::SessionManager for MockSessionManager { - fn end_session(_: sp_staking::SessionIndex) {} - fn start_session(_: sp_staking::SessionIndex) {} - fn new_session(idx: sp_staking::SessionIndex) -> Option> { - if idx == 0 || idx == 1 { - Some(vec![1, 2]) - } else if idx == 2 { - Some(vec![3, 4]) - } else { - None - } - } +impl pallet_authorship::Config for Test { + type FindAuthor = (); + type EventHandler = (); +} + +impl pallet_balances::Config for Test { + type MaxLocks = (); + type MaxReserves = (); + type ReserveIdentifier = [u8; 8]; + type Balance = u128; + type DustRemoval = (); + type RuntimeEvent = RuntimeEvent; + type ExistentialDeposit = ConstU128<1>; + type AccountStore = System; + type WeightInfo = (); +} + +impl pallet_timestamp::Config for Test { + type Moment = u64; + type OnTimestampSet = (); + type MinimumPeriod = ConstU64<3>; + type WeightInfo = (); +} + +pallet_staking_reward_curve::build! { + const REWARD_CURVE: PiecewiseLinear<'static> = curve!( + min_inflation: 0_025_000u64, + max_inflation: 0_100_000, + ideal_stake: 0_500_000, + falloff: 0_050_000, + max_piece_count: 40, + test_precision: 0_005_000, + ); +} + +parameter_types! { + pub const SessionsPerEra: SessionIndex = 3; + pub const BondingDuration: EraIndex = 3; + pub const RewardCurve: &'static PiecewiseLinear<'static> = &REWARD_CURVE; + pub const OffendingValidatorsThreshold: Perbill = Perbill::from_percent(17); +} + +pub struct OnChainSeqPhragmen; +impl onchain::Config for OnChainSeqPhragmen { + type System = Test; + type Solver = SequentialPhragmen; + type DataProvider = Staking; + type WeightInfo = (); + type MaxWinners = ConstU32<100>; + type VotersBound = ConstU32<{ u32::MAX }>; + type TargetsBound = ConstU32<{ u32::MAX }>; +} + +impl pallet_staking::Config for Test { + type MaxNominations = ConstU32<16>; + type RewardRemainder = (); + type CurrencyToVote = frame_support::traits::SaturatingCurrencyToVote; + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type CurrencyBalance = ::Balance; + type Slash = (); + type Reward = (); + type SessionsPerEra = SessionsPerEra; + type BondingDuration = BondingDuration; + type SlashDeferDuration = (); + type AdminOrigin = frame_system::EnsureRoot; + type SessionInterface = Self; + type UnixTime = pallet_timestamp::Pallet; + type EraPayout = pallet_staking::ConvertCurve; + type MaxNominatorRewardedPerValidator = ConstU32<64>; + type OffendingValidatorsThreshold = OffendingValidatorsThreshold; + type NextNewSession = Session; + type ElectionProvider = onchain::OnChainExecution; + type GenesisElectionProvider = Self::ElectionProvider; + type VoterList = pallet_staking::UseNominatorsAndValidatorsMap; + type TargetList = pallet_staking::UseValidatorsMap; + type MaxUnlockingChunks = ConstU32<32>; + type HistoryDepth = ConstU32<84>; + type OnStakerSlash = (); + type BenchmarkingConfig = pallet_staking::TestBenchmarkingConfig; + type WeightInfo = (); +} + +impl pallet_offences::Config for Test { + type RuntimeEvent = RuntimeEvent; + type IdentificationTuple = pallet_session::historical::IdentificationTuple; + type OnOffenceHandler = Staking; } // Note, that we can't use `UintAuthorityId` here. Reason is that the implementation @@ -134,20 +251,27 @@ pub fn mock_beefy_id(id: u8) -> BeefyId { BeefyId::from(pk) } -pub fn mock_authorities(vec: Vec) -> Vec<(u64, BeefyId)> { - vec.into_iter().map(|id| ((id as u64), mock_beefy_id(id))).collect() +pub fn mock_authorities(vec: Vec) -> Vec { + vec.into_iter().map(|id| mock_beefy_id(id)).collect() } pub fn new_test_ext(ids: Vec) -> TestExternalities { new_test_ext_raw_authorities(mock_authorities(ids)) } -pub fn new_test_ext_raw_authorities(authorities: Vec<(u64, BeefyId)>) -> TestExternalities { +pub fn new_test_ext_raw_authorities(authorities: Vec) -> TestExternalities { let mut t = frame_system::GenesisConfig::default().build_storage::().unwrap(); + let balances: Vec<_> = (0..authorities.len()).map(|i| (i as u64, 10_000_000)).collect(); + + pallet_balances::GenesisConfig:: { balances } + .assimilate_storage(&mut t) + .unwrap(); + let session_keys: Vec<_> = authorities .iter() - .map(|id| (id.0 as u64, id.0 as u64, MockSessionKeys { dummy: id.1.clone() })) + .enumerate() + .map(|(i, k)| (i as u64, i as u64, MockSessionKeys { dummy: k.clone() })) .collect(); BasicExternalities::execute_with_storage(&mut t, || { @@ -160,5 +284,56 @@ pub fn new_test_ext_raw_authorities(authorities: Vec<(u64, BeefyId)>) -> TestExt .assimilate_storage(&mut t) .unwrap(); + // controllers are the index + 1000 + let stakers: Vec<_> = (0..authorities.len()) + .map(|i| { + (i as u64, i as u64 + 1000, 10_000, pallet_staking::StakerStatus::::Validator) + }) + .collect(); + + let staking_config = pallet_staking::GenesisConfig:: { + stakers, + validator_count: 2, + force_era: pallet_staking::Forcing::ForceNew, + minimum_validator_count: 0, + invulnerables: vec![], + ..Default::default() + }; + + staking_config.assimilate_storage(&mut t).unwrap(); + t.into() } + +pub fn start_session(session_index: SessionIndex) { + for i in Session::current_index()..session_index { + System::on_finalize(System::block_number()); + Session::on_finalize(System::block_number()); + Staking::on_finalize(System::block_number()); + Beefy::on_finalize(System::block_number()); + + let parent_hash = if System::block_number() > 1 { + let hdr = System::finalize(); + hdr.hash() + } else { + System::parent_hash() + }; + + System::reset_events(); + System::initialize(&(i as u64 + 1), &parent_hash, &Default::default()); + System::set_block_number((i + 1).into()); + Timestamp::set_timestamp(System::block_number() * 6000); + + System::on_initialize(System::block_number()); + Session::on_initialize(System::block_number()); + Staking::on_initialize(System::block_number()); + Beefy::on_initialize(System::block_number()); + } + + assert_eq!(Session::current_index(), session_index); +} + +pub fn start_era(era_index: EraIndex) { + start_session((era_index * 3).into()); + assert_eq!(Staking::current_era(), Some(era_index)); +} diff --git a/frame/beefy/src/tests.rs b/frame/beefy/src/tests.rs index 4136b0c1f1ecf..a92f90b6107bc 100644 --- a/frame/beefy/src/tests.rs +++ b/frame/beefy/src/tests.rs @@ -17,14 +17,21 @@ use std::vec; -use beefy_primitives::ValidatorSet; +use beefy_primitives::{ + check_equivocation_proof, generate_equivocation_proof, known_payloads::MMR_ROOT_ID, + Keyring as BeefyKeyring, Payload, ValidatorSet, +}; use codec::Encode; use sp_runtime::DigestItem; -use frame_support::traits::OnInitialize; +use frame_support::{ + assert_err, assert_ok, + dispatch::{GetDispatchInfo, Pays}, + traits::{Currency, KeyOwnerProofSystem, OnInitialize}, +}; -use crate::mock::*; +use crate::{mock::*, Call, Config, Error, Weight, WeightInfo}; fn init_block(block: u64) { System::set_block_number(block); @@ -37,12 +44,13 @@ pub fn beefy_log(log: ConsensusLog) -> DigestItem { #[test] fn genesis_session_initializes_authorities() { - let want = vec![mock_beefy_id(1), mock_beefy_id(2), mock_beefy_id(3), mock_beefy_id(4)]; + let authorities = mock_authorities(vec![1, 2, 3, 4]); + let want = authorities.clone(); - new_test_ext(vec![1, 2, 3, 4]).execute_with(|| { + new_test_ext_raw_authorities(authorities).execute_with(|| { let authorities = Beefy::authorities(); - assert!(authorities.len() == 2); + assert_eq!(authorities.len(), 4); assert_eq!(want[0], authorities[0]); assert_eq!(want[1], authorities[1]); @@ -50,7 +58,7 @@ fn genesis_session_initializes_authorities() { let next_authorities = Beefy::next_authorities(); - assert!(next_authorities.len() == 2); + assert_eq!(next_authorities.len(), 4); assert_eq!(want[0], next_authorities[0]); assert_eq!(want[1], next_authorities[1]); }); @@ -58,6 +66,9 @@ fn genesis_session_initializes_authorities() { #[test] fn session_change_updates_authorities() { + let authorities = mock_authorities(vec![1, 2, 3, 4]); + let want_validators = authorities.clone(); + new_test_ext(vec![1, 2, 3, 4]).execute_with(|| { assert!(0 == Beefy::validator_set_id()); @@ -66,7 +77,7 @@ fn session_change_updates_authorities() { assert!(1 == Beefy::validator_set_id()); let want = beefy_log(ConsensusLog::AuthoritiesChange( - ValidatorSet::new(vec![mock_beefy_id(1), mock_beefy_id(2)], 1).unwrap(), + ValidatorSet::new(want_validators, 1).unwrap(), )); let log = System::digest().logs[0].clone(); @@ -77,7 +88,7 @@ fn session_change_updates_authorities() { assert!(2 == Beefy::validator_set_id()); let want = beefy_log(ConsensusLog::AuthoritiesChange( - ValidatorSet::new(vec![mock_beefy_id(3), mock_beefy_id(4)], 2).unwrap(), + ValidatorSet::new(vec![mock_beefy_id(2), mock_beefy_id(4)], 2).unwrap(), )); let log = System::digest().logs[1].clone(); @@ -92,16 +103,18 @@ fn session_change_updates_next_authorities() { new_test_ext(vec![1, 2, 3, 4]).execute_with(|| { let next_authorities = Beefy::next_authorities(); - assert!(next_authorities.len() == 2); + assert_eq!(next_authorities.len(), 4); assert_eq!(want[0], next_authorities[0]); assert_eq!(want[1], next_authorities[1]); + assert_eq!(want[2], next_authorities[2]); + assert_eq!(want[3], next_authorities[3]); init_block(1); let next_authorities = Beefy::next_authorities(); - assert!(next_authorities.len() == 2); - assert_eq!(want[2], next_authorities[0]); + assert_eq!(next_authorities.len(), 2); + assert_eq!(want[1], next_authorities[0]); assert_eq!(want[3], next_authorities[1]); }); } @@ -126,6 +139,10 @@ fn validator_set_updates_work() { new_test_ext(vec![1, 2, 3, 4]).execute_with(|| { let vs = Beefy::validator_set().unwrap(); assert_eq!(vs.id(), 0u64); + assert_eq!(want[0], vs.validators()[0]); + assert_eq!(want[1], vs.validators()[1]); + assert_eq!(want[2], vs.validators()[2]); + assert_eq!(want[3], vs.validators()[3]); init_block(1); @@ -140,7 +157,611 @@ fn validator_set_updates_work() { let vs = Beefy::validator_set().unwrap(); assert_eq!(vs.id(), 2u64); - assert_eq!(want[2], vs.validators()[0]); + assert_eq!(want[1], vs.validators()[0]); assert_eq!(want[3], vs.validators()[1]); }); } + +/// Returns a list with 3 authorities with known keys: +/// Alice, Bob and Charlie. +pub fn test_authorities() -> Vec { + let authorities = vec![BeefyKeyring::Alice, BeefyKeyring::Bob, BeefyKeyring::Charlie]; + authorities.into_iter().map(|id| id.public()).collect() +} + +#[test] +fn should_sign_and_verify() { + use sp_runtime::traits::Keccak256; + + let set_id = 3; + let payload1 = Payload::from_single_entry(MMR_ROOT_ID, vec![42]); + let payload2 = Payload::from_single_entry(MMR_ROOT_ID, vec![128]); + + // generate an equivocation proof, with two votes in the same round for + // same payload signed by the same key + let equivocation_proof = generate_equivocation_proof( + (1, payload1.clone(), set_id, &BeefyKeyring::Bob), + (1, payload1.clone(), set_id, &BeefyKeyring::Bob), + ); + // expect invalid equivocation proof + assert!(!check_equivocation_proof::<_, _, Keccak256>(&equivocation_proof)); + + // generate an equivocation proof, with two votes in different rounds for + // different payloads signed by the same key + let equivocation_proof = generate_equivocation_proof( + (1, payload1.clone(), set_id, &BeefyKeyring::Bob), + (2, payload2.clone(), set_id, &BeefyKeyring::Bob), + ); + // expect invalid equivocation proof + assert!(!check_equivocation_proof::<_, _, Keccak256>(&equivocation_proof)); + + // generate an equivocation proof, with two votes by different authorities + let equivocation_proof = generate_equivocation_proof( + (1, payload1.clone(), set_id, &BeefyKeyring::Alice), + (1, payload2.clone(), set_id, &BeefyKeyring::Bob), + ); + // expect invalid equivocation proof + assert!(!check_equivocation_proof::<_, _, Keccak256>(&equivocation_proof)); + + // generate an equivocation proof, with two votes in different set ids + let equivocation_proof = generate_equivocation_proof( + (1, payload1.clone(), set_id, &BeefyKeyring::Bob), + (1, payload2.clone(), set_id + 1, &BeefyKeyring::Bob), + ); + // expect invalid equivocation proof + assert!(!check_equivocation_proof::<_, _, Keccak256>(&equivocation_proof)); + + // generate an equivocation proof, with two votes in the same round for + // different payloads signed by the same key + let payload2 = Payload::from_single_entry(MMR_ROOT_ID, vec![128]); + let equivocation_proof = generate_equivocation_proof( + (1, payload1, set_id, &BeefyKeyring::Bob), + (1, payload2, set_id, &BeefyKeyring::Bob), + ); + // expect valid equivocation proof + assert!(check_equivocation_proof::<_, _, Keccak256>(&equivocation_proof)); +} + +#[test] +fn report_equivocation_current_set_works() { + let authorities = test_authorities(); + + new_test_ext_raw_authorities(authorities).execute_with(|| { + assert_eq!(Staking::current_era(), Some(0)); + assert_eq!(Session::current_index(), 0); + + start_era(1); + + let block_num = System::block_number(); + let validator_set = Beefy::validator_set().unwrap(); + let authorities = validator_set.validators(); + let set_id = validator_set.id(); + let validators = Session::validators(); + + // make sure that all validators have the same balance + for validator in &validators { + assert_eq!(Balances::total_balance(validator), 10_000_000); + assert_eq!(Staking::slashable_balance_of(validator), 10_000); + + assert_eq!( + Staking::eras_stakers(1, validator), + pallet_staking::Exposure { total: 10_000, own: 10_000, others: vec![] }, + ); + } + + assert_eq!(authorities.len(), 2); + let equivocation_authority_index = 1; + let equivocation_key = &authorities[equivocation_authority_index]; + let equivocation_keyring = BeefyKeyring::from_public(equivocation_key).unwrap(); + + let payload1 = Payload::from_single_entry(MMR_ROOT_ID, vec![42]); + let payload2 = Payload::from_single_entry(MMR_ROOT_ID, vec![128]); + // generate an equivocation proof, with two votes in the same round for + // different payloads signed by the same key + let equivocation_proof = generate_equivocation_proof( + (block_num, payload1, set_id, &equivocation_keyring), + (block_num, payload2, set_id, &equivocation_keyring), + ); + + // create the key ownership proof + let key_owner_proof = + Historical::prove((beefy_primitives::KEY_TYPE, &equivocation_key)).unwrap(); + + // report the equivocation and the tx should be dispatched successfully + assert_ok!(Beefy::report_equivocation_unsigned( + RuntimeOrigin::none(), + Box::new(equivocation_proof), + key_owner_proof, + ),); + + start_era(2); + + // check that the balance of 0-th validator is slashed 100%. + let equivocation_validator_id = validators[equivocation_authority_index]; + + assert_eq!(Balances::total_balance(&equivocation_validator_id), 10_000_000 - 10_000); + assert_eq!(Staking::slashable_balance_of(&equivocation_validator_id), 0); + assert_eq!( + Staking::eras_stakers(2, equivocation_validator_id), + pallet_staking::Exposure { total: 0, own: 0, others: vec![] }, + ); + + // check that the balances of all other validators are left intact. + for validator in &validators { + if *validator == equivocation_validator_id { + continue + } + + assert_eq!(Balances::total_balance(validator), 10_000_000); + assert_eq!(Staking::slashable_balance_of(validator), 10_000); + + assert_eq!( + Staking::eras_stakers(2, validator), + pallet_staking::Exposure { total: 10_000, own: 10_000, others: vec![] }, + ); + } + }); +} + +#[test] +fn report_equivocation_old_set_works() { + let authorities = test_authorities(); + + new_test_ext_raw_authorities(authorities).execute_with(|| { + start_era(1); + + let block_num = System::block_number(); + let validator_set = Beefy::validator_set().unwrap(); + let authorities = validator_set.validators(); + let validators = Session::validators(); + let old_set_id = validator_set.id(); + + assert_eq!(authorities.len(), 2); + let equivocation_authority_index = 0; + let equivocation_key = &authorities[equivocation_authority_index]; + + // create the key ownership proof in the "old" set + let key_owner_proof = + Historical::prove((beefy_primitives::KEY_TYPE, &equivocation_key)).unwrap(); + + start_era(2); + + // make sure that all authorities have the same balance + for validator in &validators { + assert_eq!(Balances::total_balance(validator), 10_000_000); + assert_eq!(Staking::slashable_balance_of(validator), 10_000); + + assert_eq!( + Staking::eras_stakers(2, validator), + pallet_staking::Exposure { total: 10_000, own: 10_000, others: vec![] }, + ); + } + + let validator_set = Beefy::validator_set().unwrap(); + let new_set_id = validator_set.id(); + assert_eq!(old_set_id + 3, new_set_id); + + let equivocation_keyring = BeefyKeyring::from_public(equivocation_key).unwrap(); + + let payload1 = Payload::from_single_entry(MMR_ROOT_ID, vec![42]); + let payload2 = Payload::from_single_entry(MMR_ROOT_ID, vec![128]); + // generate an equivocation proof for the old set, + let equivocation_proof = generate_equivocation_proof( + (block_num, payload1, old_set_id, &equivocation_keyring), + (block_num, payload2, old_set_id, &equivocation_keyring), + ); + + // report the equivocation and the tx should be dispatched successfully + assert_ok!(Beefy::report_equivocation_unsigned( + RuntimeOrigin::none(), + Box::new(equivocation_proof), + key_owner_proof, + ),); + + start_era(3); + + // check that the balance of 0-th validator is slashed 100%. + let equivocation_validator_id = validators[equivocation_authority_index]; + + assert_eq!(Balances::total_balance(&equivocation_validator_id), 10_000_000 - 10_000); + assert_eq!(Staking::slashable_balance_of(&equivocation_validator_id), 0); + assert_eq!( + Staking::eras_stakers(3, equivocation_validator_id), + pallet_staking::Exposure { total: 0, own: 0, others: vec![] }, + ); + + // check that the balances of all other validators are left intact. + for validator in &validators { + if *validator == equivocation_validator_id { + continue + } + + assert_eq!(Balances::total_balance(validator), 10_000_000); + assert_eq!(Staking::slashable_balance_of(validator), 10_000); + + assert_eq!( + Staking::eras_stakers(3, validator), + pallet_staking::Exposure { total: 10_000, own: 10_000, others: vec![] }, + ); + } + }); +} + +#[test] +fn report_equivocation_invalid_set_id() { + let authorities = test_authorities(); + + new_test_ext_raw_authorities(authorities).execute_with(|| { + start_era(1); + + let block_num = System::block_number(); + let validator_set = Beefy::validator_set().unwrap(); + let authorities = validator_set.validators(); + let set_id = validator_set.id(); + + let equivocation_authority_index = 0; + let equivocation_key = &authorities[equivocation_authority_index]; + let equivocation_keyring = BeefyKeyring::from_public(equivocation_key).unwrap(); + + let key_owner_proof = + Historical::prove((beefy_primitives::KEY_TYPE, &equivocation_key)).unwrap(); + + let payload1 = Payload::from_single_entry(MMR_ROOT_ID, vec![42]); + let payload2 = Payload::from_single_entry(MMR_ROOT_ID, vec![128]); + // generate an equivocation for a future set + let equivocation_proof = generate_equivocation_proof( + (block_num, payload1, set_id + 1, &equivocation_keyring), + (block_num, payload2, set_id + 1, &equivocation_keyring), + ); + + // the call for reporting the equivocation should error + assert_err!( + Beefy::report_equivocation_unsigned( + RuntimeOrigin::none(), + Box::new(equivocation_proof), + key_owner_proof, + ), + Error::::InvalidEquivocationProof, + ); + }); +} + +#[test] +fn report_equivocation_invalid_session() { + let authorities = test_authorities(); + + new_test_ext_raw_authorities(authorities).execute_with(|| { + start_era(1); + + let block_num = System::block_number(); + let validator_set = Beefy::validator_set().unwrap(); + let authorities = validator_set.validators(); + + let equivocation_authority_index = 0; + let equivocation_key = &authorities[equivocation_authority_index]; + let equivocation_keyring = BeefyKeyring::from_public(equivocation_key).unwrap(); + + // generate a key ownership proof at current era set id + let key_owner_proof = + Historical::prove((beefy_primitives::KEY_TYPE, &equivocation_key)).unwrap(); + + start_era(2); + + let set_id = Beefy::validator_set().unwrap().id(); + + let payload1 = Payload::from_single_entry(MMR_ROOT_ID, vec![42]); + let payload2 = Payload::from_single_entry(MMR_ROOT_ID, vec![128]); + // generate an equivocation proof at following era set id = 2 + let equivocation_proof = generate_equivocation_proof( + (block_num, payload1, set_id, &equivocation_keyring), + (block_num, payload2, set_id, &equivocation_keyring), + ); + + // report an equivocation for the current set using an key ownership + // proof from the previous set, the session should be invalid. + assert_err!( + Beefy::report_equivocation_unsigned( + RuntimeOrigin::none(), + Box::new(equivocation_proof), + key_owner_proof, + ), + Error::::InvalidEquivocationProof, + ); + }); +} + +#[test] +fn report_equivocation_invalid_key_owner_proof() { + let authorities = test_authorities(); + + new_test_ext_raw_authorities(authorities).execute_with(|| { + start_era(1); + + let block_num = System::block_number(); + let validator_set = Beefy::validator_set().unwrap(); + let authorities = validator_set.validators(); + let set_id = validator_set.id(); + + let invalid_owner_authority_index = 1; + let invalid_owner_key = &authorities[invalid_owner_authority_index]; + + // generate a key ownership proof for the authority at index 1 + let invalid_key_owner_proof = + Historical::prove((beefy_primitives::KEY_TYPE, &invalid_owner_key)).unwrap(); + + let equivocation_authority_index = 0; + let equivocation_key = &authorities[equivocation_authority_index]; + let equivocation_keyring = BeefyKeyring::from_public(equivocation_key).unwrap(); + + let payload1 = Payload::from_single_entry(MMR_ROOT_ID, vec![42]); + let payload2 = Payload::from_single_entry(MMR_ROOT_ID, vec![128]); + // generate an equivocation proof for the authority at index 0 + let equivocation_proof = generate_equivocation_proof( + (block_num, payload1, set_id + 1, &equivocation_keyring), + (block_num, payload2, set_id + 1, &equivocation_keyring), + ); + + // we need to start a new era otherwise the key ownership proof won't be + // checked since the authorities are part of the current session + start_era(2); + + // report an equivocation for the current set using a key ownership + // proof for a different key than the one in the equivocation proof. + assert_err!( + Beefy::report_equivocation_unsigned( + RuntimeOrigin::none(), + Box::new(equivocation_proof), + invalid_key_owner_proof, + ), + Error::::InvalidKeyOwnershipProof, + ); + }); +} + +#[test] +fn report_equivocation_invalid_equivocation_proof() { + let authorities = test_authorities(); + + new_test_ext_raw_authorities(authorities).execute_with(|| { + start_era(1); + + let block_num = System::block_number(); + let validator_set = Beefy::validator_set().unwrap(); + let authorities = validator_set.validators(); + let set_id = validator_set.id(); + + let equivocation_authority_index = 0; + let equivocation_key = &authorities[equivocation_authority_index]; + let equivocation_keyring = BeefyKeyring::from_public(equivocation_key).unwrap(); + + // generate a key ownership proof at set id in era 1 + let key_owner_proof = + Historical::prove((beefy_primitives::KEY_TYPE, &equivocation_key)).unwrap(); + + let assert_invalid_equivocation_proof = |equivocation_proof| { + assert_err!( + Beefy::report_equivocation_unsigned( + RuntimeOrigin::none(), + Box::new(equivocation_proof), + key_owner_proof.clone(), + ), + Error::::InvalidEquivocationProof, + ); + }; + + start_era(2); + + let payload1 = Payload::from_single_entry(MMR_ROOT_ID, vec![42]); + let payload2 = Payload::from_single_entry(MMR_ROOT_ID, vec![128]); + + // both votes target the same block number and payload, + // there is no equivocation. + assert_invalid_equivocation_proof(generate_equivocation_proof( + (block_num, payload1.clone(), set_id, &equivocation_keyring), + (block_num, payload1.clone(), set_id, &equivocation_keyring), + )); + + // votes targeting different rounds, there is no equivocation. + assert_invalid_equivocation_proof(generate_equivocation_proof( + (block_num, payload1.clone(), set_id, &equivocation_keyring), + (block_num + 1, payload2.clone(), set_id, &equivocation_keyring), + )); + + // votes signed with different authority keys + assert_invalid_equivocation_proof(generate_equivocation_proof( + (block_num, payload1.clone(), set_id, &equivocation_keyring), + (block_num, payload1.clone(), set_id, &BeefyKeyring::Charlie), + )); + + // votes signed with a key that isn't part of the authority set + assert_invalid_equivocation_proof(generate_equivocation_proof( + (block_num, payload1.clone(), set_id, &equivocation_keyring), + (block_num, payload1.clone(), set_id, &BeefyKeyring::Dave), + )); + + // votes targeting different set ids + assert_invalid_equivocation_proof(generate_equivocation_proof( + (block_num, payload1, set_id, &equivocation_keyring), + (block_num, payload2, set_id + 1, &equivocation_keyring), + )); + }); +} + +#[test] +fn report_equivocation_validate_unsigned_prevents_duplicates() { + use sp_runtime::transaction_validity::{ + InvalidTransaction, TransactionPriority, TransactionSource, TransactionValidity, + ValidTransaction, + }; + + let authorities = test_authorities(); + + new_test_ext_raw_authorities(authorities).execute_with(|| { + start_era(1); + + let block_num = System::block_number(); + let validator_set = Beefy::validator_set().unwrap(); + let authorities = validator_set.validators(); + let set_id = validator_set.id(); + + // generate and report an equivocation for the validator at index 0 + let equivocation_authority_index = 0; + let equivocation_key = &authorities[equivocation_authority_index]; + let equivocation_keyring = BeefyKeyring::from_public(equivocation_key).unwrap(); + + let payload1 = Payload::from_single_entry(MMR_ROOT_ID, vec![42]); + let payload2 = Payload::from_single_entry(MMR_ROOT_ID, vec![128]); + let equivocation_proof = generate_equivocation_proof( + (block_num, payload1, set_id, &equivocation_keyring), + (block_num, payload2, set_id, &equivocation_keyring), + ); + + let key_owner_proof = + Historical::prove((beefy_primitives::KEY_TYPE, &equivocation_key)).unwrap(); + + let call = Call::report_equivocation_unsigned { + equivocation_proof: Box::new(equivocation_proof.clone()), + key_owner_proof: key_owner_proof.clone(), + }; + + // only local/inblock reports are allowed + assert_eq!( + ::validate_unsigned( + TransactionSource::External, + &call, + ), + InvalidTransaction::Call.into(), + ); + + // the transaction is valid when passed as local + let tx_tag = (equivocation_key, set_id, 3u64); + + assert_eq!( + ::validate_unsigned( + TransactionSource::Local, + &call, + ), + TransactionValidity::Ok(ValidTransaction { + priority: TransactionPriority::max_value(), + requires: vec![], + provides: vec![("BeefyEquivocation", tx_tag).encode()], + longevity: ReportLongevity::get(), + propagate: false, + }) + ); + + // the pre dispatch checks should also pass + assert_ok!(::pre_dispatch(&call)); + + // we submit the report + Beefy::report_equivocation_unsigned( + RuntimeOrigin::none(), + Box::new(equivocation_proof), + key_owner_proof, + ) + .unwrap(); + + // the report should now be considered stale and the transaction is invalid + // the check for staleness should be done on both `validate_unsigned` and on `pre_dispatch` + assert_err!( + ::validate_unsigned( + TransactionSource::Local, + &call, + ), + InvalidTransaction::Stale, + ); + + assert_err!( + ::pre_dispatch(&call), + InvalidTransaction::Stale, + ); + }); +} + +#[test] +fn report_equivocation_has_valid_weight() { + // the weight depends on the size of the validator set, + // but there's a lower bound of 100 validators. + assert!((1..=100) + .map(::WeightInfo::report_equivocation) + .collect::>() + .windows(2) + .all(|w| w[0] == w[1])); + + // after 100 validators the weight should keep increasing + // with every extra validator. + assert!((100..=1000) + .map(::WeightInfo::report_equivocation) + .collect::>() + .windows(2) + .all(|w| w[0].ref_time() < w[1].ref_time())); +} + +#[test] +fn valid_equivocation_reports_dont_pay_fees() { + let authorities = test_authorities(); + + new_test_ext_raw_authorities(authorities).execute_with(|| { + start_era(1); + + let block_num = System::block_number(); + let validator_set = Beefy::validator_set().unwrap(); + let authorities = validator_set.validators(); + let set_id = validator_set.id(); + + let equivocation_authority_index = 0; + let equivocation_key = &authorities[equivocation_authority_index]; + let equivocation_keyring = BeefyKeyring::from_public(equivocation_key).unwrap(); + + // generate equivocation proof + let payload1 = Payload::from_single_entry(MMR_ROOT_ID, vec![42]); + let payload2 = Payload::from_single_entry(MMR_ROOT_ID, vec![128]); + let equivocation_proof = generate_equivocation_proof( + (block_num, payload1, set_id, &equivocation_keyring), + (block_num, payload2, set_id, &equivocation_keyring), + ); + + // create the key ownership proof. + let key_owner_proof = + Historical::prove((beefy_primitives::KEY_TYPE, &equivocation_key)).unwrap(); + + // check the dispatch info for the call. + let info = Call::::report_equivocation_unsigned { + equivocation_proof: Box::new(equivocation_proof.clone()), + key_owner_proof: key_owner_proof.clone(), + } + .get_dispatch_info(); + + // it should have non-zero weight and the fee has to be paid. + assert!(info.weight.any_gt(Weight::zero())); + assert_eq!(info.pays_fee, Pays::Yes); + + // report the equivocation. + let post_info = Beefy::report_equivocation_unsigned( + RuntimeOrigin::none(), + Box::new(equivocation_proof.clone()), + key_owner_proof.clone(), + ) + .unwrap(); + + // the original weight should be kept, but given that the report + // is valid the fee is waived. + assert!(post_info.actual_weight.is_none()); + assert_eq!(post_info.pays_fee, Pays::No); + + // report the equivocation again which is invalid now since it is + // duplicate. + let post_info = Beefy::report_equivocation_unsigned( + RuntimeOrigin::none(), + Box::new(equivocation_proof), + key_owner_proof, + ) + .err() + .unwrap() + .post_info; + + // the fee is not waived and the original weight is kept. + assert!(post_info.actual_weight.is_none()); + assert_eq!(post_info.pays_fee, Pays::Yes); + }) +} diff --git a/primitives/beefy/src/lib.rs b/primitives/beefy/src/lib.rs index 08467a723b704..ad9c1b8d1432a 100644 --- a/primitives/beefy/src/lib.rs +++ b/primitives/beefy/src/lib.rs @@ -34,10 +34,14 @@ mod commitment; pub mod mmr; mod payload; +#[cfg(feature = "std")] +mod test_utils; pub mod witness; pub use commitment::{Commitment, SignedCommitment, VersionedFinalityProof}; pub use payload::{known_payloads, BeefyPayloadId, Payload, PayloadProvider}; +#[cfg(feature = "std")] +pub use test_utils::*; use codec::{Codec, Decode, Encode}; use scale_info::TypeInfo; @@ -183,6 +187,83 @@ pub struct VoteMessage { pub signature: Signature, } +/// Proof of voter misbehavior on a given set id. Misbehavior/equivocation in +/// BEEFY happens when a voter votes on the same round/block for different payloads. +/// Proving is achieved by collecting the signed commitments of conflicting votes. +#[derive(Clone, Debug, Decode, Encode, PartialEq, TypeInfo)] +pub struct EquivocationProof { + /// The first vote in the equivocation. + pub first: VoteMessage, + /// The second vote in the equivocation. + pub second: VoteMessage, +} + +impl EquivocationProof { + /// Returns the authority id of the equivocator. + pub fn offender_id(&self) -> &Id { + &self.first.id + } + /// Returns the round number at which the equivocation occurred. + pub fn round_number(&self) -> &Number { + &self.first.commitment.block_number + } + /// Returns the set id at which the equivocation occurred. + pub fn set_id(&self) -> ValidatorSetId { + self.first.commitment.validator_set_id + } +} + +/// Check a commitment signature by encoding the commitment and +/// verifying the provided signature using the expected authority id. +pub fn check_commitment_signature( + commitment: &Commitment, + authority_id: &Id, + signature: &::Signature, +) -> bool +where + Id: BeefyAuthorityId, + Number: Clone + Encode + PartialEq, + MsgHash: Hash, +{ + let encoded_commitment = commitment.encode(); + BeefyAuthorityId::::verify(authority_id, signature, &encoded_commitment) +} + +/// Verifies the equivocation proof by making sure that both votes target +/// different blocks and that its signatures are valid. +pub fn check_equivocation_proof( + report: &EquivocationProof::Signature>, +) -> bool +where + Id: BeefyAuthorityId + PartialEq, + Number: Clone + Encode + PartialEq, + MsgHash: Hash, +{ + let first = &report.first; + let second = &report.second; + + // if votes + // come from different authorities, + // are for different rounds, + // have different validator set ids, + // or both votes have the same commitment, + // --> the equivocation is invalid. + if first.id != second.id || + first.commitment.block_number != second.commitment.block_number || + first.commitment.validator_set_id != second.commitment.validator_set_id || + first.commitment.payload == second.commitment.payload + { + return false + } + + // check signatures on both votes are valid + let valid_first = check_commitment_signature(&first.commitment, &first.id, &first.signature); + let valid_second = + check_commitment_signature(&second.commitment, &second.id, &second.signature); + + return valid_first && valid_second +} + /// New BEEFY validator set notification hook. pub trait OnNewValidatorSet { /// Function called by the pallet when BEEFY validator set changes. @@ -197,6 +278,28 @@ impl OnNewValidatorSet for () { fn on_new_validator_set(_: &ValidatorSet, _: &ValidatorSet) {} } +/// An opaque type used to represent the key ownership proof at the runtime API +/// boundary. The inner value is an encoded representation of the actual key +/// ownership proof which will be parameterized when defining the runtime. At +/// the runtime API boundary this type is unknown and as such we keep this +/// opaque representation, implementors of the runtime API will have to make +/// sure that all usages of `OpaqueKeyOwnershipProof` refer to the same type. +#[derive(Decode, Encode, PartialEq)] +pub struct OpaqueKeyOwnershipProof(Vec); +impl OpaqueKeyOwnershipProof { + /// Create a new `OpaqueKeyOwnershipProof` using the given encoded + /// representation. + pub fn new(inner: Vec) -> OpaqueKeyOwnershipProof { + OpaqueKeyOwnershipProof(inner) + } + + /// Try to decode this `OpaqueKeyOwnershipProof` into the given concrete key + /// ownership proof type. + pub fn decode(self) -> Option { + codec::Decode::decode(&mut &self.0[..]).ok() + } +} + sp_api::decl_runtime_apis! { /// API necessary for BEEFY voters. pub trait BeefyApi @@ -206,83 +309,36 @@ sp_api::decl_runtime_apis! { /// Return the current active BEEFY validator set fn validator_set() -> Option>; - } -} -#[cfg(feature = "std")] -/// Test accounts using [`crate::crypto`] types. -pub mod keyring { - use super::*; - use sp_core::{ecdsa, keccak_256, Pair}; - use std::collections::HashMap; - use strum::IntoEnumIterator; - - /// Set of test accounts using [`crate::crypto`] types. - #[allow(missing_docs)] - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, strum::Display, strum::EnumIter)] - pub enum Keyring { - Alice, - Bob, - Charlie, - Dave, - Eve, - Ferdie, - One, - Two, - } - - impl Keyring { - /// Sign `msg`. - pub fn sign(self, msg: &[u8]) -> crypto::Signature { - // todo: use custom signature hashing type - let msg = keccak_256(msg); - ecdsa::Pair::from(self).sign_prehashed(&msg).into() - } - - /// Return key pair. - pub fn pair(self) -> crypto::Pair { - ecdsa::Pair::from_string(self.to_seed().as_str(), None).unwrap().into() - } - - /// Return public key. - pub fn public(self) -> crypto::Public { - self.pair().public() - } - - /// Return seed string. - pub fn to_seed(self) -> String { - format!("//{}", self) - } - - /// Get Keyring from public key. - pub fn from_public(who: &crypto::Public) -> Option { - Self::iter().find(|&k| &crypto::Public::from(k) == who) - } - } - - lazy_static::lazy_static! { - static ref PRIVATE_KEYS: HashMap = - Keyring::iter().map(|i| (i, i.pair())).collect(); - static ref PUBLIC_KEYS: HashMap = - PRIVATE_KEYS.iter().map(|(&name, pair)| (name, pair.public())).collect(); - } - - impl From for crypto::Pair { - fn from(k: Keyring) -> Self { - k.pair() - } - } - - impl From for ecdsa::Pair { - fn from(k: Keyring) -> Self { - k.pair().into() - } - } - - impl From for crypto::Public { - fn from(k: Keyring) -> Self { - (*PUBLIC_KEYS).get(&k).cloned().unwrap() - } + /// Submits an unsigned extrinsic to report an equivocation. The caller + /// must provide the equivocation proof and a key ownership proof + /// (should be obtained using `generate_key_ownership_proof`). The + /// extrinsic will be unsigned and should only be accepted for local + /// authorship (not to be broadcast to the network). This method returns + /// `None` when creation of the extrinsic fails, e.g. if equivocation + /// reporting is disabled for the given runtime (i.e. this method is + /// hardcoded to return `None`). Only useful in an offchain context. + fn submit_report_equivocation_unsigned_extrinsic( + equivocation_proof: + EquivocationProof, crypto::AuthorityId, crypto::Signature>, + key_owner_proof: OpaqueKeyOwnershipProof, + ) -> Option<()>; + + /// Generates a proof of key ownership for the given authority in the + /// given set. An example usage of this module is coupled with the + /// session historical module to prove that a given authority key is + /// tied to a given staking identity during a specific session. Proofs + /// of key ownership are necessary for submitting equivocation reports. + /// NOTE: even though the API takes a `set_id` as parameter the current + /// implementations ignores this parameter and instead relies on this + /// method being called at the correct block height, i.e. any point at + /// which the given set id is live on-chain. Future implementations will + /// instead use indexed data through an offchain worker, not requiring + /// older states to be available. + fn generate_key_ownership_proof( + set_id: ValidatorSetId, + authority_id: crypto::AuthorityId, + ) -> Option; } } diff --git a/primitives/beefy/src/test_utils.rs b/primitives/beefy/src/test_utils.rs new file mode 100644 index 0000000000000..1a5e03be33fb4 --- /dev/null +++ b/primitives/beefy/src/test_utils.rs @@ -0,0 +1,110 @@ +// This file is part of Substrate. + +// Copyright (C) 2021-2023 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#![cfg(feature = "std")] + +use crate::{crypto, Commitment, EquivocationProof, Payload, ValidatorSetId, VoteMessage}; +use codec::Encode; +use sp_core::{ecdsa, keccak_256, Pair}; +use std::collections::HashMap; +use strum::IntoEnumIterator; + +/// Set of test accounts using [`crate::crypto`] types. +#[allow(missing_docs)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, strum::Display, strum::EnumIter)] +pub enum Keyring { + Alice, + Bob, + Charlie, + Dave, + Eve, + Ferdie, + One, + Two, +} + +impl Keyring { + /// Sign `msg`. + pub fn sign(self, msg: &[u8]) -> crypto::Signature { + // todo: use custom signature hashing type + let msg = keccak_256(msg); + ecdsa::Pair::from(self).sign_prehashed(&msg).into() + } + + /// Return key pair. + pub fn pair(self) -> crypto::Pair { + ecdsa::Pair::from_string(self.to_seed().as_str(), None).unwrap().into() + } + + /// Return public key. + pub fn public(self) -> crypto::Public { + self.pair().public() + } + + /// Return seed string. + pub fn to_seed(self) -> String { + format!("//{}", self) + } + + /// Get Keyring from public key. + pub fn from_public(who: &crypto::Public) -> Option { + Self::iter().find(|&k| &crypto::Public::from(k) == who) + } +} + +lazy_static::lazy_static! { + static ref PRIVATE_KEYS: HashMap = + Keyring::iter().map(|i| (i, i.pair())).collect(); + static ref PUBLIC_KEYS: HashMap = + PRIVATE_KEYS.iter().map(|(&name, pair)| (name, pair.public())).collect(); +} + +impl From for crypto::Pair { + fn from(k: Keyring) -> Self { + k.pair() + } +} + +impl From for ecdsa::Pair { + fn from(k: Keyring) -> Self { + k.pair().into() + } +} + +impl From for crypto::Public { + fn from(k: Keyring) -> Self { + (*PUBLIC_KEYS).get(&k).cloned().unwrap() + } +} + +/// Create a new `EquivocationProof` based on given arguments. +pub fn generate_equivocation_proof( + vote1: (u64, Payload, ValidatorSetId, &Keyring), + vote2: (u64, Payload, ValidatorSetId, &Keyring), +) -> EquivocationProof { + let signed_vote = |block_number: u64, + payload: Payload, + validator_set_id: ValidatorSetId, + keyring: &Keyring| { + let commitment = Commitment { validator_set_id, block_number, payload }; + let signature = keyring.sign(&commitment.encode()); + VoteMessage { commitment, id: keyring.public(), signature } + }; + let first = signed_vote(vote1.0, vote1.1, vote1.2, vote1.3); + let second = signed_vote(vote2.0, vote2.1, vote2.2, vote2.3); + EquivocationProof { first, second } +} diff --git a/test-utils/runtime/src/lib.rs b/test-utils/runtime/src/lib.rs index c1a66eb6acb5c..35906107015c5 100644 --- a/test-utils/runtime/src/lib.rs +++ b/test-utils/runtime/src/lib.rs @@ -980,6 +980,20 @@ cfg_if! { fn validator_set() -> Option> { None } + + fn submit_report_equivocation_unsigned_extrinsic( + _equivocation_proof: beefy_primitives::EquivocationProof< + NumberFor, + beefy_primitives::crypto::AuthorityId, + beefy_primitives::crypto::Signature + >, + _key_owner_proof: beefy_primitives::OpaqueKeyOwnershipProof, + ) -> Option<()> { None } + + fn generate_key_ownership_proof( + _set_id: beefy_primitives::ValidatorSetId, + _authority_id: beefy_primitives::crypto::AuthorityId, + ) -> Option { None } } impl pallet_beefy_mmr::BeefyMmrApi for Runtime {