-
Notifications
You must be signed in to change notification settings - Fork 13
feat(dash-spv): prune spent single-use CoinJoin addresses from the filter scan query #949
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
1f7414f
perf(dash-spv): prune spent single-use CoinJoin addresses from the fi…
QuantumExplorer 14b4123
test(key-wallet-manager): benchmark filter matching for mixing-heavy …
QuantumExplorer 53fd906
test(key-wallet-manager): generate the benchmark wallet from a random…
QuantumExplorer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| //! Compact-filter matching cost: full monitored set vs the pruned | ||
| //! forward-scan set for a mixing-heavy CoinJoin wallet | ||
| //! (dashpay/rust-dashcore#948). | ||
| //! | ||
| //! Mimics a wallet mid-recovery after many mixing rounds. Every CoinJoin | ||
| //! round pays a fresh single-use address, so the account accumulates `used` | ||
| //! spent addresses, keeps a small set of still-funded denominations | ||
| //! ([`LIVE_UTXOS`]), and watches the usual gap-limit lookahead on top. One | ||
| //! scan batch of BIP158 filters is then matched with | ||
| //! `monitored_script_pubkeys_for` (the pre-#948 query, which drags every | ||
| //! historical address through SipHash + sort per filter) and with | ||
| //! `scan_script_pubkeys_for` (the pruned query, bounded by live UTXOs + gap | ||
| //! lookahead). | ||
| //! | ||
| //! BIP158 keys each filter's SipHashes off the block hash, so the whole | ||
| //! query set is re-hashed and re-sorted per filter — which is exactly why | ||
| //! the query size dominates and why nothing is cacheable across filters. | ||
| //! | ||
| //! Run with: | ||
| //! `cargo bench -p key-wallet-manager --bench filter_scan` | ||
|
|
||
| use std::collections::HashMap; | ||
| use std::hint::black_box; | ||
|
|
||
| use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; | ||
| use dashcore::bip158::BlockFilter; | ||
| use dashcore::hashes::Hash; | ||
| use dashcore::{Address, Block, OutPoint, Transaction, TxOut, Txid}; | ||
| use key_wallet::account::ManagedAccountTrait; | ||
| use key_wallet::wallet::initialization::WalletAccountCreationOptions; | ||
| use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; | ||
| use key_wallet::{KeySource, ManagedAccountType, Network, Utxo}; | ||
| use key_wallet_manager::{ | ||
| check_compact_filters_for_elements, FilterMatchKey, WalletInterface, WalletManager, | ||
| }; | ||
|
|
||
| /// Deterministic test mnemonic (throwaway; same one the unit tests use). | ||
| const MNEMONIC: &str = | ||
| "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; | ||
|
|
||
| /// Denominated coins still unspent in the CoinJoin account — the wallet's | ||
| /// active mixing balance, which stays in the scan query. | ||
| const LIVE_UTXOS: usize = 200; | ||
|
|
||
| /// Filters matched per iteration — one scan batch. | ||
| const FILTERS: u32 = 512; | ||
|
|
||
| /// Historical single-use address counts to sweep, roughly | ||
| /// `denominations x rounds` at different points of a recovery scan. The | ||
| /// issue's reference wallet starts a mainnet recovery at a few hundred | ||
| /// monitored scripts and ends at several thousand. | ||
| const USED_ADDRESSES: [u32; 3] = [500, 2_000, 6_000]; | ||
|
|
||
| type Manager = WalletManager<ManagedWalletInfo>; | ||
|
|
||
| /// Build a wallet whose CoinJoin account carries `used` spent single-use | ||
| /// addresses, [`LIVE_UTXOS`] still-funded ones, and the default gap-limit | ||
| /// lookahead of unused addresses above them. | ||
| fn wallet_with_mixing_history(used: u32) -> (Manager, [u8; 32]) { | ||
| let mut manager = Manager::new(Network::Regtest); | ||
| let wallet_id = manager | ||
| .create_wallet_from_mnemonic(MNEMONIC, 0, WalletAccountCreationOptions::Default) | ||
| .expect("create wallet"); | ||
|
|
||
| let key_source = KeySource::Public( | ||
| manager | ||
| .get_wallet(&wallet_id) | ||
| .expect("wallet") | ||
| .accounts | ||
| .coinjoin_accounts | ||
| .get(&0) | ||
| .expect("CoinJoin account 0") | ||
| .account_xpub, | ||
| ); | ||
|
|
||
| let info = manager.get_wallet_info_mut(&wallet_id).expect("wallet info"); | ||
| let coinjoin = info.accounts.coinjoin_accounts.get_mut(&0).expect("managed CoinJoin account"); | ||
|
|
||
| // Extend the external (mixed-coin) branch so the pool holds `used` | ||
| // historical addresses plus the pre-generated gap window above them. | ||
| let addresses = { | ||
| let ManagedAccountType::CoinJoin { | ||
| external_addresses, | ||
| .. | ||
| } = coinjoin.managed_account_type_mut() | ||
| else { | ||
| panic!("expected CoinJoin managed account type"); | ||
| }; | ||
| external_addresses | ||
| .generate_addresses(used, &key_source, true) | ||
| .expect("derive CoinJoin addresses"); | ||
| external_addresses.all_addresses() | ||
| }; | ||
|
|
||
| // The first `used` indices each received one mixing round's payout... | ||
| let spent = &addresses[..used as usize]; | ||
| for address in spent { | ||
| assert!(coinjoin.mark_address_used(address), "address should belong to the pool"); | ||
| } | ||
| // ...and only the most recent LIVE_UTXOS denominations remain unspent. | ||
| for (i, address) in spent.iter().rev().take(LIVE_UTXOS).enumerate() { | ||
| let mut txid = [0u8; 32]; | ||
| txid[..4].copy_from_slice(&(i as u32).to_le_bytes()); | ||
| txid[31] = 0xc1; | ||
| let utxo = Utxo::new( | ||
| OutPoint::new(Txid::from_byte_array(txid), 0), | ||
| TxOut { | ||
| value: 100_001, | ||
| script_pubkey: address.script_pubkey(), | ||
| }, | ||
| address.clone(), | ||
| 100 + i as u32, | ||
| false, | ||
| ); | ||
| coinjoin.utxos.insert(utxo.outpoint, utxo); | ||
| } | ||
|
|
||
| (manager, wallet_id) | ||
| } | ||
|
|
||
| /// One scan batch of realistic filters over blocks that do not pay the | ||
| /// wallet. Each block's hash differs, so every filter re-keys its SipHashes | ||
| /// — the property that forces the per-filter re-hash being measured. | ||
| fn scan_batch_filters(count: u32) -> HashMap<FilterMatchKey, BlockFilter> { | ||
| (0..count) | ||
| .map(|height| { | ||
| let third_party = Address::dummy(Network::Regtest, 1_000_000 + height as usize); | ||
| let tx = Transaction::dummy(&third_party, 0..2, &[u64::from(height) + 1, 546]); | ||
| let block = Block::dummy(height, vec![tx]); | ||
| (FilterMatchKey::new(height, block.block_hash()), BlockFilter::dummy(&block)) | ||
| }) | ||
| .collect() | ||
| } | ||
|
|
||
| fn bench_filter_scan(c: &mut Criterion) { | ||
| let filters = scan_batch_filters(FILTERS); | ||
|
|
||
| let mut group = c.benchmark_group("filter_scan"); | ||
| group.sample_size(10); | ||
| group.throughput(Throughput::Elements(u64::from(FILTERS))); | ||
|
|
||
| for used in USED_ADDRESSES { | ||
| let (manager, wallet_id) = wallet_with_mixing_history(used); | ||
| let monitored = manager.monitored_script_pubkeys_for(&wallet_id); | ||
| let pruned = manager.scan_script_pubkeys_for(&wallet_id); | ||
| assert!( | ||
| pruned.len() < monitored.len(), | ||
| "the scan query must shrink once CoinJoin addresses are spent" | ||
| ); | ||
| println!( | ||
| "used={used}: monitored query = {} scripts, pruned scan query = {} scripts", | ||
| monitored.len(), | ||
| pruned.len() | ||
| ); | ||
|
|
||
| for (name, scripts) in [("monitored", &monitored), ("pruned", &pruned)] { | ||
| group.bench_with_input(BenchmarkId::new(name, used), scripts, |b, scripts| { | ||
| b.iter(|| { | ||
| check_compact_filters_for_elements( | ||
| black_box(&filters), | ||
| black_box(scripts), | ||
| &[], | ||
| 0, | ||
| ) | ||
| }) | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| group.finish(); | ||
| } | ||
|
|
||
| criterion_group!(benches, bench_filter_scan); | ||
| criterion_main!(benches); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.