diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock/v0/mod.rs index b20a5882dec..a2f6e165078 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock/v0/mod.rs @@ -166,10 +166,311 @@ where #[cfg(test)] mod tests { use super::*; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::chain_lock_quorums::{ + chain_lock_test_platform, sign_chain_lock, QuorumMinedAfterCoreHeight, TestQuorum, + }; use crate::test::helpers::setup::TestPlatformBuilder; use dpp::dashcore::hashes::Hash; use dpp::dashcore::{BlockHash, ChainLock}; - use dpp::version::PlatformVersion; + use dpp::dashcore_rpc::Error as DashCoreRpcError; + use dpp::version::{PlatformVersion, PLATFORM_VERSIONS}; + + #[test] + fn should_accept_through_core_chain_lock_signed_by_quorum_mined_after_last_committed_core_height( + ) { + // Core mined quorum n at 1114 and signed the proposed chain lock with it. Platform does + // not hold n, so it hands the chain lock to Core, which verifies its signature and + // already has it. + let mut platform = chain_lock_test_platform(); + let scenario = QuorumMinedAfterCoreHeight::default(); + let state = scenario.platform_state(&platform.platform.state.load()); + let chain_lock = scenario.chain_lock_signed_by_new_quorum(); + let chain_lock_height = chain_lock.block_height; + + let mut core_rpc = MockCoreRPCLike::new(); + let asked = chain_lock.clone(); + core_rpc + .expect_verify_chain_lock() + .withf(move |chain_lock| *chain_lock == asked) + .times(1) + .returning(|_| Ok(true)); + core_rpc + .expect_submit_chain_lock() + .times(1) + .returning(move |_| Ok(chain_lock_height)); + platform.core_rpc = core_rpc; + + let result = platform + .platform + .verify_chain_lock_v0(0, &state, &chain_lock, true, PlatformVersion::latest()) + .expect("verification runs"); + + assert!(result.chain_lock_signature_is_deserializable); + assert_eq!(result.found_valid_locally, None); + assert_eq!(result.found_valid_by_core, None); + assert_eq!(result.core_is_synced, Some(true)); + } + + #[test] + fn should_reject_forged_chain_lock_without_asking_core_when_signing_height_quorums_are_known() { + // The signing height is within what Platform holds the exact quorums for, so a chain + // lock none of them signed is rejected locally; Core's answer, which skips the + // signature check for heights it already has a chain lock for, is never asked. + let mut platform = chain_lock_test_platform(); + let scenario = QuorumMinedAfterCoreHeight::default(); + let state = scenario.platform_state(&platform.platform.state.load()); + let chain_lock = sign_chain_lock(&TestQuorum::new(9, 0), 1113 + 8); + + let mut core_rpc = MockCoreRPCLike::new(); + core_rpc.expect_verify_chain_lock().times(0); + core_rpc.expect_submit_chain_lock().times(0); + platform.core_rpc = core_rpc; + + let result = platform + .platform + .verify_chain_lock_v0(0, &state, &chain_lock, true, PlatformVersion::latest()) + .expect("verification runs"); + + assert!(result.chain_lock_signature_is_deserializable); + assert_eq!(result.found_valid_locally, Some(false)); + assert_eq!(result.found_valid_by_core, None); + assert_eq!(result.core_is_synced, None); + } + + #[test] + fn should_reject_forged_chain_lock_left_to_core_when_core_already_has_a_chain_lock_at_its_height( + ) { + // Platform cannot check these chain locks itself: at 1122 a quorum mined at 1114 may + // have signed it, at 1136 the signing height is past the DKG interval of Platform's core + // height 1106. Core already has a chain lock at 1140, so submitchainlock returns that + // height without checking the signature; verifychainlock does check it and fails it. + // The rejection is Core's verdict, so the block proposal reports the chain lock as + // invalid based on a core request, not on platform state. + const CORE_BEST_CHAIN_LOCK_HEIGHT: u32 = 1140; + let mut platform = chain_lock_test_platform(); + let scenario = QuorumMinedAfterCoreHeight::default(); + let state = scenario.platform_state(&platform.platform.state.load()); + + for chain_lock_height in [1114 + 8, 1128 + 8] { + let forged = sign_chain_lock(&TestQuorum::new(9, 0), chain_lock_height); + + let mut core_rpc = MockCoreRPCLike::new(); + let asked = forged.clone(); + core_rpc + .expect_verify_chain_lock() + .withf(move |chain_lock| *chain_lock == asked) + .times(1) + .returning(|_| Ok(false)); + core_rpc + .expect_submit_chain_lock() + .times(0) + .returning(|_| Ok(CORE_BEST_CHAIN_LOCK_HEIGHT)); + platform.core_rpc = core_rpc; + + let result = platform + .platform + .verify_chain_lock_v0(0, &state, &forged, true, PlatformVersion::latest()) + .expect("verification runs"); + + assert!(result.chain_lock_signature_is_deserializable); + assert_eq!( + result.found_valid_by_core, + Some(false), + "a forged chain lock at {chain_lock_height} must be rejected by Core's verdict, \ + not accepted through Core's early return (core is synced: {:?})", + result.core_is_synced + ); + assert_eq!( + result.found_valid_locally, None, + "Platform could not check the chain lock against the quorums it holds" + ); + assert_eq!( + result.core_is_synced, None, + "submitchainlock must not be asked" + ); + } + } + + /// Whether the block proposal accepts a chain lock with this verification result, following + /// the checks `run_block_proposal` makes on it + fn accepted_by_block_proposal(result: &VerifyChainLockResult) -> bool { + result.chain_lock_signature_is_deserializable + && result.found_valid_locally != Some(false) + && result.found_valid_by_core != Some(false) + && result.core_is_synced != Some(false) + } + + #[test] + fn should_never_accept_forged_chain_lock_signed_where_a_new_quorum_may_have_signed() { + // Protocol versions up to 14 reject this forged chain lock locally, because Platform + // holds the quorums of signing heights up to 1113 and assumes they hold up to the + // interval end. From 15 Platform leaves it to Core, since a quorum mined at 1114 may + // have signed it, so a protocol version must not leave chain locks to Core without + // having verifychainlock check them: Core, already holding a chain lock at 1140, would + // answer submitchainlock with that height without checking the signature. + const CORE_BEST_CHAIN_LOCK_HEIGHT: u32 = 1140; + let mut platform = chain_lock_test_platform(); + let scenario = QuorumMinedAfterCoreHeight::default(); + let state = scenario.platform_state(&platform.platform.state.load()); + let forged = sign_chain_lock(&TestQuorum::new(9, 0), 1114 + 8); + + let mut core_rpc = MockCoreRPCLike::new(); + core_rpc.expect_verify_chain_lock().returning(|_| Ok(false)); + core_rpc + .expect_submit_chain_lock() + .returning(|_| Ok(CORE_BEST_CHAIN_LOCK_HEIGHT)); + platform.core_rpc = core_rpc; + + for platform_version in PLATFORM_VERSIONS { + let result = platform + .platform + .verify_chain_lock(0, &state, &forged, true, platform_version) + .expect("verification runs"); + + assert!( + !accepted_by_block_proposal(&result), + "protocol version {} accepted a forged chain lock", + platform_version.protocol_version + ); + } + } + + #[test] + fn should_not_accept_chain_lock_left_to_core_that_core_is_not_synced_to() { + // Core's verifychainlock accepts the chain lock signed by the quorum mined at 1114, but + // Core's best chain lock is 10 blocks behind it, so submitchainlock does not make Core + // synced to it. + let mut platform = chain_lock_test_platform(); + let scenario = QuorumMinedAfterCoreHeight::default(); + let state = scenario.platform_state(&platform.platform.state.load()); + let chain_lock = scenario.chain_lock_signed_by_new_quorum(); + let core_best_chain_lock_height = chain_lock.block_height - 10; + + let mut core_rpc = MockCoreRPCLike::new(); + core_rpc + .expect_verify_chain_lock() + .times(1) + .returning(|_| Ok(true)); + core_rpc + .expect_submit_chain_lock() + .times(1) + .returning(move |_| Ok(core_best_chain_lock_height)); + platform.core_rpc = core_rpc; + + let result = platform + .platform + .verify_chain_lock(0, &state, &chain_lock, true, PlatformVersion::latest()) + .expect("verification runs"); + + assert!(result.chain_lock_signature_is_deserializable); + assert_eq!(result.found_valid_locally, None); + assert_eq!(result.found_valid_by_core, Some(true)); + assert_eq!(result.core_is_synced, Some(false)); + assert!(!accepted_by_block_proposal(&result)); + } + + #[test] + fn should_ask_verifychainlock_once_about_chain_lock_left_to_core_without_submitting() { + // Without submitting, Core's verifychainlock is the whole verification of a chain lock + // Platform leaves to Core, and it is asked once. + let mut platform = chain_lock_test_platform(); + let scenario = QuorumMinedAfterCoreHeight::default(); + let state = scenario.platform_state(&platform.platform.state.load()); + let chain_lock = scenario.chain_lock_signed_by_new_quorum(); + + for verdict in [true, false] { + let mut core_rpc = MockCoreRPCLike::new(); + let asked = chain_lock.clone(); + core_rpc + .expect_verify_chain_lock() + .withf(move |chain_lock| *chain_lock == asked) + .times(1) + .returning(move |_| Ok(verdict)); + core_rpc.expect_submit_chain_lock().times(0); + platform.core_rpc = core_rpc; + + let result = platform + .platform + .verify_chain_lock_v0(0, &state, &chain_lock, false, PlatformVersion::latest()) + .expect("verification runs"); + + assert!(result.chain_lock_signature_is_deserializable); + assert_eq!(result.found_valid_locally, None); + assert_eq!(result.found_valid_by_core, Some(verdict)); + assert_eq!(result.core_is_synced, None); + } + } + + #[test] + fn should_keep_protocol_version_14_verdict_on_chain_lock_left_to_core_when_core_already_has_a_chain_lock_at_its_height( + ) { + // Protocol version 14 leaves a chain lock signed past the DKG interval of Platform's + // core height to submitchainlock without a signature check, and Core, already holding a + // chain lock at 1140, answers that height. Blocks committed under protocol version 14 + // replay the same way only if it keeps accepting such a chain lock. + const CORE_BEST_CHAIN_LOCK_HEIGHT: u32 = 1140; + let mut platform = chain_lock_test_platform(); + let scenario = QuorumMinedAfterCoreHeight::default(); + let state = scenario.platform_state(&platform.platform.state.load()); + let forged = sign_chain_lock(&TestQuorum::new(9, 0), 1128 + 8); + + let mut core_rpc = MockCoreRPCLike::new(); + core_rpc.expect_verify_chain_lock().times(0); + core_rpc + .expect_submit_chain_lock() + .times(1) + .returning(|_| Ok(CORE_BEST_CHAIN_LOCK_HEIGHT)); + platform.core_rpc = core_rpc; + + let result = platform + .platform + .verify_chain_lock_v0( + 0, + &state, + &forged, + true, + PlatformVersion::get(14).expect("protocol version 14 exists"), + ) + .expect("verification runs"); + + assert!(result.chain_lock_signature_is_deserializable); + assert_eq!(result.found_valid_locally, None); + assert_eq!(result.found_valid_by_core, None); + assert_eq!(result.core_is_synced, Some(true)); + } + + #[test] + fn should_fail_verification_of_chain_lock_left_to_core_when_verifychainlock_fails() { + // The block proposal turns this error into an invalid chain lock, as it does when + // submitchainlock fails. + let mut platform = chain_lock_test_platform(); + let scenario = QuorumMinedAfterCoreHeight::default(); + let state = scenario.platform_state(&platform.platform.state.load()); + let chain_lock = scenario.chain_lock_signed_by_new_quorum(); + + let mut core_rpc = MockCoreRPCLike::new(); + core_rpc.expect_verify_chain_lock().times(1).returning(|_| { + Err(DashCoreRpcError::UnexpectedStructure( + "simulated rpc failure".to_string(), + )) + }); + core_rpc.expect_submit_chain_lock().times(0); + platform.core_rpc = core_rpc; + + let result = platform.platform.verify_chain_lock_v0( + 0, + &state, + &chain_lock, + true, + PlatformVersion::latest(), + ); + + assert!( + matches!(result, Err(Error::CoreRpc(_))), + "the verifychainlock failure must reach the block proposal" + ); + } /// Exercises the `Err(Error::BLSError(_))` branch of `verify_chain_lock_v0`: /// when the inner `verify_chain_lock_locally` returns a BLS deserialization diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_locally/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_locally/mod.rs index 2e022adf5dd..304179e0b59 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_locally/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_locally/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::error::execution::ExecutionError; use crate::error::Error; @@ -15,8 +16,28 @@ impl Platform where C: CoreRPCLike, { - /// Returning None here means we were unable to verify the chain lock because of an absence of - /// the quorum + /// Verify the chain lock against the chain lock quorums held in the platform state, without + /// Core. + /// + /// # Parameters + /// + /// * `round` - The consensus round of the block proposal, used for logging + /// * `platform_state` - The platform state holding the chain lock quorums + /// * `chain_lock` - The chain lock to verify + /// * `platform_version` - The platform version selecting the implementation + /// + /// # Returns + /// + /// * `Ok(Some(true))` - The chain lock is signed by a quorum Platform holds + /// * `Ok(Some(false))` - The chain lock is invalid. From version 1 this is only returned when + /// Platform holds exactly the quorums active at the signing height; version 0 also returns + /// it when Platform might not hold the quorum the chain lock was signed with + /// * `Ok(None)` - Platform cannot tell on its own, so Core has to decide. Nothing about the + /// signature is known yet: `verify_chain_lock_through_core` has to check it + /// * `Err(Error::BLSError)` - The signature could not be deserialized + /// * `Err(Error::Execution)` - An unknown method version, no quorums to verify against, or + /// from version 1 a platform version without DKG mining schedules or with a schedule whose + /// interval is 0 pub fn verify_chain_lock_locally( &self, round: u32, @@ -36,11 +57,307 @@ where chain_lock, platform_version, ), + 1 => self.verify_chain_lock_locally_v1( + round, + platform_state, + chain_lock, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "verify_chain_lock_locally".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } } } + +#[cfg(test)] +mod tests { + use crate::platform_types::platform_state::PlatformState; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::chain_lock_quorums::{ + chain_lock_test_platform, core_signer, llmq_test_chain_lock_config, quorums, + sign_chain_lock, state_with_chain_lock_quorums, QuorumChange, QuorumMinedAfterCoreHeight, + TestQuorum, + }; + use dpp::version::PlatformVersion; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + #[test] + fn should_stop_rejecting_chain_lock_signed_by_newly_mined_quorum_from_protocol_version_15() { + // Platform committed core height 1106 holding the llmq_test quorums {a, b}; Core mined + // quorum n at 1114 and signed the next chain lock with it. Protocol versions up to 14 + // keep rejecting such a chain lock locally; 15 and later leave it to Core. The local + // check never asks Core itself: checking the signature of a chain lock left to Core is + // `verify_chain_lock_through_core`'s job. + let mut platform = chain_lock_test_platform(); + let scenario = QuorumMinedAfterCoreHeight::default(); + let state = scenario.platform_state(&platform.platform.state.load()); + let chain_lock = scenario.chain_lock_signed_by_new_quorum(); + + let mut core_rpc = MockCoreRPCLike::new(); + core_rpc.expect_verify_chain_lock().times(0); + core_rpc.expect_submit_chain_lock().times(0); + platform.core_rpc = core_rpc; + + let verify = |platform_version: &PlatformVersion| { + platform + .platform + .verify_chain_lock_locally(0, &state, &chain_lock, platform_version) + .expect("verification runs") + }; + + assert_eq!( + verify(PlatformVersion::get(13).expect("protocol version 13 exists")), + Some(false), + "protocol version 13 must keep its verdict" + ); + assert_eq!( + verify(PlatformVersion::get(14).expect("protocol version 14 exists")), + Some(false), + "protocol version 14 must keep its verdict" + ); + assert_eq!( + verify(PlatformVersion::get(15).expect("protocol version 15 exists")), + None + ); + assert_eq!(verify(PlatformVersion::latest()), None); + } + + /// llmq_test quorums, each mined in the mining window (offsets 10 to 18) of its own DKG + /// interval, two of them active at a time like on a local network. + struct MinedQuorums(Vec<(u32, TestQuorum)>); + + impl MinedQuorums { + fn new() -> Self { + Self( + [1018, 1050, 1066, 1098, 1114, 1146, 1162] + .into_iter() + .zip(1u8..) + .map(|(mined_at, seed)| (mined_at, TestQuorum::new(seed, 0))) + .collect(), + ) + } + + fn active_at(&self, core_height: u32) -> Vec<&TestQuorum> { + self.0 + .iter() + .rev() + .filter(|(mined_at, _)| *mined_at <= core_height) + .take(2) + .map(|(_, quorum)| quorum) + .collect() + } + + /// The platform state after committing `core_heights` in order, recording a quorum + /// change whenever the active quorums differ, as Platform does. + fn state_after(&self, base: &PlatformState, core_heights: &[u32]) -> PlatformState { + let mut changes = vec![]; + for pair in core_heights.windows(2) { + let before: Vec<_> = self.active_at(pair[0]).iter().map(|q| q.hash).collect(); + let after: Vec<_> = self.active_at(pair[1]).iter().map(|q| q.hash).collect(); + if before != after { + changes.push(QuorumChange { + quorums: quorums(&self.active_at(pair[1])), + from_core_height: pair[0], + to_core_height: pair[1], + }); + } + } + state_with_chain_lock_quorums( + base, + llmq_test_chain_lock_config(), + *core_heights.last().expect("at least one core height"), + quorums(&self.active_at(core_heights[0])), + changes, + ) + } + } + + #[test] + fn should_only_turn_wrong_local_rejections_into_core_checks() { + // Version 1 may only leave to Core what version 0 rejected; everything version 0 + // accepted or left to Core must stay the same, so no block with a valid chain lock that + // version 0 accepted is rejected. Valid chain locks must never be rejected by version 1. + // Neither version asks Core, and protocol version 14 must give version 0's verdicts. + // Through `verify_chain_lock`, Core's verifychainlock must then check exactly the chain + // locks version 1 leaves to Core, before any of them is submitted, while protocol + // version 14 keeps submitting the chain locks version 0 leaves to Core without that + // check, as the blocks committed under it were. + let mut platform = chain_lock_test_platform(); + // This Core fails the signature of every chain lock it is asked about, and already has + // a chain lock above all of them, so submitchainlock would report it synced without a + // signature check. The counters show which chain locks it was asked about. + let verify_calls = Arc::new(AtomicUsize::new(0)); + let submit_calls = Arc::new(AtomicUsize::new(0)); + let verify_counter = Arc::clone(&verify_calls); + let submit_counter = Arc::clone(&submit_calls); + let mut core_rpc = MockCoreRPCLike::new(); + core_rpc.expect_verify_chain_lock().returning(move |_| { + verify_counter.fetch_add(1, Ordering::SeqCst); + Ok(false) + }); + core_rpc.expect_submit_chain_lock().returning(move |_| { + submit_counter.fetch_add(1, Ordering::SeqCst); + Ok(2000) + }); + platform.core_rpc = core_rpc; + let core_calls = || { + ( + verify_calls.load(Ordering::SeqCst), + submit_calls.load(Ordering::SeqCst), + ) + }; + let base = platform.platform.state.load(); + let mined = MinedQuorums::new(); + let unrelated = TestQuorum::new(99, 0); + let v0_version = PlatformVersion::get(14).expect("protocol version 14 exists"); + let v1_version = PlatformVersion::latest(); + + // Each history ends at Platform's last committed core height. The flag says whether + // version 0 wrongly rejects a valid chain lock there, so the history cannot silently + // stop exercising the fix. + let histories: [(&[u32], bool); 9] = [ + // one block at a time past the quorum mined at 1098, then early in the DKG + // interval 1104..=1127, before the quorum mined at 1114 + ( + &[1097, 1098, 1099, 1100, 1101, 1102, 1103, 1104, 1105, 1106], + true, + ), + // one jump over the quorums mined at 1098 and 1114, ending right after the second + (&[1085, 1114], true), + // the same jump ending later, where Platform holds every quorum it needs + (&[1085, 1120], false), + // past the mining window of the interval 1104..=1127 + (&[1100, 1120, 1123], false), + // two changes, so the previous quorums are known exactly from 1067 to 1097 + (&[1060, 1067, 1097, 1099], false), + // no previous quorums + (&[1100], false), + // inside the mining window of the interval 1128..=1151, before the quorum mined at + // its last height 1146 + (&[1110, 1140], true), + (&[1110, 1145], true), + // inside the mining window after its quorum was mined + (&[1110, 1116], false), + ]; + + let mut rejected_by_v1 = 0; + for (history, v0_wrongly_rejects) in histories { + let state = mined.state_after(&base, history); + let core_height = *history.last().expect("non empty"); + let mut wrongly_rejected_by_v0 = 0; + for chain_lock_height in core_height + 1..=core_height + 40 { + let signing_height = chain_lock_height - 8; + let valid = core_signer(&mined.active_at(signing_height), chain_lock_height); + for (signer, is_valid) in [(valid, true), (&unrelated, false)] { + let chain_lock = sign_chain_lock(signer, chain_lock_height); + let calls_before = core_calls(); + let v0 = platform + .platform + .verify_chain_lock_locally_v0(0, &state, &chain_lock, v0_version) + .expect("v0 runs"); + let selected_at_v0_version = platform + .platform + .verify_chain_lock_locally(0, &state, &chain_lock, v0_version) + .expect("protocol version 14 runs"); + let v1 = platform + .platform + .verify_chain_lock_locally(0, &state, &chain_lock, v1_version) + .expect("v1 runs"); + let case = format!( + "history {history:?}, chain lock height {chain_lock_height}, valid {is_valid}: v0 {v0:?}, v1 {v1:?}" + ); + + assert_eq!(core_calls(), calls_before, "{case}: Core was asked"); + assert_eq!(selected_at_v0_version, v0, "{case}"); + match v0 { + Some(true) => assert_eq!(v1, Some(true), "{case}"), + None => assert_eq!(v1, None, "{case}"), + Some(false) => assert_ne!(v1, Some(true), "{case}"), + } + if is_valid { + assert_ne!(v1, Some(false), "{case}"); + wrongly_rejected_by_v0 += usize::from(v0 == Some(false)); + } + rejected_by_v1 += usize::from(v1 == Some(false)); + + // the block proposal's route, which submits, and the one that does not + for submit in [true, false] { + let (verify_before, submit_before) = core_calls(); + let result = platform + .platform + .verify_chain_lock(0, &state, &chain_lock, submit, v1_version) + .expect("verification runs"); + let (verify_after, submit_after) = core_calls(); + let case = format!("{case}, submit {submit}"); + + assert_eq!( + verify_after - verify_before, + usize::from(v1.is_none()), + "{case}" + ); + assert_eq!( + submit_after - submit_before, + usize::from(submit && v1 == Some(true)), + "{case}" + ); + assert_eq!(result.found_valid_locally, v1, "{case}"); + assert_eq!( + result.found_valid_by_core, + v1.is_none().then_some(false), + "{case}" + ); + assert_eq!( + result.core_is_synced, + (submit && v1 == Some(true)).then_some(true), + "{case}" + ); + + let (verify_before, submit_before) = core_calls(); + let result = platform + .platform + .verify_chain_lock(0, &state, &chain_lock, submit, v0_version) + .expect("protocol version 14 runs"); + let (verify_after, submit_after) = core_calls(); + let case = format!("{case}, protocol version 14"); + + assert_eq!( + verify_after - verify_before, + usize::from(!submit && v0.is_none()), + "{case}" + ); + assert_eq!( + submit_after - submit_before, + usize::from(submit && v0 != Some(false)), + "{case}" + ); + assert_eq!(result.found_valid_locally, v0, "{case}"); + assert_eq!( + result.found_valid_by_core, + (!submit && v0.is_none()).then_some(false), + "{case}" + ); + assert_eq!( + result.core_is_synced, + (submit && v0 != Some(false)).then_some(true), + "{case}" + ); + } + } + } + assert_eq!( + wrongly_rejected_by_v0 > 0, + v0_wrongly_rejects, + "history {history:?}: version 0 wrongly rejected {wrongly_rejected_by_v0} valid chain locks" + ); + } + + assert!( + rejected_by_v1 > 0, + "the histories must reach version 1's local rejections" + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_locally/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_locally/v0/mod.rs index c924a8f3c74..30fb92982bb 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_locally/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_locally/v0/mod.rs @@ -337,7 +337,7 @@ mod tests { .set_initial_state_structure(); let platform_state = platform.platform.state.load(); - let platform_version = PlatformVersion::latest(); + let platform_version = PlatformVersion::get(14).expect("protocol version 14 exists"); // An all-zero compressed BLS signature is not a valid G2 point; the // G2 decoder expects a flag bit, so this must fail deserialization. diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_locally/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_locally/v1/mod.rs new file mode 100644 index 00000000000..056fb6afd30 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_locally/v1/mod.rs @@ -0,0 +1,862 @@ +use dpp::bls::Signature; + +use dpp::dashcore::hashes::{sha256d, Hash, HashEngine}; +use dpp::dashcore::{ChainLock, QuorumSigningRequestId}; +use dpp::dashcore_rpc::json::QuorumType; + +use crate::error::Error; + +use crate::platform_types::platform::Platform; + +use crate::rpc::core::CoreRPCLike; + +use crate::error::execution::ExecutionError; +use crate::platform_types::platform_state::PlatformState; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use crate::platform_types::signature_verification_quorum_set::SignatureVerificationQuorumSetV0Methods; +use dpp::version::drive_abci_versions::drive_abci_method_versions::DkgMiningSchedule; +use dpp::version::PlatformVersion; + +const CHAIN_LOCK_REQUEST_ID_PREFIX: &str = "clsig"; + +const SIGN_OFFSET: u32 = 8; + +impl Platform +where + C: CoreRPCLike, +{ + /// Verify the chain lock against the chain lock quorums Platform holds, without Core. + /// Returning None here means Platform cannot tell on its own whether the chain lock is + /// valid, because it might not hold the quorums Core signed it with. `Some(false)` is only + /// returned when Platform holds exactly the quorums active at the signing height. + /// + /// This leaves to Core chain locks that version 0 rejects, so it relies on + /// `verify_chain_lock_through_core` 1 having Core's `verifychainlock` check their signature + /// before `submitchainlock`, which skips that check for a height Core already has a chain + /// lock at or above. + #[inline(always)] + pub(super) fn verify_chain_lock_locally_v1( + &self, + round: u32, + platform_state: &PlatformState, + chain_lock: &ChainLock, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let quorum_set = platform_state.chain_lock_validating_quorums(); + let quorum_config = quorum_set.config(); + + // First verify that the signature conforms to a signature + + let signature = match Signature::from_compressed(chain_lock.signature.as_bytes()) { + Some(signature) => signature, + None => { + tracing::error!( + ?chain_lock, + "chain lock signature was not deserializable: h:{} r:{}", + platform_state.last_committed_block_height() + 1, + round + ); + return Err(Error::BLSError(dpp::bls::BlsError::DeserializationError( + "chain lock signature was not deserializable".to_string(), + ))); + } + }; + + // we attempt to verify the chain lock locally + let chain_lock_height = chain_lock.block_height; + + let window_width = quorum_config.window; + + // The last block in the window where the quorums would be the same + let last_block_in_window = platform_state.last_committed_core_height() + - platform_state.last_committed_core_height() % window_width + + window_width + - 1; + + let verification_height = chain_lock_height.saturating_sub(SIGN_OFFSET); + + if verification_height > last_block_in_window { + tracing::debug!( + ?chain_lock, + "h:{} r:{} skipped message_digest for chain lock at core height {} is {}, verification height {}, last block in window {}", + platform_state.last_committed_block_height() + 1, + round, + chain_lock.block_height, + platform_state.last_committed_core_height(), + verification_height, + last_block_in_window, + ); + return Ok(None); // the chain lock is too far in the future or the past to verify locally + } + + // The quorums Platform holds are the ones active at the last committed core height. They + // stay active at least until a block in which a new quorum commitment can be mined, which + // is only inside the DKG mining window. That window ends well before the DKG interval + // does, so the quorums can change before `last_block_in_window`. + let dkg_mining_schedules = platform_version + .drive_abci + .methods + .core_chain_lock + .dkg_mining_schedules + .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "verify_chain_lock_locally v1 requires core_chain_lock.dkg_mining_schedules", + )))?; + let first_possible_quorum_change = first_possible_quorum_change_after( + dkg_mining_schedules, + quorum_config.quorum_type, + platform_state.last_committed_core_height(), + )?; + + let mut selected_quorums = platform_state + .chain_lock_validating_quorums() + .select_quorums(chain_lock_height, verification_height); + + // TODO: We can use chain_lock.request_id() + + // From DIP 8: https://github.com/dashpay/dips/blob/master/dip-0008.md#finalization-of-signed-blocks + // The request id is SHA256("clsig", blockHeight) and the message hash is the block hash of the previously successful attempt. + + let mut engine = QuorumSigningRequestId::engine(); + + engine.input(&[CHAIN_LOCK_REQUEST_ID_PREFIX.len() as u8]); + engine.input(CHAIN_LOCK_REQUEST_ID_PREFIX.as_bytes()); + engine.input(chain_lock.block_height.to_le_bytes().as_slice()); + + let request_id = QuorumSigningRequestId::from_engine(engine); + + tracing::trace!( + ?chain_lock, + "request id for chain lock at height {} is {}", + chain_lock.block_height, + hex::encode(request_id.as_byte_array()) + ); + + // Based on the deterministic masternode list at the given height, a quorum must be selected that was active at the time this block was mined + + let probable_quorums = selected_quorums.next().ok_or_else(|| { + Error::Execution(ExecutionError::CorruptedCodeExecution( + "No quorums selected for chain lock signature verification for specified height", + )) + })?; + + let Some((quorum_hash, quorum)) = probable_quorums.choose_quorum(request_id.as_ref()) + else { + return Ok(None); + }; + + // The signature must verify against the quorum public key and SHA256(llmqType, quorumHash, SHA256(height), blockHash). llmqType and quorumHash must be taken from the quorum selected in 1. + + let mut engine = sha256d::Hash::engine(); + + let mut reversed_quorum_hash = quorum_hash.to_byte_array(); + reversed_quorum_hash.reverse(); + + engine.input(&[quorum_config.quorum_type as u8]); + engine.input(reversed_quorum_hash.as_slice()); + engine.input(request_id.as_byte_array()); + engine.input(chain_lock.block_hash.as_byte_array()); + + let message_digest = sha256d::Hash::from_engine(engine); + + let mut chain_lock_verified = signature + .verify( + &quorum.public_key, + message_digest.as_byte_array().as_slice(), + ) + .is_ok(); + + tracing::debug!( + ?chain_lock, + "h:{} r:{} message_digest for chain lock at core height {} is {}, quorum hash is {}, block hash is {}, chain lock was {}, last committed core height {}, verification height {}, last block in window {}", + platform_state.last_committed_block_height() + 1, + round, + chain_lock.block_height, + hex::encode(message_digest.as_byte_array()), + hex::encode(reversed_quorum_hash.as_slice()), + hex::encode(chain_lock.block_hash.as_byte_array()), + if chain_lock_verified { "verified"} else {"not verified"}, + platform_state.last_committed_core_height(), + verification_height, + last_block_in_window, + ); + + if !chain_lock_verified { + // We should also check the other quorum, as there could be the situation where the core height wasn't updated every block. + if let Some(second_to_check_quorums) = selected_quorums.next() { + let Some((quorum_hash, quorum)) = + second_to_check_quorums.choose_quorum(request_id.as_ref()) + else { + // we return that we are not able to verify + return Ok(None); + }; + + // The signature must verify against the quorum public key and SHA256(llmqType, quorumHash, SHA256(height), blockHash). llmqType and quorumHash must be taken from the quorum selected in 1. + + let mut engine = sha256d::Hash::engine(); + + let mut reversed_quorum_hash = quorum_hash.to_byte_array(); + reversed_quorum_hash.reverse(); + + engine.input(&[quorum_config.quorum_type as u8]); + engine.input(reversed_quorum_hash.as_slice()); + engine.input(request_id.as_byte_array()); + engine.input(chain_lock.block_hash.as_byte_array()); + + let message_digest = sha256d::Hash::from_engine(engine); + + chain_lock_verified = signature + .verify( + &quorum.public_key, + message_digest.as_byte_array().as_slice(), + ) + .is_ok(); + + tracing::debug!( + ?chain_lock, + "h:{} r:{} tried second quorums message_digest for chain lock at height {} is {}, quorum hash is {}, block hash is {}, chain lock was {}", + platform_state.last_committed_block_height() + 1, + round, + chain_lock.block_height, + hex::encode(message_digest.as_byte_array()), + hex::encode(reversed_quorum_hash.as_slice()), + hex::encode(chain_lock.block_hash.as_byte_array()), + if chain_lock_verified { "verified"} else {"not verified"} + ); + if !chain_lock_verified { + // The signing height is between the two stored quorum sets. If more than one + // quorum was mined in between, the quorums active at the signing height are + // neither of them. + tracing::debug!( + "chain lock was invalid for both recent and old chain lock quorums, we should validate through core" + ); + return Ok(None); + } + } else if !platform_state + .chain_lock_validating_quorums() + .has_previous_past_quorums() + { + // we don't have old quorums, this means our node is very new. + tracing::debug!( + "we had no previous quorums locally, we should validate through core", + ); + return Ok(None); + } else if !selected_quorums.should_be_verifiable() { + tracing::debug!( + "we were in a situation where it would be possible we didn't have all quorums and we couldn't verify locally, we should validate through core", + ); + return Ok(None); + } else if verification_height >= first_possible_quorum_change { + tracing::debug!( + first_possible_quorum_change, + "a quorum may have been mined after the last committed core height and signed the chain lock, we should validate through core", + ); + return Ok(None); + } else { + tracing::debug!("chain lock was invalid, and we deemed there was no reason to check old quorums"); + } + } + + Ok(Some(chain_lock_verified)) + } +} + +/// The first core height after `last_committed_core_height` at which a quorum commitment of +/// `quorum_type` can be mined, from Dash Core's DKG mining schedules. Core only changes the active +/// quorums when a commitment is mined, so the quorums active at `last_committed_core_height` stay +/// the active ones up to the block before the returned height. +fn first_possible_quorum_change_after( + dkg_mining_schedules: &[DkgMiningSchedule], + quorum_type: QuorumType, + last_committed_core_height: u32, +) -> Result { + let next_height = last_committed_core_height.saturating_add(1); + + let Some(schedule) = dkg_mining_schedules + .iter() + .find(|schedule| schedule.quorum_type == quorum_type as u8) + else { + // Without the type's DKG schedule, any later block may change the quorums + return Ok(next_height); + }; + let offset_in_interval = last_committed_core_height + .checked_rem(schedule.interval) + .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "a DKG mining schedule has an interval of 0", + )))?; + let interval_start = last_committed_core_height - offset_in_interval; + + Ok(if offset_in_interval < schedule.mining_window_start { + interval_start.saturating_add(schedule.mining_window_start) + } else if offset_in_interval < schedule.mining_window_end { + next_height + } else { + interval_start + .saturating_add(schedule.interval) + .saturating_add(schedule.mining_window_start) + }) +} + +#[cfg(test)] +mod tests { + use super::first_possible_quorum_change_after; + use crate::config::ChainLockConfig; + use crate::error::execution::ExecutionError; + use crate::error::Error; + use crate::platform_types::platform_state::PlatformState; + use crate::platform_types::signature_verification_quorum_set::{Quorums, VerificationQuorum}; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::chain_lock_quorums::{ + chain_lock_test_platform, core_signer, llmq_test_chain_lock_config, quorums, + sign_chain_lock, state_with_chain_lock_quorums, QuorumChange, QuorumMinedAfterCoreHeight, + TestQuorum, + }; + use crate::test::helpers::setup::TempPlatform; + use dpp::bls::PublicKey; + use dpp::dashcore::hashes::Hash; + use dpp::dashcore::{BlockHash, ChainLock, QuorumHash}; + use dpp::dashcore_rpc::json::QuorumType; + use dpp::version::drive_abci_versions::drive_abci_method_versions::DkgMiningSchedule; + use dpp::version::{PlatformVersion, PLATFORM_VERSIONS}; + use std::str::FromStr; + + fn verify( + platform: &TempPlatform, + state: &PlatformState, + chain_lock: &ChainLock, + ) -> Option { + platform + .platform + .verify_chain_lock_locally_v1(0, state, chain_lock, PlatformVersion::latest()) + .expect("verification runs") + } + + fn llmq_400_60_chain_lock_config() -> ChainLockConfig { + ChainLockConfig { + quorum_type: QuorumType::Llmq400_60, + quorum_size: 400, + quorum_window: 288, + quorum_active_signers: 4, + quorum_rotation: false, + } + } + + #[test] + fn should_not_reject_chain_lock_signed_by_quorum_mined_after_last_committed_core_height() { + // The chain lock's signing height is past 1114, where Core mined n, and still inside + // the DKG interval of Platform's core height 1106, so Core signed it with a quorum + // Platform does not hold. + let platform = chain_lock_test_platform(); + let scenario = QuorumMinedAfterCoreHeight::default(); + let state = scenario.platform_state(&platform.platform.state.load()); + let chain_lock = scenario.chain_lock_signed_by_new_quorum(); + + assert_eq!( + verify(&platform, &state, &chain_lock), + None, + "a chain lock signed by a quorum Platform could not hold yet must be left to Core \ + (chain lock height {})", + chain_lock.block_height + ); + } + + #[test] + fn should_not_reject_chain_lock_signed_by_quorum_set_between_two_stored_sets() { + // Platform moved its core height from 1095 straight to 1125, across two quorum mining + // heights: q1 at 1098 and q2 at 1122. It stored {a, b} (active at 1095) as previous and + // {q2, q1} (active at 1125) as current, but the quorums active at 1118..=1121 were + // {q1, a}, which it never saw. A chain lock signed at those heights can be valid while + // failing against both stored sets. + let platform = chain_lock_test_platform(); + + let (q1, q2, a, b, chain_lock_height) = (0..=u8::MAX) + .find_map(|salt| { + let a = TestQuorum::new(2, salt); + let b = TestQuorum::new(3, salt); + let q1 = TestQuorum::new(4, salt); + let q2 = TestQuorum::new(5, salt); + (1126..=1129) + .find(|height| { + let signer = core_signer(&[&q1, &a], *height).hash; + signer != core_signer(&[&q2, &q1], *height).hash + && signer != core_signer(&[&a, &b], *height).hash + }) + .map(|height| (q1, q2, a, b, height)) + }) + .expect("some salt yields a signer neither stored set would choose"); + + let state = state_with_chain_lock_quorums( + &platform.platform.state.load(), + llmq_test_chain_lock_config(), + 1125, + quorums(&[&a, &b]), + vec![QuorumChange { + quorums: quorums(&[&q2, &q1]), + from_core_height: 1095, + to_core_height: 1125, + }], + ); + let signer = core_signer(&[&q1, &a], chain_lock_height); + + assert_eq!( + verify( + &platform, + &state, + &sign_chain_lock(signer, chain_lock_height) + ), + None, + "a chain lock signed by quorums active between two stored sets must be left to Core \ + (chain lock height {chain_lock_height})" + ); + } + + #[test] + fn should_reject_wrong_signer_locally_until_a_quorum_can_be_mined() { + // From 1106 the next llmq_test commitment can be mined at 1114, so the quorums Platform + // holds are exactly the ones active at signing heights up to 1113. + let platform = chain_lock_test_platform(); + let scenario = QuorumMinedAfterCoreHeight::default(); + let state = scenario.platform_state(&platform.platform.state.load()); + let unrelated = TestQuorum::new(9, 0); + + assert_eq!( + verify(&platform, &state, &sign_chain_lock(&unrelated, 1113 + 8)), + Some(false) + ); + assert_eq!( + verify(&platform, &state, &sign_chain_lock(&unrelated, 1114 + 8)), + None + ); + } + + #[test] + fn should_reject_wrong_signer_locally_up_to_interval_end_once_mining_window_passed() { + // At 1123 the mining window of the interval 1104..=1127 (1114..=1122) is over, so no + // quorum can change before the next interval's window at 1138. Signing heights past the + // interval end are left to Core, as version 0 does. + let platform = chain_lock_test_platform(); + let (z, a, b, unrelated) = ( + TestQuorum::new(1, 0), + TestQuorum::new(2, 0), + TestQuorum::new(3, 0), + TestQuorum::new(9, 0), + ); + let state = state_with_chain_lock_quorums( + &platform.platform.state.load(), + llmq_test_chain_lock_config(), + 1123, + quorums(&[&z, &b]), + vec![QuorumChange { + quorums: quorums(&[&a, &b]), + from_core_height: 1113, + to_core_height: 1115, + }], + ); + + assert_eq!( + verify(&platform, &state, &sign_chain_lock(&unrelated, 1127 + 8)), + Some(false) + ); + assert_eq!( + verify(&platform, &state, &sign_chain_lock(&unrelated, 1128 + 8)), + None + ); + } + + #[test] + fn should_verify_and_reject_locally_when_signing_height_quorums_are_known() { + // The signing height is at or below the last committed core height and after the last + // quorum change, so Platform holds exactly the quorums Core chooses from and decides on + // its own, without asking Core. + let platform = chain_lock_test_platform(); + let scenario = QuorumMinedAfterCoreHeight::default(); + let state = scenario.platform_state(&platform.platform.state.load()); + + let chain_lock_height = 1110; + let signer = core_signer(&[&scenario.a, &scenario.b], chain_lock_height); + let other = if signer.hash == scenario.a.hash { + &scenario.b + } else { + &scenario.a + }; + + assert_eq!( + verify( + &platform, + &state, + &sign_chain_lock(signer, chain_lock_height) + ), + Some(true) + ); + assert_eq!( + verify( + &platform, + &state, + &sign_chain_lock(other, chain_lock_height) + ), + Some(false) + ); + } + + #[test] + fn should_reject_wrong_signer_in_previous_quorums_only_where_they_are_known() { + // Platform held {z, y} until 1062, fetched {a, z} at 1067 and held it up to 1068, and + // holds {b, a} from 1070. It rejects locally against {a, z} only at signing height 1068; + // 1067, the height it fetched them at, is left to Core. The heights are close together to + // reach the branch, not to model a network. + let platform = chain_lock_test_platform(); + let (y, z, a, b, unrelated) = ( + TestQuorum::new(1, 0), + TestQuorum::new(2, 0), + TestQuorum::new(3, 0), + TestQuorum::new(4, 0), + TestQuorum::new(9, 0), + ); + let state = state_with_chain_lock_quorums( + &platform.platform.state.load(), + llmq_test_chain_lock_config(), + 1070, + quorums(&[&z, &y]), + vec![ + QuorumChange { + quorums: quorums(&[&a, &z]), + from_core_height: 1062, + to_core_height: 1067, + }, + QuorumChange { + quorums: quorums(&[&b, &a]), + from_core_height: 1068, + to_core_height: 1070, + }, + ], + ); + + assert_eq!( + verify(&platform, &state, &sign_chain_lock(&unrelated, 1068 + 8)), + Some(false) + ); + assert_eq!( + verify(&platform, &state, &sign_chain_lock(&unrelated, 1067 + 8)), + None + ); + } + + #[test] + fn should_leave_chain_lock_to_core_without_previous_quorums() { + let platform = chain_lock_test_platform(); + let (a, b, unrelated) = ( + TestQuorum::new(2, 0), + TestQuorum::new(3, 0), + TestQuorum::new(9, 0), + ); + let state = state_with_chain_lock_quorums( + &platform.platform.state.load(), + llmq_test_chain_lock_config(), + 1106, + quorums(&[&a, &b]), + vec![], + ); + + assert_eq!( + verify(&platform, &state, &sign_chain_lock(&unrelated, 1110)), + None + ); + } + + #[test] + fn should_follow_mainnet_dkg_schedule_when_rejecting_locally() { + // llmq_400_60 quorums are mined at offsets 20 to 28 of a 288 block interval; the + // interval starting at 2555424 mines them at 2555444..=2555452. The chain lock is signed + // for llmq_test, so it is invalid for llmq_400_60 quorums whoever signs it. + let platform = chain_lock_test_platform(); + let (z, a, b, c, d, unrelated) = ( + TestQuorum::new(1, 0), + TestQuorum::new(2, 0), + TestQuorum::new(3, 0), + TestQuorum::new(4, 0), + TestQuorum::new(5, 0), + TestQuorum::new(9, 0), + ); + let state_at = |core_height: u32| { + state_with_chain_lock_quorums( + &platform.platform.state.load(), + llmq_400_60_chain_lock_config(), + core_height, + quorums(&[&z, &a, &b, &c]), + vec![QuorumChange { + quorums: quorums(&[&a, &b, &c, &d]), + from_core_height: core_height - 2, + to_core_height: core_height - 1, + }], + ) + }; + let verify_at = |core_height: u32, signing_height: u32| { + verify( + &platform, + &state_at(core_height), + &sign_chain_lock(&unrelated, signing_height + 8), + ) + }; + + // before the mining window + assert_eq!(verify_at(2555430, 2555443), Some(false)); + assert_eq!(verify_at(2555430, 2555444), None); + // inside it, the next block can carry a commitment + assert_eq!(verify_at(2555444, 2555445), None); + assert_eq!(verify_at(2555451, 2555452), None); + // after its last height, nothing changes until the next interval's window + assert_eq!(verify_at(2555452, 2555453), Some(false)); + } + + #[test] + fn should_read_dkg_mining_schedule_from_platform_version() { + // From 1106 the next llmq_test commitment can be mined at 1114, so a wrong signer at + // signing height 1113 is rejected locally. Without llmq_test's schedule any later block + // may change the quorums, so the same chain lock is left to Core. + let platform = chain_lock_test_platform(); + let scenario = QuorumMinedAfterCoreHeight::default(); + let state = scenario.platform_state(&platform.platform.state.load()); + let chain_lock = sign_chain_lock(&TestQuorum::new(9, 0), 1113 + 8); + let verify_with = |platform_version: &PlatformVersion| { + platform + .platform + .verify_chain_lock_locally_v1(0, &state, &chain_lock, platform_version) + }; + + assert_eq!( + verify_with(PlatformVersion::latest()).expect("verification runs"), + Some(false) + ); + + let mut without_schedules = PlatformVersion::latest().clone(); + without_schedules + .drive_abci + .methods + .core_chain_lock + .dkg_mining_schedules = Some(&[]); + assert_eq!( + verify_with(&without_schedules).expect("verification runs"), + None + ); + + without_schedules + .drive_abci + .methods + .core_chain_lock + .dkg_mining_schedules = None; + assert!(matches!( + verify_with(&without_schedules), + Err(Error::Execution(ExecutionError::CorruptedCodeExecution(_))) + )); + + let mut zero_interval = PlatformVersion::latest().clone(); + zero_interval + .drive_abci + .methods + .core_chain_lock + .dkg_mining_schedules = Some(&[DkgMiningSchedule { + quorum_type: QuorumType::LlmqTest as u8, + interval: 0, + mining_window_start: 10, + mining_window_end: 18, + }]); + assert!( + matches!( + verify_with(&zero_interval), + Err(Error::Execution(ExecutionError::CorruptedCodeExecution(_))) + ), + "a schedule with an interval of 0 must fail instead of dividing by it" + ); + } + + #[test] + fn should_have_well_formed_dkg_mining_schedules_in_every_platform_version() { + // Version 1 takes the first schedule of a quorum type and computes offsets in its + // interval, so a schedule must be unique per type and its mining window must lie inside + // the interval. + for platform_version in PLATFORM_VERSIONS { + let Some(dkg_mining_schedules) = platform_version + .drive_abci + .methods + .core_chain_lock + .dkg_mining_schedules + else { + continue; + }; + for (index, schedule) in dkg_mining_schedules.iter().enumerate() { + let case = format!( + "protocol version {}, quorum type {}", + platform_version.protocol_version, schedule.quorum_type + ); + assert!( + schedule.mining_window_start <= schedule.mining_window_end + && schedule.mining_window_end < schedule.interval, + "{case}: {schedule:?}" + ); + assert!( + dkg_mining_schedules[index + 1..] + .iter() + .all(|other| other.quorum_type != schedule.quorum_type), + "{case} has more than one schedule" + ); + } + } + } + + #[test] + fn should_find_first_possible_quorum_change_from_dash_core_dkg_schedule() { + // Dash Core mines a commitment only at offsets mining_window_start..=mining_window_end + // of each DKG interval (`llmq/params.h`). The expected schedules are keyed by dashcore's + // `QuorumType` and the version table by Core's LLMQ type number, so this also checks + // that numbering. + let dkg_mining_schedules = PlatformVersion::latest() + .drive_abci + .methods + .core_chain_lock + .dkg_mining_schedules + .expect("the latest protocol version has DKG mining schedules"); + let schedules = [ + (QuorumType::LlmqTest, 24, 10, 18), + (QuorumType::LlmqTestInstantsend, 24, 10, 18), + (QuorumType::LlmqTestV17, 24, 10, 18), + (QuorumType::LlmqTestPlatform, 24, 10, 18), + (QuorumType::LlmqDevnetPlatform, 24, 10, 18), + (QuorumType::LlmqDevnet, 24, 10, 18), + (QuorumType::Llmq50_60, 24, 10, 18), + (QuorumType::Llmq100_67, 24, 10, 18), + (QuorumType::Llmq25_67, 24, 10, 18), + (QuorumType::Llmq400_60, 288, 20, 28), + (QuorumType::Llmq400_85, 576, 20, 48), + ]; + for (quorum_type, interval, start, end) in schedules { + let interval_start = interval * 8873; + let first_change = |core_height| { + first_possible_quorum_change_after(dkg_mining_schedules, quorum_type, core_height) + .expect("the schedule has an interval") + }; + assert_eq!( + first_change(interval_start), + interval_start + start, + "{quorum_type}" + ); + assert_eq!( + first_change(interval_start + start - 1), + interval_start + start, + "{quorum_type}" + ); + assert_eq!( + first_change(interval_start + start), + interval_start + start + 1, + "{quorum_type}" + ); + assert_eq!( + first_change(interval_start + end - 1), + interval_start + end, + "{quorum_type}" + ); + assert_eq!( + first_change(interval_start + end), + interval_start + interval + start, + "{quorum_type}" + ); + } + + // Rotating and unknown types fall back to any later block + for quorum_type in [ + QuorumType::Llmq60_75, + QuorumType::LlmqTestDip0024, + QuorumType::LlmqDevnetDip0024, + QuorumType::LlmqSingleNode, + ] { + assert_eq!( + first_possible_quorum_change_after(dkg_mining_schedules, quorum_type, 1110) + .expect("no schedule to read"), + 1111 + ); + } + } + + #[test] + fn should_verify_mainnet_chain_locks_against_quorums_stored_as_core_reports_them() { + // Mainnet chain locks 2555653 and 2555665, both signed with the llmq_400_60 quorums + // active at 2555645..=2555657, whose hashes Drive stores in display order. Dash Core + // signed the first with 0000000000000008726d…6e1b and the second with + // 000000000000000751333…75bd; choosing by the hashes as displayed would pick + // 00000000000000057dd2…d873 for the second. The other quorums' keys do not matter for + // the choice. + let platform = chain_lock_test_platform(); + + let hex_bytes = |hex: &str| hex::decode(hex).expect("hex"); + let public_key = + |hex: &str| PublicKey::try_from(hex_bytes(hex).as_slice()).expect("public key"); + let (x, y) = (TestQuorum::new(1, 0), TestQuorum::new(2, 0)); + let unrelated_quorums = quorums(&[&x, &y]); + let mut unrelated_keys = unrelated_quorums + .iter() + .map(|(_, quorum)| quorum.public_key); + + let active: Quorums = [ + ("000000000000000ef024cd737d450c3595da6abfde2f0481a07e39310a8e6ba3", None), + ("00000000000000057dd284100e0cf0e9477ac8f4d5602534eb1930ea12f0d873", None), + ( + "0000000000000008726d8f2945f2953f8f530f3e0718a2ebafdb1548e3196e1b", + Some("b9c07aa758b3267f957991cfaef62d818266fd7ff63579d1706aee637967dfc6a7da48df28407ef652cff21c9292965d"), + ), + ( + "000000000000000751333cdbfdb75d4b33200d69abf46451365ea133920175bd", + Some("996e4c9baf03bf03e167799415a537b71f6724d36ef7068992796cdfa6cd340b7928a093ada63e5d5f061419d74707d5"), + ), + ] + .into_iter() + .map(|(display_hash, signer_public_key)| { + ( + QuorumHash::from_byte_array( + hex_bytes(display_hash).try_into().expect("32 bytes"), + ), + VerificationQuorum { + public_key: signer_public_key.map(public_key).unwrap_or_else(|| { + unrelated_keys.next().expect("two unrelated keys") + }), + index: None, + }, + ) + }) + .collect(); + + let state = state_with_chain_lock_quorums( + &platform.platform.state.load(), + llmq_400_60_chain_lock_config(), + 2555657, + unrelated_quorums.clone(), + vec![QuorumChange { + quorums: active, + from_core_height: 2555599, + to_core_height: 2555600, + }], + ); + + let chain_lock = |block_height: u32, block_hash: &str, signature: &str| ChainLock { + block_height, + block_hash: BlockHash::from_str(block_hash).expect("block hash"), + signature: <[u8; 96]>::try_from(hex_bytes(signature)) + .expect("96 bytes") + .into(), + }; + let first = chain_lock( + 2555653, + "0000000000000001a26bc13523d40ae79f5680207c0d8486c4939ce2ef01c6c8", + "83fae388eb55f8e8d9fd89623b5018a1294550f6032b96d5c89de71bf7d567d19cced4023eefcbf9898d77744a2c90860614854ae35d32c0d9268ef06c4859effaf3b9e5f132af58aae0f760cd94ab8b987a6663f01f660d61dbe2ea0856b197", + ); + let second = chain_lock( + 2555665, + "000000000000001f346cf0065ba7524b794350270a52fa59d91972182a7513d3", + "8eedc7f87cc2395b214a0f8dfb7a53c7525f0a5eb16386f2bd30ccbe7666afb7fee8cd4f46416137c0469e1f37d6ad2816c4a05de094276437d5b3d1d64e059294d8ff14acf0ba4a5bb6dc7f649414085d3cccb54db46d89f722ecfa5908a810", + ); + + assert_eq!(verify(&platform, &state, &first), Some(true)); + assert_eq!(verify(&platform, &state, &second), Some(true)); + + let mut other_block = first.clone(); + other_block.block_hash = BlockHash::from_byte_array([1u8; 32]); + assert_eq!(verify(&platform, &state, &other_block), Some(false)); + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_through_core/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_through_core/mod.rs index e7c585498d7..2ea97ab2159 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_through_core/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_through_core/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::error::execution::ExecutionError; use crate::error::Error; @@ -15,7 +16,27 @@ impl Platform where C: CoreRPCLike, { - /// Verify the chain lock through core + /// Verify the chain lock through core, for a chain lock Platform could not verify against + /// the chain lock quorums it holds. + /// + /// # Parameters + /// + /// * `chain_lock` - The chain lock to verify + /// * `submit` - Whether to submit the chain lock to Core to find out if Core is synced to it + /// * `platform_version` - The platform version selecting the implementation + /// + /// # Returns + /// + /// * `Ok((valid, None))` - Without `submit`, Core's `verifychainlock` verdict. From version + /// 1 also with `submit`, when `verifychainlock` fails the chain lock, which is then not + /// submitted + /// * `Ok((true, Some(status)))` - With `submit`, whether Core is synced to the chain lock, + /// from `submitchainlock`. Version 0 submits without checking the signature, and + /// `submitchainlock` skips that check for a height Core already has a chain lock at or + /// above; version 1 submits only a chain lock `verifychainlock` accepted + /// * `Err(Error::CoreRpc)` - A Core RPC call failed + /// * `Err(Error::Execution)` - An unknown method version, here or in + /// `make_sure_core_is_synced_to_chain_lock`, which version 1 submits through pub fn verify_chain_lock_through_core( &self, chain_lock: &ChainLock, @@ -29,11 +50,87 @@ where .verify_chain_lock_through_core { 0 => self.verify_chain_lock_through_core_v0(chain_lock, submit, platform_version), + 1 => self.verify_chain_lock_through_core_v1(chain_lock, submit, platform_version), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "verify_chain_lock_through_core".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } } } + +#[cfg(test)] +mod tests { + use crate::execution::platform_events::core_chain_lock::make_sure_core_is_synced_to_chain_lock::CoreSyncStatus; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::chain_lock_quorums::{ + chain_lock_test_platform, sign_chain_lock, TestQuorum, + }; + use dpp::version::PlatformVersion; + + #[test] + fn should_check_signature_before_submitting_from_protocol_version_15() { + // A forged chain lock left to Core, with Core already holding a chain lock at 1140, so + // submitchainlock answers that height without checking the signature. Protocol versions + // up to 14 submit it without asking for a signature check, and must keep doing so for + // the blocks committed under them to replay the same way. From 15, Core's + // verifychainlock checks the signature first, and its failure keeps the chain lock from + // being submitted. + const CORE_BEST_CHAIN_LOCK_HEIGHT: u32 = 1140; + let mut platform = chain_lock_test_platform(); + let forged = sign_chain_lock(&TestQuorum::new(9, 0), 1128 + 8); + + for protocol_version in [13, 14] { + let mut core_rpc = MockCoreRPCLike::new(); + core_rpc.expect_verify_chain_lock().times(0); + core_rpc + .expect_submit_chain_lock() + .times(1) + .returning(|_| Ok(CORE_BEST_CHAIN_LOCK_HEIGHT)); + platform.core_rpc = core_rpc; + + let (valid, status) = platform + .platform + .verify_chain_lock_through_core( + &forged, + true, + PlatformVersion::get(protocol_version).expect("protocol version exists"), + ) + .expect("verification runs"); + + assert!(valid, "protocol version {protocol_version}"); + assert!( + matches!(status, Some(CoreSyncStatus::Done)), + "protocol version {protocol_version} must keep its verdict" + ); + } + + for platform_version in [ + PlatformVersion::get(15).expect("protocol version 15 exists"), + PlatformVersion::latest(), + ] { + let mut core_rpc = MockCoreRPCLike::new(); + let asked = forged.clone(); + core_rpc + .expect_verify_chain_lock() + .withf(move |chain_lock| *chain_lock == asked) + .times(1) + .returning(|_| Ok(false)); + core_rpc.expect_submit_chain_lock().times(0); + platform.core_rpc = core_rpc; + + let (valid, status) = platform + .platform + .verify_chain_lock_through_core(&forged, true, platform_version) + .expect("verification runs"); + + assert!( + !valid, + "protocol version {}", + platform_version.protocol_version + ); + assert!(status.is_none(), "submitchainlock must not be asked"); + } + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_through_core/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_through_core/v1/mod.rs new file mode 100644 index 00000000000..08a41fb9eec --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_through_core/v1/mod.rs @@ -0,0 +1,45 @@ +use crate::error::Error; +use crate::execution::platform_events::core_chain_lock::make_sure_core_is_synced_to_chain_lock::CoreSyncStatus; +use dpp::dashcore::ChainLock; +use dpp::version::PlatformVersion; + +use crate::platform_types::platform::Platform; + +use crate::rpc::core::CoreRPCLike; + +impl Platform +where + C: CoreRPCLike, +{ + /// Verify the chain lock through core v1: Core's `verifychainlock` checks the signature + /// first, and only a chain lock it accepts is submitted. `submitchainlock` returns Core's + /// best chain lock height without checking the signature when that height is at or above the + /// chain lock's, so submitting alone does not authenticate a chain lock Platform left to + /// Core. + #[inline(always)] + pub(super) fn verify_chain_lock_through_core_v1( + &self, + chain_lock: &ChainLock, + submit: bool, + platform_version: &PlatformVersion, + ) -> Result<(bool, Option), Error> { + // `verifychainlock` also fails a valid chain lock while Core does not have the block at + // the signing height, where `submitchainlock` fails too. + if !self.core_rpc.verify_chain_lock(chain_lock)? { + tracing::warn!( + ?chain_lock, + "Core's verifychainlock rejected the chain lock at core height {}: its signature is invalid, or Core does not have the block it was signed at yet", + chain_lock.block_height, + ); + return Ok((false, None)); + } + + if !submit { + return Ok((true, None)); + } + + let sync_status = + self.make_sure_core_is_synced_to_chain_lock(chain_lock, platform_version)?; + Ok((true, Some(sync_status))) + } +} diff --git a/packages/rs-drive-abci/src/test/helpers/chain_lock_quorums.rs b/packages/rs-drive-abci/src/test/helpers/chain_lock_quorums.rs new file mode 100644 index 00000000000..e8055d97f86 --- /dev/null +++ b/packages/rs-drive-abci/src/test/helpers/chain_lock_quorums.rs @@ -0,0 +1,225 @@ +//! Chain lock quorums with known keys, and chain locks signed with them the way Dash Core signs +//! them, for testing chain lock verification against crafted platform states. + +use crate::config::ChainLockConfig; +use crate::platform_types::platform_state::{PlatformState, PlatformStateV0Methods}; +use crate::platform_types::signature_verification_quorum_set::{ + Quorums, SignatureVerificationQuorumSet, SignatureVerificationQuorumSetV0Methods, + VerificationQuorum, +}; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; +use dpp::block::block_info::BlockInfo; +use dpp::bls::SecretKey; +use dpp::dashcore::hashes::{sha256d, Hash, HashEngine}; +use dpp::dashcore::{BlockHash, ChainLock, QuorumHash, QuorumSigningRequestId}; +use dpp::dashcore_rpc::json::QuorumType; + +/// The chain lock quorum type of local networks (llmq_test): a 24 block DKG interval whose +/// commitment is mined at offsets 10 to 18, with two quorums active. +pub fn llmq_test_chain_lock_config() -> ChainLockConfig { + ChainLockConfig { + quorum_type: QuorumType::LlmqTest, + quorum_size: 3, + quorum_window: 24, + quorum_active_signers: 2, + quorum_rotation: false, + } +} + +/// A chain lock quorum whose threshold secret key is known +pub struct TestQuorum { + /// The quorum hash + pub hash: QuorumHash, + secret_key: SecretKey, +} + +impl TestQuorum { + /// A quorum derived from `seed`; `salt` varies the quorum hash, and with it the quorum's + /// score for every request id, without changing the key + pub fn new(seed: u8, salt: u8) -> Self { + let mut key_bytes = [0u8; 32]; + key_bytes[0] = seed; + key_bytes[31] = 1; + let mut hash_bytes = [seed; 32]; + hash_bytes[0] = salt; + Self { + hash: QuorumHash::from_byte_array(hash_bytes), + secret_key: SecretKey::from_be_bytes(&key_bytes).expect("valid secret key"), + } + } + + fn entry(&self) -> (QuorumHash, VerificationQuorum) { + ( + self.hash, + VerificationQuorum { + public_key: self.secret_key.public_key(), + index: None, + }, + ) + } +} + +/// The verification quorums of `members` +pub fn quorums(members: &[&TestQuorum]) -> Quorums { + members.iter().map(|quorum| quorum.entry()).collect() +} + +/// The DIP8 request id of a chain lock at `height` +pub fn chain_lock_request_id(height: u32) -> QuorumSigningRequestId { + let mut engine = QuorumSigningRequestId::engine(); + engine.input(&[5u8]); + engine.input(b"clsig"); + engine.input(height.to_le_bytes().as_slice()); + QuorumSigningRequestId::from_engine(engine) +} + +/// The member of `active_at_signing_height` that Dash Core signs a llmq_test chain lock at +/// `height` with: the DIP8 choice among the quorums active 8 blocks before `height`. It is +/// computed with Platform's own quorum choice, which the mainnet chain lock tests check against +/// quorums Dash Core actually chose. +pub fn core_signer<'q>(active_at_signing_height: &[&'q TestQuorum], height: u32) -> &'q TestQuorum { + let config = SignatureVerificationQuorumSet::V0(llmq_test_chain_lock_config().into()); + let chosen = quorums(active_at_signing_height) + .choose_quorum( + config.config(), + chain_lock_request_id(height).as_byte_array(), + ) + .expect("a quorum is chosen") + .0; + active_at_signing_height + .iter() + .find(|quorum| quorum.hash == chosen) + .expect("the chosen quorum is one of the active ones") +} + +/// A llmq_test chain lock at `height` signed by `signer` +pub fn sign_chain_lock(signer: &TestQuorum, height: u32) -> ChainLock { + let block_hash = BlockHash::from_byte_array([7u8; 32]); + let mut reversed_quorum_hash = signer.hash.to_byte_array(); + reversed_quorum_hash.reverse(); + + let mut engine = sha256d::Hash::engine(); + engine.input(&[QuorumType::LlmqTest as u8]); + engine.input(reversed_quorum_hash.as_slice()); + engine.input(chain_lock_request_id(height).as_byte_array()); + engine.input(block_hash.as_byte_array()); + let message_digest = sha256d::Hash::from_engine(engine); + + let signature = signer + .secret_key + .sign(message_digest.as_byte_array()) + .expect("signs"); + + ChainLock { + block_height: height, + block_hash, + signature: signature.to_bytes().into(), + } +} + +/// A change of the chain lock quorums Platform recorded when its core height moved from +/// `from_core_height` to `to_core_height` +pub struct QuorumChange { + /// The quorums active at `to_core_height` + pub quorums: Quorums, + /// The last core height Platform had committed with the quorums before the change + pub from_core_height: u32, + /// The core height at which Platform fetched `quorums` + pub to_core_height: u32, +} + +/// `base` with last committed core height `core_height` and chain lock quorums of `config` that +/// started as `initial` and then went through `changes`, in order +pub fn state_with_chain_lock_quorums( + base: &PlatformState, + config: ChainLockConfig, + core_height: u32, + initial: Quorums, + changes: Vec, +) -> PlatformState { + let mut state = base.clone(); + state.set_last_committed_block_info(None); + state.set_genesis_block_info(Some(BlockInfo { + core_height, + ..Default::default() + })); + + let mut quorum_set = SignatureVerificationQuorumSet::V0(config.into()); + quorum_set.set_current_quorums(initial); + for change in changes { + quorum_set.replace_quorums( + change.quorums, + change.from_core_height, + change.to_core_height, + ); + } + state.set_chain_lock_validating_quorums(quorum_set); + state +} + +/// A platform with a mock Core RPC, for chain lock verification tests +pub fn chain_lock_test_platform() -> TempPlatform { + TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_initial_state_structure() +} + +/// Platform committed core height 1106, early in the llmq_test DKG interval 1104..=1127, +/// holding the quorums {a, b} it fetched at 1091 (before them {z, b}). Core then mines quorum `n` +/// at 1114, the first height of the interval's mining window, and keeps signing chain locks. +pub struct QuorumMinedAfterCoreHeight { + /// In the quorums before the change at 1091 + pub z: TestQuorum, + /// In the quorums Platform holds, and still active after `n` is mined + pub a: TestQuorum, + /// In the quorums Platform holds + pub b: TestQuorum, + /// Mined at 1114, after Platform's core height + pub n: TestQuorum, +} + +impl Default for QuorumMinedAfterCoreHeight { + fn default() -> Self { + Self { + z: TestQuorum::new(1, 0), + a: TestQuorum::new(2, 0), + b: TestQuorum::new(3, 0), + n: TestQuorum::new(4, 0), + } + } +} + +impl QuorumMinedAfterCoreHeight { + /// Platform's last committed core height + pub const CORE_HEIGHT: u32 = 1106; + /// The height Core mines `n` at + pub const QUORUM_MINED_AT: u32 = 1114; + /// The last height of the DKG interval + pub const INTERVAL_END: u32 = 1127; + + /// `base` holding the chain lock quorums Platform had at core height 1106 + pub fn platform_state(&self, base: &PlatformState) -> PlatformState { + state_with_chain_lock_quorums( + base, + llmq_test_chain_lock_config(), + Self::CORE_HEIGHT, + quorums(&[&self.z, &self.b]), + vec![QuorumChange { + quorums: quorums(&[&self.a, &self.b]), + from_core_height: 1089, + to_core_height: 1091, + }], + ) + } + + /// A valid chain lock Core signed with `n`: its signing height is past 1114 and inside the + /// DKG interval, where Core chooses from {n, a} + pub fn chain_lock_signed_by_new_quorum(&self) -> ChainLock { + let height = (Self::QUORUM_MINED_AT + 8..=Self::INTERVAL_END + 8) + .find(|height| core_signer(&[&self.n, &self.a], *height).hash == self.n.hash) + .expect("some height in the interval is signed by the new quorum"); + sign_chain_lock(&self.n, height) + } +} diff --git a/packages/rs-drive-abci/src/test/helpers/mod.rs b/packages/rs-drive-abci/src/test/helpers/mod.rs index ebc1ad5982b..3944caa3dcf 100644 --- a/packages/rs-drive-abci/src/test/helpers/mod.rs +++ b/packages/rs-drive-abci/src/test/helpers/mod.rs @@ -1,3 +1,6 @@ +/// Chain lock quorum fixtures +#[cfg(test)] +pub mod chain_lock_quorums; /// Contract group fixtures #[cfg(test)] pub mod contract_groups; diff --git a/packages/rs-drive-abci/tests/strategy_tests/execution.rs b/packages/rs-drive-abci/tests/strategy_tests/execution.rs index e0f3b4fac3e..3c232ff5311 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/execution.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/execution.rs @@ -769,6 +769,11 @@ pub(crate) async fn run_chain_for_strategy<'a>( .expect_submit_chain_lock() .returning(move |chain_lock: &ChainLock| Ok(chain_lock.block_height)); + platform + .core_rpc + .expect_verify_chain_lock() + .returning(|_| Ok(true)); + create_chain_for_strategy( platform, block_count, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs index 111ec0c7211..0b4a236b28c 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs @@ -2,6 +2,7 @@ use versioned_feature_core::{FeatureVersion, OptionalFeatureVersion}; pub mod v1; pub mod v10; +pub mod v11; pub mod v2; pub mod v3; pub mod v4; @@ -103,6 +104,26 @@ pub struct DriveAbciCoreChainLockMethodVersionsAndConstants { pub verify_chain_lock_through_core: FeatureVersion, pub make_sure_core_is_synced_to_chain_lock: FeatureVersion, pub recent_block_count_amount: u32, //what constitutes a recent block, for v0 it's 2. + /// Dash Core's DKG mining schedule (`llmq/params.h`) of each non-rotating chain lock quorum + /// type, read by `verify_chain_lock_locally` 1 to find the first core height at which the + /// quorums Platform holds can change. A quorum type missing from it can change its quorums at + /// any later height. `None` for the protocol versions that select `verify_chain_lock_locally` + /// 0, which does not read it. + pub dkg_mining_schedules: Option<&'static [DkgMiningSchedule]>, +} + +/// When Dash Core can mine a commitment of a non-rotating quorum type: at the core heights whose +/// offset in the DKG interval is within the mining window, both ends included. +#[derive(Clone, Copy, Debug)] +pub struct DkgMiningSchedule { + /// The LLMQ type, as Dash Core numbers it (`llmq_test` is 100) + pub quorum_type: u8, + /// The length of the DKG interval in blocks; intervals start at multiples of it + pub interval: u32, + /// The first offset in the interval at which a commitment can be mined + pub mining_window_start: u32, + /// The last offset in the interval at which a commitment can be mined + pub mining_window_end: u32, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs index e0c2edd2657..410eb8ba8c8 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v1.rs @@ -65,6 +65,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V1: DriveAbciMethodVersions = DriveAbciMeth verify_chain_lock_through_core: 0, make_sure_core_is_synced_to_chain_lock: 0, recent_block_count_amount: 2, + dkg_mining_schedules: None, }, core_instant_send_lock: DriveAbciCoreInstantSendLockMethodVersions { verify_recent_signature_locally: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs index 85be67e2997..d2e1249af35 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs @@ -89,6 +89,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMet verify_chain_lock_through_core: 0, make_sure_core_is_synced_to_chain_lock: 0, recent_block_count_amount: 2, + dkg_mining_schedules: None, }, core_instant_send_lock: DriveAbciCoreInstantSendLockMethodVersions { verify_recent_signature_locally: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v11.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v11.rs new file mode 100644 index 00000000000..c232a2170be --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v11.rs @@ -0,0 +1,109 @@ +use super::v10::DRIVE_ABCI_METHOD_VERSIONS_V10; +use super::{ + DkgMiningSchedule, DriveAbciCoreChainLockMethodVersionsAndConstants, DriveAbciMethodVersions, +}; + +/// Dash Core's DKG schedule (`llmq/params.h`) of the non-rotating quorum types, the ones chain +/// locks are signed with. Frozen here rather than read from the dashcore dependency, so a +/// dependency update cannot change which chain locks are rejected locally. +const DKG_MINING_SCHEDULES_V1: &[DkgMiningSchedule] = &[ + // llmq_50_60 + DkgMiningSchedule { + quorum_type: 1, + interval: 24, + mining_window_start: 10, + mining_window_end: 18, + }, + // llmq_400_60 + DkgMiningSchedule { + quorum_type: 2, + interval: 288, + mining_window_start: 20, + mining_window_end: 28, + }, + // llmq_400_85 + DkgMiningSchedule { + quorum_type: 3, + interval: 576, + mining_window_start: 20, + mining_window_end: 48, + }, + // llmq_100_67 + DkgMiningSchedule { + quorum_type: 4, + interval: 24, + mining_window_start: 10, + mining_window_end: 18, + }, + // llmq_25_67 + DkgMiningSchedule { + quorum_type: 6, + interval: 24, + mining_window_start: 10, + mining_window_end: 18, + }, + // llmq_test + DkgMiningSchedule { + quorum_type: 100, + interval: 24, + mining_window_start: 10, + mining_window_end: 18, + }, + // llmq_devnet + DkgMiningSchedule { + quorum_type: 101, + interval: 24, + mining_window_start: 10, + mining_window_end: 18, + }, + // llmq_test_v17 + DkgMiningSchedule { + quorum_type: 102, + interval: 24, + mining_window_start: 10, + mining_window_end: 18, + }, + // llmq_test_instantsend + DkgMiningSchedule { + quorum_type: 104, + interval: 24, + mining_window_start: 10, + mining_window_end: 18, + }, + // llmq_test_platform + DkgMiningSchedule { + quorum_type: 106, + interval: 24, + mining_window_start: 10, + mining_window_end: 18, + }, + // llmq_devnet_platform + DkgMiningSchedule { + quorum_type: 107, + interval: 24, + mining_window_start: 10, + mining_window_end: 18, + }, +]; + +/// Drive ABCI method versions 11, introduced in protocol v15. Differs from v10 in the chain lock +/// verification, which only works as a whole: +/// - `core_chain_lock.verify_chain_lock_locally` is 1 rather than 0: a chain lock is rejected +/// locally only when Platform holds exactly the quorums active at its signing height, using +/// Dash Core's DKG mining schedule in `core_chain_lock.dkg_mining_schedules`. When a quorum may +/// have been mined after the last committed core height, or the signing height lies between +/// the two stored chain lock quorum sets, the chain lock is left to Core instead of rejected. +/// - `core_chain_lock.verify_chain_lock_through_core` is 1 rather than 0: Core's +/// `verifychainlock` checks the signature of every chain lock left to Core before +/// `submitchainlock`, which skips that check for a height Core already has a chain lock at or +/// above. `verify_chain_lock_locally` 1 leaves to Core chain locks that 0 rejected, so it is +/// only safe together with this check. +pub const DRIVE_ABCI_METHOD_VERSIONS_V11: DriveAbciMethodVersions = DriveAbciMethodVersions { + core_chain_lock: DriveAbciCoreChainLockMethodVersionsAndConstants { + verify_chain_lock_locally: 1, + verify_chain_lock_through_core: 1, + dkg_mining_schedules: Some(DKG_MINING_SCHEDULES_V1), + ..DRIVE_ABCI_METHOD_VERSIONS_V10.core_chain_lock + }, + ..DRIVE_ABCI_METHOD_VERSIONS_V10 +}; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs index f0c138b912a..741535ee010 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v2.rs @@ -66,6 +66,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V2: DriveAbciMethodVersions = DriveAbciMeth verify_chain_lock_through_core: 0, make_sure_core_is_synced_to_chain_lock: 0, recent_block_count_amount: 2, + dkg_mining_schedules: None, }, core_instant_send_lock: DriveAbciCoreInstantSendLockMethodVersions { verify_recent_signature_locally: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs index b1232c723af..1c559a0b1c5 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v3.rs @@ -65,6 +65,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V3: DriveAbciMethodVersions = DriveAbciMeth verify_chain_lock_through_core: 0, make_sure_core_is_synced_to_chain_lock: 0, recent_block_count_amount: 2, + dkg_mining_schedules: None, }, core_instant_send_lock: DriveAbciCoreInstantSendLockMethodVersions { verify_recent_signature_locally: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs index 16389b8230d..fcd85d382b8 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v4.rs @@ -65,6 +65,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V4: DriveAbciMethodVersions = DriveAbciMeth verify_chain_lock_through_core: 0, make_sure_core_is_synced_to_chain_lock: 0, recent_block_count_amount: 2, + dkg_mining_schedules: None, }, core_instant_send_lock: DriveAbciCoreInstantSendLockMethodVersions { verify_recent_signature_locally: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs index 8af13d666c2..34b722d7e0f 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v5.rs @@ -65,6 +65,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V5: DriveAbciMethodVersions = DriveAbciMeth verify_chain_lock_through_core: 0, make_sure_core_is_synced_to_chain_lock: 0, recent_block_count_amount: 2, + dkg_mining_schedules: None, }, core_instant_send_lock: DriveAbciCoreInstantSendLockMethodVersions { verify_recent_signature_locally: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs index 61b49f620e7..9bf22b9afa7 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v6.rs @@ -66,6 +66,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V6: DriveAbciMethodVersions = DriveAbciMeth verify_chain_lock_through_core: 0, make_sure_core_is_synced_to_chain_lock: 0, recent_block_count_amount: 2, + dkg_mining_schedules: None, }, core_instant_send_lock: DriveAbciCoreInstantSendLockMethodVersions { verify_recent_signature_locally: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs index 6706865c4e3..2105c268ec5 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v7.rs @@ -74,6 +74,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V7: DriveAbciMethodVersions = DriveAbciMeth verify_chain_lock_through_core: 0, make_sure_core_is_synced_to_chain_lock: 0, recent_block_count_amount: 2, + dkg_mining_schedules: None, }, core_instant_send_lock: DriveAbciCoreInstantSendLockMethodVersions { verify_recent_signature_locally: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs index 6773f557a58..cbe35a4ea23 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v8.rs @@ -76,6 +76,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V8: DriveAbciMethodVersions = DriveAbciMeth verify_chain_lock_through_core: 0, make_sure_core_is_synced_to_chain_lock: 0, recent_block_count_amount: 2, + dkg_mining_schedules: None, }, core_instant_send_lock: DriveAbciCoreInstantSendLockMethodVersions { verify_recent_signature_locally: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs index d34b017e96e..d4f38b73665 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v9.rs @@ -85,6 +85,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V9: DriveAbciMethodVersions = DriveAbciMeth verify_chain_lock_through_core: 0, make_sure_core_is_synced_to_chain_lock: 0, recent_block_count_amount: 2, + dkg_mining_schedules: None, }, core_instant_send_lock: DriveAbciCoreInstantSendLockMethodVersions { verify_recent_signature_locally: 0, diff --git a/packages/rs-platform-version/src/version/mocks/v3_test.rs b/packages/rs-platform-version/src/version/mocks/v3_test.rs index 6062953e1b4..f56f03c806c 100644 --- a/packages/rs-platform-version/src/version/mocks/v3_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v3_test.rs @@ -100,6 +100,7 @@ pub const TEST_PLATFORM_V3: PlatformVersion = PlatformVersion { verify_chain_lock_through_core: 0, make_sure_core_is_synced_to_chain_lock: 0, recent_block_count_amount: 2, + dkg_mining_schedules: None, }, core_instant_send_lock: DriveAbciCoreInstantSendLockMethodVersions { verify_recent_signature_locally: 0, diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 151896fed16..bc087826e30 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -15,7 +15,7 @@ use crate::version::dpp_versions::dpp_validation_versions::v5::DPP_VALIDATION_VE use crate::version::dpp_versions::dpp_voting_versions::v2::VOTING_VERSION_V2; use crate::version::dpp_versions::DPPVersion; use crate::version::drive_abci_versions::drive_abci_checkpoint_parameters::v1::DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1; -use crate::version::drive_abci_versions::drive_abci_method_versions::v10::DRIVE_ABCI_METHOD_VERSIONS_V10; +use crate::version::drive_abci_versions::drive_abci_method_versions::v11::DRIVE_ABCI_METHOD_VERSIONS_V11; use crate::version::drive_abci_versions::drive_abci_query_versions::v2::DRIVE_ABCI_QUERY_VERSIONS_V2; use crate::version::drive_abci_versions::drive_abci_structure_versions::v2::DRIVE_ABCI_STRUCTURE_VERSIONS_V2; use crate::version::drive_abci_versions::drive_abci_validation_versions::v10::DRIVE_ABCI_VALIDATION_VERSIONS_V10; @@ -35,12 +35,36 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// version structs, no behavior change. Each change that needs this gate /// lands in its own follow-up and bumps the component table it consumes here; /// keeping v15 == v14 until then lets mixed-version validators agree. +/// +/// v15 hosts these consensus changes: +/// +/// 1. **Chain locks signed by quorums Platform does not hold yet**: a block +/// proposal's chain lock is checked against the chain lock quorums Platform +/// fetched at its last committed core height. When a quorum was mined after +/// that height, inside the same DKG interval, or when the signing height +/// lies between the two stored quorum sets after a jump of the core height, +/// the chain lock is signed by quorums Platform does not hold, and +/// `verify_chain_lock_locally` 0 rejected it although it was valid, failing +/// rounds until Core produced a chain lock Platform could verify or one +/// signed past the DKG interval. `verify_chain_lock_locally` 1 +/// (`DRIVE_ABCI_METHOD_VERSIONS_V11`) rejects locally only when Platform +/// holds exactly the quorums active at the signing height, using Dash Core's +/// DKG mining schedule (`core_chain_lock.dkg_mining_schedules`), and leaves +/// the other chain locks to Core. `verify_chain_lock_through_core` 1 has +/// Core's `verifychainlock` check the signature of every chain lock left to +/// Core before `submitchainlock`, which skips that check for a height Core +/// already has a chain lock at or above, and reports a failure as Core's +/// verdict. So v15 accepts valid chain locks that v14 rejected, and rejects +/// forged ones that v14 left to `submitchainlock`'s early return. Protocol +/// v14 and earlier +/// keep `verify_chain_lock_locally` 0 and `verify_chain_lock_through_core` 0, +/// so the blocks committed under them replay with the same verdicts. pub const PLATFORM_V15: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_15, drive: DRIVE_VERSION_V9, drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, - methods: DRIVE_ABCI_METHOD_VERSIONS_V10, + methods: DRIVE_ABCI_METHOD_VERSIONS_V11, // changed: verify_chain_lock_locally 1 rejects a chain lock locally only when Platform holds the quorums active at its signing height, and verify_chain_lock_through_core 1 has Core's verifychainlock check the others before submitchainlock validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, query: DRIVE_ABCI_QUERY_VERSIONS_V2,