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>
This commit is contained in:
Maciej
2023-11-27 09:30:13 +00:00
committed by GitHub
parent 4f8048b9c7
commit dc69dbba72
10 changed files with 462 additions and 3 deletions
+16
View File
@@ -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
+46
View File
@@ -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);
});
}
}
+2 -2
View File
@@ -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 {
@@ -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())
}
}
+2
View File
@@ -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},
};
@@ -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 {
@@ -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"
@@ -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
@@ -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"
@@ -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