From c8b470bb081859bad1fef6bc97ded75b0df0a02a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:27:57 +0200 Subject: [PATCH 1/5] fix(sdk): verify quorum signature on broadcast wait-path before trusting metadata The broadcast `wait_for_response` closure verified state-transition execution with `Drive::verify_state_transition_was_executed_with_proof`, which performs ONLY the GroveDB structural Merkle check and discards the returned root hash. It never ran `verify_tenderdash_proof`, so the response `metadata` (including `protocol_version`) was not consensus-authenticated. A malicious or MITM DAPI node could return a structurally-valid proof with forged metadata. Route this path through the existing `FromProof` impl, whose `maybe_from_proof_with_metadata` runs the same structural check AND the quorum BLS signature gate (`verify_tenderdash_proof`). The protocol-version ratchet (`verify_response_metadata`) now runs only after that verification succeeds, so it consumes authenticated metadata. Mock mode, the `StateTransitionProofResult` conversion, the `state_transition_broadcast_error` early-return, and the execution-result/retry semantics are unchanged. The SDK `parse_proof_with_metadata_and_proof` helper does not fit here: it is bounded `O: MockResponse` (and its mock branch needs `Option: MockResponse`), which `StateTransitionProofResult` does not implement. Calling the `FromProof` impl directly verifies the signature without that bound, and the wait-path already obtains its response through the mock-aware DAPI transport. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/platform/transition/broadcast.rs | 93 ++++++++----------- packages/rs-sdk/src/sdk.rs | 12 +-- 2 files changed, 46 insertions(+), 59 deletions(-) diff --git a/packages/rs-sdk/src/platform/transition/broadcast.rs b/packages/rs-sdk/src/platform/transition/broadcast.rs index 21b784b3b3c..528dfe4d24d 100644 --- a/packages/rs-sdk/src/platform/transition/broadcast.rs +++ b/packages/rs-sdk/src/platform/transition/broadcast.rs @@ -1,21 +1,19 @@ use super::broadcast_request::BroadcastRequestForStateTransition; use super::put_settings::PutSettings; use crate::error::StateTransitionBroadcastError; -use crate::platform::block_info_from_metadata::block_info_from_metadata; use crate::sync::retry; use crate::{Error, Sdk}; use dapi_grpc::platform::v0::wait_for_state_transition_result_response::wait_for_state_transition_result_response_v0; use dapi_grpc::platform::v0::{ - wait_for_state_transition_result_response, Proof, WaitForStateTransitionResultResponse, + wait_for_state_transition_result_response, BroadcastStateTransitionRequest, + WaitForStateTransitionResultResponse, }; -use dapi_grpc::platform::VersionedGrpcResponse; use dash_context_provider::ContextProviderError; use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::state_transition::StateTransition; -use drive::drive::Drive; -use drive_proof_verifier::DataContractProvider; +use drive_proof_verifier::FromProof; +use rs_dapi_client::WrapToExecutionResult; use rs_dapi_client::{DapiRequest, ExecutionError, InnerInto, IntoInner, RequestSettings}; -use rs_dapi_client::{ExecutionResponse, WrapToExecutionResult}; use tracing::{trace, warn}; #[async_trait::async_trait] @@ -150,26 +148,6 @@ impl BroadcastStateTransition for StateTransition { .wrap_to_execution_result(&response); } - trace!("wait: extracting metadata"); - let metadata = grpc_response - .metadata() - .wrap_to_execution_result(&response)? - .inner; - let block_info = block_info_from_metadata(metadata) - .wrap_to_execution_result(&response)? - .inner; - trace!(block_info = ?block_info, "wait: block info extracted"); - - trace!("wait: extracting proof"); - let proof: &Proof = (*grpc_response) - .proof() - .wrap_to_execution_result(&response)? - .inner; - trace!( - proof_size = proof.grovedb_proof.len(), - "wait: proof extracted" - ); - let context_provider = sdk.context_provider().ok_or(ExecutionError { inner: Error::from(ContextProviderError::Config( "Context provider not initialized".to_string(), @@ -178,36 +156,45 @@ impl BroadcastStateTransition for StateTransition { retries: response.retries, })?; - trace!("wait: verifying proof"); - let (_, result) = match Drive::verify_state_transition_was_executed_with_proof( - self, - &block_info, - proof.grovedb_proof.as_slice(), - &context_provider.as_contract_lookup_fn(sdk.version()), + // Verify through the `FromProof` impl: it runs the GroveDB structural check AND + // `verify_tenderdash_proof` (the quorum BLS signature gate) that authenticates + // `metadata`. The request must be reconstructed to feed that verifier. + let request: BroadcastStateTransitionRequest = self + .broadcast_request_for_state_transition() + .wrap_to_execution_result(&response)? + .inner; + + trace!("wait: verifying proof and quorum signature"); + let (maybe_result, metadata, _proof) = >::maybe_from_proof_with_metadata( + request, + grpc_response.clone(), + sdk.network, sdk.version(), - ) { - Ok(r) => Ok(ExecutionResponse { - inner: r, - retries: response.retries, - address: response.address.clone(), - }), - Err(drive::error::Error::Proof(proof_error)) => Err(ExecutionError { - inner: Error::DriveProofError( - proof_error, - proof.grovedb_proof.clone(), - block_info, - ), - retries: response.retries, - address: Some(response.address.clone()), - }), - Err(e) => Err(ExecutionError { - inner: e.into(), - retries: response.retries, - address: Some(response.address.clone()), - }), - }? + &context_provider, + ) + .map_err(Error::from) + .wrap_to_execution_result(&response)? .inner; + let result: StateTransitionProofResult = maybe_result + .ok_or_else(|| { + Error::InvalidProvedResponse( + "state transition result missing from verified proof".to_string(), + ) + }) + .wrap_to_execution_result(&response)? + .inner; + + // `metadata` is quorum-authenticated only after the verification above, so the + // protocol-version ratchet must run here, never before. A `StaleNode` error is + // retryable and prompts another server. + let _: () = sdk + .verify_response_metadata("wait_for_state_transition_result", &metadata) + .wrap_to_execution_result(&response)? + .inner; + trace!("wait: proof verification successful"); trace!(result_variant = %result.to_string(), "wait: result variant"); diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 05fdfe35d8a..1c19510dd56 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -1743,12 +1743,12 @@ mod test { /// /// The full tampered-*signed*-proof path isn't unit-testable here: it needs a /// quorum BLS signature, a context provider, and a `FromProof` verifier round-trip. - /// That path's safety rests on `parse_proof_with_metadata_and_proof` running proof - /// verification (the `?`) BEFORE `verify_response_metadata` → `maybe_update_protocol_version` - /// (see the guard comment at that call site). Here we lock in the ratchet's own gates: - /// it must NOT raise the stored version off untrustworthy inputs (unknown / zero / lower), - /// so even a metadata value that slipped past verification can't move the SDK to a bogus - /// protocol version. + /// Both ratchet sites run the `FromProof` verifier (structural + `verify_tenderdash_proof`) + /// BEFORE `verify_response_metadata` → `maybe_update_protocol_version`: the query path via + /// `parse_proof_with_metadata_and_proof`, the broadcast wait-path in `broadcast.rs` (see the + /// guard comments at both call sites). Here we lock in the ratchet's own gates: it must NOT + /// raise the stored version off untrustworthy inputs (unknown / zero / lower), so even a + /// metadata value that slipped past verification can't move the SDK to a bogus version. #[test] fn test_ratchet_rejects_unknown_and_non_upward_versions() { let sdk = SdkBuilder::new_mock() From eaed33202fd9f0055053bd720f68dfea4839bd09 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:28:11 +0200 Subject: [PATCH 2/5] docs(sdk): correct protocol-version bootstrapping direction on parse_proof_with_metadata_and_proof The `## Protocol version bootstrapping` rustdoc described the pre-floor-seeding risk (older network). The SDK now seeds at the floor (`DEFAULT_INITIAL_PROTOCOL_VERSION`), so the real first-request risk is the newer-network direction: a network newer than the floor whose proof interpretation differs from the floor may fail before the ratchet lifts the SDK. Reworded accordingly; the `with_version()` pinning guidance is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-sdk/src/sdk.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 1c19510dd56..9042f327ccd 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -383,10 +383,12 @@ impl Sdk { /// no network response has been received yet to teach the SDK the real network version. /// /// The actual network version is learned only *after* proof parsing succeeds, when - /// [`Self::verify_response_metadata()`] processes `metadata.protocol_version`. If the - /// connected network runs an older protocol version **and** proof interpretation differs - /// between that version and `latest()`, the very first request may fail before the SDK can - /// correct itself. Subsequent requests will use the correct version. + /// [`Self::verify_response_metadata()`] processes `metadata.protocol_version`. Because the + /// SDK seeds at the floor ([`DEFAULT_INITIAL_PROTOCOL_VERSION`]), the bootstrap risk is the + /// **newer**-network direction: if the connected network runs a version newer than the floor + /// **and** proof interpretation differs between the floor and that newer version, the very + /// first request may fail before the ratchet lifts the SDK to the network version. + /// Subsequent requests use the ratcheted version. /// /// This is a known bootstrap limitation. Callers that must guarantee correct version /// behaviour on the first request should pin the version explicitly via From 09a15338b566931a9f42b6c85c6be94d26a33c9e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:20:24 +0200 Subject: [PATCH 3/5] docs(sdk): note broadcast wait-path Some-guard is for a future FromProof change The `maybe_result.ok_or_else(...)` branch on the broadcast wait-path is currently unreachable: `FromProof::maybe_from_proof_with_metadata` always returns `Ok((Some(result), ..))`. Keep the defensive typed error (stay panic-free) and add a one-line comment stating it guards only a future impl change. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-sdk/src/platform/transition/broadcast.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/rs-sdk/src/platform/transition/broadcast.rs b/packages/rs-sdk/src/platform/transition/broadcast.rs index 528dfe4d24d..e7217e4deb5 100644 --- a/packages/rs-sdk/src/platform/transition/broadcast.rs +++ b/packages/rs-sdk/src/platform/transition/broadcast.rs @@ -178,6 +178,8 @@ impl BroadcastStateTransition for StateTransition { .wrap_to_execution_result(&response)? .inner; + // The current `FromProof` impl always yields `Some`; this guards only a future + // impl change, so it stays a typed error rather than an unwrap. let result: StateTransitionProofResult = maybe_result .ok_or_else(|| { Error::InvalidProvedResponse( From ba7c15069427e0a7324dc7174c577a3b12f1774d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:20:35 +0200 Subject: [PATCH 4/5] test(sdk): add ignored vector test for broadcast wait-path signature verification Drive the broadcast wait-path's security-critical verifier directly: `>::maybe_from_proof_with_metadata`, which runs the GroveDB structural check AND `verify_tenderdash_proof` (the quorum BLS signature gate). The test asserts a valid quorum-signed proof verifies and exposes authenticated metadata, and that three forgeries are rejected: a tampered proof signature, a wrong quorum public key, and a mutated `protocol_version` (which alters the signed `StateId.app_version`). A fifth case proves verify-before-ratchet: a valid proof's authenticated metadata lifts a fresh mock SDK via `verify_response_metadata`. The five tests are `#[ignore]`d because they need a captured quorum-signed broadcast proof vector that is not yet committed; they compile against the real APIs so they cannot bitrot. The module documents exactly which vector files to capture (response, state transition, quorum public key) from a v12 devnet and to un-`#[ignore]` once committed. The test is not network-gated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/fetch/broadcast_wait_signature.rs | 288 ++++++++++++++++++ packages/rs-sdk/tests/fetch/mod.rs | 1 + 2 files changed, 289 insertions(+) create mode 100644 packages/rs-sdk/tests/fetch/broadcast_wait_signature.rs diff --git a/packages/rs-sdk/tests/fetch/broadcast_wait_signature.rs b/packages/rs-sdk/tests/fetch/broadcast_wait_signature.rs new file mode 100644 index 00000000000..be796250b49 --- /dev/null +++ b/packages/rs-sdk/tests/fetch/broadcast_wait_signature.rs @@ -0,0 +1,288 @@ +//! Security regression test for the broadcast wait-path quorum-signature gate. +//! +//! The broadcast `wait_for_response` path verifies a state-transition execution result through +//! `>::maybe_from_proof_with_metadata`, +//! which runs the GroveDB structural check AND `verify_tenderdash_proof` (the quorum BLS signature +//! gate) before the SDK ratchets its protocol version from the response metadata. This test drives +//! that verifier directly against a captured, real signed proof and asserts both that a valid proof +//! verifies and that tampered variants are rejected — proving forged metadata cannot pass the gate. +//! +//! The test is `#[ignore]`d because it requires a captured vector that is not yet committed. It is +//! written against the real APIs so it cannot bitrot; remove the `#[ignore]` once the vector lands. +//! +//! TODO: Capture and commit the vector under +//! `packages/rs-sdk/tests/vectors/broadcast_wait_signed_proof/`, recording all three files from the +//! same response so they stay consistent, then delete `#[ignore]` and set `EXPECTED_PROTOCOL_VERSION` +//! and the happy-path result assertion to the captured transition's variant: +//! +//! - `response.bin`: a protobuf-encoded `WaitForStateTransitionResultResponse` carrying a real +//! signed proof, captured from a v12 devnet for a known, already broadcast state transition +//! (`prove: true`). +//! - `state_transition.bin`: the platform-serialized `StateTransition` bytes that were broadcast +//! (the body of the originating `BroadcastStateTransitionRequest`). +//! - `quorum_public_key.bin`: the 48-byte BLS public key of the quorum that signed the proof, +//! looked up by the proof's `quorum_type` / `quorum_hash` at the metadata's +//! `core_chain_locked_height`. + +use dapi_grpc::platform::v0::{ + BroadcastStateTransitionRequest, Proof, ResponseMetadata, WaitForStateTransitionResultResponse, +}; +use dapi_grpc::platform::VersionedGrpcResponse; +use dapi_grpc::Message; +use dash_context_provider::{ContextProvider, ContextProviderError}; +use dpp::data_contract::TokenConfiguration; +use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; +use dpp::state_transition::proof_result::StateTransitionProofResult; +use dpp::version::PlatformVersion; +use drive_proof_verifier::FromProof; +use std::path::PathBuf; +use std::sync::Arc; + +/// Directory holding the captured broadcast wait-path vector (see the module TODO). +const VECTOR_DIR: &str = "tests/vectors/broadcast_wait_signed_proof"; + +/// Protocol version the captured proof's quorum signed over. The v12 devnet capture must match. +const EXPECTED_PROTOCOL_VERSION: u32 = dpp::version::v12::PROTOCOL_VERSION_12; + +/// Absolute path to a vector file, rooted at the crate manifest dir so it resolves under `cargo test`. +fn vector_path(file: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join(VECTOR_DIR) + .join(file) +} + +fn load_bytes(file: &str) -> Vec { + let path = vector_path(file); + std::fs::read(&path).unwrap_or_else(|e| panic!("read vector {}: {e}", path.display())) +} + +/// Decode the captured signed broadcast response. +fn load_response() -> WaitForStateTransitionResultResponse { + WaitForStateTransitionResultResponse::decode(load_bytes("response.bin").as_slice()) + .expect("decode WaitForStateTransitionResultResponse vector") +} + +/// Reconstruct the broadcast request from the captured state-transition bytes, exactly as the +/// wait-path does via `broadcast_request_for_state_transition`. +fn load_request() -> BroadcastStateTransitionRequest { + BroadcastStateTransitionRequest { + state_transition: load_bytes("state_transition.bin"), + } +} + +fn load_quorum_public_key() -> [u8; 48] { + load_bytes("quorum_public_key.bin") + .try_into() + .expect("quorum public key vector must be exactly 48 bytes") +} + +/// Minimal [`ContextProvider`] serving one fixed quorum public key, as the broadcast wait-path's +/// provider would for the captured proof. Only `get_quorum_public_key` is exercised by +/// `verify_tenderdash_proof`; the other methods are unreachable for a state-transition execution +/// proof and fail loudly if ever called. +struct FixedQuorumKeyProvider { + quorum_public_key: [u8; 48], +} + +impl ContextProvider for FixedQuorumKeyProvider { + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + Ok(self.quorum_public_key) + } + + fn get_data_contract( + &self, + _id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Err(ContextProviderError::Generic( + "data contract lookup not provided by the broadcast signature-verification vector" + .to_string(), + )) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + Err(ContextProviderError::Generic( + "token configuration lookup not provided by the broadcast signature-verification vector" + .to_string(), + )) + } + + fn get_platform_activation_height(&self) -> Result { + Err(ContextProviderError::Generic( + "platform activation height not provided by the broadcast signature-verification vector" + .to_string(), + )) + } +} + +/// Run the production verifier exactly as the broadcast wait-path does. +#[expect( + clippy::result_large_err, + reason = "mirrors the production FromProof return shape; tests assert on the error" +)] +fn verify( + request: BroadcastStateTransitionRequest, + response: WaitForStateTransitionResultResponse, + provider: &dyn ContextProvider, +) -> Result< + (Option, ResponseMetadata, Proof), + drive_proof_verifier::Error, +> { + >::maybe_from_proof_with_metadata( + request, + response, + dpp::dashcore::Network::Regtest, + PlatformVersion::get(EXPECTED_PROTOCOL_VERSION).expect("known platform version"), + provider, + ) +} + +/// Happy path: a valid signed proof verifies, yields a result, and exposes authenticated metadata. +#[test] +#[ignore = "needs a captured quorum-signed broadcast proof vector + matching quorum public key; vectors generated later"] +fn broadcast_wait_path_verifies_quorum_signed_proof() { + let provider = FixedQuorumKeyProvider { + quorum_public_key: load_quorum_public_key(), + }; + + let (maybe_result, metadata, _proof) = verify(load_request(), load_response(), &provider) + .expect("valid quorum-signed proof must verify"); + + assert!( + maybe_result.is_some(), + "a valid execution proof must yield a verified result" + ); + assert_eq!( + metadata.protocol_version, EXPECTED_PROTOCOL_VERSION, + "verified metadata must carry the quorum-signed protocol version" + ); +} + +/// Tampering with the proof signature must be rejected by the BLS gate. +#[test] +#[ignore = "needs a captured quorum-signed broadcast proof vector + matching quorum public key; vectors generated later"] +fn broadcast_wait_path_rejects_tampered_signature() { + let provider = FixedQuorumKeyProvider { + quorum_public_key: load_quorum_public_key(), + }; + + let mut response = load_response(); + let mut tampered = response + .proof() + .expect("captured response must contain a proof") + .clone(); + // Flip one bit of the BLS signature; everything else stays valid. + let first = tampered + .signature + .first_mut() + .expect("signature is non-empty"); + *first ^= 0x01; + set_proof(&mut response, tampered); + + let err = verify(load_request(), response, &provider) + .expect_err("a tampered signature must fail the quorum signature gate"); + assert_signature_rejection(&err); +} + +/// A wrong quorum public key (e.g. an attacker substituting a quorum) must be rejected. +#[test] +#[ignore = "needs a captured quorum-signed broadcast proof vector + matching quorum public key; vectors generated later"] +fn broadcast_wait_path_rejects_wrong_quorum_key() { + let mut wrong_key = load_quorum_public_key(); + wrong_key[0] ^= 0xFF; + let provider = FixedQuorumKeyProvider { + quorum_public_key: wrong_key, + }; + + let err = verify(load_request(), load_response(), &provider) + .expect_err("the wrong quorum key must fail the quorum signature gate"); + assert_signature_rejection(&err); +} + +/// Forging `metadata.protocol_version` must be rejected: it feeds `StateId.app_version`, so the +/// signed message hash changes and the quorum signature no longer matches. This is the exact +/// attack the fix closes — unauthenticated metadata must never reach the protocol-version ratchet. +#[test] +#[ignore = "needs a captured quorum-signed broadcast proof vector + matching quorum public key; vectors generated later"] +fn broadcast_wait_path_rejects_forged_protocol_version() { + let provider = FixedQuorumKeyProvider { + quorum_public_key: load_quorum_public_key(), + }; + + let mut response = load_response(); + mutate_protocol_version(&mut response, EXPECTED_PROTOCOL_VERSION + 1); + + let err = verify(load_request(), response, &provider) + .expect_err("a forged protocol_version must fail the quorum signature gate"); + assert_signature_rejection(&err); +} + +/// Verify-before-ratchet: a valid proof's authenticated metadata lifts a fresh auto-detect SDK to +/// the proof's protocol version via `verify_response_metadata`, the same call the wait-path makes +/// only after signature verification succeeds. +#[test] +#[ignore = "needs a captured quorum-signed broadcast proof vector + matching quorum public key; vectors generated later"] +fn broadcast_wait_path_valid_proof_ratchets_sdk() { + use dash_sdk::SdkBuilder; + + let provider = FixedQuorumKeyProvider { + quorum_public_key: load_quorum_public_key(), + }; + + let (_maybe_result, metadata, _proof) = verify(load_request(), load_response(), &provider) + .expect("valid quorum-signed proof must verify"); + + let sdk = SdkBuilder::new_mock().build().expect("build mock sdk"); + sdk.verify_response_metadata("wait_for_state_transition_result", &metadata) + .expect("authenticated metadata must pass verification"); + + assert_eq!( + sdk.version().protocol_version, + EXPECTED_PROTOCOL_VERSION, + "verify_response_metadata must ratchet the SDK to the quorum-signed protocol version" + ); +} + +/// Tenderdash-signature failures surface as `drive_proof_verifier` errors; the structural GroveDB +/// proof is untouched in every tamper case here, so a non-error is a real regression. +fn assert_signature_rejection(err: &drive_proof_verifier::Error) { + // Any verifier error proves the gate rejected the forgery; the message aids triage. + tracing::debug!(%err, "broadcast wait-path rejected tampered proof as expected"); +} + +/// Replace the proof on a V0 response, preserving its result and metadata. +fn set_proof(response: &mut WaitForStateTransitionResultResponse, proof: Proof) { + use dapi_grpc::platform::v0::wait_for_state_transition_result_response::{ + wait_for_state_transition_result_response_v0::Result as V0Result, Version, + }; + + match response.version.as_mut() { + Some(Version::V0(v0)) => { + v0.result = Some(V0Result::Proof(proof)); + } + None => panic!("captured response must be versioned"), + } +} + +/// Mutate `metadata.protocol_version` on a V0 response in place. +fn mutate_protocol_version(response: &mut WaitForStateTransitionResultResponse, version: u32) { + use dapi_grpc::platform::v0::wait_for_state_transition_result_response::Version; + + match response.version.as_mut() { + Some(Version::V0(v0)) => { + v0.metadata + .as_mut() + .expect("captured response must carry metadata") + .protocol_version = version; + } + None => panic!("captured response must be versioned"), + } +} diff --git a/packages/rs-sdk/tests/fetch/mod.rs b/packages/rs-sdk/tests/fetch/mod.rs index f611f30be02..38e0ab8e804 100644 --- a/packages/rs-sdk/tests/fetch/mod.rs +++ b/packages/rs-sdk/tests/fetch/mod.rs @@ -9,6 +9,7 @@ compile_error!("network-testing or offline-testing must be enabled for tests"); mod address_funds; mod address_sync; mod broadcast; +mod broadcast_wait_signature; mod common; mod config; mod contested_resource; From cbe21e5816ee2da7304e7cf27b883d172afceb94 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:25:23 +0200 Subject: [PATCH 5/5] test(sdk): drop placeholder broadcast sig-verify vector test (deferred to devnet-injection follow-up) Remove the bespoke `tests/fetch/broadcast_wait_signature.rs` (the `FixedQuorumKeyProvider` + hand-authored vector loaders) and its `mod` line. The test relied on captured vectors that the current SDK test harness cannot generate (no funded identity or signer for a broadcast), so it never ran. The signature-verification coverage is deferred to a separate devnet-injection follow-up that can produce a real signed proof vector. The production fix (broadcast wait-path quorum-signature verification) and its docs are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/fetch/broadcast_wait_signature.rs | 288 ------------------ packages/rs-sdk/tests/fetch/mod.rs | 1 - 2 files changed, 289 deletions(-) delete mode 100644 packages/rs-sdk/tests/fetch/broadcast_wait_signature.rs diff --git a/packages/rs-sdk/tests/fetch/broadcast_wait_signature.rs b/packages/rs-sdk/tests/fetch/broadcast_wait_signature.rs deleted file mode 100644 index be796250b49..00000000000 --- a/packages/rs-sdk/tests/fetch/broadcast_wait_signature.rs +++ /dev/null @@ -1,288 +0,0 @@ -//! Security regression test for the broadcast wait-path quorum-signature gate. -//! -//! The broadcast `wait_for_response` path verifies a state-transition execution result through -//! `>::maybe_from_proof_with_metadata`, -//! which runs the GroveDB structural check AND `verify_tenderdash_proof` (the quorum BLS signature -//! gate) before the SDK ratchets its protocol version from the response metadata. This test drives -//! that verifier directly against a captured, real signed proof and asserts both that a valid proof -//! verifies and that tampered variants are rejected — proving forged metadata cannot pass the gate. -//! -//! The test is `#[ignore]`d because it requires a captured vector that is not yet committed. It is -//! written against the real APIs so it cannot bitrot; remove the `#[ignore]` once the vector lands. -//! -//! TODO: Capture and commit the vector under -//! `packages/rs-sdk/tests/vectors/broadcast_wait_signed_proof/`, recording all three files from the -//! same response so they stay consistent, then delete `#[ignore]` and set `EXPECTED_PROTOCOL_VERSION` -//! and the happy-path result assertion to the captured transition's variant: -//! -//! - `response.bin`: a protobuf-encoded `WaitForStateTransitionResultResponse` carrying a real -//! signed proof, captured from a v12 devnet for a known, already broadcast state transition -//! (`prove: true`). -//! - `state_transition.bin`: the platform-serialized `StateTransition` bytes that were broadcast -//! (the body of the originating `BroadcastStateTransitionRequest`). -//! - `quorum_public_key.bin`: the 48-byte BLS public key of the quorum that signed the proof, -//! looked up by the proof's `quorum_type` / `quorum_hash` at the metadata's -//! `core_chain_locked_height`. - -use dapi_grpc::platform::v0::{ - BroadcastStateTransitionRequest, Proof, ResponseMetadata, WaitForStateTransitionResultResponse, -}; -use dapi_grpc::platform::VersionedGrpcResponse; -use dapi_grpc::Message; -use dash_context_provider::{ContextProvider, ContextProviderError}; -use dpp::data_contract::TokenConfiguration; -use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; -use dpp::state_transition::proof_result::StateTransitionProofResult; -use dpp::version::PlatformVersion; -use drive_proof_verifier::FromProof; -use std::path::PathBuf; -use std::sync::Arc; - -/// Directory holding the captured broadcast wait-path vector (see the module TODO). -const VECTOR_DIR: &str = "tests/vectors/broadcast_wait_signed_proof"; - -/// Protocol version the captured proof's quorum signed over. The v12 devnet capture must match. -const EXPECTED_PROTOCOL_VERSION: u32 = dpp::version::v12::PROTOCOL_VERSION_12; - -/// Absolute path to a vector file, rooted at the crate manifest dir so it resolves under `cargo test`. -fn vector_path(file: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join(VECTOR_DIR) - .join(file) -} - -fn load_bytes(file: &str) -> Vec { - let path = vector_path(file); - std::fs::read(&path).unwrap_or_else(|e| panic!("read vector {}: {e}", path.display())) -} - -/// Decode the captured signed broadcast response. -fn load_response() -> WaitForStateTransitionResultResponse { - WaitForStateTransitionResultResponse::decode(load_bytes("response.bin").as_slice()) - .expect("decode WaitForStateTransitionResultResponse vector") -} - -/// Reconstruct the broadcast request from the captured state-transition bytes, exactly as the -/// wait-path does via `broadcast_request_for_state_transition`. -fn load_request() -> BroadcastStateTransitionRequest { - BroadcastStateTransitionRequest { - state_transition: load_bytes("state_transition.bin"), - } -} - -fn load_quorum_public_key() -> [u8; 48] { - load_bytes("quorum_public_key.bin") - .try_into() - .expect("quorum public key vector must be exactly 48 bytes") -} - -/// Minimal [`ContextProvider`] serving one fixed quorum public key, as the broadcast wait-path's -/// provider would for the captured proof. Only `get_quorum_public_key` is exercised by -/// `verify_tenderdash_proof`; the other methods are unreachable for a state-transition execution -/// proof and fail loudly if ever called. -struct FixedQuorumKeyProvider { - quorum_public_key: [u8; 48], -} - -impl ContextProvider for FixedQuorumKeyProvider { - fn get_quorum_public_key( - &self, - _quorum_type: u32, - _quorum_hash: [u8; 32], - _core_chain_locked_height: u32, - ) -> Result<[u8; 48], ContextProviderError> { - Ok(self.quorum_public_key) - } - - fn get_data_contract( - &self, - _id: &Identifier, - _platform_version: &PlatformVersion, - ) -> Result>, ContextProviderError> { - Err(ContextProviderError::Generic( - "data contract lookup not provided by the broadcast signature-verification vector" - .to_string(), - )) - } - - fn get_token_configuration( - &self, - _token_id: &Identifier, - ) -> Result, ContextProviderError> { - Err(ContextProviderError::Generic( - "token configuration lookup not provided by the broadcast signature-verification vector" - .to_string(), - )) - } - - fn get_platform_activation_height(&self) -> Result { - Err(ContextProviderError::Generic( - "platform activation height not provided by the broadcast signature-verification vector" - .to_string(), - )) - } -} - -/// Run the production verifier exactly as the broadcast wait-path does. -#[expect( - clippy::result_large_err, - reason = "mirrors the production FromProof return shape; tests assert on the error" -)] -fn verify( - request: BroadcastStateTransitionRequest, - response: WaitForStateTransitionResultResponse, - provider: &dyn ContextProvider, -) -> Result< - (Option, ResponseMetadata, Proof), - drive_proof_verifier::Error, -> { - >::maybe_from_proof_with_metadata( - request, - response, - dpp::dashcore::Network::Regtest, - PlatformVersion::get(EXPECTED_PROTOCOL_VERSION).expect("known platform version"), - provider, - ) -} - -/// Happy path: a valid signed proof verifies, yields a result, and exposes authenticated metadata. -#[test] -#[ignore = "needs a captured quorum-signed broadcast proof vector + matching quorum public key; vectors generated later"] -fn broadcast_wait_path_verifies_quorum_signed_proof() { - let provider = FixedQuorumKeyProvider { - quorum_public_key: load_quorum_public_key(), - }; - - let (maybe_result, metadata, _proof) = verify(load_request(), load_response(), &provider) - .expect("valid quorum-signed proof must verify"); - - assert!( - maybe_result.is_some(), - "a valid execution proof must yield a verified result" - ); - assert_eq!( - metadata.protocol_version, EXPECTED_PROTOCOL_VERSION, - "verified metadata must carry the quorum-signed protocol version" - ); -} - -/// Tampering with the proof signature must be rejected by the BLS gate. -#[test] -#[ignore = "needs a captured quorum-signed broadcast proof vector + matching quorum public key; vectors generated later"] -fn broadcast_wait_path_rejects_tampered_signature() { - let provider = FixedQuorumKeyProvider { - quorum_public_key: load_quorum_public_key(), - }; - - let mut response = load_response(); - let mut tampered = response - .proof() - .expect("captured response must contain a proof") - .clone(); - // Flip one bit of the BLS signature; everything else stays valid. - let first = tampered - .signature - .first_mut() - .expect("signature is non-empty"); - *first ^= 0x01; - set_proof(&mut response, tampered); - - let err = verify(load_request(), response, &provider) - .expect_err("a tampered signature must fail the quorum signature gate"); - assert_signature_rejection(&err); -} - -/// A wrong quorum public key (e.g. an attacker substituting a quorum) must be rejected. -#[test] -#[ignore = "needs a captured quorum-signed broadcast proof vector + matching quorum public key; vectors generated later"] -fn broadcast_wait_path_rejects_wrong_quorum_key() { - let mut wrong_key = load_quorum_public_key(); - wrong_key[0] ^= 0xFF; - let provider = FixedQuorumKeyProvider { - quorum_public_key: wrong_key, - }; - - let err = verify(load_request(), load_response(), &provider) - .expect_err("the wrong quorum key must fail the quorum signature gate"); - assert_signature_rejection(&err); -} - -/// Forging `metadata.protocol_version` must be rejected: it feeds `StateId.app_version`, so the -/// signed message hash changes and the quorum signature no longer matches. This is the exact -/// attack the fix closes — unauthenticated metadata must never reach the protocol-version ratchet. -#[test] -#[ignore = "needs a captured quorum-signed broadcast proof vector + matching quorum public key; vectors generated later"] -fn broadcast_wait_path_rejects_forged_protocol_version() { - let provider = FixedQuorumKeyProvider { - quorum_public_key: load_quorum_public_key(), - }; - - let mut response = load_response(); - mutate_protocol_version(&mut response, EXPECTED_PROTOCOL_VERSION + 1); - - let err = verify(load_request(), response, &provider) - .expect_err("a forged protocol_version must fail the quorum signature gate"); - assert_signature_rejection(&err); -} - -/// Verify-before-ratchet: a valid proof's authenticated metadata lifts a fresh auto-detect SDK to -/// the proof's protocol version via `verify_response_metadata`, the same call the wait-path makes -/// only after signature verification succeeds. -#[test] -#[ignore = "needs a captured quorum-signed broadcast proof vector + matching quorum public key; vectors generated later"] -fn broadcast_wait_path_valid_proof_ratchets_sdk() { - use dash_sdk::SdkBuilder; - - let provider = FixedQuorumKeyProvider { - quorum_public_key: load_quorum_public_key(), - }; - - let (_maybe_result, metadata, _proof) = verify(load_request(), load_response(), &provider) - .expect("valid quorum-signed proof must verify"); - - let sdk = SdkBuilder::new_mock().build().expect("build mock sdk"); - sdk.verify_response_metadata("wait_for_state_transition_result", &metadata) - .expect("authenticated metadata must pass verification"); - - assert_eq!( - sdk.version().protocol_version, - EXPECTED_PROTOCOL_VERSION, - "verify_response_metadata must ratchet the SDK to the quorum-signed protocol version" - ); -} - -/// Tenderdash-signature failures surface as `drive_proof_verifier` errors; the structural GroveDB -/// proof is untouched in every tamper case here, so a non-error is a real regression. -fn assert_signature_rejection(err: &drive_proof_verifier::Error) { - // Any verifier error proves the gate rejected the forgery; the message aids triage. - tracing::debug!(%err, "broadcast wait-path rejected tampered proof as expected"); -} - -/// Replace the proof on a V0 response, preserving its result and metadata. -fn set_proof(response: &mut WaitForStateTransitionResultResponse, proof: Proof) { - use dapi_grpc::platform::v0::wait_for_state_transition_result_response::{ - wait_for_state_transition_result_response_v0::Result as V0Result, Version, - }; - - match response.version.as_mut() { - Some(Version::V0(v0)) => { - v0.result = Some(V0Result::Proof(proof)); - } - None => panic!("captured response must be versioned"), - } -} - -/// Mutate `metadata.protocol_version` on a V0 response in place. -fn mutate_protocol_version(response: &mut WaitForStateTransitionResultResponse, version: u32) { - use dapi_grpc::platform::v0::wait_for_state_transition_result_response::Version; - - match response.version.as_mut() { - Some(Version::V0(v0)) => { - v0.metadata - .as_mut() - .expect("captured response must carry metadata") - .protocol_version = version; - } - None => panic!("captured response must be versioned"), - } -} diff --git a/packages/rs-sdk/tests/fetch/mod.rs b/packages/rs-sdk/tests/fetch/mod.rs index 38e0ab8e804..f611f30be02 100644 --- a/packages/rs-sdk/tests/fetch/mod.rs +++ b/packages/rs-sdk/tests/fetch/mod.rs @@ -9,7 +9,6 @@ compile_error!("network-testing or offline-testing must be enabled for tests"); mod address_funds; mod address_sync; mod broadcast; -mod broadcast_wait_signature; mod common; mod config; mod contested_resource;