Skip to content

Commit 3625495

Browse files
committed
Fix wallet sync not finding coins of addresses which are not cached
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, cacheing inbetween subsequent calls (if needed).
1 parent 9165fae commit 3625495

File tree

6 files changed

+326
-44
lines changed

6 files changed

+326
-44
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: 70 additions & 31 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,13 @@ 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<_>>();
39+
println!("start: scripts_needed count: {}", scripts_needed.len());
3840
let state = State::new(db);
3941

4042
Ok(Request::Script(ScriptReq {
4143
state,
44+
initial_scripts_needed: scripts_needed.len(),
4245
scripts_needed,
4346
script_index: 0,
4447
stop_gap,
@@ -50,6 +53,7 @@ pub fn start<D: BatchDatabase>(db: &D, stop_gap: usize) -> Result<Request<'_, D>
5053
pub struct ScriptReq<'a, D: BatchDatabase> {
5154
state: State<'a, D>,
5255
script_index: usize,
56+
initial_scripts_needed: usize, // if this is 1, we assume the descriptor is not derivable
5357
scripts_needed: VecDeque<Script>,
5458
stop_gap: usize,
5559
keychain: KeychainKind,
@@ -117,39 +121,71 @@ impl<'a, D: BatchDatabase> ScriptReq<'a, D> {
117121
self.scripts_needed.pop_front();
118122
}
119123

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

@@ -294,6 +330,8 @@ struct State<'a, D> {
294330
tx_missing_conftime: BTreeMap<Txid, TransactionDetails>,
295331
/// The start of the sync
296332
start_time: Instant,
333+
/// Missing number of scripts to cache per keychain
334+
missing_script_counts: HashMap<KeychainKind, usize>,
297335
}
298336

299337
impl<'a, D: BatchDatabase> State<'a, D> {
@@ -305,6 +343,7 @@ impl<'a, D: BatchDatabase> State<'a, D> {
305343
tx_needed: BTreeSet::default(),
306344
tx_missing_conftime: BTreeMap::default(),
307345
start_time: Instant::new(),
346+
missing_script_counts: HashMap::default(),
308347
}
309348
}
310349
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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -742,9 +742,11 @@ macro_rules! bdk_blockchain_tests {
742742
};
743743

744744
blockchain.broadcast(&tx1).expect("broadcasting first");
745-
blockchain.broadcast(&tx2).expect("broadcasting replacement");
745+
receiver_wallet.sync(&blockchain, SyncOptions::default()).expect("syncing receiver");
746746

747+
blockchain.broadcast(&tx2).expect("broadcasting replacement");
747748
receiver_wallet.sync(&blockchain, SyncOptions::default()).expect("syncing receiver");
749+
748750
assert_eq!(receiver_wallet.get_balance().expect("balance"), 49_000, "should have received coins once and only once");
749751
}
750752

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)