From 990212c288694987e57605a6141d1031ff41fb68 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:14:40 +0000 Subject: [PATCH 1/3] test(sdk): fill the verified protocol version in identities contract keys query tests The two QuerySettings literals added in #5491 predate the verified_protocol_version field, so `cargo test -p dash-sdk --lib` no longer compiled. Set it to 0 like the crate's other test literals. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019H9THphMnuQyh9oPY13SoE --- packages/rs-sdk/src/platform/identities_contract_keys_query.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/rs-sdk/src/platform/identities_contract_keys_query.rs b/packages/rs-sdk/src/platform/identities_contract_keys_query.rs index 749eb2a071a..ad19d6bbf1f 100644 --- a/packages/rs-sdk/src/platform/identities_contract_keys_query.rs +++ b/packages/rs-sdk/src/platform/identities_contract_keys_query.rs @@ -222,6 +222,7 @@ mod tests { let settings = QuerySettings { request_settings: &request_settings, protocol_version: PlatformVersion::latest(), + verified_protocol_version: 0, prove: true, }; let sent: GetIdentitiesContractKeysRequest = query.query(&settings).expect("request"); @@ -243,6 +244,7 @@ mod tests { let settings = QuerySettings { request_settings: &request_settings, protocol_version: PlatformVersion::latest(), + verified_protocol_version: 0, prove: false, }; let sent: GetIdentitiesContractKeysRequest = query.query(&settings).expect("request"); From d817fc42ffe23b77eb7700d992522b059082b27d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:20:40 +0000 Subject: [PATCH 2/3] test(sdk): reproduce the skipped compacted phase when the recent proof has no boundary Extract the Phase 2 decision of incremental_catch_up into the pure needs_compacted_phase and test it with real, decodable recent proofs. Mock expectations return an empty proof, whose decode error takes the "run compacted" arm and hides the bug. Reproduces testnet SH-040: the trunk checkpoint predates a funding in a since-compacted block, and the RangeFrom recent proof holds a later block but no boundary, so the compacted phase is skipped and the credit is lost. The no-boundary test fails until the fix lands. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019H9THphMnuQyh9oPY13SoE --- .../rs-sdk/src/platform/address_sync/mod.rs | 150 ++++++++++++++---- 1 file changed, 121 insertions(+), 29 deletions(-) diff --git a/packages/rs-sdk/src/platform/address_sync/mod.rs b/packages/rs-sdk/src/platform/address_sync/mod.rs index f9096966ec6..72c7402407b 100644 --- a/packages/rs-sdk/src/platform/address_sync/mod.rs +++ b/packages/rs-sdk/src/platform/address_sync/mod.rs @@ -616,35 +616,7 @@ async fn incremental_catch_up( // If last_recent_block < checkpoint_height → there's a gap that may contain // compacted data. Query compacted. // If no boundaries → recent tree is empty, nothing to compact. Skip. - let need_compacted = match get_last_recent_block_from_proof(&recent_proof) { - Ok(Some(last_recent_block)) => { - if last_recent_block >= result.checkpoint_height { - debug!( - "Address sync: last recent block {} >= checkpoint {} — skipping compacted", - last_recent_block, result.checkpoint_height - ); - false - } else { - debug!( - "Address sync: last recent block {} < checkpoint {} — need compacted", - last_recent_block, result.checkpoint_height - ); - true - } - } - Ok(None) => { - // No boundaries in recent tree → empty, nothing to compact - debug!("Address sync: recent tree empty (no boundaries) — skipping compacted"); - false - } - Err(e) => { - debug!( - "Address sync: boundary extraction failed ({}), running compacted to be safe", - e - ); - true - } - }; + let need_compacted = needs_compacted_phase(&recent_proof, result.checkpoint_height); // Phase 2b — Compacted (historical) catch-up (conditional) if need_compacted { @@ -786,6 +758,40 @@ async fn incremental_catch_up( Ok(()) } +/// Phase 2 of [`incremental_catch_up`]: whether the compacted phase must run, +/// judged from the boundary nodes of the recent query's proof. +fn needs_compacted_phase(recent_proof: &Proof, checkpoint_height: u64) -> bool { + match get_last_recent_block_from_proof(recent_proof) { + Ok(Some(last_recent_block)) => { + if last_recent_block >= checkpoint_height { + debug!( + "Address sync: last recent block {} >= checkpoint {} — skipping compacted", + last_recent_block, checkpoint_height + ); + false + } else { + debug!( + "Address sync: last recent block {} < checkpoint {} — need compacted", + last_recent_block, checkpoint_height + ); + true + } + } + Ok(None) => { + // No boundaries in recent tree → empty, nothing to compact + debug!("Address sync: recent tree empty (no boundaries) — skipping compacted"); + false + } + Err(e) => { + debug!( + "Address sync: boundary extraction failed ({}), running compacted to be safe", + e + ); + true + } + } +} + // ── Address-balance change application ──────────────────────────────── /// A single borrowed address balance change, abstracting the recent @@ -1394,6 +1400,12 @@ impl Sdk { #[cfg(test)] mod tests { use super::*; + use drive::grovedb::operations::proof::{ + GroveDBProof, GroveDBProofV0, MerkOnlyLayerProof, ProveOptions, + }; + use drive::grovedb::{MerkProofNode, MerkProofOp}; + use drive::grovedb_query::proofs::encode_into; + use std::collections::BTreeMap; #[test] fn test_extract_funds_from_element() { @@ -1526,6 +1538,86 @@ mod tests { assert!(result.is_err()); } + /// A decodable recent-query proof whose `[SavedBlockTransactions, 'm']` + /// layer holds `ops`. Mock expectations return an empty proof, which takes + /// the decode-error arm, so the boundary arms need a real one. + fn recent_proof(ops: &[MerkProofOp]) -> Proof { + let mut merk_proof = Vec::new(); + encode_into(ops.iter(), &mut merk_proof); + let layer = |merk_proof, key: u8, child| MerkOnlyLayerProof { + merk_proof, + lower_layers: BTreeMap::from([(vec![key], child)]), + }; + let address_balances = MerkOnlyLayerProof { + merk_proof, + lower_layers: BTreeMap::new(), + }; + let root_layer = layer( + Vec::new(), + RootTree::SavedBlockTransactions as u8, + layer(Vec::new(), ADDRESS_BALANCES_KEY_U8, address_balances), + ); + let proof = GroveDBProof::V0(GroveDBProofV0 { + root_layer, + prove_options: ProveOptions::default(), + }); + let config = dpp::bincode::config::standard() + .with_big_endian() + .with_limit::<{ 256 * 1024 * 1024 }>(); + Proof { + grovedb_proof: dpp::bincode::encode_to_vec(&proof, config).expect("encode proof"), + ..Default::default() + } + } + + /// A block returned as a query result: a `KV` node, never a boundary. + fn block_result(height: u64) -> MerkProofOp { + MerkProofOp::Push(MerkProofNode::KV(height.to_be_bytes().to_vec(), vec![0; 4])) + } + + /// The start key of an exclusive range that is still in the recent tree. + fn boundary(height: u64) -> MerkProofOp { + MerkProofOp::Push(MerkProofNode::KVDigest( + height.to_be_bytes().to_vec(), + [0; 32], + )) + } + + /// Testnet SH-040: the trunk checkpoint (642131) predates a funding in a + /// block the server then compacted. The `RangeFrom` recent proof holds only + /// the later block 642139 and no boundary, so only the compacted phase can + /// deliver the credit. A genuinely empty recent zone proves no more. + #[test] + fn should_run_compacted_phase_when_recent_proof_has_no_boundary() { + for proof in [recent_proof(&[block_result(642_139)]), recent_proof(&[])] { + assert!(matches!(get_last_recent_block_from_proof(&proof), Ok(None))); + assert!(needs_compacted_phase(&proof, 642_131)); + } + } + + #[test] + fn should_skip_compacted_phase_when_a_boundary_reaches_the_checkpoint() { + for height in [642_131, 642_135] { + let proof = recent_proof(&[boundary(height), block_result(height + 1)]); + assert!(!needs_compacted_phase(&proof, 642_131)); + } + } + + #[test] + fn should_run_compacted_phase_when_the_boundary_predates_the_checkpoint() { + let proof = recent_proof(&[boundary(642_125), block_result(642_126)]); + assert!(needs_compacted_phase(&proof, 642_131)); + } + + #[test] + fn should_run_compacted_phase_when_recent_proof_is_undecodable() { + let proof = Proof { + grovedb_proof: vec![0xFF, 0xFE, 0xFD, 0xFC], + ..Default::default() + }; + assert!(needs_compacted_phase(&proof, 642_131)); + } + #[test] fn test_result_new_sync_height_max() { // new_sync_height should be max of current and observed tip From ddbcda1e7313867e280e6a3a5fbd93a8ee7a40f1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:21:42 +0000 Subject: [PATCH 3/3] fix(sdk): query compacted address balances when the recent proof has no boundary Phase 2 of incremental catch-up read "no boundary in the recent proof" as "recent tree empty, nothing to compact" and skipped the compacted query. A RangeFrom proof (every first sync of a fresh wallet) does not carry its start key as a boundary, and a RangeAfter start key that was compacted away leaves none either. Either way, a credit that landed after the checkpoint in a since-compacted block was lost until the next full rescan (about 7 days by default). Seen on testnet as a 2.22e9-credit funding the wallet reported as 0. Run the compacted phase whenever the proof carries no boundary. The per-operation height pin in apply_op keeps compacted operations at or below the checkpoint, and recent blocks already inside a compacted range, from being counted twice. Cost: one extra compacted RPC in that case. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019H9THphMnuQyh9oPY13SoE --- .../rs-sdk/src/platform/address_sync/mod.rs | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/rs-sdk/src/platform/address_sync/mod.rs b/packages/rs-sdk/src/platform/address_sync/mod.rs index 72c7402407b..27fea204a16 100644 --- a/packages/rs-sdk/src/platform/address_sync/mod.rs +++ b/packages/rs-sdk/src/platform/address_sync/mod.rs @@ -495,25 +495,27 @@ fn seed_base_balances( /// address balance changes RPCs. /// /// The function queries recent changes **first** to obtain a GroveDB proof, then -/// uses [`Drive::verify_key_exists_as_boundary`] to check whether the boundary -/// height still exists in the recent address balances tree. If it does, the -/// compacted phase is skipped entirely -- this is the common hot path for -/// frequent 15-second re-syncs where only the recent zone has changed. +/// reads the boundary nodes of the recent address balances tree from it. If a +/// boundary at or above the checkpoint is present, the compacted phase is +/// skipped entirely -- this is the common hot path for frequent 15-second +/// re-syncs where only the recent zone has changed. /// /// When `last_known_recent_block > 0`, the recent query uses **exclusive start** -/// (`RangeAfter`) with that height. This causes the height to appear as a -/// **boundary node** in the proof, which `key_exists_as_boundary` can detect. -/// When `last_known_recent_block == 0`, the query falls back to inclusive start -/// (`RangeFrom` on `start_height`) and the boundary check is skipped (compacted -/// phase always runs). +/// (`RangeAfter`) with that height. While the height is still in the recent +/// tree, it appears as a **boundary node** in the proof. Once compacted away, +/// it does not. When `last_known_recent_block == 0`, the query falls back to +/// inclusive start (`RangeFrom` on `start_height`), whose proof does not carry +/// its start key as a boundary. A proof with no boundary cannot show that the +/// recent zone covers the blocks since `start_height`, so the compacted phase +/// runs. /// /// - **Phase 1 (recent, always first)**: Single non-paginated query via /// `fetch_with_metadata_and_proof`. The recent (uncompacted) zone covers at /// most 64 blocks, well under the server limit of 100 entries per request. /// /// - **Phase 2 (compacted, conditional)**: Paginated query (25-entry batches) -/// covering the compacted range. Only runs when the boundary check detects that -/// the cursor height was compacted away. +/// covering the compacted range. Skipped only when the recent proof shows the +/// recent zone reaches back to the checkpoint. /// /// - **Phase 3 (apply recent)**: The recent results fetched in Phase 1 are applied /// after the (optional) compacted phase. @@ -607,15 +609,19 @@ async fn incremental_catch_up( // // Based on three values: // 1. checkpoint_height: the tree scan snapshot height - // 2. last_recent_block: highest boundary in the recent proof (the actual - // last block in the recent tree) + // 2. last_recent_block: highest boundary in the recent proof, a block + // proven to still be in the recent tree // 3. Whether boundaries exist at all // // If last_recent_block >= checkpoint_height → recent covers the full range, // no compacted data can exist in the gap. Skip compacted. // If last_recent_block < checkpoint_height → there's a gap that may contain // compacted data. Query compacted. - // If no boundaries → recent tree is empty, nothing to compact. Skip. + // If no boundaries → nothing proves the recent tree reaches back to + // start_height. A `RangeFrom` proof does not carry its start key as + // one, and a `RangeAfter` start key compacted away leaves none, while + // the blocks since start_height may sit in the compacted tree. Query + // compacted. let need_compacted = needs_compacted_phase(&recent_proof, result.checkpoint_height); // Phase 2b — Compacted (historical) catch-up (conditional) @@ -778,9 +784,8 @@ fn needs_compacted_phase(recent_proof: &Proof, checkpoint_height: u64) -> bool { } } Ok(None) => { - // No boundaries in recent tree → empty, nothing to compact - debug!("Address sync: recent tree empty (no boundaries) — skipping compacted"); - false + debug!("Address sync: recent proof has no boundary — need compacted"); + true } Err(e) => { debug!( @@ -1178,7 +1183,8 @@ async fn refresh_and_replay_unknown( /// /// Returns: /// - `Ok(Some(height))` — the highest block height found as a boundary -/// - `Ok(None)` — no boundaries found (recent tree is empty) +/// - `Ok(None)` — no boundaries found; this does not mean the recent tree is +/// empty, only that the proof shows no block still in it /// - `Err(...)` — proof decoding failed fn get_last_recent_block_from_proof(proof: &Proof) -> Result, Error> { let path: [&[u8]; 2] = [