Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d718ad8
test(key-wallet-manager): deterministic repro of the out-of-order spe…
lklimek Jul 7, 2026
adaf9a3
fix(key-wallet): root-cause fix for out-of-order spend-before-funding…
lklimek Jul 7, 2026
e4406b0
test(key-wallet-manager): adversarial single-block scaling check for …
lklimek Jul 7, 2026
0a2c6f9
style(key-wallet-manager): fmt fix for adversarial stress test
lklimek Jul 7, 2026
7ef88d3
fix(key-wallet): keep transaction_history in step with balance after …
lklimek Jul 7, 2026
0185922
fix(key-wallet): make #649 guard removal idempotent across reprocessi…
lklimek Jul 7, 2026
1ac46f1
test(key-wallet): round-3 adversarial QA for #649 reprocessing fix
lklimek Jul 7, 2026
20600fe
test(key-wallet): pin non-invariant — history_net != balance for prun…
lklimek Jul 7, 2026
e2703df
refactor(key-wallet): thread observed-spent view into record/update_u…
lklimek Jul 7, 2026
71d17b8
fix(key-wallet): check-before-insert against observed_spent (#649 B3)
lklimek Jul 7, 2026
62bbf35
fix(key-wallet): declarative funding-first recompute in guard removal…
lklimek Jul 7, 2026
4db4c01
test(key-wallet): fix over-asserting gap-4 test — scope β to live rec…
lklimek Jul 7, 2026
f4dbf1c
docs(key-wallet): fix stale self-review comment on record_transaction…
lklimek Jul 7, 2026
c2184f3
fix(key-wallet): anchor net_amount to account_match, not a re-derived…
lklimek Jul 7, 2026
8bde8eb
test(key-wallet): Marvin adversarial QA for #649 restructure — gap #5…
lklimek Jul 7, 2026
c272fa6
docs(key-wallet): trim over-budget comments, drop history/contrast fr…
lklimek Jul 7, 2026
6217f1f
fix(key-wallet): clear script_pubkey_index alongside address_index in…
lklimek Jul 7, 2026
58d0662
revert(key-wallet): drop account_match-anchoring refactor for RUST-00…
lklimek Jul 7, 2026
52d978d
test(key-wallet): Marvin follow-up on Smythe's SEC-02 — net_amount so…
lklimek Jul 7, 2026
5558434
test(key-wallet): construct Adams's RUST-001 scenarios 1 and 2, both …
lklimek Jul 7, 2026
d48e078
fix(key-wallet): adapt ported SEC-02 desync test to construct via dir…
lklimek Jul 7, 2026
48a07d3
fix(key-wallet): reword doc comment to satisfy typos pre-commit hook
lklimek Jul 8, 2026
45c518f
refactor(key-wallet): address PR #851 review findings for #649 fix
lklimek Jul 8, 2026
5d7737b
feat(key-wallet): bound observed_spent_outpoints via finality-boundar…
lklimek Jul 8, 2026
8d103eb
test(key-wallet): pin QA-002 — late-added account resurrects pruned #…
lklimek Jul 8, 2026
98bc636
fix(key-wallet): rewind sync checkpoint on account add to close QA-001
lklimek Jul 8, 2026
5a61739
test: Marvin round-2 adversarial checks on QA-001 rewind/contiguity fix
lklimek Jul 8, 2026
b3fb824
Merge branch 'dev' into fix/key-wallet-out-of-order-utxo-spend-649
lklimek Jul 9, 2026
be6e776
Merge branch 'dev' into fix/key-wallet-out-of-order-utxo-spend-649
lklimek Jul 13, 2026
73dcf3d
Merge branch 'dev' into fix/key-wallet-out-of-order-utxo-spend-649
lklimek Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 157 additions & 1 deletion dash-spv/src/sync/filters/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,15 @@ impl<H: BlockHeaderStorage, FH: FilterHeaderStorage, F: FilterStorage, W: Wallet
if !scanned_wallets.is_empty() {
let mut wallet = self.wallet.write().await;
for wallet_id in &scanned_wallets {
wallet.update_wallet_synced_height(wallet_id, end);
// Contiguity guard: a batch extends a wallet's certified
// coverage only if the wallet was already certified up to
// the batch's start. A checkpoint rewound after this batch
// was scanned (an account added mid-flight) stays behind
// and is picked up by the tick rescan instead of being
// silently clobbered forward (dashpay/rust-dashcore#649).
if wallet.wallet_synced_height(wallet_id).saturating_add(1) >= batch_start {
wallet.update_wallet_synced_height(wallet_id, end);
}
}
}
}
Expand Down Expand Up @@ -1373,6 +1381,154 @@ mod tests {
assert_eq!(multi.read().await.wallet_synced_height(&wallet_b), 0);
}

