diff --git a/.github/workflows/kotlin-sdk-build.yml b/.github/workflows/kotlin-sdk-build.yml index 809c1d7e983..66cdff7cba2 100644 --- a/.github/workflows/kotlin-sdk-build.yml +++ b/.github/workflows/kotlin-sdk-build.yml @@ -170,13 +170,10 @@ jobs: # Unlock the keyguard. With the screen kept on above, the device now # stays unlocked for the whole run instead of re-locking. - adb shell input keyevent KEYCODE_WAKEUP - adb shell wm dismiss-keyguard - sleep 5 - - # Fail loudly if the device is still locked, so a spurious - # InvalidKeyException can't masquerade as a real test failure. - adb shell dumpsys trust | grep -q 'deviceLocked=0' || { echo "::error::Emulator is still locked (deviceLocked=1); Keystore-backed tests would fail spuriously."; adb shell dumpsys trust; exit 1; } + # Credential acceptance and keyguard dismissal can race during a + # cold emulator boot. Retry the complete sequence atomically, then + # fail loudly before tests if the device never reaches unlocked. + for attempt in 1 2 3; do adb shell input keyevent KEYCODE_WAKEUP; adb shell wm dismiss-keyguard; adb shell input text 1234; adb shell input keyevent KEYCODE_ENTER; sleep 2; adb shell wm dismiss-keyguard; sleep 1; adb shell dumpsys trust | grep -q 'deviceLocked=0' && exit 0; done; echo "::error::Emulator is still locked (deviceLocked=1); Keystore-backed tests would fail spuriously."; adb shell dumpsys trust; exit 1 ./gradlew :sdk:connectedDebugAndroidTest --stacktrace diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index 29bd4839d00..d1232bc0d04 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -14,7 +14,7 @@ jobs: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository || github.event.pull_request.head.repo.owner.login == 'thepastaclaw' - timeout-minutes: 30 + timeout-minutes: 90 steps: - name: Check out repo uses: actions/checkout@v4 @@ -238,7 +238,16 @@ jobs: run: | if [ -d target/llvm-cov-target ]; then du -sh target/llvm-cov-target || true - rm -rf target/llvm-cov-target + for attempt in 1 2 3; do + if rm -rf target/llvm-cov-target; then + break + fi + echo "::warning::Coverage cleanup attempt ${attempt} failed; retrying" + sleep 2 + done + if [ -d target/llvm-cov-target ]; then + echo "::warning::Coverage artifacts could not be fully removed" + fi fi du -sh target 2>/dev/null || true diff --git a/packages/rs-drive/src/drive/saved_block_transactions/fetch_compacted_address_balances/v0/mod.rs b/packages/rs-drive/src/drive/saved_block_transactions/fetch_compacted_address_balances/v0/mod.rs index df29ec6f9f2..a5038cbd51f 100644 --- a/packages/rs-drive/src/drive/saved_block_transactions/fetch_compacted_address_balances/v0/mod.rs +++ b/packages/rs-drive/src/drive/saved_block_transactions/fetch_compacted_address_balances/v0/mod.rs @@ -8,6 +8,8 @@ use grovedb::{Element, PathQuery, Query, SizedQuery, TransactionArg}; use platform_version::version::PlatformVersion; use std::collections::BTreeMap; +use crate::verify::address_funds::verify_compacted_address_balance_changes::CompactedAddressBalanceProof; + /// Result type for fetched compacted address balance changes /// Each entry is (start_block, end_block, address_balance_map) pub type CompactedAddressBalanceChanges = Vec<( @@ -189,9 +191,11 @@ impl Drive { /// Version 0 implementation for proving compacted address balance changes. /// - /// Uses a two-step approach: - /// 1. First query (non-proving): descending to find any range containing start_block_height - /// 2. Second query (proving): ascending from the found start_block or start_block_height + /// Uses two independently verifiable proofs: + /// 1. A descending predecessor proof authenticates which range, if any, + /// contains `start_block_height`. + /// 2. A forward proof starts at that authenticated range (or at the + /// request-derived fallback key when no range contains the height). /// /// This ensures the proof covers all relevant ranges efficiently. pub(super) fn prove_compacted_address_balance_changes_v0( @@ -203,7 +207,7 @@ impl Drive { ) -> Result, Error> { let path = Self::saved_compacted_block_transactions_address_balances_path_vec(); - // Step 1: Non-proving descending query to find any range containing start_block_height + // Step 1: Authenticate the predecessor used to select the forward query. let mut desc_end_key = Vec::with_capacity(16); desc_end_key.extend_from_slice(&start_block_height.to_be_bytes()); desc_end_key.extend_from_slice(&u64::MAX.to_be_bytes()); @@ -222,6 +226,13 @@ impl Drive { &platform_version.drive, )?; + let predecessor_proof = self.grove_get_proved_path_query( + &desc_path_query, + transaction, + &mut vec![], + &platform_version.drive, + )?; + // Determine the actual start key for the proved query // If we found a containing range, use its exact key // Otherwise use (start_block_height, start_block_height) since end_block >= start_block always @@ -258,12 +269,27 @@ impl Drive { let path_query = PathQuery::new(path, SizedQuery::new(query, limit, None)); - self.grove_get_proved_path_query( + let forward_proof = self.grove_get_proved_path_query( &path_query, transaction, &mut vec![], &platform_version.drive, + )?; + + bincode::encode_to_vec( + CompactedAddressBalanceProof { + predecessor_proof, + forward_proof, + }, + bincode::config::standard() + .with_big_endian() + .with_no_limit(), ) + .map_err(|e| { + Error::Protocol(Box::new(ProtocolError::CorruptedSerialization(format!( + "cannot encode compacted address balance proof: {e}" + )))) + }) } } diff --git a/packages/rs-drive/src/verify/address_funds/verify_compacted_address_balance_changes/mod.rs b/packages/rs-drive/src/verify/address_funds/verify_compacted_address_balance_changes/mod.rs index 3306fced90a..c8f8acbda76 100644 --- a/packages/rs-drive/src/verify/address_funds/verify_compacted_address_balance_changes/mod.rs +++ b/packages/rs-drive/src/verify/address_funds/verify_compacted_address_balance_changes/mod.rs @@ -17,6 +17,17 @@ pub type VerifiedCompactedAddressBalanceChanges = Vec<( BTreeMap, )>; +/// Proof envelope for compacted address balance changes. +/// +/// The predecessor proof independently authenticates which range, if any, +/// contains the requested height. The forward proof can then be verified +/// against a query derived only from that authenticated result. +#[derive(Debug, bincode::Encode, bincode::Decode)] +pub(crate) struct CompactedAddressBalanceProof { + pub(crate) predecessor_proof: Vec, + pub(crate) forward_proof: Vec, +} + impl Drive { /// Verifies the proof of compacted address balance changes starting from a given block height. /// diff --git a/packages/rs-drive/src/verify/address_funds/verify_compacted_address_balance_changes/v0/mod.rs b/packages/rs-drive/src/verify/address_funds/verify_compacted_address_balance_changes/v0/mod.rs index bd34a9d70e5..8d5abe825e7 100644 --- a/packages/rs-drive/src/verify/address_funds/verify_compacted_address_balance_changes/v0/mod.rs +++ b/packages/rs-drive/src/verify/address_funds/verify_compacted_address_balance_changes/v0/mod.rs @@ -7,154 +7,130 @@ use dpp::address_funds::PlatformAddress; /// The subtree key for compacted address balances storage as u8 const COMPACTED_ADDRESS_BALANCES_KEY_U8: u8 = b'c'; +/// Standalone decode budget for the two-proof envelope. Not derived from any +/// transport limit (tonic clients default to a 4 MiB response cap and the +/// DAPI server encodes at most 32 MiB): it only needs to sit far above any +/// realistic proof while bounding hostile allocations before GroveDB +/// verification runs. +const MAX_COMPACTED_PROOF_DECODE_BYTES: usize = 16 * 1024 * 1024; +/// A compacted row contains at most one configured address chunk. Keep a +/// separate semantic-object budget after the GroveDB envelope is decoded. +const MAX_COMPACTED_BALANCE_ROW_DECODE_BYTES: usize = 1024 * 1024; use dpp::balances::credits::BlockAwareCreditOperation; -use grovedb::operations::proof::{GroveDBProof, ProofBytes}; -use grovedb::{ - GroveDb, MerkProofDecoder, MerkProofNode, MerkProofOp, PathQuery, Query, SizedQuery, -}; +use grovedb::{GroveDb, PathQuery, Query, SizedQuery}; use platform_version::version::PlatformVersion; use std::collections::BTreeMap; -use super::VerifiedCompactedAddressBalanceChanges; - -/// Extract KV entries from merk proof bytes using the proper decoder. -#[allow(clippy::type_complexity)] -fn extract_kv_entries_from_merk_proof(merk_proof: &[u8]) -> Result, Vec)>, Error> { - let mut entries = Vec::new(); - - let decoder = MerkProofDecoder::new(merk_proof); - - for op in decoder { - match op { - Ok(MerkProofOp::Push(MerkProofNode::KV(key, value))) - | Ok(MerkProofOp::PushInverted(MerkProofNode::KV(key, value))) => { - entries.push((key, value)); - } - Err(e) => { - tracing::error!(%e, "merk proof decode error"); - return Err(Error::Proof(ProofError::CorruptedProof(format!( - "failed to decode merk proof op: {}", - e - )))); - } - _ => {} - } - } - - Ok(entries) -} +use super::{CompactedAddressBalanceProof, VerifiedCompactedAddressBalanceChanges}; impl Drive { /// Verifies compacted address balance changes proof. /// - /// This verification works by: - /// 1. Decoding the GroveDBProof structure - /// 2. Navigating to the compacted address balances layer ('c') - /// 3. Extracting KV entries from the merk proof - /// 4. Filtering entries where the key range contains start_block_height - /// 5. Verifying the root hash using a subset query + /// The request-derived predecessor query is verified first. Its + /// authenticated result selects the start key for a separately verified + /// forward query, and both proofs must commit to the same root. pub(super) fn verify_compacted_address_balance_changes_v0( proof: &[u8], start_block_height: u64, limit: Option, platform_version: &PlatformVersion, ) -> Result<(RootHash, VerifiedCompactedAddressBalanceChanges), Error> { - let bincode_config = bincode::config::standard() + if proof.len() > MAX_COMPACTED_PROOF_DECODE_BYTES { + return Err(Error::Proof(ProofError::CorruptedProof( + "compacted address balance proof exceeds the decoding limit".to_string(), + ))); + } + + let proof_decode_config = bincode::config::standard() .with_big_endian() - .with_no_limit(); + .with_limit::(); - // Decode the GroveDBProof to navigate its structure - let grovedb_proof: GroveDBProof = bincode::decode_from_slice(proof, bincode_config) - .map(|(p, _)| p) - .map_err(|e| { + let (proof_envelope, consumed): (CompactedAddressBalanceProof, usize) = + bincode::decode_from_slice(proof, proof_decode_config).map_err(|e| { Error::Proof(ProofError::CorruptedProof(format!( - "cannot decode GroveDBProof: {}", + "cannot decode compacted address balance proof: {}", e ))) })?; + if consumed != proof.len() { + return Err(Error::Proof(ProofError::CorruptedProof( + "compacted address balance proof contains trailing bytes".to_string(), + ))); + } - // Navigate to the compacted address balances layer - // Path: SavedBlockTransactions ('$' = 0x24) -> CompactedAddressBalances ('c' = 0x63) - let saved_block_key = vec![RootTree::SavedBlockTransactions as u8]; - let compacted_key = vec![COMPACTED_ADDRESS_BALANCES_KEY_U8]; - - // Extract KV entries from the compacted layer's merk proof to find - // if there's a containing range for start_block_height. - // V0 and V1 proofs have different layer types (MerkOnlyLayerProof vs LayerProof), - // so we handle them separately. - let kv_entries = match &grovedb_proof { - GroveDBProof::V0(v0) => { - let compacted_layer = v0 - .root_layer - .lower_layers - .get(&saved_block_key) - .and_then(|layer| layer.lower_layers.get(&compacted_key)); - compacted_layer - .map(|layer| extract_kv_entries_from_merk_proof(&layer.merk_proof)) - .transpose()? - .unwrap_or_default() - } - GroveDBProof::V1(v1) => { - let compacted_layer = v1 - .root_layer - .lower_layers - .get(&saved_block_key) - .and_then(|layer| layer.lower_layers.get(&compacted_key)); - compacted_layer - .map(|layer| match &layer.merk_proof { - ProofBytes::Merk(bytes) => extract_kv_entries_from_merk_proof(bytes), - other => Err(Error::Proof(ProofError::CorruptedProof(format!( - "unsupported V1 proof bytes variant for compacted address balances: {:?}", - std::mem::discriminant(other) - )))), - }) - .transpose()? - .unwrap_or_default() - } - }; + let path = vec![ + vec![RootTree::SavedBlockTransactions as u8], + vec![COMPACTED_ADDRESS_BALANCES_KEY_U8], + ]; - // Look for a KV entry where the range contains start_block_height - // Keys are 16 bytes: (start_block, end_block), both big-endian - let containing_key = kv_entries.iter().find_map(|(key, _)| { - if key.len() != 16 { - return None; - } - let range_start = u64::from_be_bytes(key[0..8].try_into().unwrap()); - let range_end = u64::from_be_bytes(key[8..16].try_into().unwrap()); + let mut predecessor_end_key = Vec::with_capacity(16); + predecessor_end_key.extend_from_slice(&start_block_height.to_be_bytes()); + predecessor_end_key.extend_from_slice(&u64::MAX.to_be_bytes()); + let mut predecessor_query = Query::new_with_direction(false); + predecessor_query.insert_range_to_inclusive(..=predecessor_end_key); + let predecessor_path_query = PathQuery::new( + path.clone(), + SizedQuery::new(predecessor_query, Some(1), None), + ); + let (predecessor_root_hash, predecessor_results) = GroveDb::verify_query( + &proof_envelope.predecessor_proof, + &predecessor_path_query, + &platform_version.drive.grove_version, + )?; - // Check if this range contains start_block_height - if range_start <= start_block_height && start_block_height <= range_end { - Some(key.clone()) + let mut authenticated_predecessors = predecessor_results + .into_iter() + .filter_map(|(_path, key, element)| element.map(|element| (key, element))); + let authenticated_predecessor = authenticated_predecessors.next(); + if authenticated_predecessors.next().is_some() { + return Err(Error::Proof(ProofError::CorruptedProof( + "predecessor proof returned more than one compacted range".to_string(), + ))); + } + + let start_key = if let Some((key, _element)) = authenticated_predecessor { + let key_bytes: [u8; 16] = key.as_slice().try_into().map_err(|_| { + Error::Proof(ProofError::CorruptedProof( + "invalid compacted predecessor key length".to_string(), + )) + })?; + let range_start = u64::from_be_bytes(key_bytes[..8].try_into().expect("key length")); + let range_end = u64::from_be_bytes(key_bytes[8..].try_into().expect("key length")); + if range_start > range_end { + return Err(Error::Proof(ProofError::CorruptedProof( + "compacted predecessor has an invalid block range".to_string(), + ))); + } + if range_end >= start_block_height { + key } else { - None + let mut fallback = Vec::with_capacity(16); + fallback.extend_from_slice(&start_block_height.to_be_bytes()); + fallback.extend_from_slice(&start_block_height.to_be_bytes()); + fallback } - }); - - // Determine the start_key for the query - // Use the containing range's key if found, otherwise (start_block_height, start_block_height) - let start_key = containing_key.unwrap_or_else(|| { + } else { let mut key = Vec::with_capacity(16); key.extend_from_slice(&start_block_height.to_be_bytes()); key.extend_from_slice(&start_block_height.to_be_bytes()); key - }); - - // Verify the proof and get results using subset query - let path = vec![ - vec![RootTree::SavedBlockTransactions as u8], - vec![COMPACTED_ADDRESS_BALANCES_KEY_U8], - ]; + }; let mut query = Query::new(); query.insert_range_from(start_key..); let path_query = PathQuery::new(path, SizedQuery::new(query, limit, None)); - let (root_hash, proved_key_values) = GroveDb::verify_subset_query( - proof, + let (root_hash, proved_key_values) = GroveDb::verify_query( + &proof_envelope.forward_proof, &path_query, &platform_version.drive.grove_version, )?; + if root_hash != predecessor_root_hash { + return Err(Error::Proof(ProofError::CorruptedProof( + "compacted address balance proofs commit to different roots".to_string(), + ))); + } // Process the verified results let mut compacted_changes = Vec::new(); @@ -181,15 +157,28 @@ impl Drive { }; // Deserialize the address balance map - let (address_balances, _): ( + if serialized_data.len() > MAX_COMPACTED_BALANCE_ROW_DECODE_BYTES { + return Err(Error::Proof(ProofError::CorruptedProof( + "compacted address balance row exceeds the decoding limit".to_string(), + ))); + } + let row_decode_config = bincode::config::standard() + .with_big_endian() + .with_limit::(); + let (address_balances, consumed): ( BTreeMap, usize, - ) = bincode::decode_from_slice(&serialized_data, bincode_config).map_err(|e| { + ) = bincode::decode_from_slice(&serialized_data, row_decode_config).map_err(|e| { Error::Proof(ProofError::CorruptedProof(format!( "cannot decode compacted address balances: {}", e ))) })?; + if consumed != serialized_data.len() { + return Err(Error::Proof(ProofError::CorruptedProof( + "compacted address balance row contains trailing bytes".to_string(), + ))); + } compacted_changes.push((range_start, range_end, address_balances)); } @@ -270,6 +259,26 @@ mod tests { assert!(*start <= *end, "start should be <= end"); assert!(!changes.is_empty(), "each entry should have changes"); } + + // A query beginning inside a compacted range must include that range. + // This exercises the independently authenticated predecessor witness. + let interior_height = max_blocks / 2; + let interior_proof = drive + .prove_compacted_address_balance_changes(interior_height, None, None, platform_version) + .expect("should prove changes from inside a compacted range"); + let (_, interior_changes) = Drive::verify_compacted_address_balance_changes( + &interior_proof, + interior_height, + None, + platform_version, + ) + .expect("should verify the authenticated predecessor proof"); + assert!( + interior_changes + .iter() + .any(|(start, end, _)| *start <= interior_height && interior_height <= *end), + "the compacted range containing the requested height must be returned" + ); } #[test] @@ -298,8 +307,134 @@ mod tests { ); } + /// Stores enough per-block changes to trigger compaction, leaving at + /// least one compacted range and one uncompacted recent block. + fn setup_drive_with_compacted_ranges() -> (Drive, u64) { + let drive = setup_drive_with_initial_state_structure(None); + let platform_version = PlatformVersion::latest(); + let address = PlatformAddress::P2pkh([10; 20]); + let max_blocks = platform_version + .drive + .methods + .saved_block_transactions + .max_blocks_before_compaction as u64; + + for block_height in 1u64..=(max_blocks + 1) { + let mut changes = BTreeMap::new(); + changes.insert(address, CreditOperation::AddToCredits(block_height * 1000)); + drive + .store_address_balances_for_block( + &changes, + block_height, + block_height * 1000, + None, + platform_version, + ) + .expect("should store balances"); + } + + (drive, max_blocks) + } + + #[test] + fn beyond_last_compacted_range_uses_fallback_and_returns_empty() { + // Exercises the predecessor-exists-but-does-not-contain branch: a + // compacted range sits below the requested height, so the verifier + // must fall back to the request-derived start key instead of the + // authenticated predecessor key. + let (drive, max_blocks) = setup_drive_with_compacted_ranges(); + let platform_version = PlatformVersion::latest(); + + // Precondition: compaction produced at least one range below. + let proof = drive + .prove_compacted_address_balance_changes(1, None, None, platform_version) + .expect("should prove from genesis"); + let (_, changes) = + Drive::verify_compacted_address_balance_changes(&proof, 1, None, platform_version) + .expect("should verify from genesis"); + assert!(!changes.is_empty(), "compaction must have produced ranges"); + + let beyond_height = max_blocks * 10; + let beyond_proof = drive + .prove_compacted_address_balance_changes(beyond_height, None, None, platform_version) + .expect("should prove beyond the last compacted range"); + let (_, beyond_changes) = Drive::verify_compacted_address_balance_changes( + &beyond_proof, + beyond_height, + None, + platform_version, + ) + .expect("a non-containing predecessor must verify via the fallback key"); + assert!( + beyond_changes.is_empty(), + "no compacted range lies at or beyond the requested height" + ); + } + #[test] - fn test_verify_compacted_address_balance_changes_proof() { + fn rejects_envelope_halves_committing_to_different_roots() { + // Splice a predecessor proof from one state with a forward proof from + // a later state. Each half verifies on its own, so only the explicit + // cross-proof root binding can reject the mixed envelope. + let (drive, max_blocks) = setup_drive_with_compacted_ranges(); + let platform_version = PlatformVersion::latest(); + let interior_height = max_blocks / 2; + + let proof_before = drive + .prove_compacted_address_balance_changes(interior_height, None, None, platform_version) + .expect("should prove at the earlier state"); + + // Mutate uncompacted recent state only: the root changes while the + // compacted ranges (and therefore the derived start key) do not. + let address = PlatformAddress::P2pkh([10; 20]); + let mut changes = BTreeMap::new(); + changes.insert(address, CreditOperation::AddToCredits(1)); + drive + .store_address_balances_for_block( + &changes, + max_blocks + 2, + (max_blocks + 2) * 1000, + None, + platform_version, + ) + .expect("should store an additional recent block"); + + let proof_after = drive + .prove_compacted_address_balance_changes(interior_height, None, None, platform_version) + .expect("should prove at the later state"); + + let envelope_config = bincode::config::standard().with_big_endian(); + let (before_envelope, _): (CompactedAddressBalanceProof, usize) = + bincode::decode_from_slice(&proof_before, envelope_config) + .expect("decode earlier envelope"); + let (after_envelope, _): (CompactedAddressBalanceProof, usize) = + bincode::decode_from_slice(&proof_after, envelope_config) + .expect("decode later envelope"); + + let spliced = bincode::encode_to_vec( + CompactedAddressBalanceProof { + predecessor_proof: before_envelope.predecessor_proof, + forward_proof: after_envelope.forward_proof, + }, + envelope_config, + ) + .expect("encode spliced envelope"); + + let error = Drive::verify_compacted_address_balance_changes( + &spliced, + interior_height, + None, + platform_version, + ) + .expect_err("mixed-root envelope halves must be rejected"); + assert!( + error.to_string().contains("different roots"), + "expected the cross-proof root binding to reject the envelope, got: {error}" + ); + } + + #[test] + fn rejects_legacy_single_compacted_address_balance_proof() { // This proof was generated with start_block_height = 329 // Path: [[36], [99]] = [['$'], ['c']] = SavedBlockTransactions -> CompactedAddressBalances // Query: RangeTo(..[0, 0, 0, 0, 0, 0, 1, 73, 0, 0, 0, 0, 0, 0, 1, 73]) = RangeTo(..(329, 329)) @@ -350,34 +485,8 @@ mod tests { ); assert!( - result.is_ok(), - "proof verification failed: {:?}", - result.err() + result.is_err(), + "a single adaptive proof must fail closed without an authenticated predecessor" ); - - let (root_hash, compacted_changes) = result.unwrap(); - - // Verify we got a valid root hash - assert!(!root_hash.is_empty(), "root hash should not be empty"); - - // The proof shows entry (288, 292) is the rightmost in the tree. - // Since 292 < 329 (our start_block_height), there are no results. - // The KVDigest at the boundary proves nothing exists >= (329, 329). - assert!( - compacted_changes.is_empty(), - "expected empty results since start_block_height 329 > last entry end_block 292" - ); - - // Log what we found for debugging - eprintln!("Root hash: {:?}", root_hash); - eprintln!("Number of compacted entries: {}", compacted_changes.len()); - for (start, end, changes) in &compacted_changes { - eprintln!( - " Blocks {}-{}: {} address changes", - start, - end, - changes.len() - ); - } } } diff --git a/packages/rs-drive/src/verify/address_funds/verify_recent_address_balance_changes/v0/mod.rs b/packages/rs-drive/src/verify/address_funds/verify_recent_address_balance_changes/v0/mod.rs index 30f8d9a8b1f..63eae115b29 100644 --- a/packages/rs-drive/src/verify/address_funds/verify_recent_address_balance_changes/v0/mod.rs +++ b/packages/rs-drive/src/verify/address_funds/verify_recent_address_balance_changes/v0/mod.rs @@ -7,6 +7,10 @@ use dpp::address_funds::PlatformAddress; /// The subtree key for address balances storage as u8 const ADDRESS_BALANCES_KEY_U8: u8 = b'm'; +/// A per-block row holds one block's address change map. Mirrors the +/// compacted verifier's semantic-object budget so proof-derived compact +/// length prefixes cannot request attacker-selected allocations. +const MAX_ADDRESS_BALANCE_ROW_DECODE_BYTES: usize = 1024 * 1024; use dpp::balances::credits::CreditOperation; use grovedb::{Element, GroveDb, PathQuery, Query, SizedQuery}; use platform_version::version::PlatformVersion; @@ -14,6 +18,36 @@ use std::collections::BTreeMap; use super::VerifiedAddressBalanceChangesPerBlock; +/// Bounded, exact-consumption decoding for a proof-derived per-block address +/// balance row. +fn decode_address_balance_row( + serialized_data: &[u8], +) -> Result, Error> { + if serialized_data.len() > MAX_ADDRESS_BALANCE_ROW_DECODE_BYTES { + return Err(Error::Proof(ProofError::CorruptedProof( + "address balance row exceeds the decoding limit".to_string(), + ))); + } + + let config = bincode::config::standard() + .with_big_endian() + .with_limit::(); + let (address_balances, consumed): (BTreeMap, usize) = + bincode::decode_from_slice(serialized_data, config).map_err(|e| { + Error::Proof(ProofError::CorruptedProof(format!( + "cannot decode address balances: {}", + e + ))) + })?; + if consumed != serialized_data.len() { + return Err(Error::Proof(ProofError::CorruptedProof( + "address balance row contains trailing bytes".to_string(), + ))); + } + + Ok(address_balances) +} + impl Drive { /// Verifies recent address balance changes proof. /// @@ -31,10 +65,6 @@ impl Drive { vec![ADDRESS_BALANCES_KEY_U8], ]; - let config = bincode::config::standard() - .with_big_endian() - .with_no_limit(); - // Create the same range query as the prove function let mut query = Query::new(); query.insert_range_from(start_block_height.to_be_bytes().to_vec()..); @@ -69,14 +99,8 @@ impl Drive { ))); }; - // Deserialize the address balance map - let (address_balances, _): (BTreeMap, usize) = - bincode::decode_from_slice(&serialized_data, config).map_err(|e| { - Error::Proof(ProofError::CorruptedProof(format!( - "cannot decode address balances: {}", - e - ))) - })?; + // Deserialize the address balance map within its bounded budget + let address_balances = decode_address_balance_row(&serialized_data)?; address_balance_changes.push((block_height, address_balances)); } @@ -100,10 +124,6 @@ impl Drive { vec![ADDRESS_BALANCES_KEY_U8], ]; - let config = bincode::config::standard() - .with_big_endian() - .with_no_limit(); - // Create the same exclusive range query as the prove_after function let mut query = Query::new(); query.insert_range_after(after_block_height.to_be_bytes().to_vec()..); @@ -138,14 +158,8 @@ impl Drive { ))); }; - // Deserialize the address balance map - let (address_balances, _): (BTreeMap, usize) = - bincode::decode_from_slice(&serialized_data, config).map_err(|e| { - Error::Proof(ProofError::CorruptedProof(format!( - "cannot decode address balances: {}", - e - ))) - })?; + // Deserialize the address balance map within its bounded budget + let address_balances = decode_address_balance_row(&serialized_data)?; address_balance_changes.push((block_height, address_balances)); } @@ -163,6 +177,27 @@ mod tests { use platform_version::version::PlatformVersion; use std::collections::BTreeMap; + #[test] + fn address_balance_row_decoder_rejects_hostile_and_trailing_input() { + // A compact bincode length prefix declaring a huge map with no + // corresponding entries must fail within the bounded budget rather + // than requesting an attacker-selected allocation. + let hostile_row = [0xfdu8, 0xff, 0xff, 0xff, 0xff]; + assert!(decode_address_balance_row(&hostile_row).is_err()); + + // A valid row followed by trailing bytes must be rejected. + let mut row = BTreeMap::new(); + row.insert( + PlatformAddress::P2pkh([1; 20]), + CreditOperation::AddToCredits(1), + ); + let config = bincode::config::standard().with_big_endian(); + let mut bytes = bincode::encode_to_vec(&row, config).expect("encode row"); + assert!(decode_address_balance_row(&bytes).is_ok()); + bytes.push(0); + assert!(decode_address_balance_row(&bytes).is_err()); + } + #[test] fn should_prove_and_verify_recent_address_balance_changes() { let drive = setup_drive_with_initial_state_structure(None); diff --git a/packages/rs-drive/src/verify/bounded_decode.rs b/packages/rs-drive/src/verify/bounded_decode.rs new file mode 100644 index 00000000000..c886bd0ab7b --- /dev/null +++ b/packages/rs-drive/src/verify/bounded_decode.rs @@ -0,0 +1,149 @@ +use crate::drive::votes::storage_form::contested_document_resource_reference_storage_form::ContestedDocumentResourceVoteReferenceStorageForm; +use crate::error::drive::DriveError; +use crate::error::Error; +use dpp::bincode; +use dpp::data_contract::serialized_version::DataContractInSerializationFormat; +use dpp::prelude::DataContract; +use dpp::ProtocolError; +use platform_version::version::PlatformVersion; + +/// Maximum decoded resource budget for a proof-derived vote reference. +/// +/// Canonical vote references contain only a short, bounded Drive path. This +/// leaves substantial compatibility headroom while preventing compact length +/// prefixes from requesting attacker-selected allocations. +const MAX_VOTE_REFERENCE_DECODE_BYTES: usize = 64 * 1024; + +/// Maximum in-memory decode budget for a proof-derived data contract. +/// +/// Contract input bytes are separately capped by the active protocol +/// version's `max_serialized_size` (currently 65,000 bytes). Bincode charges +/// this budget for decoded containers rather than wire bytes, so the larger +/// value preserves ample compatibility headroom while still bounding hostile +/// compact length prefixes before a proof root is trusted. +const MAX_CONTRACT_DECODE_MEMORY_BYTES: usize = 16 * 1024 * 1024; + +pub(super) fn decode_proof_data_contract( + serialized_contract: &[u8], + platform_version: &PlatformVersion, +) -> Result { + let max_serialized_size = platform_version.dpp.contract_versions.max_serialized_size as usize; + if serialized_contract.len() > max_serialized_size { + return Err(ProtocolError::PlatformDeserializationError(format!( + "serialized proof data contract exceeds the protocol limit of {max_serialized_size} bytes" + )) + .into()); + } + + let config = bincode::config::standard() + .with_big_endian() + .with_limit::(); + let (serialized_format, consumed) = bincode::borrow_decode_from_slice::< + DataContractInSerializationFormat, + _, + >(serialized_contract, config) + .map_err(|e| { + ProtocolError::PlatformDeserializationError(format!( + "unable to deserialize proof data contract within its bounded budget: {e}" + )) + })?; + + if consumed != serialized_contract.len() { + return Err(ProtocolError::PlatformDeserializationError( + "serialized proof data contract contains trailing bytes".to_string(), + ) + .into()); + } + + DataContract::try_from_platform_versioned( + serialized_format, + // Contract semantics were validated before insertion into authenticated + // Platform state; proof decoding only reconstructs that stored object. + false, + &mut vec![], + platform_version, + ) + .map_err(Error::from) +} + +pub(super) fn decode_vote_reference( + serialized_reference: &[u8], +) -> Result { + if serialized_reference.len() > MAX_VOTE_REFERENCE_DECODE_BYTES { + return Err(Error::Drive(DriveError::CorruptedSerialization( + "serialized vote reference exceeds the proof decoding limit".to_string(), + ))); + } + + let config = bincode::config::standard() + .with_big_endian() + .with_limit::(); + let (reference, consumed) = + bincode::decode_from_slice(serialized_reference, config).map_err(|e| { + Error::Drive(DriveError::CorruptedSerialization(format!( + "serialized vote reference is invalid: {e}" + ))) + })?; + + if consumed != serialized_reference.len() { + return Err(Error::Drive(DriveError::CorruptedSerialization( + "serialized vote reference contains trailing bytes".to_string(), + ))); + } + + Ok(reference) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::serialization::PlatformSerializableWithPlatformVersion; + use dpp::tests::fixtures::get_data_contract_fixture; + + #[test] + fn rejects_oversized_nested_vote_reference_lengths_without_panicking() { + // CousinReference with a compact bincode length prefix declaring a + // 64 MiB outer path and no corresponding elements. + let hostile_reference = hex::decode("04fd0000000004000000").expect("test bytes"); + + let result = std::panic::catch_unwind(|| decode_vote_reference(&hostile_reference)); + + assert!(result.is_ok(), "bounded proof decoding must not panic"); + assert!(result.expect("decode result").is_err()); + } + + #[test] + fn rejects_trailing_vote_reference_bytes() { + let reference = ContestedDocumentResourceVoteReferenceStorageForm { + reference_path_type: + grovedb::element::reference_path::ReferencePathType::SiblingReference(vec![1]), + identity_vote_times: 1, + }; + let config = bincode::config::standard().with_big_endian(); + let mut bytes = bincode::encode_to_vec(reference, config).expect("encode reference"); + bytes.push(0); + + assert!(decode_vote_reference(&bytes).is_err()); + } + + #[test] + fn proof_contract_decoder_requires_exact_consumption() { + let platform_version = PlatformVersion::latest(); + let contract = get_data_contract_fixture(None, 0, platform_version.protocol_version); + let mut bytes = contract + .serialize_to_bytes_with_platform_version(platform_version) + .expect("serialize contract fixture"); + bytes.push(0); + + assert!(decode_proof_data_contract(&bytes, platform_version).is_err()); + } + + #[test] + fn proof_contract_decoder_enforces_protocol_wire_size() { + let platform_version = PlatformVersion::latest(); + let bytes = + vec![0; platform_version.dpp.contract_versions.max_serialized_size as usize + 1]; + + assert!(decode_proof_data_contract(&bytes, platform_version).is_err()); + } +} diff --git a/packages/rs-drive/src/verify/contract/verify_contract/v0/mod.rs b/packages/rs-drive/src/verify/contract/verify_contract/v0/mod.rs index 21512bd6c8e..7067b7176ac 100644 --- a/packages/rs-drive/src/verify/contract/verify_contract/v0/mod.rs +++ b/packages/rs-drive/src/verify/contract/verify_contract/v0/mod.rs @@ -4,10 +4,10 @@ use crate::drive::contract::paths::{contract_keeping_history_root_path, contract use crate::drive::Drive; use crate::error::proof::ProofError; use crate::error::Error; +use crate::verify::bounded_decode::decode_proof_data_contract; use crate::verify::contract::retry_contract_verification_with_history; use crate::verify::RootHash; use dpp::prelude::DataContract; -use dpp::serialization::PlatformDeserializableWithPotentialValidationFromVersionedStructure; use platform_version::version::PlatformVersion; use crate::error::query::QuerySyntaxError; @@ -137,10 +137,9 @@ impl Drive { .into_item_bytes() .map_err(Error::from) .and_then(|bytes| { - // we don't need to validate the contract locally because it was proved to be in platform - // and hence it is valid - DataContract::versioned_deserialize(&bytes, false, platform_version) - .map_err(Error::from) + // The computed proof root is authenticated by the caller. Keep + // proof-derived object construction bounded until that happens. + decode_proof_data_contract(&bytes, platform_version) }) }) .transpose()?; diff --git a/packages/rs-drive/src/verify/contract/verify_contract_history/v0/mod.rs b/packages/rs-drive/src/verify/contract/verify_contract_history/v0/mod.rs index f1a0c3db3b2..677e9231dc4 100644 --- a/packages/rs-drive/src/verify/contract/verify_contract_history/v0/mod.rs +++ b/packages/rs-drive/src/verify/contract/verify_contract_history/v0/mod.rs @@ -9,7 +9,7 @@ use std::collections::BTreeMap; use crate::error::drive::DriveError; use crate::util::common::decode; -use dpp::serialization::PlatformDeserializableWithPotentialValidationFromVersionedStructure; +use crate::verify::bounded_decode::decode_proof_data_contract; use dpp::version::PlatformVersion; use grovedb::GroveDb; @@ -93,12 +93,7 @@ impl Drive { element .into_item_bytes() .map_err(Error::from) - .and_then(|bytes| { - // we don't need to validate the contract locally because it was proved to be in platform - // and hence it is valid - DataContract::versioned_deserialize(&bytes, false, platform_version) - .map_err(Error::from) - }) + .and_then(|bytes| decode_proof_data_contract(&bytes, platform_version)) }) .transpose()?; diff --git a/packages/rs-drive/src/verify/contract/verify_contract_return_serialization/v0/mod.rs b/packages/rs-drive/src/verify/contract/verify_contract_return_serialization/v0/mod.rs index 0abc34af2d7..b93d3f4ece8 100644 --- a/packages/rs-drive/src/verify/contract/verify_contract_return_serialization/v0/mod.rs +++ b/packages/rs-drive/src/verify/contract/verify_contract_return_serialization/v0/mod.rs @@ -2,10 +2,10 @@ use crate::drive::contract::paths::{contract_keeping_history_root_path, contract use crate::drive::Drive; use crate::error::proof::ProofError; use crate::error::Error; +use crate::verify::bounded_decode::decode_proof_data_contract; use crate::verify::contract::retry_contract_verification_with_history; use crate::verify::RootHash; use dpp::prelude::DataContract; -use dpp::serialization::PlatformDeserializableWithPotentialValidationFromVersionedStructure; use platform_version::version::PlatformVersion; use grovedb::GroveDb; @@ -139,17 +139,7 @@ impl Drive { .into_item_bytes() .map_err(Error::from) .and_then(|bytes| { - // we don't need to validate the contract locally because it was proved to be in platform - // and hence it is valid - Ok(( - DataContract::versioned_deserialize( - &bytes, - false, - platform_version, - ) - .map_err(Error::from)?, - bytes, - )) + Ok((decode_proof_data_contract(&bytes, platform_version)?, bytes)) }) }) .transpose()?; diff --git a/packages/rs-drive/src/verify/mod.rs b/packages/rs-drive/src/verify/mod.rs index 75ff083fb2b..a84eff8a837 100644 --- a/packages/rs-drive/src/verify/mod.rs +++ b/packages/rs-drive/src/verify/mod.rs @@ -31,5 +31,7 @@ pub mod tokens; /// Voting proof verification module pub mod voting; +mod bounded_decode; + /// Represents the root hash of the grovedb tree pub type RootHash = [u8; 32]; diff --git a/packages/rs-drive/src/verify/voting/verify_identity_votes_given_proof/v0/mod.rs b/packages/rs-drive/src/verify/voting/verify_identity_votes_given_proof/v0/mod.rs index 7369dd69ec3..73330be21dc 100644 --- a/packages/rs-drive/src/verify/voting/verify_identity_votes_given_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/voting/verify_identity_votes_given_proof/v0/mod.rs @@ -1,12 +1,11 @@ -use crate::drive::votes::storage_form::contested_document_resource_reference_storage_form::ContestedDocumentResourceVoteReferenceStorageForm; use crate::drive::votes::storage_form::contested_document_resource_storage_form::ContestedDocumentResourceVoteStorageForm; use crate::drive::votes::tree_path_storage_form::TreePathStorageForm; use crate::error::drive::DriveError; use crate::error::Error; use crate::query::contested_resource_votes_given_by_identity_query::ContestedResourceVotesGivenByIdentityQuery; use crate::query::ContractLookupFn; +use crate::verify::bounded_decode::decode_vote_reference; use crate::verify::RootHash; -use dpp::bincode; use dpp::identifier::Identifier; use dpp::voting::votes::resource_vote::ResourceVote; use grovedb::GroveDb; @@ -32,19 +31,7 @@ impl ContestedResourceVotesGivenByIdentityQuery { .filter_map(|(path, key, element)| element.map(|element| (path, key, element))) .map(|(path, key, element)| { let serialized_reference = element.into_item_bytes()?; - let bincode_config = bincode::config::standard() - .with_big_endian() - .with_no_limit(); - let reference_storage_form: ContestedDocumentResourceVoteReferenceStorageForm = - bincode::decode_from_slice(&serialized_reference, bincode_config) - .map_err(|e| { - Error::Drive(DriveError::CorruptedSerialization(format!( - "serialization of reference {} is corrupted: {}", - hex::encode(serialized_reference), - e - ))) - })? - .0; + let reference_storage_form = decode_vote_reference(&serialized_reference)?; let absolute_path = reference_storage_form .reference_path_type .absolute_path(path.as_slice(), Some(key.as_slice()))?; diff --git a/packages/rs-drive/src/verify/voting/verify_masternode_vote/v0/mod.rs b/packages/rs-drive/src/verify/voting/verify_masternode_vote/v0/mod.rs index b5009875bf6..6c831b47b07 100644 --- a/packages/rs-drive/src/verify/voting/verify_masternode_vote/v0/mod.rs +++ b/packages/rs-drive/src/verify/voting/verify_masternode_vote/v0/mod.rs @@ -7,12 +7,11 @@ use crate::error::Error; use crate::verify::RootHash; use crate::drive::votes::paths::vote_contested_resource_identity_votes_tree_path_for_identity_vec; -use crate::drive::votes::storage_form::contested_document_resource_reference_storage_form::ContestedDocumentResourceVoteReferenceStorageForm; use crate::drive::votes::storage_form::contested_document_resource_storage_form::ContestedDocumentResourceVoteStorageForm; use crate::drive::votes::tree_path_storage_form::TreePathStorageForm; -use crate::error::drive::DriveError; use crate::error::proof::ProofError; use crate::query::Query; +use crate::verify::bounded_decode::decode_vote_reference; use dpp::voting::votes::Vote; use platform_version::version::PlatformVersion; @@ -82,19 +81,7 @@ impl Drive { let maybe_vote = maybe_element .map(|element| { let serialized_reference = element.into_item_bytes()?; - let bincode_config = bincode::config::standard() - .with_big_endian() - .with_no_limit(); - let reference_storage_form: ContestedDocumentResourceVoteReferenceStorageForm = - bincode::decode_from_slice(&serialized_reference, bincode_config) - .map_err(|e| { - Error::Drive(DriveError::CorruptedSerialization(format!( - "serialization of reference {} is corrupted: {}", - hex::encode(serialized_reference), - e - ))) - })? - .0; + let reference_storage_form = decode_vote_reference(&serialized_reference)?; let absolute_path = reference_storage_form .reference_path_type .absolute_path(path.as_slice(), Some(key.as_slice()))?; diff --git a/packages/rs-unified-sdk-jni/src/tx_decode.rs b/packages/rs-unified-sdk-jni/src/tx_decode.rs index 3dc8cb840ad..02aeafc3f49 100644 --- a/packages/rs-unified-sdk-jni/src/tx_decode.rs +++ b/packages/rs-unified-sdk-jni/src/tx_decode.rs @@ -130,7 +130,13 @@ fn decode_to_blob(tx_bytes: &[u8], network: FFINetwork) -> Result, (i32, }; let mut out: *mut DecodedTransactionFFI = std::ptr::null_mut(); let ok = unsafe { - transaction_decode(tx_bytes.as_ptr(), tx_bytes.len(), network, &mut out, &mut error) + transaction_decode( + tx_bytes.as_ptr(), + tx_bytes.len(), + network, + &mut out, + &mut error, + ) }; if !ok || out.is_null() { let message = if error.message.is_null() { @@ -281,7 +287,10 @@ mod tests { let bytes = serialize(&tx); let blob = decode_to_blob(&bytes, FFINetwork::Testnet).expect("decode ok"); - let mut r = Reader { blob: &blob, pos: 0 }; + let mut r = Reader { + blob: &blob, + pos: 0, + }; assert_eq!(r.take(32), tx.txid().to_byte_array()); assert_eq!(r.u32(), 1, "one input"); @@ -321,7 +330,10 @@ mod tests { fn network_changes_rendered_addresses() { let (tx, addr) = p2pkh_spend_tx(Network::Testnet); let blob = decode_to_blob(&serialize(&tx), FFINetwork::Mainnet).expect("decode ok"); - let mut r = Reader { blob: &blob, pos: 0 }; + let mut r = Reader { + blob: &blob, + pos: 0, + }; r.take(32); r.u32(); r.take(36); diff --git a/packages/swift-sdk/build_ios.sh b/packages/swift-sdk/build_ios.sh index 59e3a77760b..b858f846fb2 100755 --- a/packages/swift-sdk/build_ios.sh +++ b/packages/swift-sdk/build_ios.sh @@ -70,7 +70,7 @@ stage_target_artifacts() { # The final static library and generated headers are all xcodebuild needs. # Release the much larger per-architecture dependency tree before building # the next target so persistent CI runners cannot exhaust their disk. - rm -rf "$TARGET_DIR/$target" + rm -rf "${TARGET_DIR:?}/${target:?}" } # ------------------------------- @@ -132,7 +132,7 @@ done if $CLEAN; then log_info "Cleaning all build artifacts..." - rm -rf "$TARGET_DIR" + rm -rf "${TARGET_DIR:?}" rm -rf "$XCFRAMEWORK" fi @@ -160,9 +160,9 @@ if [ "$PRUNE_CARGO_TARGETS" = "1" ]; then # from an earlier job. Start the bounded build with only Cargo's shared host # cache, then prune each Apple target after staging its final artifacts. rm -rf \ - "$TARGET_DIR/aarch64-apple-ios" \ - "$TARGET_DIR/aarch64-apple-ios-sim" \ - "$TARGET_DIR/aarch64-apple-darwin" + "${TARGET_DIR:?}/aarch64-apple-ios" \ + "${TARGET_DIR:?}/aarch64-apple-ios-sim" \ + "${TARGET_DIR:?}/aarch64-apple-darwin" fi # ------------------------------- diff --git a/packages/swift-sdk/run_tests.sh b/packages/swift-sdk/run_tests.sh index a68320563ff..582e70c5b6b 100755 --- a/packages/swift-sdk/run_tests.sh +++ b/packages/swift-sdk/run_tests.sh @@ -28,14 +28,14 @@ if [ -n "${CI:-}${GITHUB_ACTIONS:-}" ]; then PREV_DEFAULT_KEYCHAIN="$(security default-keychain -d user | sed -E 's/^[[:space:]]*"?//;s/"?[[:space:]]*$//')" restore_default_keychain() { if [ -n "${PREV_DEFAULT_KEYCHAIN:-}" ] && [ -e "$PREV_DEFAULT_KEYCHAIN" ]; then - security default-keychain -s "$PREV_DEFAULT_KEYCHAIN" || true + security default-keychain -d user -s "$PREV_DEFAULT_KEYCHAIN" || true fi } trap restore_default_keychain EXIT security create-keychain -p "" "$CI_KEYCHAIN" 2>/dev/null || true security unlock-keychain -p "" "$CI_KEYCHAIN" security set-keychain-settings "$CI_KEYCHAIN" # no auto-lock timeout - security default-keychain -s "$CI_KEYCHAIN" + security default-keychain -d user -s "$CI_KEYCHAIN" fi # Pick a concrete iOS Simulator for the `xcodebuild test` run. A name