Skip to content

Commit 3644a45

Browse files
committed
Merge #672: Fix wallet sync not finding coins of addresses which are not cached
5c940c3 Fix wallet sync not finding coins of addresses which are not cached (志宇) Pull request description: Fixes #521 Fixes #451 ^ However, only for electrum-based `Blockchain` implementations. For RPC and Compact Block Filters, syncing works differently, and so are the bugs - I have created a separate ticket for this (#677). ### Description Previously, electrum-based blockchain implementations only synced for `scriptPubKey`s that are already cached in `Database`. This PR introduces a feedback mechanism, that uses `stop_gap` and the difference between "current index" and "last active index" to determine whether we need to cache more `scriptPubKeys`. The `WalletSync::wallet_setup` trait now may return an `Error::MissingCachedScripts` error which contains the number of extra `scriptPubKey`s to cache, in order to satisfy `stop_gap` for the next call. `Wallet::sync` now calls `WalletSync` in a loop, caching in-between subsequent calls (if needed). #### Notes to reviewers 1. The caveat to this solution is that it is not the most efficient. Every call to `WalletSync::wallet_setup` starts polling the Electrum-based server for `scriptPubKey`s starting from index 0. However, I feel like this solution is the least "destructive" to the API of `Blockchain`. Also, once the `bdk_core` sync logic is integration, we can select specific ranges of `scriptPubKey`s to sync. 2. Also note that this PR only fixes the issue for electrum-based `Blockchain` implementations (currently `blockchain::electrum` and `blockchain::esplora` only). 3. Another thing to note is that, although `Database` assumes 1-2 keychains, the current `WalletSync` "feedback" only returns one number (which is interpreted as the larger "missing count" of the two keychains). This is done for simplicity, and because we are planning to only have one keychain per database in the future. https://github.com/bitcoindevkit/bdk/blob/f0c876e7bf38566c0d224cbe421ee312ffc06660/src/blockchain/mod.rs#L157-L161 4. Please have a read of #672 (comment) for additional context. ### Checklists #### All Submissions: * [x] I've signed all my commits * [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md) * [x] I ran `cargo fmt` and `cargo clippy` before committing #### Bugfixes: * [x] This pull request breaks the existing API * [x] I've added tests to reproduce the issue which are now passing * [x] I'm linking the issue being fixed by this PR ACKs for top commit: afilini: ACK 5c940c3 Tree-SHA512: aee917ed4821438fc0675241432a7994603a09a77d5a72e96bad863e7cdd55a9bc6fbd931ce096fef1153905cf1b786e1d8d932dc19032d549480bcda7c75d1b
2 parents 277e18f + 5c940c3 commit 3644a45

File tree

6 files changed

+254
-49
lines changed

6 files changed

+254
-49
lines changed

src/blockchain/esplora/reqwest.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,6 @@ impl WalletSync for EsploraBlockchain {
213213
};
214214

215215
database.commit_batch(batch_update)?;
216-
217216
Ok(())
218217
}
219218
}

src/blockchain/script_sync.rs

Lines changed: 68 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ returns associated transactions i.e. electrum.
55
#![allow(dead_code)]
66
use crate::{
77
database::{BatchDatabase, BatchOperations, DatabaseUtils},
8+
error::MissingCachedScripts,
89
wallet::time::Instant,
910
BlockTime, Error, KeychainKind, LocalUtxo, TransactionDetails,
1011
};
@@ -34,11 +35,12 @@ pub fn start<D: BatchDatabase>(db: &D, stop_gap: usize) -> Result<Request<'_, D>
3435
let scripts_needed = db
3536
.iter_script_pubkeys(Some(keychain))?
3637
.into_iter()
37-
.collect();
38+
.collect::<VecDeque<_>>();
3839
let state = State::new(db);
3940