/// Contiguity guard (dashpay/rust-dashcore#649): a batch scanned before an
/// account-add rewinds the wallet's checkpoint must NOT clobber the rewound
/// value forward at commit time — otherwise the account-addition rescan is
/// silently cancelled. The wallet stays behind and the tick picks it up.
#[tokio::test]
async fn mid_flight_account_add_does_not_clobber_rescan_floor() {
let wallet_a: WalletId = [0xAA; 32];
let multi = Arc::new(RwLock::new(MultiMockWallet::new()));
{
let mut w = multi.write().await;
// Already synced up to 4999, so the next batch legitimately starts at 5000.
w.insert_wallet(
wallet_a,
MockWalletState {
synced_height: 4999,
..MockWalletState::default()
},
);
}
let mut manager = create_multi_test_manager(multi.clone()).await;
manager.set_state(SyncState::Syncing);

// Batch [5000..9999] scanned with wallet_a recorded, ready to commit.
let mut batch = FiltersBatch::new(5000, 9999, HashMap::new());
batch.set_pending_blocks(0);
batch.mark_scanned();
batch.mark_rescan_complete();
batch.set_scanned_wallets(BTreeSet::from([wallet_a]));
manager.active_batches.insert(5000, batch);

// Simulate an account being added mid-flight: the wallet's checkpoint is
// rewound to just below birth, far below this batch's start.
multi.write().await.wallet_mut(&wallet_a).synced_height = 49;

manager.try_commit_batches().await.unwrap();

// committed_height still advances (chain progress), but the wallet's
// checkpoint is NOT dragged forward over the gap it must rescan.
assert_eq!(manager.progress.committed_height(), 9999);
assert_eq!(
multi.read().await.wallet_synced_height(&wallet_a),
49,
"the rewound checkpoint must survive commit — the batch is non-contiguous with it"
);
// The wallet is still behind, so the next tick rescans it.
assert!(
multi.read().await.wallets_behind(9999).contains(&wallet_a),
"the wallet remains behind and is picked up by the tick rescan"
);
}

/// The contiguity guard is transparent in normal operation: contiguous
/// ascending batches advance each wallet's checkpoint exactly as before.
#[tokio::test]
async fn contiguity_guard_permits_normal_advance() {
let wallet_a: WalletId = [0xCC; 32];
let multi = Arc::new(RwLock::new(MultiMockWallet::new()));
multi.write().await.insert_wallet(wallet_a, MockWalletState::default());
let mut manager = create_multi_test_manager(multi.clone()).await;
manager.set_state(SyncState::Syncing);

// First batch starts at 0 = synced_height (0) + ... the wallet is fresh,
// so this batch is contiguous and advances the checkpoint.
let mut batch1 = FiltersBatch::new(0, 4999, HashMap::new());
batch1.set_pending_blocks(0);
batch1.mark_scanned();
batch1.mark_rescan_complete();
batch1.set_scanned_wallets(BTreeSet::from([wallet_a]));
manager.active_batches.insert(0, batch1);

manager.try_commit_batches().await.unwrap();
assert_eq!(multi.read().await.wallet_synced_height(&wallet_a), 4999);

// Second batch starts exactly at synced_height + 1 = 5000: still
// contiguous, so it advances too.
let mut batch2 = FiltersBatch::new(5000, 9999, HashMap::new());
batch2.set_pending_blocks(0);
batch2.mark_scanned();
batch2.mark_rescan_complete();
batch2.set_scanned_wallets(BTreeSet::from([wallet_a]));
manager.active_batches.insert(5000, batch2);

manager.try_commit_batches().await.unwrap();
assert_eq!(
multi.read().await.wallet_synced_height(&wallet_a),
9999,
"contiguous ascending batches advance the checkpoint unchanged by the guard"
);
}

