From e3739319723595f004f8b31f300f58fe7886dbbd Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 15 Oct 2026 18:45:04 +0700 Subject: [PATCH 1/8] fix(drive-abci)!: leave chain locks signed by quorums Platform does not hold to Core (PV15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_chain_lock_locally v0 rejects (Some(false)) valid chain locks whose signing height (H - 8) falls where Platform does not hold the active quorums: - a quorum mined after the last committed core height C inside the same DKG interval: v0 only defers to Core once the signing height passes the end of C's interval, but commitments are mined mid-interval; - a signing height between the two stored quorum sets after a core height jump across two mining windows: v0 rejects when both sets fail. Seen in the 4.1.1 -> 5.0.0-rc.2 local upgrade rehearsal: with C = 1782 (llmq_test interval 1776..=1799, mining window 1786..=1794), chain locks 1795 and 1800 were rejected by every other validator, and Platform block 1122 took 4 rounds. The logic is unchanged since at least v4.1.1. Liveness only: the rejection is deterministic, so validators agree. verify_chain_lock_locally v1 is v0 with three decision changes: 1. after a failed check against the current quorums, return None (Core decides) when a commitment of the chain lock quorum type can have been mined between C and the signing height, using Dash Core's DKG mining schedule (llmq/params.h) kept in v1; rotating and unknown types defer for any signing height past C; 2. return None when both stored quorum sets fail; 3. drop the round >= 10 branch, which only logged. v1 differs from v0 only by turning some Some(false) into None; every remaining local rejection is on a quorum set Platform holds exactly. The schedule is owned by v1 rather than read from rust-dashcore, whose table has interval 288 for llmq_test_platform and llmq_devnet_platform (Core: 24) and wrong mining offsets for llmq_devnet_dip0024, and so that a dependency bump cannot change protocol v15. DRIVE_ABCI_METHOD_VERSIONS_V11 (V10 with verify_chain_lock_locally: 1) is used only by PLATFORM_V15; protocol v14 and earlier keep v0. #4648 and #4652 also create DRIVE_ABCI_METHOD_VERSIONS_V11; whichever lands second adds its slot to the existing table. Test would have caught this: the dispatcher test for case A and the verify_chain_lock test accepting it through Core fail with PV15's verify_chain_lock_locally slot at 0 (v0) and pass at 1 (v1): ✖ before fix, ✔ after. The first repro tests, written before the fix, returned Some(false) for both cases under the latest protocol version. Mutating the mining window boundaries or restoring the both-sets rejection fails 2 to 3 tests each. Two mainnet chain locks (2555653, 2555665) pin quorum choice, digest and hash byte order to real Core data. Co-Authored-By: Claude Opus 5.5 --- .../verify_chain_lock/v0/mod.rs | 58 ++ .../verify_chain_lock_locally/mod.rs | 204 ++++- .../verify_chain_lock_locally/v0/mod.rs | 2 +- .../verify_chain_lock_locally/v1/mod.rs | 754 ++++++++++++++++++ .../src/test/helpers/chain_lock_quorums.rs | 225 ++++++ .../rs-drive-abci/src/test/helpers/mod.rs | 3 + .../drive_abci_method_versions/mod.rs | 1 + .../drive_abci_method_versions/v11.rs | 146 ++++ .../rs-platform-version/src/version/v15.rs | 19 +- 9 files changed, 1406 insertions(+), 6 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_locally/v1/mod.rs create mode 100644 packages/rs-drive-abci/src/test/helpers/chain_lock_quorums.rs create mode 100644 packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v11.rs 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..0bb6df64ffb 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,11 +166,69 @@ 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; + #[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 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(); + 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_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); + } + /// Exercises the `Err(Error::BLSError(_))` branch of `verify_chain_lock_v0`: /// when the inner `verify_chain_lock_locally` returns a BLS deserialization /// error, `verify_chain_lock_v0` must swallow it and return a 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..7c43de3bf7e 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,24 @@ 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 + /// + /// # 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 returned only 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 + /// * `Err(Error::BLSError)` - The signature could not be deserialized + /// * `Err(Error::Execution)` - An unknown method version, or no quorums to verify against pub fn verify_chain_lock_locally( &self, round: u32, @@ -36,11 +53,192 @@ 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::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; + + #[test] + fn should_stop_rejecting_chain_lock_signed_by_newly_mined_quorum_after_protocol_version_14() { + // 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; later ones leave it to Core. + 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(); + + let verify = |platform_version: &PlatformVersion| { + platform + .platform + .verify_chain_lock_locally(0, &state, &chain_lock, platform_version) + .expect("verification runs") + }; + + for protocol_version in [13, 14] { + assert_eq!( + verify(PlatformVersion::get(protocol_version).expect("known version")), + Some(false), + "protocol version {protocol_version} must keep its verdict" + ); + } + 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 version 0 accepted is + // rejected. Valid chain locks must never be rejected locally by version 1. + let platform = chain_lock_test_platform(); + 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 v0 = platform + .platform + .verify_chain_lock_locally_v0(0, &state, &chain_lock, v0_version) + .expect("v0 runs"); + let v1 = platform + .platform + .verify_chain_lock_locally_v1(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:?}" + ); + + 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)); + } + } + 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..31809f9f3d0 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_locally/v1/mod.rs @@ -0,0 +1,754 @@ +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::PlatformVersion; + +const CHAIN_LOCK_REQUEST_ID_PREFIX: &str = "clsig"; + +const SIGN_OFFSET: u32 = 8; + +impl Platform +where + C: CoreRPCLike, +{ + /// 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, so Core has to decide. + /// `Some(false)` is only returned when Platform holds exactly the quorums active at the + /// signing height. + #[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 first_possible_quorum_change = first_possible_quorum_change_after( + 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().to_vec(); + 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().to_vec(); + 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)) + } +} + +/// 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. +struct DkgMiningSchedule { + interval: u32, + mining_window_start: u32, + mining_window_end: u32, +} + +/// Dash Core's DKG schedule (`llmq/params.h`) of the non-rotating quorum types, the ones chain +/// locks are signed with. `None` for rotating and unknown types. +fn dkg_mining_schedule(quorum_type: QuorumType) -> Option { + let (interval, mining_window_start, mining_window_end) = match quorum_type { + QuorumType::Llmq50_60 + | QuorumType::Llmq100_67 + | QuorumType::Llmq25_67 + | QuorumType::LlmqTest + | QuorumType::LlmqDevnet + | QuorumType::LlmqTestV17 + | QuorumType::LlmqTestInstantsend + | QuorumType::LlmqTestPlatform + | QuorumType::LlmqDevnetPlatform => (24, 10, 18), + QuorumType::Llmq400_60 => (288, 20, 28), + QuorumType::Llmq400_85 => (576, 20, 48), + _ => return None, + }; + Some(DkgMiningSchedule { + interval, + mining_window_start, + mining_window_end, + }) +} + +/// The first core height after `last_committed_core_height` at which a quorum commitment of +/// `quorum_type` can be mined. 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( + quorum_type: QuorumType, + last_committed_core_height: u32, +) -> u32 { + let next_height = last_committed_core_height.saturating_add(1); + + let Some(schedule) = dkg_mining_schedule(quorum_type) else { + // Without the type's DKG schedule, any later block may change the quorums + return next_height; + }; + let offset_in_interval = last_committed_core_height % schedule.interval; + let interval_start = last_committed_core_height - offset_in_interval; + + 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::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::PlatformVersion; + 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_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`). + let schedules = [ + (QuorumType::LlmqTest, 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::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(quorum_type, core_height); + 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::LlmqSingleNode, + ] { + assert_eq!(first_possible_quorum_change_after(quorum_type, 1110), 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/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-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..e1281a6a0fc 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; 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..01471e882ca --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v11.rs @@ -0,0 +1,146 @@ +use crate::version::drive_abci_versions::drive_abci_method_versions::{ + DriveAbciBlockEndMethodVersions, DriveAbciBlockFeeProcessingMethodVersions, + DriveAbciBlockStartMethodVersions, DriveAbciCoreBasedUpdatesMethodVersions, + DriveAbciCoreChainLockMethodVersionsAndConstants, DriveAbciCoreInstantSendLockMethodVersions, + DriveAbciEngineMethodVersions, DriveAbciEpochMethodVersions, + DriveAbciFeePoolInwardsDistributionMethodVersions, + DriveAbciFeePoolOutwardsDistributionMethodVersions, + DriveAbciIdentityCreditWithdrawalMethodVersions, DriveAbciInitializationMethodVersions, + DriveAbciMasternodeIdentitiesUpdatesMethodVersions, DriveAbciMethodVersions, + DriveAbciPlatformStateStorageMethodVersions, DriveAbciProtocolUpgradeMethodVersions, + DriveAbciStateTransitionProcessingMethodVersions, DriveAbciTokensProcessingMethodVersions, + DriveAbciVotingMethodVersions, +}; + +/// Drive ABCI method versions 11. Introduced in protocol v15: +/// `verify_chain_lock_locally` 1 leaves a chain lock to Core instead of rejecting it when a quorum +/// may have been mined after the last committed core height, or when the signing height lies +/// between the two stored chain lock quorum sets, so a chain lock signed by quorums Platform does +/// not hold is no longer rejected. +/// Everything else matches `DRIVE_ABCI_METHOD_VERSIONS_V10`. +pub const DRIVE_ABCI_METHOD_VERSIONS_V11: DriveAbciMethodVersions = DriveAbciMethodVersions { + engine: DriveAbciEngineMethodVersions { + init_chain: 0, + check_tx: 0, + run_block_proposal: 0, + finalize_block_proposal: 0, + consensus_params_update: 1, + }, + initialization: DriveAbciInitializationMethodVersions { + initial_core_height_and_time: 0, + create_genesis_state: 1, + }, + core_based_updates: DriveAbciCoreBasedUpdatesMethodVersions { + update_core_info: 0, + update_masternode_list: 1, + update_quorum_info: 0, + masternode_updates: DriveAbciMasternodeIdentitiesUpdatesMethodVersions { + get_voter_identity_key: 0, + get_operator_identity_keys: 0, + get_owner_identity_withdrawal_key: 0, + get_owner_identity_owner_key: 0, + get_voter_identifier_from_masternode_list_item: 0, + get_operator_identifier_from_masternode_list_item: 0, + create_operator_identity: 0, + create_owner_identity: 2, + create_voter_identity: 0, + disable_identity_keys: 0, + update_masternode_identities: 1, + update_operator_identity: 0, + update_owner_withdrawal_address: 1, + update_voter_identity: 0, + }, + }, + protocol_upgrade: DriveAbciProtocolUpgradeMethodVersions { + check_for_desired_protocol_upgrade: 1, + upgrade_protocol_version_on_epoch_change: 0, + perform_events_on_first_block_of_protocol_change: Some(2), + protocol_version_upgrade_percentage_needed: 67, + }, + block_fee_processing: DriveAbciBlockFeeProcessingMethodVersions { + add_process_epoch_change_operations: 0, + process_block_fees_and_validate_sum_trees: 1, + }, + tokens_processing: DriveAbciTokensProcessingMethodVersions { + validate_token_aggregated_balance: 0, + }, + core_chain_lock: DriveAbciCoreChainLockMethodVersionsAndConstants { + choose_quorum: 0, + verify_chain_lock: 0, + verify_chain_lock_locally: 1, // changed: rejects a chain lock locally only when Platform holds the quorums active at its signing height + verify_chain_lock_through_core: 0, + make_sure_core_is_synced_to_chain_lock: 0, + recent_block_count_amount: 2, + }, + core_instant_send_lock: DriveAbciCoreInstantSendLockMethodVersions { + verify_recent_signature_locally: 0, + }, + fee_pool_inwards_distribution: DriveAbciFeePoolInwardsDistributionMethodVersions { + add_distribute_block_fees_into_pools_operations: 1, + add_distribute_storage_fee_to_epochs_operations: 1, + }, + fee_pool_outwards_distribution: DriveAbciFeePoolOutwardsDistributionMethodVersions { + add_distribute_fees_from_oldest_unpaid_epoch_pool_to_proposers_operations: 1, + add_epoch_pool_to_proposers_payout_operations: 1, + find_oldest_epoch_needing_payment: 0, + fetch_reward_shares_list_for_masternode: 0, + }, + withdrawals: DriveAbciIdentityCreditWithdrawalMethodVersions { + build_untied_withdrawal_transactions_from_documents: 0, + dequeue_and_build_unsigned_withdrawal_transactions: 0, + fetch_transactions_block_inclusion_status: 0, + pool_withdrawals_into_transactions_queue: 2, + update_broadcasted_withdrawal_statuses: 0, + rebroadcast_expired_withdrawal_documents: 2, + append_signatures_and_broadcast_withdrawal_transactions: 0, + has_pending_withdrawal_work: 0, + cleanup_expired_locks_of_withdrawal_amounts: 1, + record_credit_inflows_for_withdrawals: Some(0), + record_total_credits_history_for_withdrawals: Some(0), + scan_core_blocks_for_withdrawals: Some(0), + calculate_core_anchored_withdrawal_limit: Some(0), + }, + voting: DriveAbciVotingMethodVersions { + keep_record_of_finished_contested_resource_vote_poll: 0, + clean_up_after_vote_poll_end: 0, + clean_up_after_contested_resources_vote_poll_end: 1, + check_for_ended_vote_polls: 1, + tally_votes_for_contested_document_resource_vote_poll: 0, + award_document_to_winner: 0, + delay_vote_poll: 0, + run_dao_platform_events: 0, + remove_votes_for_removed_masternodes: 0, + }, + state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { + execute_event: 1, + process_raw_state_transitions: 0, + process_validation_result: 1, + decode_raw_state_transitions: 1, + validate_fees_of_event: 1, + store_address_balances_to_recent_block_storage: Some(0), + cleanup_recent_block_storage_address_balances: Some(0), + record_added_balance_outputs: 1, + }, + epoch: DriveAbciEpochMethodVersions { + gather_epoch_info: 0, + get_genesis_time: 0, + }, + block_start: DriveAbciBlockStartMethodVersions { + clear_drive_block_cache: 0, + }, + block_end: DriveAbciBlockEndMethodVersions { + update_state_cache: 0, + update_drive_cache: 0, + validator_set_update: 2, + should_checkpoint: Some(0), + update_checkpoints: Some(0), + record_shielded_pool_anchor: Some(0), + prune_shielded_pool_anchors: Some(0), + record_token_shielded_pool_anchors: Some(0), + expire_documents: Some(0), + }, + platform_state_storage: DriveAbciPlatformStateStorageMethodVersions { + fetch_platform_state: 1, + store_platform_state: 1, + }, +}; diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 151896fed16..8f0c4aa7de0 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,27 @@ 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, and leaves the other chain locks to Core. 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 leaves chain locks signed by quorums Platform does not hold to Core validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, query: DRIVE_ABCI_QUERY_VERSIONS_V2, From 42cc17889223c6a66ff5daa5b257642b5edbeb2c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 15 Oct 2026 21:26:28 +0700 Subject: [PATCH 2/8] fix(drive-abci)!: select verify_chain_lock_locally v1 at protocol 14, as on v5.0-dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5459 selects verify_chain_lock_locally v1 in place at protocol 14 on v5.0-dev, so it ships in 5.0.0-rc.4. Protocol 15 inherits protocol 14's tables (PLATFORM_V15 uses DRIVE_ABCI_METHOD_VERSIONS_V10), so a separate table for protocol 15 is no longer needed: - drop DRIVE_ABCI_METHOD_VERSIONS_V11 and point nothing new at v15.rs, which is again functionally identical to v14; - DRIVE_ABCI_METHOD_VERSIONS_V10 sets verify_chain_lock_locally to 1, and v14.rs lists it as item 96, with the same text as #5459 so the forward merge of v5.0-dev sees identical edits; - the gate test now checks protocol 13 (v0) against 14 and the latest (v1), the v0-against-v1 test runs v0 at protocol 13, and v0's own test is pinned to protocol 13; - the DKG schedule test covers all eleven non-rotating quorum types and llmq_devnet_dip0024 among the rotating ones. Replay-safe for protocol 14 blocks committed under v0: v1 answers differently only where v0 answers Some(false), which refuses the proposal, so every block v0 accepted is accepted the same way by v1. Test would have caught this: as in #5459, the dispatcher gate test and the verify_chain_lock accept-through-Core test fail with protocol 14's slot at 0 and pass at 1 (✖ before, ✔ after). Co-Authored-By: Claude Opus 5.5 --- .../verify_chain_lock_locally/mod.rs | 24 +-- .../verify_chain_lock_locally/v0/mod.rs | 2 +- .../verify_chain_lock_locally/v1/mod.rs | 5 + .../drive_abci_method_versions/mod.rs | 1 - .../drive_abci_method_versions/v10.rs | 6 +- .../drive_abci_method_versions/v11.rs | 146 ------------------ .../rs-platform-version/src/version/v14.rs | 17 ++ .../rs-platform-version/src/version/v15.rs | 19 +-- 8 files changed, 43 insertions(+), 177 deletions(-) delete mode 100644 packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v11.rs 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 7c43de3bf7e..816352e4ab0 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 @@ -79,10 +79,10 @@ mod tests { use dpp::version::PlatformVersion; #[test] - fn should_stop_rejecting_chain_lock_signed_by_newly_mined_quorum_after_protocol_version_14() { + fn should_stop_rejecting_chain_lock_signed_by_newly_mined_quorum_from_protocol_version_14() { // 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; later ones leave it to Core. + // quorum n at 1114 and signed the next chain lock with it. Protocol versions up to 13 + // keep rejecting such a chain lock locally; 14 and later leave it to Core. let platform = chain_lock_test_platform(); let scenario = QuorumMinedAfterCoreHeight::default(); let state = scenario.platform_state(&platform.platform.state.load()); @@ -95,13 +95,15 @@ mod tests { .expect("verification runs") }; - for protocol_version in [13, 14] { - assert_eq!( - verify(PlatformVersion::get(protocol_version).expect("known version")), - Some(false), - "protocol version {protocol_version} must keep its verdict" - ); - } + 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")), + None + ); assert_eq!(verify(PlatformVersion::latest()), None); } @@ -164,7 +166,7 @@ mod tests { 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 v0_version = PlatformVersion::get(13).expect("protocol version 13 exists"); let v1_version = PlatformVersion::latest(); // Each history ends at Platform's last committed core height. The flag says whether 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 30fb92982bb..919dd11bd4e 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::get(14).expect("protocol version 14 exists"); + let platform_version = PlatformVersion::get(13).expect("protocol version 13 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 index 31809f9f3d0..0d9e5664afc 100644 --- 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 @@ -619,10 +619,14 @@ mod tests { // of each DKG interval (`llmq/params.h`). 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), ]; @@ -661,6 +665,7 @@ mod tests { for quorum_type in [ QuorumType::Llmq60_75, QuorumType::LlmqTestDip0024, + QuorumType::LlmqDevnetDip0024, QuorumType::LlmqSingleNode, ] { assert_eq!(first_possible_quorum_change_after(quorum_type, 1110), 1111); 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 e1281a6a0fc..111ec0c7211 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,7 +2,6 @@ use versioned_feature_core::{FeatureVersion, OptionalFeatureVersion}; pub mod v1; pub mod v10; -pub mod v11; pub mod v2; pub mod v3; pub mod v4; 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 76f2e022cc1..0a6a8ab1f6b 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 @@ -35,6 +35,10 @@ use crate::version::drive_abci_versions::drive_abci_method_versions::{ /// recipient and disable obsolete authority; split, empty or unsupported lists disable all /// TRANSFER keys while preserving OWNER and balance. Historical updater behavior and legacy /// payout-address rotation remain unchanged. +/// `verify_chain_lock_locally` 1 leaves a chain lock to Core instead of rejecting it when a quorum +/// may have been mined after the last committed core height, or when the signing height lies +/// between the two stored chain lock quorum sets, so a chain lock signed by quorums Platform does +/// not hold is no longer rejected. /// Everything else matches `DRIVE_ABCI_METHOD_VERSIONS_V9`. pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMethodVersions { engine: DriveAbciEngineMethodVersions { @@ -85,7 +89,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMet core_chain_lock: DriveAbciCoreChainLockMethodVersionsAndConstants { choose_quorum: 0, verify_chain_lock: 0, - verify_chain_lock_locally: 0, + verify_chain_lock_locally: 1, // changed: rejects a chain lock locally only when Platform holds the quorums active at its signing height verify_chain_lock_through_core: 0, make_sure_core_is_synced_to_chain_lock: 0, recent_block_count_amount: 2, 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 deleted file mode 100644 index 01471e882ca..00000000000 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v11.rs +++ /dev/null @@ -1,146 +0,0 @@ -use crate::version::drive_abci_versions::drive_abci_method_versions::{ - DriveAbciBlockEndMethodVersions, DriveAbciBlockFeeProcessingMethodVersions, - DriveAbciBlockStartMethodVersions, DriveAbciCoreBasedUpdatesMethodVersions, - DriveAbciCoreChainLockMethodVersionsAndConstants, DriveAbciCoreInstantSendLockMethodVersions, - DriveAbciEngineMethodVersions, DriveAbciEpochMethodVersions, - DriveAbciFeePoolInwardsDistributionMethodVersions, - DriveAbciFeePoolOutwardsDistributionMethodVersions, - DriveAbciIdentityCreditWithdrawalMethodVersions, DriveAbciInitializationMethodVersions, - DriveAbciMasternodeIdentitiesUpdatesMethodVersions, DriveAbciMethodVersions, - DriveAbciPlatformStateStorageMethodVersions, DriveAbciProtocolUpgradeMethodVersions, - DriveAbciStateTransitionProcessingMethodVersions, DriveAbciTokensProcessingMethodVersions, - DriveAbciVotingMethodVersions, -}; - -/// Drive ABCI method versions 11. Introduced in protocol v15: -/// `verify_chain_lock_locally` 1 leaves a chain lock to Core instead of rejecting it when a quorum -/// may have been mined after the last committed core height, or when the signing height lies -/// between the two stored chain lock quorum sets, so a chain lock signed by quorums Platform does -/// not hold is no longer rejected. -/// Everything else matches `DRIVE_ABCI_METHOD_VERSIONS_V10`. -pub const DRIVE_ABCI_METHOD_VERSIONS_V11: DriveAbciMethodVersions = DriveAbciMethodVersions { - engine: DriveAbciEngineMethodVersions { - init_chain: 0, - check_tx: 0, - run_block_proposal: 0, - finalize_block_proposal: 0, - consensus_params_update: 1, - }, - initialization: DriveAbciInitializationMethodVersions { - initial_core_height_and_time: 0, - create_genesis_state: 1, - }, - core_based_updates: DriveAbciCoreBasedUpdatesMethodVersions { - update_core_info: 0, - update_masternode_list: 1, - update_quorum_info: 0, - masternode_updates: DriveAbciMasternodeIdentitiesUpdatesMethodVersions { - get_voter_identity_key: 0, - get_operator_identity_keys: 0, - get_owner_identity_withdrawal_key: 0, - get_owner_identity_owner_key: 0, - get_voter_identifier_from_masternode_list_item: 0, - get_operator_identifier_from_masternode_list_item: 0, - create_operator_identity: 0, - create_owner_identity: 2, - create_voter_identity: 0, - disable_identity_keys: 0, - update_masternode_identities: 1, - update_operator_identity: 0, - update_owner_withdrawal_address: 1, - update_voter_identity: 0, - }, - }, - protocol_upgrade: DriveAbciProtocolUpgradeMethodVersions { - check_for_desired_protocol_upgrade: 1, - upgrade_protocol_version_on_epoch_change: 0, - perform_events_on_first_block_of_protocol_change: Some(2), - protocol_version_upgrade_percentage_needed: 67, - }, - block_fee_processing: DriveAbciBlockFeeProcessingMethodVersions { - add_process_epoch_change_operations: 0, - process_block_fees_and_validate_sum_trees: 1, - }, - tokens_processing: DriveAbciTokensProcessingMethodVersions { - validate_token_aggregated_balance: 0, - }, - core_chain_lock: DriveAbciCoreChainLockMethodVersionsAndConstants { - choose_quorum: 0, - verify_chain_lock: 0, - verify_chain_lock_locally: 1, // changed: rejects a chain lock locally only when Platform holds the quorums active at its signing height - verify_chain_lock_through_core: 0, - make_sure_core_is_synced_to_chain_lock: 0, - recent_block_count_amount: 2, - }, - core_instant_send_lock: DriveAbciCoreInstantSendLockMethodVersions { - verify_recent_signature_locally: 0, - }, - fee_pool_inwards_distribution: DriveAbciFeePoolInwardsDistributionMethodVersions { - add_distribute_block_fees_into_pools_operations: 1, - add_distribute_storage_fee_to_epochs_operations: 1, - }, - fee_pool_outwards_distribution: DriveAbciFeePoolOutwardsDistributionMethodVersions { - add_distribute_fees_from_oldest_unpaid_epoch_pool_to_proposers_operations: 1, - add_epoch_pool_to_proposers_payout_operations: 1, - find_oldest_epoch_needing_payment: 0, - fetch_reward_shares_list_for_masternode: 0, - }, - withdrawals: DriveAbciIdentityCreditWithdrawalMethodVersions { - build_untied_withdrawal_transactions_from_documents: 0, - dequeue_and_build_unsigned_withdrawal_transactions: 0, - fetch_transactions_block_inclusion_status: 0, - pool_withdrawals_into_transactions_queue: 2, - update_broadcasted_withdrawal_statuses: 0, - rebroadcast_expired_withdrawal_documents: 2, - append_signatures_and_broadcast_withdrawal_transactions: 0, - has_pending_withdrawal_work: 0, - cleanup_expired_locks_of_withdrawal_amounts: 1, - record_credit_inflows_for_withdrawals: Some(0), - record_total_credits_history_for_withdrawals: Some(0), - scan_core_blocks_for_withdrawals: Some(0), - calculate_core_anchored_withdrawal_limit: Some(0), - }, - voting: DriveAbciVotingMethodVersions { - keep_record_of_finished_contested_resource_vote_poll: 0, - clean_up_after_vote_poll_end: 0, - clean_up_after_contested_resources_vote_poll_end: 1, - check_for_ended_vote_polls: 1, - tally_votes_for_contested_document_resource_vote_poll: 0, - award_document_to_winner: 0, - delay_vote_poll: 0, - run_dao_platform_events: 0, - remove_votes_for_removed_masternodes: 0, - }, - state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { - execute_event: 1, - process_raw_state_transitions: 0, - process_validation_result: 1, - decode_raw_state_transitions: 1, - validate_fees_of_event: 1, - store_address_balances_to_recent_block_storage: Some(0), - cleanup_recent_block_storage_address_balances: Some(0), - record_added_balance_outputs: 1, - }, - epoch: DriveAbciEpochMethodVersions { - gather_epoch_info: 0, - get_genesis_time: 0, - }, - block_start: DriveAbciBlockStartMethodVersions { - clear_drive_block_cache: 0, - }, - block_end: DriveAbciBlockEndMethodVersions { - update_state_cache: 0, - update_drive_cache: 0, - validator_set_update: 2, - should_checkpoint: Some(0), - update_checkpoints: Some(0), - record_shielded_pool_anchor: Some(0), - prune_shielded_pool_anchors: Some(0), - record_token_shielded_pool_anchors: Some(0), - expire_documents: Some(0), - }, - platform_state_storage: DriveAbciPlatformStateStorageMethodVersions { - fetch_platform_state: 1, - store_platform_state: 1, - }, -}; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 073f2b6647a..f02580beed7 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -2667,6 +2667,23 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// typed array and a `where`, and every contract that registers today /// binds no typed array. /// +/// 96. **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_V10`) rejects locally only when Platform +/// holds exactly the quorums active at the signing height, using Dash +/// Core's DKG mining schedule, and leaves the other chain locks to Core. +/// Version 1 answers differently from 0 only where 0 answers +/// `Some(false)`, which refuses the proposal, so every block 0 accepted +/// at this version is accepted the same way by 1. The reverse does not +/// hold: a node running 0 refuses the proposals 1 accepts this way. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 8f0c4aa7de0..151896fed16 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::v11::DRIVE_ABCI_METHOD_VERSIONS_V11; +use crate::version::drive_abci_versions::drive_abci_method_versions::v10::DRIVE_ABCI_METHOD_VERSIONS_V10; 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,27 +35,12 @@ 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, and leaves the other chain locks to Core. 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_V11, // changed: verify_chain_lock_locally 1 leaves chain locks signed by quorums Platform does not hold to Core + methods: DRIVE_ABCI_METHOD_VERSIONS_V10, validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, query: DRIVE_ABCI_QUERY_VERSIONS_V2, From 7f867eb15a24c484b08f009477fe05657458ad58 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 15 Oct 2026 22:25:39 +0700 Subject: [PATCH 3/8] fix(drive-abci)!: check deferred chain lock signatures with Core's verifychainlock (PV14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chain lock that verify_chain_lock_locally v1 leaves to Core (None) went straight to Core's submitchainlock. Dash Core returns its best chain lock height from submitchainlock without checking the signature when that height is at or above the submitted one (rpc/quorums.cpp, v23.1.8), and Drive turns that answer into "Core is synced". So a Byzantine proposer's forged chain lock at an already chain-locked height was accepted by every validator whose Core had a chain lock at or above it. verify_chain_lock_locally v1 now asks Core's verifychainlock whenever the check against the quorums Platform holds cannot decide, on every such path: - false: Some(false), the proposal is rejected and submitchainlock is not called; - true: None, unchanged: submitchainlock still decides whether Core is synced; - RPC error: Err(CoreRpc), which run_block_proposal turns into InvalidChainLock, as it already does for a submitchainlock error there. The check against the held quorums moves unchanged into verify_chain_lock_with_held_quorums, which the local-logic tests call. A false from Core is logged as a warning naming both causes (an invalid signature, or Core missing the block at H - 8); the proposal error is the existing local-rejection one. Both RPCs run the same chainlock::VerifyChainLock. For a genuine chain lock above Core's best they give the same verdict; at or below it, Core's tip is at least H - 8 (Core only keeps a best chain lock it verified), so the signing quorum is selectable and verifychainlock accepts it. A Core more than 8 blocks behind answers false instead of throwing "No quorum found": rejected before and after. So genuine chain locks are rejected in no new case, and forged ones are now rejected by every honest validator instead of depending on each validator's Core. A committed protocol 14 block with a genuine chain lock, which every honest proposer proposes, replays the same way. A block whose forged chain lock v0 accepted through the early return (a Byzantine proposer with rc.1..rc.3 validators holding 2/3) can never be applied by this version; only pre-release protocol 14 networks can hold one. verifychainlock gets the block hash in display order (BlockHash's Display), as Core parses it; this branch's rust-dashcore pin also sends display order to submitchainlock (rust-dashcore #1148). The wire-level byte order test is on v5.0-dev, whose pin still has the submitchainlock bug; its mock JSON-RPC Core is not on this branch. Protocol 14 only, in place: v1 is not in any release (5.0.0-rc.1..rc.3 run v0 at protocol 14), testnet and mainnet run protocol 13. No version table changes; item 96 of v14.rs and the V10 table docs describe the Core check. Mixed rc.3/rc.4 validators now also disagree on a forged chain lock in v0's deferred range at a height the validator's Core already has a chain lock for: rc.3 accepts it through the early return, rc.4 rejects it. Only a Byzantine proposer can produce one. Drive now calls verifychainlock on the consensus path: dashmate's drive_consensus RPC whitelist has always allowed it; a deployment without dashmate must allow it too. The strategy tests' mock Core accepts verifychainlock as it accepts submitchainlock; run_chain_lock_update_quorums_not_changing defers a chain lock and needs it. Same change as on v5.0-dev (#5459), so the forward merge sees identical edits apart from the BLS API lines. Addresses thepastaclaw's review on #5454. Refs #5438. Test would have caught this: verify_chain_lock with a forged chain lock at 1122 (newly deferred by v1) and 1136 (past the DKG interval end, deferred by v0 too), mock Core best chain lock 1140: ✖ before fix (accepted, Core synced), ✔ after (rejected, submitchainlock not asked). The v0/v1 differential test now also runs v1 through the dispatcher against a Core that fails every chain lock: exactly the chain locks the held quorums leave to Core are asked about and rejected. Co-Authored-By: Claude Opus 5.5 --- .../verify_chain_lock/v0/mod.rs | 91 ++++++++++++++++++- .../verify_chain_lock_locally/mod.rs | 78 ++++++++++++---- .../verify_chain_lock_locally/v1/mod.rs | 48 +++++++++- .../tests/strategy_tests/execution.rs | 5 + .../drive_abci_method_versions/v10.rs | 6 +- .../rs-platform-version/src/version/v14.rs | 14 ++- 6 files changed, 209 insertions(+), 33 deletions(-) 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 0bb6df64ffb..d8d837dff69 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 @@ -173,13 +173,15 @@ mod tests { use crate::test::helpers::setup::TestPlatformBuilder; use dpp::dashcore::hashes::Hash; use dpp::dashcore::{BlockHash, ChainLock}; + use dpp::dashcore_rpc::Error as DashCoreRpcError; use dpp::version::PlatformVersion; #[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 already has it. + // 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()); @@ -187,6 +189,12 @@ mod tests { 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) @@ -215,6 +223,7 @@ mod tests { 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; @@ -229,6 +238,86 @@ mod tests { 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. + 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() + .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_locally, + Some(false), + "a forged chain lock at {chain_lock_height} must be rejected, not accepted \ + through Core's early return (core is synced: {:?})", + result.core_is_synced + ); + assert_eq!(result.found_valid_by_core, None); + assert_eq!( + result.core_is_synced, None, + "submitchainlock must not be asked" + ); + } + } + + #[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 /// error, `verify_chain_lock_v0` must swallow it and return a 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 816352e4ab0..afa9aa6117b 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 @@ -16,7 +16,9 @@ impl Platform where C: CoreRPCLike, { - /// Verify the chain lock against the chain lock quorums held in the platform state + /// Verify the chain lock against the chain lock quorums held in the platform state. From + /// version 1, a chain lock Platform cannot verify that way has its signature checked by + /// Core's `verifychainlock`. /// /// # Parameters /// @@ -28,11 +30,14 @@ where /// # 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 returned only 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 + /// * `Ok(Some(false))` - The chain lock is invalid. From version 1 this is returned when + /// Platform holds exactly the quorums active at the signing height, or when Core's + /// `verifychainlock` fails the signature; 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. From version 1, + /// Core's `verifychainlock` has already accepted the signature /// * `Err(Error::BLSError)` - The signature could not be deserialized + /// * `Err(Error::CoreRpc)` - From version 1, the `verifychainlock` call failed /// * `Err(Error::Execution)` - An unknown method version, or no quorums to verify against pub fn verify_chain_lock_locally( &self, @@ -71,23 +76,36 @@ where #[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_14() { // 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 13 - // keep rejecting such a chain lock locally; 14 and later leave it to Core. - let platform = chain_lock_test_platform(); + // keep rejecting such a chain lock locally; 14 and later leave it to Core, which + // verifies its signature. + 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(); + let asked = chain_lock.clone(); + core_rpc + .expect_verify_chain_lock() + .withf(move |chain_lock| *chain_lock == asked) + .times(2) + .returning(|_| Ok(true)); + platform.core_rpc = core_rpc; + let verify = |platform_version: &PlatformVersion| { platform .platform @@ -159,10 +177,22 @@ mod tests { #[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 version 0 accepted is - // rejected. Valid chain locks must never be rejected locally by version 1. - let platform = chain_lock_test_platform(); + // Version 1's check against the quorums Platform holds 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 that check. Version 1 then has Core's verifychainlock + // check exactly the chain locks that check leaves to Core. + let mut platform = chain_lock_test_platform(); + // This Core fails every chain lock it is asked about, so version 1's answer and the + // number of calls show which chain locks it asked about. + let core_checks = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&core_checks); + let mut core_rpc = MockCoreRPCLike::new(); + core_rpc.expect_verify_chain_lock().returning(move |_| { + counter.fetch_add(1, Ordering::SeqCst); + Ok(false) + }); + platform.core_rpc = core_rpc; let base = platform.platform.state.load(); let mined = MinedQuorums::new(); let unrelated = TestQuorum::new(99, 0); @@ -197,7 +227,7 @@ mod tests { (&[1110, 1116], false), ]; - let mut rejected_by_v1 = 0; + let mut rejected_by_held_quorums = 0; for (history, v0_wrongly_rejects) in histories { let state = mined.state_after(&base, history); let core_height = *history.last().expect("non empty"); @@ -211,24 +241,32 @@ mod tests { .platform .verify_chain_lock_locally_v0(0, &state, &chain_lock, v0_version) .expect("v0 runs"); + let held = platform + .platform + .verify_chain_lock_with_held_quorums(0, &state, &chain_lock) + .expect("the check against the held quorums runs"); + let core_checks_before = core_checks.load(Ordering::SeqCst); let v1 = platform .platform - .verify_chain_lock_locally_v1(0, &state, &chain_lock, v1_version) + .verify_chain_lock_locally(0, &state, &chain_lock, v1_version) .expect("v1 runs"); + let core_checked = core_checks.load(Ordering::SeqCst) - core_checks_before; let case = format!( - "history {history:?}, chain lock height {chain_lock_height}, valid {is_valid}: v0 {v0:?}, v1 {v1:?}" + "history {history:?}, chain lock height {chain_lock_height}, valid {is_valid}: v0 {v0:?}, held quorums {held:?}, v1 {v1:?}" ); match v0 { - Some(true) => assert_eq!(v1, Some(true), "{case}"), - None => assert_eq!(v1, None, "{case}"), - Some(false) => assert_ne!(v1, Some(true), "{case}"), + Some(true) => assert_eq!(held, Some(true), "{case}"), + None => assert_eq!(held, None, "{case}"), + Some(false) => assert_ne!(held, Some(true), "{case}"), } if is_valid { - assert_ne!(v1, Some(false), "{case}"); + assert_ne!(held, Some(false), "{case}"); wrongly_rejected_by_v0 += usize::from(v0 == Some(false)); } - rejected_by_v1 += usize::from(v1 == Some(false)); + assert_eq!(v1, held.or(Some(false)), "{case}"); + assert_eq!(core_checked, usize::from(held.is_none()), "{case}"); + rejected_by_held_quorums += usize::from(held == Some(false)); } } assert_eq!( @@ -239,7 +277,7 @@ mod tests { } assert!( - rejected_by_v1 > 0, + rejected_by_held_quorums > 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/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_locally/v1/mod.rs index 0d9e5664afc..96edb8a39ec 100644 --- 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 @@ -25,9 +25,11 @@ where C: CoreRPCLike, { /// 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, so Core has to decide. - /// `Some(false)` is only returned when Platform holds exactly the quorums active at the - /// signing height. + /// valid, because it might not hold the quorums Core signed it with, and Core's + /// `verifychainlock` accepted its signature; submitting it to Core then decides whether Core + /// is synced to it. `Some(false)` is returned when the signature fails against exactly the + /// quorums active at the signing height, which Platform holds, or when `verifychainlock` + /// fails it. #[inline(always)] pub(super) fn verify_chain_lock_locally_v1( &self, @@ -35,6 +37,43 @@ where platform_state: &PlatformState, chain_lock: &ChainLock, _platform_version: &PlatformVersion, + ) -> Result, Error> { + if let Some(valid) = + self.verify_chain_lock_with_held_quorums(round, platform_state, chain_lock)? + { + return Ok(Some(valid)); + } + + // `submitchainlock` returns Core's best chain lock height without checking the signature + // when that height is at or above the chain lock's, so the signature is checked with + // `verifychainlock` first. + // + // `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)? { + Ok(None) + } else { + tracing::warn!( + ?chain_lock, + "h:{} r:{} Core's verifychainlock rejected the chain lock at core height {}: its signature is invalid, or Core does not have the block at core height {} yet", + platform_state.last_committed_block_height() + 1, + round, + chain_lock.block_height, + chain_lock.block_height.saturating_sub(SIGN_OFFSET), + ); + Ok(Some(false)) + } + } + + /// 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. + pub(super) fn verify_chain_lock_with_held_quorums( + &self, + round: u32, + platform_state: &PlatformState, + chain_lock: &ChainLock, ) -> Result, Error> { let quorum_set = platform_state.chain_lock_validating_quorums(); let quorum_config = quorum_set.config(); @@ -322,7 +361,6 @@ mod tests { use dpp::dashcore::hashes::Hash; use dpp::dashcore::{BlockHash, ChainLock, QuorumHash}; use dpp::dashcore_rpc::json::QuorumType; - use dpp::version::PlatformVersion; use std::str::FromStr; fn verify( @@ -332,7 +370,7 @@ mod tests { ) -> Option { platform .platform - .verify_chain_lock_locally_v1(0, state, chain_lock, PlatformVersion::latest()) + .verify_chain_lock_with_held_quorums(0, state, chain_lock) .expect("verification runs") } 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/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs index 0a6a8ab1f6b..bde84ab8b91 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 @@ -38,7 +38,9 @@ use crate::version::drive_abci_versions::drive_abci_method_versions::{ /// `verify_chain_lock_locally` 1 leaves a chain lock to Core instead of rejecting it when a quorum /// may have been mined after the last committed core height, or when the signing height lies /// between the two stored chain lock quorum sets, so a chain lock signed by quorums Platform does -/// not hold is no longer rejected. +/// not hold is no longer rejected. Core's `verifychainlock` checks the signature of every chain +/// lock it leaves to Core, since `submitchainlock` skips that check for a height Core already has +/// a chain lock at or above. /// Everything else matches `DRIVE_ABCI_METHOD_VERSIONS_V9`. pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMethodVersions { engine: DriveAbciEngineMethodVersions { @@ -89,7 +91,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMet core_chain_lock: DriveAbciCoreChainLockMethodVersionsAndConstants { choose_quorum: 0, verify_chain_lock: 0, - verify_chain_lock_locally: 1, // changed: rejects a chain lock locally only when Platform holds the quorums active at its signing height + verify_chain_lock_locally: 1, // changed: rejects a chain lock locally only when Platform holds the quorums active at its signing height, and has Core's verifychainlock check the others verify_chain_lock_through_core: 0, make_sure_core_is_synced_to_chain_lock: 0, recent_block_count_amount: 2, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index f02580beed7..027facba12c 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -2678,11 +2678,15 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// verify or one signed past the DKG interval. `verify_chain_lock_locally` /// 1 (`DRIVE_ABCI_METHOD_VERSIONS_V10`) rejects locally only when Platform /// holds exactly the quorums active at the signing height, using Dash -/// Core's DKG mining schedule, and leaves the other chain locks to Core. -/// Version 1 answers differently from 0 only where 0 answers -/// `Some(false)`, which refuses the proposal, so every block 0 accepted -/// at this version is accepted the same way by 1. The reverse does not -/// hold: a node running 0 refuses the proposals 1 accepts this way. +/// Core's DKG mining schedule, and leaves the other chain locks to Core +/// after Core's `verifychainlock` accepts their signature: +/// `submitchainlock` skips the signature check for a height Core already +/// has a chain lock at or above. So 1 accepts valid chain locks that 0 +/// rejected, and rejects forged ones that 0 left to that early return. +/// A block committed at this version with a genuine chain lock, which an +/// honest proposer always proposes, replays the same way under 1. A block +/// whose forged chain lock 0 accepted through that early return cannot be +/// applied by 1. A node running 0 refuses the proposals 1 accepts this way. /// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by From 394c56144600748ae2c450063a5809c4f0016df8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 16 Oct 2026 11:20:54 +0700 Subject: [PATCH 4/8] test(drive-abci): forbid submitchainlock after verifychainlock rejects a forged chain lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forged-chain-lock test asserted `core_is_synced == None` but its `submit_chain_lock` expectation allowed any number of calls, so an implementation that submitted the chain lock before checking its signature, then returned the rejection, still passed. `.times(0)` makes the mock enforce verify-before-submit, like the neighbouring rejection and RPC-error tests. Checked with a mutant that calls `submit_chain_lock` before `verify_chain_lock` in `verify_chain_lock_locally_v1`: ✔ old test passes on the mutant (blind), ✖ new test fails it ("submit_chain_lock: Expectation() should not have been called"), ✔ new test passes on the real code. All 48 chain_lock lib tests pass. Addresses review suggestion on #5459. Co-Authored-By: Claude Opus 5.5 --- .../platform_events/core_chain_lock/verify_chain_lock/v0/mod.rs | 1 + 1 file changed, 1 insertion(+) 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 d8d837dff69..0c0b0b53146 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 @@ -262,6 +262,7 @@ mod tests { .returning(|_| Ok(false)); core_rpc .expect_submit_chain_lock() + .times(0) .returning(|_| Ok(CORE_BEST_CHAIN_LOCK_HEIGHT)); platform.core_rpc = core_rpc; From bd5fe9bed19514dd7c594de87ee4acbaf04ce43c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sat, 17 Oct 2026 10:42:17 +0700 Subject: [PATCH 5/8] fix(drive-abci)!: select verify_chain_lock_locally v1 at protocol 15 only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol 14 keeps verify_chain_lock_locally v0. Testnet activated protocol 14 on 5.0.0-rc.3, and v1 rejects ChainLocks v0 accepts: a forged ChainLock that v0 leaves to Core passes through submitchainlock's early return when Core already has a ChainLock at or above its height, while v1 has verifychainlock check its signature first. A protocol 14 block committed with such a ChainLock would not replay under v1, so v1 moves to the next protocol boundary instead of changing protocol 14 in place. - DRIVE_ABCI_METHOD_VERSIONS_V10 is back to the base table (verify_chain_lock_locally 0, as on v5.0-dev), and v14.rs loses the entry this branch added. - DRIVE_ABCI_METHOD_VERSIONS_V11 is V10 with verify_chain_lock_locally 1, in struct update form, used only by PLATFORM_V15; v15.rs lists the change. #4648 and #4652 also add a V11 for protocol 15; whichever lands second adds its slot to the existing table. - The dispatcher gate test keeps protocols 13 and 14 on v0's rejection of a ChainLock signed by a newly mined quorum and moves the deferral to 15. - A new dispatcher test keeps protocols 13 and 14 leaving a forged ChainLock past the DKG interval to Core without a verifychainlock call, and rejects it at 15 once verifychainlock fails. - A new verify_chain_lock test keeps protocol 14 accepting that ChainLock through submitchainlock's early return, which replaying protocol 14 blocks needs. - The v0-against-v1 differential test also checks that protocol 14 gives v0's verdict on every history without asking Core; v0's own test runs at protocol 14. Test would have caught a wrong selection: with V10's slot at 1 (protocol 14 on v1) 4 chain lock tests fail, with V11's slot at 0 (protocol 15 on v0) 6 fail (✖); with the tables as committed all 49 pass (✔). Co-Authored-By: Claude Opus 5.5 --- .../verify_chain_lock/v0/mod.rs | 38 ++++++++++ .../verify_chain_lock_locally/mod.rs | 76 +++++++++++++++++-- .../verify_chain_lock_locally/v0/mod.rs | 2 +- .../drive_abci_method_versions/mod.rs | 1 + .../drive_abci_method_versions/v10.rs | 8 +- .../drive_abci_method_versions/v11.rs | 18 +++++ .../rs-platform-version/src/version/v14.rs | 21 ----- .../rs-platform-version/src/version/v15.rs | 25 +++++- 8 files changed, 153 insertions(+), 36 deletions(-) create mode 100644 packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v11.rs 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 0c0b0b53146..1d4445237f5 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 @@ -287,6 +287,44 @@ mod tests { } } + #[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 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 afa9aa6117b..3e3ba90c7f8 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 @@ -87,10 +87,10 @@ mod tests { use std::sync::Arc; #[test] - fn should_stop_rejecting_chain_lock_signed_by_newly_mined_quorum_from_protocol_version_14() { + 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 13 - // keep rejecting such a chain lock locally; 14 and later leave it to Core, which + // 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, which // verifies its signature. let mut platform = chain_lock_test_platform(); let scenario = QuorumMinedAfterCoreHeight::default(); @@ -120,11 +120,71 @@ mod tests { ); 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); } + #[test] + fn should_check_signature_of_chain_lock_left_to_core_from_protocol_version_15() { + // The signing height 1128 of this forged chain lock is past the DKG interval of + // Platform's core height 1106. Protocol versions up to 14 leave it to Core 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, and + // its failure rejects the chain lock. + 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); + platform.core_rpc = core_rpc; + + for protocol_version in [13, 14] { + assert_eq!( + platform + .platform + .verify_chain_lock_locally( + 0, + &state, + &forged, + PlatformVersion::get(protocol_version).expect("protocol version exists"), + ) + .expect("verification runs"), + None, + "protocol version {protocol_version} must keep its verdict" + ); + } + + 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)); + platform.core_rpc = core_rpc; + + assert_eq!( + platform + .platform + .verify_chain_lock_locally( + 0, + &state, + &forged, + PlatformVersion::get(15).expect("protocol version 15 exists"), + ) + .expect("verification runs"), + Some(false) + ); + } + /// 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)>); @@ -181,7 +241,8 @@ mod tests { // 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 that check. Version 1 then has Core's verifychainlock - // check exactly the chain locks that check leaves to Core. + // check exactly the chain locks that check leaves to Core. Protocol version 14 must give + // version 0's verdicts without asking Core. let mut platform = chain_lock_test_platform(); // This Core fails every chain lock it is asked about, so version 1's answer and the // number of calls show which chain locks it asked about. @@ -196,7 +257,7 @@ mod tests { let base = platform.platform.state.load(); let mined = MinedQuorums::new(); let unrelated = TestQuorum::new(99, 0); - let v0_version = PlatformVersion::get(13).expect("protocol version 13 exists"); + 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 @@ -246,6 +307,10 @@ mod tests { .verify_chain_lock_with_held_quorums(0, &state, &chain_lock) .expect("the check against the held quorums runs"); let core_checks_before = core_checks.load(Ordering::SeqCst); + 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) @@ -255,6 +320,7 @@ mod tests { "history {history:?}, chain lock height {chain_lock_height}, valid {is_valid}: v0 {v0:?}, held quorums {held:?}, v1 {v1:?}" ); + assert_eq!(selected_at_v0_version, v0, "{case}"); match v0 { Some(true) => assert_eq!(held, Some(true), "{case}"), None => assert_eq!(held, None, "{case}"), 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 919dd11bd4e..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::get(13).expect("protocol version 13 exists"); + 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-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..e1281a6a0fc 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; 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 bde84ab8b91..76f2e022cc1 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 @@ -35,12 +35,6 @@ use crate::version::drive_abci_versions::drive_abci_method_versions::{ /// recipient and disable obsolete authority; split, empty or unsupported lists disable all /// TRANSFER keys while preserving OWNER and balance. Historical updater behavior and legacy /// payout-address rotation remain unchanged. -/// `verify_chain_lock_locally` 1 leaves a chain lock to Core instead of rejecting it when a quorum -/// may have been mined after the last committed core height, or when the signing height lies -/// between the two stored chain lock quorum sets, so a chain lock signed by quorums Platform does -/// not hold is no longer rejected. Core's `verifychainlock` checks the signature of every chain -/// lock it leaves to Core, since `submitchainlock` skips that check for a height Core already has -/// a chain lock at or above. /// Everything else matches `DRIVE_ABCI_METHOD_VERSIONS_V9`. pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMethodVersions { engine: DriveAbciEngineMethodVersions { @@ -91,7 +85,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMet core_chain_lock: DriveAbciCoreChainLockMethodVersionsAndConstants { choose_quorum: 0, verify_chain_lock: 0, - verify_chain_lock_locally: 1, // changed: rejects a chain lock locally only when Platform holds the quorums active at its signing height, and has Core's verifychainlock check the others + verify_chain_lock_locally: 0, verify_chain_lock_through_core: 0, make_sure_core_is_synced_to_chain_lock: 0, recent_block_count_amount: 2, 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..00512d9e928 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v11.rs @@ -0,0 +1,18 @@ +use super::v10::DRIVE_ABCI_METHOD_VERSIONS_V10; +use super::{DriveAbciCoreChainLockMethodVersionsAndConstants, DriveAbciMethodVersions}; + +/// Drive ABCI method versions 11, introduced in protocol v15. Differs from v10 in one slot. +/// `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. 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, and Core's `verifychainlock` checks the signature of every chain lock left to Core, +/// since `submitchainlock` skips that check for a height Core already has a chain lock at or +/// above. +pub const DRIVE_ABCI_METHOD_VERSIONS_V11: DriveAbciMethodVersions = DriveAbciMethodVersions { + core_chain_lock: DriveAbciCoreChainLockMethodVersionsAndConstants { + verify_chain_lock_locally: 1, + ..DRIVE_ABCI_METHOD_VERSIONS_V10.core_chain_lock + }, + ..DRIVE_ABCI_METHOD_VERSIONS_V10 +}; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 027facba12c..073f2b6647a 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -2667,27 +2667,6 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// typed array and a `where`, and every contract that registers today /// binds no typed array. /// -/// 96. **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_V10`) rejects locally only when Platform -/// holds exactly the quorums active at the signing height, using Dash -/// Core's DKG mining schedule, and leaves the other chain locks to Core -/// after Core's `verifychainlock` accepts their signature: -/// `submitchainlock` skips the signature check for a height Core already -/// has a chain lock at or above. So 1 accepts valid chain locks that 0 -/// rejected, and rejects forged ones that 0 left to that early return. -/// A block committed at this version with a genuine chain lock, which an -/// honest proposer always proposes, replays the same way under 1. A block -/// whose forged chain lock 0 accepted through that early return cannot be -/// applied by 1. A node running 0 refuses the proposals 1 accepts this way. -/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 151896fed16..b4e71e2fb1b 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,33 @@ 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, and leaves the other chain locks to Core after +/// Core's `verifychainlock` accepts their signature: `submitchainlock` skips +/// the signature check for a height Core already has a chain lock at or +/// above. So 1 accepts valid chain locks that 0 rejected, and rejects forged +/// ones that 0 left to that early return. Protocol v14 and earlier keep +/// `verify_chain_lock_locally` 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 has Core's verifychainlock check the others validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, query: DRIVE_ABCI_QUERY_VERSIONS_V2, From 71733fd528002b4fb283f543ccd03428072d243e Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sun, 18 Oct 2026 12:02:04 +0700 Subject: [PATCH 6/8] fix(drive-abci): check deferred chain lock signatures in verify_chain_lock_through_core v1 (PV15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two review suggestions on the protocol 15 chain lock verification. Core's verifychainlock check of a chain lock Platform leaves to Core moves from verify_chain_lock_locally v1 into a new verify_chain_lock_through_core v1, selected by DRIVE_ABCI_METHOD_VERSIONS_V11 (protocol 15 only): - verify_chain_lock_locally v1 is now only the check against the quorums Platform holds and never calls Core. - verify_chain_lock_through_core v1 asks verifychainlock first. A failure is returned as Core's verdict, so the block proposal reports it as invalid based on a core request instead of on platform state, and the chain lock is not submitted. An accepted one is submitted through make_sure_core_is_synced_to_chain_lock for the sync status, as before. - Accept/reject verdicts and the RPC sequence of the block proposal route are unchanged; the non-submitting route asks verifychainlock once instead of twice. Dash Core's DKG mining schedules, which decide whether a failed check rejects a chain lock or leaves it to Core, move from a match in verify_chain_lock_locally v1 into the version tables: DriveAbciCoreChainLockMethodVersionsAndConstants.dkg_mining_schedules, Some(schedules) in V11 and None in V1 to V10 and the mock table, which select verify_chain_lock_locally 0 and never read it. v1 reads it through platform_version, fails with CorruptedCodeExecution on None, and uses checked_rem so a zero interval cannot panic. Protocol 14 and earlier keep verify_chain_lock_locally 0 and verify_chain_lock_through_core 0; the only change to their tables is the unread None field. Tests: - verify_chain_lock: a forged deferred chain lock Core fails is rejected with found_valid_by_core Some(false), not found_valid_locally Some(false) (✖ before, ✔ after); the non-submitting route asks verifychainlock once (✖ before: twice, ✔ after); over every protocol version, a forged chain lock at a height v1 newly defers is never accepted, which also guards pairing verify_chain_lock_locally 1 with verify_chain_lock_through_core 1. - verify_chain_lock_through_core dispatcher: protocols 13 and 14 submit without verifychainlock; 15 and latest do not submit after it fails (✖ before, ✔ after). - verify_chain_lock_locally: the local check asks Core nothing at any protocol version; the v0/v1 differential test also runs the outer verification on both routes against a Core that fails every signature. - v1 reads the schedules from platform_version (an empty table leaves a chain lock to Core that the latest table rejects, None errors). This one does not compile against the previous code, which has no such field, so it has no behavioural red. Co-Authored-By: Claude Opus 5.5 --- .../verify_chain_lock/v0/mod.rs | 90 ++++++++- .../verify_chain_lock_locally/mod.rs | 182 ++++++++--------- .../verify_chain_lock_locally/v1/mod.rs | 189 +++++++++--------- .../verify_chain_lock_through_core/mod.rs | 100 ++++++++- .../verify_chain_lock_through_core/v1/mod.rs | 45 +++++ .../drive_abci_method_versions/mod.rs | 20 ++ .../drive_abci_method_versions/v1.rs | 1 + .../drive_abci_method_versions/v10.rs | 1 + .../drive_abci_method_versions/v11.rs | 109 +++++++++- .../drive_abci_method_versions/v2.rs | 1 + .../drive_abci_method_versions/v3.rs | 1 + .../drive_abci_method_versions/v4.rs | 1 + .../drive_abci_method_versions/v5.rs | 1 + .../drive_abci_method_versions/v6.rs | 1 + .../drive_abci_method_versions/v7.rs | 1 + .../drive_abci_method_versions/v8.rs | 1 + .../drive_abci_method_versions/v9.rs | 1 + .../src/version/mocks/v3_test.rs | 1 + .../rs-platform-version/src/version/v15.rs | 18 +- 19 files changed, 550 insertions(+), 214 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_through_core/v1/mod.rs 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 1d4445237f5..47f71431533 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 @@ -174,7 +174,7 @@ mod tests { use dpp::dashcore::hashes::Hash; use dpp::dashcore::{BlockHash, ChainLock}; use dpp::dashcore_rpc::Error as DashCoreRpcError; - use dpp::version::PlatformVersion; + use dpp::version::{PlatformVersion, PLATFORM_VERSIONS}; #[test] fn should_accept_through_core_chain_lock_signed_by_quorum_mined_after_last_committed_core_height( @@ -245,6 +245,8 @@ mod tests { // 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(); @@ -273,13 +275,16 @@ mod tests { assert!(result.chain_lock_signature_is_deserializable); assert_eq!( - result.found_valid_locally, + result.found_valid_by_core, Some(false), - "a forged chain lock at {chain_lock_height} must be rejected, not accepted \ - through Core's early return (core is synced: {:?})", + "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_by_core, None); + 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" @@ -287,6 +292,81 @@ mod tests { } } + /// Whether the block proposal accepts a chain lock with this verification result + 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_v0(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_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( ) { 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 3e3ba90c7f8..5d56312e0de 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 @@ -16,9 +16,8 @@ impl Platform where C: CoreRPCLike, { - /// Verify the chain lock against the chain lock quorums held in the platform state. From - /// version 1, a chain lock Platform cannot verify that way has its signature checked by - /// Core's `verifychainlock`. + /// Verify the chain lock against the chain lock quorums held in the platform state, without + /// Core. /// /// # Parameters /// @@ -30,15 +29,14 @@ where /// # 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 returned when - /// Platform holds exactly the quorums active at the signing height, or when Core's - /// `verifychainlock` fails the signature; 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. From version 1, - /// Core's `verifychainlock` has already accepted the signature + /// * `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::CoreRpc)` - From version 1, the `verifychainlock` call failed - /// * `Err(Error::Execution)` - An unknown method version, or no quorums to verify against + /// * `Err(Error::Execution)` - An unknown method version, no quorums to verify against, or + /// from version 1 a platform version without DKG mining schedules pub fn verify_chain_lock_locally( &self, round: u32, @@ -90,20 +88,17 @@ mod tests { 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, which - // verifies its signature. + // 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(); - let asked = chain_lock.clone(); - core_rpc - .expect_verify_chain_lock() - .withf(move |chain_lock| *chain_lock == asked) - .times(2) - .returning(|_| Ok(true)); + 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| { @@ -130,61 +125,6 @@ mod tests { assert_eq!(verify(PlatformVersion::latest()), None); } - #[test] - fn should_check_signature_of_chain_lock_left_to_core_from_protocol_version_15() { - // The signing height 1128 of this forged chain lock is past the DKG interval of - // Platform's core height 1106. Protocol versions up to 14 leave it to Core 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, and - // its failure rejects the chain lock. - 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); - platform.core_rpc = core_rpc; - - for protocol_version in [13, 14] { - assert_eq!( - platform - .platform - .verify_chain_lock_locally( - 0, - &state, - &forged, - PlatformVersion::get(protocol_version).expect("protocol version exists"), - ) - .expect("verification runs"), - None, - "protocol version {protocol_version} must keep its verdict" - ); - } - - 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)); - platform.core_rpc = core_rpc; - - assert_eq!( - platform - .platform - .verify_chain_lock_locally( - 0, - &state, - &forged, - PlatformVersion::get(15).expect("protocol version 15 exists"), - ) - .expect("verification runs"), - Some(false) - ); - } - /// 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)>); @@ -237,23 +177,36 @@ mod tests { #[test] fn should_only_turn_wrong_local_rejections_into_core_checks() { - // Version 1's check against the quorums Platform holds 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 that check. Version 1 then has Core's verifychainlock - // check exactly the chain locks that check leaves to Core. Protocol version 14 must give - // version 0's verdicts without asking Core. + // 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. let mut platform = chain_lock_test_platform(); - // This Core fails every chain lock it is asked about, so version 1's answer and the - // number of calls show which chain locks it asked about. - let core_checks = Arc::new(AtomicUsize::new(0)); - let counter = Arc::clone(&core_checks); + // 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 |_| { - counter.fetch_add(1, Ordering::SeqCst); + 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); @@ -288,7 +241,7 @@ mod tests { (&[1110, 1116], false), ]; - let mut rejected_by_held_quorums = 0; + 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"); @@ -298,15 +251,11 @@ mod tests { 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 held = platform - .platform - .verify_chain_lock_with_held_quorums(0, &state, &chain_lock) - .expect("the check against the held quorums runs"); - let core_checks_before = core_checks.load(Ordering::SeqCst); let selected_at_v0_version = platform .platform .verify_chain_lock_locally(0, &state, &chain_lock, v0_version) @@ -315,24 +264,55 @@ mod tests { .platform .verify_chain_lock_locally(0, &state, &chain_lock, v1_version) .expect("v1 runs"); - let core_checked = core_checks.load(Ordering::SeqCst) - core_checks_before; let case = format!( - "history {history:?}, chain lock height {chain_lock_height}, valid {is_valid}: v0 {v0:?}, held quorums {held:?}, v1 {v1:?}" + "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!(held, Some(true), "{case}"), - None => assert_eq!(held, None, "{case}"), - Some(false) => assert_ne!(held, Some(true), "{case}"), + 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!(held, Some(false), "{case}"); + assert_ne!(v1, Some(false), "{case}"); wrongly_rejected_by_v0 += usize::from(v0 == Some(false)); } - assert_eq!(v1, held.or(Some(false)), "{case}"); - assert_eq!(core_checked, usize::from(held.is_none()), "{case}"); - rejected_by_held_quorums += usize::from(held == 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}" + ); + } } } assert_eq!( @@ -343,7 +323,7 @@ mod tests { } assert!( - rejected_by_held_quorums > 0, + 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/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/core_chain_lock/verify_chain_lock_locally/v1/mod.rs index 96edb8a39ec..bc30352f09f 100644 --- 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 @@ -14,6 +14,7 @@ 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"; @@ -24,56 +25,22 @@ impl Platform where C: CoreRPCLike, { - /// 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, and Core's - /// `verifychainlock` accepted its signature; submitting it to Core then decides whether Core - /// is synced to it. `Some(false)` is returned when the signature fails against exactly the - /// quorums active at the signing height, which Platform holds, or when `verifychainlock` - /// fails it. - #[inline(always)] - pub(super) fn verify_chain_lock_locally_v1( - &self, - round: u32, - platform_state: &PlatformState, - chain_lock: &ChainLock, - _platform_version: &PlatformVersion, - ) -> Result, Error> { - if let Some(valid) = - self.verify_chain_lock_with_held_quorums(round, platform_state, chain_lock)? - { - return Ok(Some(valid)); - } - - // `submitchainlock` returns Core's best chain lock height without checking the signature - // when that height is at or above the chain lock's, so the signature is checked with - // `verifychainlock` first. - // - // `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)? { - Ok(None) - } else { - tracing::warn!( - ?chain_lock, - "h:{} r:{} Core's verifychainlock rejected the chain lock at core height {}: its signature is invalid, or Core does not have the block at core height {} yet", - platform_state.last_committed_block_height() + 1, - round, - chain_lock.block_height, - chain_lock.block_height.saturating_sub(SIGN_OFFSET), - ); - Ok(Some(false)) - } - } - /// 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. - pub(super) fn verify_chain_lock_with_held_quorums( + /// + /// 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(); @@ -126,10 +93,19 @@ where // 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() @@ -284,56 +260,32 @@ where } } -/// 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. -struct DkgMiningSchedule { - interval: u32, - mining_window_start: u32, - mining_window_end: u32, -} - -/// Dash Core's DKG schedule (`llmq/params.h`) of the non-rotating quorum types, the ones chain -/// locks are signed with. `None` for rotating and unknown types. -fn dkg_mining_schedule(quorum_type: QuorumType) -> Option { - let (interval, mining_window_start, mining_window_end) = match quorum_type { - QuorumType::Llmq50_60 - | QuorumType::Llmq100_67 - | QuorumType::Llmq25_67 - | QuorumType::LlmqTest - | QuorumType::LlmqDevnet - | QuorumType::LlmqTestV17 - | QuorumType::LlmqTestInstantsend - | QuorumType::LlmqTestPlatform - | QuorumType::LlmqDevnetPlatform => (24, 10, 18), - QuorumType::Llmq400_60 => (288, 20, 28), - QuorumType::Llmq400_85 => (576, 20, 48), - _ => return None, - }; - Some(DkgMiningSchedule { - interval, - mining_window_start, - mining_window_end, - }) -} - /// The first core height after `last_committed_core_height` at which a quorum commitment of -/// `quorum_type` can be mined. 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. +/// `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, -) -> u32 { +) -> Result { let next_height = last_committed_core_height.saturating_add(1); - let Some(schedule) = dkg_mining_schedule(quorum_type) else { + 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 next_height; + return Ok(next_height); }; - let offset_in_interval = last_committed_core_height % schedule.interval; + 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; - if offset_in_interval < schedule.mining_window_start { + 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 @@ -341,13 +293,15 @@ fn first_possible_quorum_change_after( 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; @@ -361,6 +315,7 @@ mod tests { use dpp::dashcore::hashes::Hash; use dpp::dashcore::{BlockHash, ChainLock, QuorumHash}; use dpp::dashcore_rpc::json::QuorumType; + use dpp::version::PlatformVersion; use std::str::FromStr; fn verify( @@ -370,7 +325,7 @@ mod tests { ) -> Option { platform .platform - .verify_chain_lock_with_held_quorums(0, state, chain_lock) + .verify_chain_lock_locally_v1(0, state, chain_lock, PlatformVersion::latest()) .expect("verification runs") } @@ -651,10 +606,60 @@ mod tests { 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(_))) + )); + } + #[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`). + // 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), @@ -670,8 +675,10 @@ mod tests { ]; for (quorum_type, interval, start, end) in schedules { let interval_start = interval * 8873; - let first_change = - |core_height| first_possible_quorum_change_after(quorum_type, core_height); + 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, @@ -706,7 +713,11 @@ mod tests { QuorumType::LlmqDevnetDip0024, QuorumType::LlmqSingleNode, ] { - assert_eq!(first_possible_quorum_change_after(quorum_type, 1110), 1111); + assert_eq!( + first_possible_quorum_change_after(dkg_mining_schedules, quorum_type, 1110) + .expect("no schedule to read"), + 1111 + ); } } 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..5fa63351776 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,26 @@ 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 pub fn verify_chain_lock_through_core( &self, chain_lock: &ChainLock, @@ -29,11 +49,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-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 e1281a6a0fc..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 @@ -104,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 index 00512d9e928..c232a2170be 100644 --- 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 @@ -1,17 +1,108 @@ use super::v10::DRIVE_ABCI_METHOD_VERSIONS_V10; -use super::{DriveAbciCoreChainLockMethodVersionsAndConstants, DriveAbciMethodVersions}; +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 one slot. -/// `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. 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, and Core's `verifychainlock` checks the signature of every chain lock left to Core, -/// since `submitchainlock` skips that check for a height Core already has a chain lock at or -/// above. +/// 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 b4e71e2fb1b..fa2f2f5a80a 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -49,19 +49,21 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// 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, and leaves the other chain locks to Core after -/// Core's `verifychainlock` accepts their signature: `submitchainlock` skips -/// the signature check for a height Core already has a chain lock at or -/// above. So 1 accepts valid chain locks that 0 rejected, and rejects forged -/// ones that 0 left to that early return. Protocol v14 and earlier keep -/// `verify_chain_lock_locally` 0, so the blocks committed under them replay -/// with the same verdicts. +/// 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 that 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_V11, // changed: verify_chain_lock_locally 1 rejects a chain lock locally only when Platform holds the quorums active at its signing height, and has Core's verifychainlock check the others + 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, From 957da3ea85495286bdc137c61f222f0a8e0cd2f1 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sun, 18 Oct 2026 12:20:00 +0700 Subject: [PATCH 7/8] test(drive-abci): pin chain lock verification across protocol versions and Core sync answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups to moving the Core signature check of deferred chain locks into verify_chain_lock_through_core v1: - The forged-chain-lock guard over every protocol version now goes through the verify_chain_lock dispatcher. Mutation check: with V11's verify_chain_lock_through_core set back to 0 it fails ("protocol version 15 accepted a forged chain lock"), 7 tests fail in total (✖); with the table as committed all pass (✔). - A chain lock left to Core that verifychainlock accepts while Core's best chain lock is behind it is not accepted (core_is_synced Some(false)). - The v0/v1 differential test also runs verify_chain_lock at protocol 14 on both routes for every history and chain lock, pinning its replay verdicts: no verifychainlock when submitting, and submitchainlock for every chain lock v0 does not reject. - A DKG mining schedule with an interval of 0 errors instead of dividing by it, and every platform version's schedules are unique per quorum type with the mining window inside the interval. - Doc fixes: the error sources of verify_chain_lock_through_core and verify_chain_lock_locally, and v15.rs naming submitchainlock's early return. No behaviour change; tests and comments only. Co-Authored-By: Claude Opus 5.5 --- .../verify_chain_lock/v0/mod.rs | 39 ++++++++++++- .../verify_chain_lock_locally/mod.rs | 37 +++++++++++- .../verify_chain_lock_locally/v1/mod.rs | 56 ++++++++++++++++++- .../verify_chain_lock_through_core/mod.rs | 3 +- .../rs-platform-version/src/version/v15.rs | 3 +- 5 files changed, 131 insertions(+), 7 deletions(-) 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 47f71431533..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 @@ -292,7 +292,8 @@ mod tests { } } - /// Whether the block proposal accepts a chain lock with this verification result + /// 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) @@ -324,7 +325,7 @@ mod tests { for platform_version in PLATFORM_VERSIONS { let result = platform .platform - .verify_chain_lock_v0(0, &state, &forged, true, platform_version) + .verify_chain_lock(0, &state, &forged, true, platform_version) .expect("verification runs"); assert!( @@ -335,6 +336,40 @@ mod tests { } } + #[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 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 5d56312e0de..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 @@ -36,7 +36,8 @@ where /// 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 + /// 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, @@ -182,7 +183,9 @@ mod tests { // 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. + // 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 @@ -312,6 +315,36 @@ mod tests { (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}" + ); } } } 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 index bc30352f09f..048a3d3af4f 100644 --- 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 @@ -315,7 +315,8 @@ mod tests { use dpp::dashcore::hashes::Hash; use dpp::dashcore::{BlockHash, ChainLock, QuorumHash}; use dpp::dashcore_rpc::json::QuorumType; - use dpp::version::PlatformVersion; + use dpp::version::drive_abci_versions::drive_abci_method_versions::DkgMiningSchedule; + use dpp::version::{PlatformVersion, PLATFORM_VERSIONS}; use std::str::FromStr; fn verify( @@ -646,6 +647,59 @@ mod tests { 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] 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 5fa63351776..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 @@ -35,7 +35,8 @@ where /// `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 + /// * `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, diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index fa2f2f5a80a..bc087826e30 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -55,7 +55,8 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// 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 that early return. Protocol v14 and earlier +/// 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 { From 2cd9d44801240c743a2e86dc1988a87228024eaa Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sun, 18 Oct 2026 12:54:35 +0700 Subject: [PATCH 8/8] refactor(drive-abci): reverse the quorum hash in place in verify_chain_lock_locally v1 Co-Authored-By: Claude Opus 5.5 --- .../core_chain_lock/verify_chain_lock_locally/v1/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 048a3d3af4f..056fb6afd30 100644 --- 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 @@ -148,7 +148,7 @@ where let mut engine = sha256d::Hash::engine(); - let mut reversed_quorum_hash = quorum_hash.to_byte_array().to_vec(); + let mut reversed_quorum_hash = quorum_hash.to_byte_array(); reversed_quorum_hash.reverse(); engine.input(&[quorum_config.quorum_type as u8]); @@ -194,7 +194,7 @@ where let mut engine = sha256d::Hash::engine(); - let mut reversed_quorum_hash = quorum_hash.to_byte_array().to_vec(); + let mut reversed_quorum_hash = quorum_hash.to_byte_array(); reversed_quorum_hash.reverse(); engine.input(&[quorum_config.quorum_type as u8]);