4041
Ok(Request::Script(ScriptReq {
4142
state,
43+
initial_scripts_needed: scripts_needed.len(),
4244
scripts_needed,
4345
script_index: 0,
4446
stop_gap,
@@ -50,6 +52,7 @@ pub fn start<D: BatchDatabase>(db: &D, stop_gap: usize) -> Result<Request<'_, D>
5052
pub struct ScriptReq<'a, D: BatchDatabase> {
5153
state: State<'a, D>,
5254
script_index: usize,
55+
initial_scripts_needed: usize, // if this is 1, we assume the descriptor is not derivable
5356
scripts_needed: VecDeque<Script>,
5457
stop_gap: usize,
5558
keychain: KeychainKind,
@@ -113,43 +116,71 @@ impl<'a, D: BatchDatabase> ScriptReq<'a, D> {
113116
self.script_index += 1;
114117
}
115118

116-
for _ in txids {
117-
self.scripts_needed.pop_front();
118-
}
119+
self.scripts_needed.drain(..txids.len());
119120

120-
let last_active_index = self
121+
// last active index: 0 => No last active
122+
let last = self
121123
.state
122124
.last_active_index
123125
.get(&self.keychain)
124-
.map(|x| x + 1)
125-
.unwrap_or(0); // so no addresses active maps to 0
126-
127-
Ok(
128-
if self.script_index > last_active_index + self.stop_gap
129-
|| self.scripts_needed.is_empty()
130-
{
131-
debug!(
132-
"finished scanning for transactions for keychain {:?} at index {}",
133-
self.keychain, last_active_index
134-
);
135-
// we're done here -- check if we need to do the next keychain
136-
if let Some(keychain) = self.next_keychains.pop() {
137-
self.keychain = keychain;
138-
self.script_index = 0;
139-
self.scripts_needed = self
140-
.state
141-
.db
142-
.iter_script_pubkeys(Some(keychain))?
143-
.into_iter()
144-
.collect();
145-
Request::Script(self)
146-
} else {
147-
Request::Tx(TxReq { state: self.state })
148-
}
149-
} else {
150-
Request::Script(self)
151-
},
152-
)
126+
.map(|&l| l + 1)
127+
.unwrap_or(0);
128+
// remaining scripts left to check
129+
let remaining = self.scripts_needed.len();
130+
// difference between current index and last active index
131+
let current_gap = self.script_index - last;
132+
133+
// this is a hack to check whether the scripts are coming from a derivable descriptor
134+
// we assume for non-derivable descriptors, the initial script count is always 1
135+
let is_derivable = self.initial_scripts_needed > 1;
136+
137+
debug!(
138+
"sync: last={}, remaining={}, diff={}, stop_gap={}",
139+
last, remaining, current_gap, self.stop_gap
140+
);
141+
142+
if is_derivable {
143+
if remaining > 0 {
144+
// we still have scriptPubKeys to do requests for
145+
return Ok(Request::Script(self));
146+
}
147+
148+
if last > 0 && current_gap < self.stop_gap {
149+
// current gap is not large enough to stop, but we are unable to keep checking since
150+
// we have exhausted cached scriptPubKeys, so return error
151+
let err = MissingCachedScripts {
152+
last_count: self.script_index,
153+
missing_count: self.stop_gap - current_gap,
154+
};
155+
return Err(Error::MissingCachedScripts(err));
156+
}
157+
158+
// we have exhausted cached scriptPubKeys and found no txs, continue
159+
}
160+
161+
debug!(
162+
"finished scanning for txs of keychain {:?} at index {:?}",
163+
self.keychain, last
164+
);
165+
166+
if let Some(keychain) = self.next_keychains.pop() {
167+
// we still have another keychain to request txs with
168+
let scripts_needed = self
169+
.state
170+
.db
171+
.iter_script_pubkeys(Some(keychain))?
172+
.into_iter()
173+
.collect::<VecDeque<_>>();
174+
175+
self.keychain = keychain;
176+
self.script_index = 0;
177+
self.initial_scripts_needed = scripts_needed.len();
178+
self.scripts_needed = scripts_needed;
179+
return Ok(Request::Script(self));
180+
}
181+
182+
// We have finished requesting txids, let's get the actual txs.
183+
Ok(Request::Tx(TxReq { state: self.state }))
153184
}
154185
}
155186

@@ -294,6 +325,8 @@ struct State<'a, D> {
294325
tx_missing_conftime: BTreeMap<Txid, TransactionDetails>,
295326
/// The start of the sync
296327
start_time: Instant,
328+
/// Missing number of scripts to cache per keychain
329+
missing_script_counts: HashMap<KeychainKind, usize>,
297330
}
298331