/// Marvin QA-001 round 2: two wallets in the SAME batch, only one of them
/// rewound mid-flight (simulating an account add on wallet_a only). The
/// contiguity guard must block wallet_a's advance while still advancing
/// wallet_b normally — a per-wallet leak here would either strand a
/// healthy wallet or silently clobber the rewound one.
#[tokio::test]
async fn contiguity_guard_is_per_wallet_not_batch_wide() {
let wallet_a: WalletId = [0xAA; 32];
let wallet_b: WalletId = [0xBB; 32];
let multi = Arc::new(RwLock::new(MultiMockWallet::new()));
{
let mut w = multi.write().await;
w.insert_wallet(
wallet_a,
MockWalletState {
synced_height: 4999,
..MockWalletState::default()
},
);
w.insert_wallet(
wallet_b,
MockWalletState {
synced_height: 4999,
..MockWalletState::default()
},
);
}
let mut manager = create_multi_test_manager(multi.clone()).await;
manager.set_state(SyncState::Syncing);

// Batch [5000..9999] scanned with BOTH wallets recorded.
let mut batch = FiltersBatch::new(5000, 9999, HashMap::new());
batch.set_pending_blocks(0);
batch.mark_scanned();
batch.mark_rescan_complete();
batch.set_scanned_wallets(BTreeSet::from([wallet_a, wallet_b]));
manager.active_batches.insert(5000, batch);

// Only wallet_a gets an account added mid-flight (rewound). wallet_b
// is untouched and remains legitimately contiguous with this batch.
multi.write().await.wallet_mut(&wallet_a).synced_height = 49;

manager.try_commit_batches().await.unwrap();

assert_eq!(manager.progress.committed_height(), 9999);
assert_eq!(
multi.read().await.wallet_synced_height(&wallet_a),
49,
"wallet_a's rewound checkpoint must survive commit"
);
assert_eq!(
multi.read().await.wallet_synced_height(&wallet_b),
9999,
"wallet_b was legitimately contiguous and must still advance despite \
sharing a batch with a rewound wallet"
);
}

/// `scan_batch` with two wallets at different `synced_height` values:
/// only the wallet whose synced_height is below the matching block's
/// height should be attributed.
Expand Down
74 changes: 74 additions & 0 deletions key-wallet-manager/tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//! Shared helpers for the observed-spent-outpoint guard integration tests
//! (dashpay/rust-dashcore#649).
//!
//! Each test binary pulls this in via `mod common;` and uses a subset of the
//! helpers, so unused-item warnings are expected per binary and silenced here.
#![allow(dead_code)]

use dashcore::blockdata::block::Block;
use dashcore::blockdata::transaction::OutPoint;
use dashcore::{Address, Network, ScriptBuf, Transaction, TxIn, TxOut, Witness};
use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
use key_wallet_manager::{WalletId, WalletInterface, WalletManager};
use std::collections::BTreeSet;

/// Deliver `block` at `height` to the given `wallets`.
pub async fn process_block_for(
manager: &mut WalletManager<ManagedWalletInfo>,
block: &Block,
height: u32,
wallets: &BTreeSet<WalletId>,
) {
manager.process_block_for_wallets(block, block.block_hash(), height, wallets).await;
}

/// Deliver `block` at `height` to every wallet the manager holds.
pub async fn process_block_all_wallets(
manager: &mut WalletManager<ManagedWalletInfo>,
block: &Block,
height: u32,
) {
let wallet_ids: BTreeSet<WalletId> = manager.list_wallets().into_iter().copied().collect();
process_block_for(manager, block, height, &wallet_ids).await;
}

