diff --git a/.gitlab/pipeline/zombienet/polkadot.yml b/.gitlab/pipeline/zombienet/polkadot.yml
index 995dd9825320..d1f3a201c80a 100644
--- a/.gitlab/pipeline/zombienet/polkadot.yml
+++ b/.gitlab/pipeline/zombienet/polkadot.yml
@@ -115,6 +115,22 @@ zombienet-polkadot-functional-0006-parachains-max-tranche0:
--local-dir="${LOCAL_DIR}/functional"
--test="0006-parachains-max-tranche0.zndsl"
+zombienet-polkadot-functional-0007-dispute-freshly-finalized:
+ extends:
+ - .zombienet-polkadot-common
+ script:
+ - /home/nonroot/zombie-net/scripts/ci/run-test-local-env-manager.sh
+ --local-dir="${LOCAL_DIR}/functional"
+ --test="0007-dispute-freshly-finalized.zndsl"
+
+zombienet-polkadot-functional-0008-dispute-old-finalized:
+ extends:
+ - .zombienet-polkadot-common
+ script:
+ - /home/nonroot/zombie-net/scripts/ci/run-test-local-env-manager.sh
+ --local-dir="${LOCAL_DIR}/functional"
+ --test="0008-dispute-old-finalized.zndsl"
+
zombienet-polkadot-smoke-0001-parachains-smoke-test:
extends:
- .zombienet-polkadot-common
diff --git a/polkadot/node/malus/src/malus.rs b/polkadot/node/malus/src/malus.rs
index 69dd7c869fc0..b8a83e54d4f5 100644
--- a/polkadot/node/malus/src/malus.rs
+++ b/polkadot/node/malus/src/malus.rs
@@ -36,6 +36,8 @@ enum NemesisVariant {
BackGarbageCandidate(BackGarbageCandidateOptions),
/// Delayed disputing of ancestors that are perfectly fine.
DisputeAncestor(DisputeAncestorOptions),
+ /// Delayed disputing of finalized candidates.
+ DisputeFinalizedCandidates(DisputeFinalizedCandidatesOptions),
}
#[derive(Debug, Parser)]
@@ -80,6 +82,15 @@ impl MalusCli {
finality_delay,
)?
},
+ NemesisVariant::DisputeFinalizedCandidates(opts) => {
+ let DisputeFinalizedCandidatesOptions { dispute_offset, cli } = opts;
+
+ polkadot_cli::run_node(
+ cli,
+ DisputeFinalizedCandidates { dispute_offset },
+ finality_delay,
+ )?
+ },
}
Ok(())
}
@@ -184,4 +195,39 @@ mod tests {
assert!(run.cli.run.base.bob);
});
}
+
+ #[test]
+ fn dispute_finalized_candidates_works() {
+ let cli = MalusCli::try_parse_from(IntoIterator::into_iter([
+ "malus",
+ "dispute-finalized-candidates",
+ "--bob",
+ ]))
+ .unwrap();
+ assert_matches::assert_matches!(cli, MalusCli {
+ variant: NemesisVariant::DisputeFinalizedCandidates(run),
+ ..
+ } => {
+ assert!(run.cli.run.base.bob);
+ });
+ }
+
+ #[test]
+ fn dispute_finalized_offset_value_works() {
+ let cli = MalusCli::try_parse_from(IntoIterator::into_iter([
+ "malus",
+ "dispute-finalized-candidates",
+ "--dispute-offset",
+ "13",
+ "--bob",
+ ]))
+ .unwrap();
+ assert_matches::assert_matches!(cli, MalusCli {
+ variant: NemesisVariant::DisputeFinalizedCandidates(opts),
+ ..
+ } => {
+ assert_eq!(opts.dispute_offset, 13); // This line checks that dispute_offset is correctly set to 13
+ assert!(opts.cli.run.base.bob);
+ });
+ }
}
diff --git a/polkadot/node/malus/src/variants/common.rs b/polkadot/node/malus/src/variants/common.rs
index 20b6654638e7..92264cd653d0 100644
--- a/polkadot/node/malus/src/variants/common.rs
+++ b/polkadot/node/malus/src/variants/common.rs
@@ -14,7 +14,7 @@
// You should have received a copy of the GNU General Public License
// along with Polkadot. If not, see .
-//! Implements common code for nemesis. Currently, only `FakeValidationResult`
+//! Implements common code for nemesis. Currently, only `ReplaceValidationResult`
//! interceptor is implemented.
use crate::{
interceptor::*,
@@ -188,7 +188,7 @@ where
let _candidate_descriptor = candidate_descriptor.clone();
let mut subsystem_sender = subsystem_sender.clone();
let (sender, receiver) = std::sync::mpsc::channel();
- self.spawner.spawn_blocking(
+ self.spawner.spawn(
"malus-get-validation-data",
Some("malus"),
Box::pin(async move {
diff --git a/polkadot/node/malus/src/variants/dispute_finalized_candidates.rs b/polkadot/node/malus/src/variants/dispute_finalized_candidates.rs
new file mode 100644
index 000000000000..113ab026879d
--- /dev/null
+++ b/polkadot/node/malus/src/variants/dispute_finalized_candidates.rs
@@ -0,0 +1,265 @@
+// Copyright (C) Parity Technologies (UK) Ltd.
+// This file is part of Polkadot.
+
+// Polkadot is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// Polkadot is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with Polkadot. If not, see .
+
+//! A malicious node variant that attempts to dispute finalized candidates.
+//!
+//! This malus variant behaves honestly in backing and approval voting.
+//! The maliciousness comes from emitting an extra dispute statement on top of the other ones.
+//!
+//! Some extra quirks which generally should be insignificant:
+//! - The malus node will not dispute at session boundaries
+//! - The malus node will not dispute blocks it backed itself
+//! - Be cautious about the size of the network to make sure disputes are not auto-confirmed
+//! (7 validators is the smallest network size as it needs [(7-1)//3]+1 = 3 votes to get
+//! confirmed but it only gets 1 from backing and 1 from malus so 2 in total)
+//!
+//!
+//! Attention: For usage with `zombienet` only!
+
+#![allow(missing_docs)]
+
+use futures::channel::oneshot;
+use polkadot_cli::{
+ prepared_overseer_builder,
+ service::{
+ AuthorityDiscoveryApi, AuxStore, BabeApi, Block, Error, HeaderBackend, Overseer,
+ OverseerConnector, OverseerGen, OverseerGenArgs, OverseerHandle, ParachainHost,
+ ProvideRuntimeApi,
+ },
+ Cli,
+};
+use polkadot_node_subsystem::{messages::ApprovalVotingMessage, SpawnGlue};
+use polkadot_node_subsystem_types::{DefaultSubsystemClient, OverseerSignal};
+use polkadot_node_subsystem_util::request_candidate_events;
+use polkadot_primitives::CandidateEvent;
+use sp_core::traits::SpawnNamed;
+
+// Filter wrapping related types.
+use crate::{interceptor::*, shared::MALUS};
+
+use std::sync::Arc;
+
+/// Wraps around ApprovalVotingSubsystem and replaces it.
+/// Listens to finalization messages and if possible triggers disputes for their ancestors.
+#[derive(Clone)]
+struct AncestorDisputer {
+ spawner: Spawner, //stores the actual ApprovalVotingSubsystem spawner
+ dispute_offset: u32, /* relative depth of the disputed block to the finalized block,
+ * 0=finalized, 1=parent of finalized etc */
+}
+
+impl MessageInterceptor for AncestorDisputer
+where
+ Sender: overseer::ApprovalVotingSenderTrait + Clone + Send + 'static,
+ Spawner: overseer::gen::Spawner + Clone + 'static,
+{
+ type Message = ApprovalVotingMessage;
+
+ /// Intercept incoming `OverseerSignal::BlockFinalized' and pass the rest as normal.
+ fn intercept_incoming(
+ &self,
+ subsystem_sender: &mut Sender,
+ msg: FromOrchestra,
+ ) -> Option> {
+ match msg {
+ FromOrchestra::Communication { msg } => Some(FromOrchestra::Communication { msg }),
+ FromOrchestra::Signal(OverseerSignal::BlockFinalized(
+ finalized_hash,
+ finalized_height,
+ )) => {
+ gum::debug!(
+ target: MALUS,
+ "😈 Block Finalization Interception! Block: {:?}", finalized_hash,
+ );
+
+ //Ensure that the chain is long enough for the target ancestor to exist
+ if finalized_height <= self.dispute_offset {
+ return Some(FromOrchestra::Signal(OverseerSignal::BlockFinalized(
+ finalized_hash,
+ finalized_height,
+ )))
+ }
+
+ let dispute_offset = self.dispute_offset;
+ let mut sender = subsystem_sender.clone();
+ self.spawner.spawn(
+ "malus-dispute-finalized-block",
+ Some("malus"),
+ Box::pin(async move {
+ // Query chain for the block hash at the target depth
+ let (tx, rx) = oneshot::channel();
+ sender
+ .send_message(ChainApiMessage::FinalizedBlockHash(
+ finalized_height - dispute_offset,
+ tx,
+ ))
+ .await;
+ let disputable_hash = match rx.await {
+ Ok(Ok(Some(hash))) => {
+ gum::debug!(
+ target: MALUS,
+ "😈 Time to search {:?}`th ancestor! Block: {:?}", dispute_offset, hash,
+ );
+ hash
+ },
+ _ => {
+ gum::debug!(
+ target: MALUS,
+ "😈 Seems the target is not yet finalized! Nothing to dispute."
+ );
+ return // Early return from the async block
+ },
+ };
+
+ // Fetch all candidate events for the target ancestor
+ let events =
+ request_candidate_events(disputable_hash, &mut sender).await.await;
+ let events = match events {
+ Ok(Ok(events)) => events,
+ Ok(Err(e)) => {
+ gum::error!(
+ target: MALUS,
+ "😈 Failed to fetch candidate events: {:?}", e
+ );
+ return // Early return from the async block
+ },
+ Err(e) => {
+ gum::error!(
+ target: MALUS,
+ "😈 Failed to fetch candidate events: {:?}", e
+ );
+ return // Early return from the async block
+ },
+ };
+
+ // Extract a token candidate from the events to use for disputing
+ let event = events.iter().find(|event| {
+ matches!(event, CandidateEvent::CandidateIncluded(_, _, _, _))
+ });
+ let candidate = match event {
+ Some(CandidateEvent::CandidateIncluded(candidate, _, _, _)) =>
+ candidate,
+ _ => {
+ gum::error!(
+ target: MALUS,
+ "😈 No candidate included event found! Nothing to dispute."
+ );
+ return // Early return from the async block
+ },
+ };
+
+ // Extract the candidate hash from the candidate
+ let candidate_hash = candidate.hash();
+
+ // Fetch the session index for the candidate
+ let (tx, rx) = oneshot::channel();
+ sender
+ .send_message(RuntimeApiMessage::Request(
+ disputable_hash,
+ RuntimeApiRequest::SessionIndexForChild(tx),
+ ))
+ .await;
+ let session_index = match rx.await {
+ Ok(Ok(session_index)) => session_index,
+ _ => {
+ gum::error!(
+ target: MALUS,
+ "😈 Failed to fetch session index for candidate."
+ );
+ return // Early return from the async block
+ },
+ };
+ gum::info!(
+ target: MALUS,
+ "😈 Disputing candidate with hash: {:?} in session {:?}", candidate_hash, session_index,
+ );
+
+ // Start dispute
+ sender.send_unbounded_message(
+ DisputeCoordinatorMessage::IssueLocalStatement(
+ session_index,
+ candidate_hash,
+ candidate.clone(),
+ false, // indicates candidate is invalid -> dispute starts
+ ),
+ );
+ }),
+ );
+
+ // Passthrough the finalization signal as usual (using it as hook only)
+ Some(FromOrchestra::Signal(OverseerSignal::BlockFinalized(
+ finalized_hash,
+ finalized_height,
+ )))
+ },
+ FromOrchestra::Signal(signal) => Some(FromOrchestra::Signal(signal)),
+ }
+ }
+}
+
+//----------------------------------------------------------------------------------
+
+#[derive(Debug, clap::Parser)]
+#[clap(rename_all = "kebab-case")]
+#[allow(missing_docs)]
+pub struct DisputeFinalizedCandidatesOptions {
+ /// relative depth of the disputed block to the finalized block, 0=finalized, 1=parent of
+ /// finalized etc
+ #[clap(long, ignore_case = true, default_value_t = 2, value_parser = clap::value_parser!(u32).range(0..=50))]
+ pub dispute_offset: u32,
+
+ #[clap(flatten)]
+ pub cli: Cli,
+}
+
+/// DisputeFinalizedCandidates implementation wrapper which implements `OverseerGen` glue.
+pub(crate) struct DisputeFinalizedCandidates {
+ /// relative depth of the disputed block to the finalized block, 0=finalized, 1=parent of
+ /// finalized etc
+ pub dispute_offset: u32,
+}
+
+impl OverseerGen for DisputeFinalizedCandidates {
+ fn generate(
+ &self,
+ connector: OverseerConnector,
+ args: OverseerGenArgs<'_, Spawner, RuntimeClient>,
+ ) -> Result<
+ (Overseer, Arc>>, OverseerHandle),
+ Error,
+ >
+ where
+ RuntimeClient: 'static + ProvideRuntimeApi + HeaderBackend + AuxStore,
+ RuntimeClient::Api: ParachainHost + BabeApi + AuthorityDiscoveryApi,
+ Spawner: 'static + SpawnNamed + Clone + Unpin,
+ {
+ gum::info!(
+ target: MALUS,
+ "😈 Started Malus node that disputes finalized blocks after they are {:?} finalizations deep.",
+ &self.dispute_offset,
+ );
+
+ let ancestor_disputer = AncestorDisputer {
+ spawner: SpawnGlue(args.spawner.clone()),
+ dispute_offset: self.dispute_offset,
+ };
+
+ prepared_overseer_builder(args)?
+ .replace_approval_voting(move |cb| InterceptedSubsystem::new(cb, ancestor_disputer))
+ .build_with_connector(connector)
+ .map_err(|e| e.into())
+ }
+}
diff --git a/polkadot/node/malus/src/variants/mod.rs b/polkadot/node/malus/src/variants/mod.rs
index 3789f33ac98b..bb4971c145ce 100644
--- a/polkadot/node/malus/src/variants/mod.rs
+++ b/polkadot/node/malus/src/variants/mod.rs
@@ -18,11 +18,13 @@
mod back_garbage_candidate;
mod common;
+mod dispute_finalized_candidates;
mod dispute_valid_candidates;
mod suggest_garbage_candidate;
pub(crate) use self::{
back_garbage_candidate::{BackGarbageCandidateOptions, BackGarbageCandidates},
+ dispute_finalized_candidates::{DisputeFinalizedCandidates, DisputeFinalizedCandidatesOptions},
dispute_valid_candidates::{DisputeAncestorOptions, DisputeValidCandidates},
suggest_garbage_candidate::{SuggestGarbageCandidateOptions, SuggestGarbageCandidates},
};
diff --git a/polkadot/node/malus/src/variants/suggest_garbage_candidate.rs b/polkadot/node/malus/src/variants/suggest_garbage_candidate.rs
index cf0ff5f809d8..817afb58437e 100644
--- a/polkadot/node/malus/src/variants/suggest_garbage_candidate.rs
+++ b/polkadot/node/malus/src/variants/suggest_garbage_candidate.rs
@@ -113,7 +113,7 @@ where
let (sender, receiver) = std::sync::mpsc::channel();
let mut new_sender = subsystem_sender.clone();
let _candidate = candidate.clone();
- self.spawner.spawn_blocking(
+ self.spawner.spawn(
"malus-get-validation-data",
Some("malus"),
Box::pin(async move {
diff --git a/polkadot/zombienet_tests/functional/0007-dispute-freshly-finalized.toml b/polkadot/zombienet_tests/functional/0007-dispute-freshly-finalized.toml
new file mode 100644
index 000000000000..69eb0804d8cb
--- /dev/null
+++ b/polkadot/zombienet_tests/functional/0007-dispute-freshly-finalized.toml
@@ -0,0 +1,40 @@
+[settings]
+timeout = 1000
+
+[relaychain.genesis.runtimeGenesis.patch.configuration.config]
+ max_validators_per_core = 1
+ needed_approvals = 1
+
+[relaychain]
+default_image = "{{ZOMBIENET_INTEGRATION_TEST_IMAGE}}"
+chain = "rococo-local"
+default_command = "polkadot"
+
+[relaychain.default_resources]
+limits = { memory = "4G", cpu = "2" }
+requests = { memory = "2G", cpu = "1" }
+
+ [[relaychain.node_groups]]
+ name = "honest"
+ count = 6
+ args = ["-lparachain=debug"]
+
+ [[relaychain.nodes]]
+ image = "{{MALUS_IMAGE}}"
+ name = "malus"
+ command = "malus dispute-finalized-candidates"
+ args = [ "--alice", "-lparachain=debug,MALUS=trace", "--dispute-offset=3" ]
+
+[[parachains]]
+id = 2000
+
+ [parachains.collator]
+ image = "{{COL_IMAGE}}"
+ name = "collator"
+ command = "undying-collator"
+ args = ["-lparachain=debug"]
+
+[types.Header]
+number = "u64"
+parent_hash = "Hash"
+post_state = "Hash"
diff --git a/polkadot/zombienet_tests/functional/0007-dispute-freshly-finalized.zndsl b/polkadot/zombienet_tests/functional/0007-dispute-freshly-finalized.zndsl
new file mode 100644
index 000000000000..62d5a9768f9e
--- /dev/null
+++ b/polkadot/zombienet_tests/functional/0007-dispute-freshly-finalized.zndsl
@@ -0,0 +1,29 @@
+Description: Test if disputes triggered on finalized blocks within scope always end as valid.
+Network: ./0007-dispute-freshly-finalized.toml
+Creds: config
+
+# Check authority status and peers.
+malus: reports node_roles is 4
+honest: reports node_roles is 4
+
+# Ensure parachains are registered.
+honest: parachain 2000 is registered within 30 seconds
+
+# Ensure parachains made progress.
+honest: parachain 2000 block height is at least 10 within 200 seconds
+
+# Ensure that malus is already attempting to dispute
+malus: log line contains "😈 Disputing candidate with hash:" within 180 seconds
+
+# Check if disputes are initiated and concluded.
+honest: reports polkadot_parachain_candidate_disputes_total is at least 2 within 100 seconds
+honest: reports polkadot_parachain_candidate_dispute_concluded{validity="valid"} is at least 2 within 100 seconds
+honest: reports polkadot_parachain_candidate_dispute_concluded{validity="invalid"} is 0 within 100 seconds
+
+# Check lag - approval
+honest: reports polkadot_parachain_approval_checking_finality_lag is 0
+
+# Check lag - dispute conclusion
+honest: reports polkadot_parachain_disputes_finality_lag is 0
+
+
diff --git a/polkadot/zombienet_tests/functional/0008-dispute-old-finalized.toml b/polkadot/zombienet_tests/functional/0008-dispute-old-finalized.toml
new file mode 100644
index 000000000000..1ea385c3a42e
--- /dev/null
+++ b/polkadot/zombienet_tests/functional/0008-dispute-old-finalized.toml
@@ -0,0 +1,40 @@
+[settings]
+timeout = 1000
+
+[relaychain.genesis.runtimeGenesis.patch.configuration.config]
+ max_validators_per_core = 1
+ needed_approvals = 1
+
+[relaychain]
+default_image = "{{ZOMBIENET_INTEGRATION_TEST_IMAGE}}"
+chain = "rococo-local"
+default_command = "polkadot"
+
+[relaychain.default_resources]
+limits = { memory = "4G", cpu = "2" }
+requests = { memory = "2G", cpu = "1" }
+
+ [[relaychain.node_groups]]
+ name = "honest"
+ count = 6
+ args = ["-lparachain=debug"]
+
+ [[relaychain.nodes]]
+ image = "{{MALUS_IMAGE}}"
+ name = "malus"
+ command = "malus dispute-finalized-candidates"
+ args = [ "--alice", "-lparachain=debug,MALUS=trace", "--dispute-offset=14" ]
+
+[[parachains]]
+id = 2000
+
+ [parachains.collator]
+ image = "{{COL_IMAGE}}"
+ name = "collator"
+ command = "undying-collator"
+ args = ["-lparachain=debug"]
+
+[types.Header]
+number = "u64"
+parent_hash = "Hash"
+post_state = "Hash"
diff --git a/polkadot/zombienet_tests/functional/0008-dispute-old-finalized.zndsl b/polkadot/zombienet_tests/functional/0008-dispute-old-finalized.zndsl
new file mode 100644
index 000000000000..b30c5801a1da
--- /dev/null
+++ b/polkadot/zombienet_tests/functional/0008-dispute-old-finalized.zndsl
@@ -0,0 +1,21 @@
+Description: Test if disputes triggered on finalized blocks out of scope never get to be confirmed and concluded.
+Network: ./0008-dispute-old-finalized.toml
+Creds: config
+
+# Check authority status and peers.
+malus: reports node_roles is 4
+honest: reports node_roles is 4
+
+
+# Ensure parachains are registered.
+honest: parachain 2000 is registered within 30 seconds
+
+# Ensure parachains made progress.
+honest: parachain 2000 block height is at least 20 within 300 seconds
+
+# Ensure that malus is already attempting to dispute
+malus: log line contains "😈 Disputing candidate with hash:" within 180 seconds
+
+# Ensure that honest nodes don't participate and conclude any disputes
+honest: count of log lines containing "Dispute on candidate concluded" is 0 within 100 seconds
+