299332
impl<'a, D: BatchDatabase> State<'a, D> {
@@ -305,6 +338,7 @@ impl<'a, D: BatchDatabase> State<'a, D> {
305338
tx_needed: BTreeSet::default(),
306339
tx_missing_conftime: BTreeMap::default(),
307340
start_time: Instant::new(),
341+
missing_script_counts: HashMap::default(),
308342
}
309343
}
310344
fn into_db_update(self) -> Result<D::Batch, Error> {

src/error.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use std::fmt;
1313

1414
use crate::bitcoin::Network;
1515
use crate::{descriptor, wallet, wallet::address_validator};
16-
use bitcoin::OutPoint;
16+
use bitcoin::{OutPoint, Txid};
1717

1818
/// Errors that can be thrown by the [`Wallet`](crate::wallet::Wallet)
1919
#[derive(Debug)]
@@ -125,6 +125,10 @@ pub enum Error {
125125
//DifferentDescriptorStructure,
126126
//Uncapable(crate::blockchain::Capability),
127127
//MissingCachedAddresses,
128+
/// [`crate::blockchain::WalletSync`] sync attempt failed due to missing scripts in cache which
129+
/// are needed to satisfy `stop_gap`.
130+
MissingCachedScripts(MissingCachedScripts),
131+
128132
#[cfg(feature = "electrum")]
129133
/// Electrum client error
130134
Electrum(electrum_client::Error),
@@ -145,6 +149,16 @@ pub enum Error {
145149
Rusqlite(rusqlite::Error),
146150
}
147151

152+
/// Represents the last failed [`crate::blockchain::WalletSync`] sync attempt in which we were short
153+
/// on cached `scriptPubKey`s.
154+
#[derive(Debug)]
155+
pub struct MissingCachedScripts {
156+
/// Number of scripts in which txs were requested during last request.
157+
pub last_count: usize,
158+
/// Minimum number of scripts to cache more of in order to satisfy `stop_gap`.
159+
pub missing_count: usize,
160+
}
161+
148162
impl fmt::Display for Error {
149163
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150164
write!(f, "{:?}", self)

src/testutils/blockchain_tests.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,6 @@ macro_rules! bdk_blockchain_tests {
743743

744744
blockchain.broadcast(&tx1).expect("broadcasting first");
745745
blockchain.broadcast(&tx2).expect("broadcasting replacement");
746-
747746
receiver_wallet.sync(&blockchain, SyncOptions::default()).expect("syncing receiver");
748747
assert_eq!(receiver_wallet.get_balance().expect("balance"), 49_000, "should have received coins once and only once");
749748
}

src/testutils/configurable_blockchain_tests.rs

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ pub trait ConfigurableBlockchainTester<B: ConfigurableBlockchain>: Sized {
2929

3030
if self.config_with_stop_gap(test_client, 0).is_some() {
3131
test_wallet_sync_with_stop_gaps(test_client, self);
32+
test_wallet_sync_fulfills_missing_script_cache(test_client, self);
33+
test_wallet_sync_self_transfer_tx(test_client, self);
3234
} else {
3335
println!(
3436
"{}: Skipped tests requiring config_with_stop_gap.",
@@ -113,16 +115,21 @@ where
113115
} else {
114116
max_balance
115117
};
118+
let details = format!(
119+
"test_vector: [stop_gap: {}, actual_gap: {}, addrs_before: {}, addrs_after: {}]",
120+
stop_gap, actual_gap, addrs_before, addrs_after,
121+
);
122+
println!("{}", details);
116123

117124
// perform wallet sync
118125
wallet.sync(&blockchain, Default::default()).unwrap();
119126

120127
let wallet_balance = wallet.get_balance().unwrap();
121-
122-
let details = format!(
123-
"test_vector: [stop_gap: {}, actual_gap: {}, addrs_before: {}, addrs_after: {}]",
124-
stop_gap, actual_gap, addrs_before, addrs_after,
128+
println!(
129+
"max: {}, min: {}, actual: {}",
130+
max_balance, min_balance, wallet_balance
125131
);
132+
126133
assert!(
127134
wallet_balance <= max_balance,
128135
"wallet balance is greater than received amount: {}",
@@ -138,3 +145,113 @@ where
138145
test_client.generate(1, None);
139146
}
140147
}
148+
149+
/// With a `stop_gap` of x and every x addresses having a balance of 1000 (for y addresses),
150+
/// we expect `Wallet::sync` to correctly self-cache addresses, so that the resulting balance,
151+
/// after sync, should be y * 1000.
152+
fn test_wallet_sync_fulfills_missing_script_cache<T, B>(test_client: &mut TestClient, tester: &T)
153+
where
154+
T: ConfigurableBlockchainTester<B>,
155+
B: ConfigurableBlockchain,
156+
{
157+
// wallet descriptor
158+
let descriptor = "wpkh([c258d2e4/84h/1h/0h]tpubDDYkZojQFQjht8Tm4jsS3iuEmKjTiEGjG6KnuFNKKJb5A6ZUCUZKdvLdSDWofKi4ToRCwb9poe1XdqfUnP4jaJjCB2Zwv11ZLgSbnZSNecE/200/*)";
159+
160+
// amount in sats per tx
161+
const AMOUNT_PER_TX: u64 = 1000;
162+
163+
// addr constants
164+
const ADDR_COUNT: usize = 6;
165+
const ADDR_GAP: usize = 60;
166+
167+
let blockchain =
168+
B::from_config(&tester.config_with_stop_gap(test_client, ADDR_GAP).unwrap()).unwrap();
169+
170+
let wallet = Wallet::new(descriptor, None, Network::Regtest, MemoryDatabase::new()).unwrap();
171+
172+
let expected_balance = (0..ADDR_COUNT).fold(0_u64, |sum, i| {
173+
let addr_i = i * ADDR_GAP;
174+
let address = wallet.get_address(AddressIndex::Peek(addr_i as _)).unwrap();
175+
176+
println!(
177+
"tx: {} sats => [{}] {}",
178+
AMOUNT_PER_TX,
179+
addr_i,
180+
address.to_string()
181+
);
182+
183+
test_client.receive(testutils! {
184+
@tx ( (@addr address.address) => AMOUNT_PER_TX )
185+
});
186+
test_client.generate(1, None);
187+
188+
sum + AMOUNT_PER_TX
189+
});
190+
println!("expected balance: {}, syncing...", expected_balance);
191+
192+
// perform sync
193+
wallet.sync(&blockchain, Default::default()).unwrap();
194+
println!("sync done!");
195+
196+
let balance = wallet.get_balance().unwrap();
197+
assert_eq!(balance, expected_balance);
198+
}
199+
200+
/// Given a `stop_gap`, a wallet with a 2 transactions, one sending to `scriptPubKey` at derivation
201+
/// index of `stop_gap`, and the other spending from the same `scriptPubKey` into another
202+
/// `scriptPubKey` at derivation index of `stop_gap * 2`, we expect `Wallet::sync` to perform
203+
/// correctly, so that we detect the total balance.
204+
fn test_wallet_sync_self_transfer_tx<T, B>(test_client: &mut TestClient, tester: &T)
205+
where
206+
T: ConfigurableBlockchainTester<B>,
207+
B: ConfigurableBlockchain,
208+
{
209+
const TRANSFER_AMOUNT: u64 = 10_000;
210+
const STOP_GAP: usize = 75;
211+
212+
let descriptor = "wpkh(tprv8i8F4EhYDMquzqiecEX8SKYMXqfmmb1Sm7deoA1Hokxzn281XgTkwsd6gL8aJevLE4aJugfVf9MKMvrcRvPawGMenqMBA3bRRfp4s1V7Eg3/*)";
213+
214+
let blockchain =
215+
B::from_config(&tester.config_with_stop_gap(test_client, STOP_GAP).unwrap()).unwrap();
216+
217+
let wallet = Wallet::new(descriptor, None, Network::Regtest, MemoryDatabase::new()).unwrap();
218+
219+
let address1 = wallet
220+
.get_address(AddressIndex::Peek(STOP_GAP as _))
221+
.unwrap();
222+
let address2 = wallet
223+
.get_address(AddressIndex::Peek((STOP_GAP * 2) as _))
224+
.unwrap();
225+
226+
test_client.receive(testutils! {
227+
@tx ( (@addr address1.address) => TRANSFER_AMOUNT )
228+
});
229+
test_client.generate(1, None);
230+
231+
wallet.sync(&blockchain, Default::default()).unwrap();
232+
233+
let mut builder = wallet.build_tx();
234+
builder.add_recipient(address2.script_pubkey(), TRANSFER_AMOUNT / 2);
235+
let (mut psbt, details) = builder.finish().unwrap();
236+
assert!(wallet.sign(&mut psbt, Default::default()).unwrap());
237+
blockchain.broadcast(&psbt.extract_tx()).unwrap();
238+
239+
test_client.generate(1, None);
240+
241+
// obtain what is expected
242+
let fee = details.fee.unwrap();
243+
let expected_balance = TRANSFER_AMOUNT - fee;
244+
println!("fee={}, expected_balance={}", fee, expected_balance);
245+
246+
// actually test the wallet
247+
wallet.sync(&blockchain, Default::default()).unwrap();
248+
let balance = wallet.get_balance().unwrap();
249+
assert_eq!(balance, expected_balance);
250+
251+
// now try with a fresh wallet
252+
let fresh_wallet =
253+
Wallet::new(descriptor, None, Network::Regtest, MemoryDatabase::new()).unwrap();
254+
fresh_wallet.sync(&blockchain, Default::default()).unwrap();
255+
let fresh_balance = fresh_wallet.get_balance().unwrap();
256+
assert_eq!(fresh_balance, expected_balance);
257+
}

0 commit comments

Comments
 (0)