/// A transaction paying `value` to `address` from one synthetic, unrelated
/// input (`input_seed` makes its txid deterministic and distinct). Only the
/// transaction's own txid/vout matter to the tests, as the coin later spent.
pub fn funding_tx(address: &Address, value: u64, input_seed: u8) -> Transaction {
Transaction {
version: 2,
lock_time: 0,
input: vec![TxIn {
previous_output: OutPoint::new(dashcore::Txid::from([input_seed; 32]), 0),
script_sig: ScriptBuf::new(),
sequence: 0xffffffff,
witness: Witness::new(),
}],
output: vec![TxOut {
value,
script_pubkey: address.script_pubkey(),
}],
special_transaction_payload: None,
}
}

/// A transaction spending `outpoint` and paying `value` to an unrelated
/// external dummy address (`ext_id` selects a distinct address).
pub fn spend_tx(outpoint: OutPoint, value: u64, ext_id: usize) -> Transaction {
Transaction {
version: 2,
lock_time: 0,
input: vec![TxIn {
previous_output: outpoint,
script_sig: ScriptBuf::new(),
sequence: 0xffffffff,
witness: Witness::new(),
}],
output: vec![TxOut {
value,
script_pubkey: Address::dummy(Network::Testnet, ext_id).script_pubkey(),
}],
special_transaction_payload: None,
}
}
143 changes: 143 additions & 0 deletions key-wallet-manager/tests/observed_spent_large_block_stress_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//! Adversarial stress test for the observed-spent-outpoint guard
//! (dashpay/rust-dashcore#649) at the *single-block* granularity, as opposed
//! to spreading growth across many small blocks.
//!
//! `process_block_for_wallets` (`key-wallet-manager/src/process_block.rs`)
//! iterates every transaction in `block.txdata` and calls
//! `check_transaction_in_wallets` for EACH ONE, unconditionally — a matched
//! block is delivered whole once ANY of its transactions matches a wallet's
//! compact filter. `ManagedWalletInfo::check_core_transaction`
//! (`key-wallet/src/transaction_checking/wallet_checker.rs`) then records
//! every input of every transaction it sees in block context into
//! `observed_spent_outpoints`, and — for transactions it judges irrelevant —
//! also calls `remove_spent_from_accounts`, which allocates a
//! `Vec<&mut ManagedCoreFundsAccount>` via `all_funding_accounts_mut()` and
//! attempts a UTXO-map removal per input, for every one of those irrelevant
//! transactions.
//!
//! The sibling `many_orphaned_spend_blocks_stay_bounded_and_linear`
//! (`key-wallet/src/tests/observed_spent_outpoints_tests.rs`) spreads growth
//! across many separate `check_transaction` calls at distinct heights. That
//! does not exercise what a real matched block looks like: many transactions
//! sharing ONE height/block, most of them entirely unrelated to the wallet.
//! This test constructs a single `Block` with 5,000 unrelated 3-input
//! transactions plus one wallet-relevant spend, and verifies both correctness
//! (the bug is still caught when the relevant tx is buried in a large noisy
//! block) and the actual growth/cost this produces.

mod common;

use common::process_block_all_wallets;
use dashcore::blockdata::block::Block;
use dashcore::blockdata::transaction::OutPoint;
use dashcore::{Network, ScriptBuf, Transaction, TxIn, TxOut, Witness};
use key_wallet::wallet::initialization::WalletAccountCreationOptions;
use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
use key_wallet_manager::WalletManager;
use std::time::Instant;

const NOISE_TXS_PER_BLOCK: usize = 5_000;
const INPUTS_PER_NOISE_TX: usize = 3;

