From dc69dbba728bf447bb447746e41a86367d939030 Mon Sep 17 00:00:00 2001
From: Maciej <maciej.zyszkiewicz@parity.io>
Date: Mon, 27 Nov 2023 09:30:13 +0000
Subject: [PATCH] Zombienet tests - disputes on finalized blocks (#2184)

**Overview:**
Adding an extra malus variant focusing on disputing finalized blocks. It
will:
- wrap around approval-voting
- listen to `OverseerSignal::BlockFinalized` and when encountered start
a dispute for the `dispute_offset`th ancestor
- simply pass through all other messages and signals

Add zombienet tests testing various edgecases:
- disputing freshly finalized blocks
- disputing stale finalized blocks
- disputing eagerly pruned finalized blocks (might be separate PR)

**TODO:**
- [x] Register new malus variant
- [x] Simple pass through wrapper (approval-voting)
- [x] Simple network definition
- [x] Listen to block finalizations
- [x] Fetch ancestor hash
- [x] Fetch session index
- [x] Fetch candidate
- [x] Construct and send dispute message
- [x] zndsl test 1 checking that disputes on fresh finalizations resolve
valid Closes #1365
- [x] zndsl test 2 checking that disputes for too old finalized blocks
are not possible Closes #1364
- [ ] zndsl test 3 checking that disputes for candidates with eagerly
pruned relay parent state are handled correctly #1359 (deferred to a
separate PR)
- [x] Unit tests for new malus variant (testing cli etc)
- [x] Clean/streamline error handling
- [ ] ~~Ensure it tests properly on session boundaries~~

---------

Co-authored-by: Javier Viola <javier@parity.io>
Co-authored-by: Marcin S. <marcin@realemail.net>
Co-authored-by: Tsvetomir Dimitrov <tsvetomir@parity.io>
---
 .gitlab/pipeline/zombienet/polkadot.yml       |  16 ++
 polkadot/node/malus/src/malus.rs              |  46 +++
 polkadot/node/malus/src/variants/common.rs    |   4 +-
 .../variants/dispute_finalized_candidates.rs  | 265 ++++++++++++++++++
 polkadot/node/malus/src/variants/mod.rs       |   2 +
 .../src/variants/suggest_garbage_candidate.rs |   2 +-
 .../0007-dispute-freshly-finalized.toml       |  40 +++
 .../0007-dispute-freshly-finalized.zndsl      |  29 ++
 .../0008-dispute-old-finalized.toml           |  40 +++
 .../0008-dispute-old-finalized.zndsl          |  21 ++
 10 files changed, 462 insertions(+), 3 deletions(-)
 create mode 100644 polkadot/node/malus/src/variants/dispute_finalized_candidates.rs
 create mode 100644 polkadot/zombienet_tests/functional/0007-dispute-freshly-finalized.toml
 create mode 100644 polkadot/zombienet_tests/functional/0007-dispute-freshly-finalized.zndsl
 create mode 100644 polkadot/zombienet_tests/functional/0008-dispute-old-finalized.toml
 create mode 100644 polkadot/zombienet_tests/functional/0008-dispute-old-finalized.zndsl

diff --git a/.gitlab/pipeline/zombienet/polkadot.yml b/.gitlab/pipeline/zombienet/polkadot.yml
index 995dd982532..d1f3a201c80 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 69dd7c869fc..b8a83e54d4f 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 20b6654638e..92264cd653d 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 <http://www.gnu.org/licenses/>.
 
-//! 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 00000000000..113ab026879
--- /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 <http://www.gnu.org/licenses/>.
+
+//! 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: 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<Sender, Spawner> MessageInterceptor<Sender> for AncestorDisputer<Spawner>
+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<Self::Message>,
+	) -> Option<FromOrchestra<Self::Message>> {
+		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<Spawner, RuntimeClient>(
+		&self,
+		connector: OverseerConnector,
+		args: OverseerGenArgs<'_, Spawner, RuntimeClient>,
+	) -> Result<
+		(Overseer<SpawnGlue<Spawner>, Arc<DefaultSubsystemClient<RuntimeClient>>>, OverseerHandle),
+		Error,
+	>
+	where
+		RuntimeClient: 'static + ProvideRuntimeApi<Block> + HeaderBackend<Block> + AuxStore,
+		RuntimeClient::Api: ParachainHost<Block> + BabeApi<Block> + AuthorityDiscoveryApi<Block>,
+		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 3789f33ac98..bb4971c145c 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 cf0ff5f809d..817afb58437 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 00000000000..69eb0804d8c
--- /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 00000000000..62d5a9768f9
--- /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 00000000000..1ea385c3a42
--- /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 00000000000..b30c5801a1d
--- /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
+
-- 
GitLab