/// An unrelated 3-input transaction paying an external address, with inputs
/// deterministically derived from `seed` so every one is distinct and none
/// resolves to any outpoint the wallet will ever fund.
fn noise_tx(seed: u32) -> Transaction {
let input = (0..INPUTS_PER_NOISE_TX as u32)
.map(|v| {
let mut bytes = [0u8; 32];
bytes[..4].copy_from_slice(&seed.to_le_bytes());
bytes[4] = v as u8;
TxIn {
previous_output: OutPoint::new(dashcore::Txid::from(bytes), v),
script_sig: ScriptBuf::new(),
sequence: 0xffffffff,
witness: Witness::new(),
}
})
.collect();
Transaction {
version: 2,
lock_time: 0,
input,
output: vec![TxOut {
value: 1_000,
script_pubkey: dashcore::Address::dummy(Network::Testnet, (seed % 90) as usize + 1)
.script_pubkey(),
}],
special_transaction_payload: None,
}
}

#[tokio::test]
async fn wallet_relevant_spend_buried_in_large_noisy_block_still_caught() {
let mut manager = WalletManager::<ManagedWalletInfo>::new(Network::Testnet);
let wallet_id = manager
.create_wallet_with_random_mnemonic(WalletAccountCreationOptions::Default)
.expect("failed to create wallet");
let funding_address =
manager.monitored_addresses().first().cloned().expect("wallet must have addresses");

let funding_value = 1_000_000u64;
let funding_tx = common::funding_tx(&funding_address, funding_value, 0xAB);
let funding_outpoint = OutPoint::new(funding_tx.txid(), 0);

let spend_tx = common::spend_tx(funding_outpoint, funding_value - 1_000, 99);

// One large block: 5,000 unrelated noise transactions with the single
// wallet-relevant spend buried in the middle — this is the realistic
// shape of "a matched block" (dash-spv delivers the whole block once any
// tx in it matches the filter), not 5,000 separate block deliveries.
let mut txdata: Vec<Transaction> = (0..NOISE_TXS_PER_BLOCK as u32).map(noise_tx).collect();
let mid = txdata.len() / 2;
txdata.insert(mid, spend_tx.clone());
let expected_inputs_in_spend_block =
NOISE_TXS_PER_BLOCK * INPUTS_PER_NOISE_TX + 1 /* spend_tx's own input */;

let spend_block = Block::dummy(200, txdata);

let start = Instant::now();
process_block_all_wallets(&mut manager, &spend_block, 200).await;
let large_block_elapsed = start.elapsed();

let funding_block = Block::dummy(100, vec![funding_tx.clone()]);
process_block_all_wallets(&mut manager, &funding_block, 100).await;

let utxos_after = manager.wallet_utxos(&wallet_id).expect("wallet must be registered");
let still_tracked = utxos_after.iter().any(|u| u.outpoint == funding_outpoint);
assert!(
!still_tracked,
"BUG #649 regression: the relevant spend buried among {NOISE_TXS_PER_BLOCK} unrelated \
transactions in the same block was not correctly reconciled against its \
out-of-order funding"
);

let observed_len =
manager.get_wallet_info(&wallet_id).expect("wallet info").observed_spent_outpoints().len();
let expected_total = expected_inputs_in_spend_block + 1 /* funding_tx's own input */;
assert_eq!(
observed_len, expected_total,
"a single matched block with {NOISE_TXS_PER_BLOCK} unrelated txs records one \
observed-spent entry per input in the WHOLE block, not just the relevant tx — \
set grew to {observed_len} entries from ONE block, confirming growth scales with \
transactions-per-block, not with block count"
);

eprintln!(
"single block with {} txs ({} inputs) processed in {:?} ({:.1} us/input); \
observed_spent_outpoints now holds {} entries after ONE block",
NOISE_TXS_PER_BLOCK + 1,
expected_inputs_in_spend_block,
large_block_elapsed,
large_block_elapsed.as_micros() as f64 / expected_inputs_in_spend_block as f64,
observed_len,
);

// Per-block processing cost, reported as a diagnostic rather than asserted:
// a wall-clock threshold flakes on shared CI runners, while correctness is
// already pinned above. A multi-second time here would flag the un-gated
// recording/removal seam as a production concern for busy blocks.
eprintln!(
"single block with {NOISE_TXS_PER_BLOCK} unrelated txs processed in {large_block_elapsed:?}"
);
}
Loading
Loading