Skip to content

Commit b2ac4a0

Browse files
committed
Merge #461: Restructure electrum/esplora sync logic
9c57708 Make stop_gap a parameter to EsploraBlockchainConfig::new (LLFourn) 0f0a01a s/vin/vout/ (LLFourn) 1a64fd9 Delete src/blockchain/utils.rs (LLFourn) d3779fa Fix comments (LLFourn) d394011 Less intermediary data states in sync (LLFourn) dfb63d3 s/observed_txs/finished_txs/g (LLFourn) 188d9a4 Make variable names consistent (LLFourn) 5eadf5c Add some logging to script_sync (LLFourn) aaad560 Always get up to chunk_size heights to request headers for (LLFourn) e7c1357 Don't request conftime during tx request (LLFourn) 808d7d8 Update changelog (LLFourn) 732166f Fix feerate calculation for esplora (LLFourn) 3f5cb69 Invert dependencies in electrum sync (LLFourn) Pull request description: ## Description This PR does dependency inversion on the previous sync logic for electrum and esplora captured in the trait `ElectrumLikeSync`. This means that the sync logic does not reference the blockchain at all. Instead the blockchain asks the sync logic (in `script_sync.rs`) what it needs to continue the sync and tries to retrieve it. The initial purpose of doing this is to remove invocations of `maybe_await` in the abstract sync logic in preparation for completely removing `maybe_await` in the future. The other major benefit is it gives a lot more freedom for the esplora logic to use the rich data from the responses to complete the sync with less HTTP requests than it did previously. ## List of changes - sync logic moved to `script_sync.rs` and `ElectrumLikeSync` is gone. - esplora makes one http request per sync address. This means it makes half the number of http requests for a fully synced wallet and N*M less requests for a wallet which has N new transactions with M unique input transactions. - electrum and esplora save less raw transactions in the database. Electrum still requests input transactions for each of its transactions to calculate the fee but it does not save them to the database anymore. - The ureq and reqwest blockchain configuration is now unified into the same struct. This is the only API change. `read_timeout` and `write_timeout` have been removed in favor of a single `timeout` option which is set in both ureq and reqwest. - ureq now does concurrent (parallel) requests using threads. - An previously unnoticed bug has been fixed where by sending a lot of double spending transactions to the same address you could trick a bdk Esplora wallet into thinking it had a lot of unconfirmed coins. This is because esplora doesn't delete double spent transactions from its indexes immediately (not sure if this is a bug or a feature). A blockchain test is added for this. - BONUS: The second commit in this PR fixes the feerate calculation for esplora and adds a test (the previous algorithm didn't work at all). I could have made a separate PR but since I was touching this file a lot I decided to fix it here. ## Notes to the reviewers - The most important thing to review is the the logic in `script_sync.rs` is sound. - Look at the two commits separately. - I think CI is failing because of MSRV problems again! - It would be cool to measure how much sync time is improved for your existing wallets/projects. For `gun` the speed improvements for modest but it is at least hammering the esplora server much less. - I noticed the performance of reqwest in blocking is much worse in this patch than previously. This is because somehow reqwest is not re-using the connection for each request in this new code. I have no idea why. The plan is to get rid of the blocking reqwest implementation in a follow up PR. ### Checklists #### All Submissions: * [x] I've signed all my commits ACKs for top commit: rajarshimaitra: Retested ACK a630685 Tree-SHA512: de74981e9d1f80758a9f20a3314ed7381c6b7c635f7ede80b177651fe2f9e9468064fae26bf80d4254098accfacfe50326ae0968e915186e13313f05bf77990b
2 parents afa1ab4 + a630685 commit b2ac4a0

File tree

11 files changed

+1125
-817
lines changed

11 files changed

+1125
-817
lines changed

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88

99
- BIP39 implementation dependency, in `keys::bip39` changed from tiny-bip39 to rust-bip39.
1010
- Add new method on the `TxBuilder` to embed data in the transaction via `OP_RETURN`. To allow that a fix to check the dust only on spendable output has been introduced.
11+
- Overhauled sync logic for electrum and esplora.
12+
- Unify ureq and reqwest esplora backends to have the same configuration parameters. This means reqwest now has a timeout parameter and ureq has a concurrency parameter.
13+
- Fixed esplora fee estimation.
1114
- Update the `Database` trait to store the last sync timestamp and block height
1215
- Rename `ConfirmationTime` to `BlockTime`
1316

@@ -393,4 +396,4 @@ final transaction is created by calling `finish` on the builder.
393396
[v0.10.0]: https://github.com/bitcoindevkit/bdk/compare/v0.9.0...v0.10.0
394397
[v0.11.0]: https://github.com/bitcoindevkit/bdk/compare/v0.10.0...v0.11.0
395398
[v0.12.0]: https://github.com/bitcoindevkit/bdk/compare/v0.11.0...v0.12.0
396-
[v0.13.0]: https://github.com/bitcoindevkit/bdk/compare/v0.12.0...v0.13.0
399+
[v0.13.0]: https://github.com/bitcoindevkit/bdk/compare/v0.12.0...v0.13.0

src/blockchain/electrum.rs

Lines changed: 175 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,20 @@
2424
//! # Ok::<(), bdk::Error>(())
2525
//! ```
2626
27-
use std::collections::HashSet;
27+
use std::collections::{HashMap, HashSet};
2828

2929
#[allow(unused_imports)]
3030
use log::{debug, error, info, trace};
3131

32-
use bitcoin::{BlockHeader, Script, Transaction, Txid};
32+
use bitcoin::{Transaction, Txid};
3333

3434
use electrum_client::{Client, ConfigBuilder, ElectrumApi, Socks5Config};
3535

36-
use self::utils::{ElectrumLikeSync, ElsGetHistoryRes};
36+
use super::script_sync::Request;
3737
use super::*;
38-
use crate::database::BatchDatabase;
38+
use crate::database::{BatchDatabase, Database};
3939
use crate::error::Error;
40-
use crate::FeeRate;
40+
use crate::{BlockTime, FeeRate};
4141

4242
/// Wrapper over an Electrum Client that implements the required blockchain traits
4343
///
@@ -71,10 +71,139 @@ impl Blockchain for ElectrumBlockchain {
7171
fn setup<D: BatchDatabase, P: Progress>(
7272
&self,
7373
database: &mut D,
74-
progress_update: P,
74+
_progress_update: P,
7575
) -> Result<(), Error> {
76-
self.client
77-
.electrum_like_setup(self.stop_gap, database, progress_update)
76+
let mut request = script_sync::start(database, self.stop_gap)?;
77+
let mut block_times = HashMap::<u32, u32>::new();
78+
let mut txid_to_height = HashMap::<Txid, u32>::new();
79+
let mut tx_cache = TxCache::new(database, &self.client);
80+
let chunk_size = self.stop_gap;
81+
// The electrum server has been inconsistent somehow in its responses during sync. For
82+
// example, we do a batch request of transactions and the response contains less
83+
// tranascations than in the request. This should never happen but we don't want to panic.
84+
let electrum_goof = || Error::Generic("electrum server misbehaving".to_string());
85+
86+
let batch_update = loop {
87+
request = match request {
88+
Request::Script(script_req) => {
89+
let scripts = script_req.request().take(chunk_size);
90+
let txids_per_script: Vec<Vec<_>> = self
91+
.client
92+
.batch_script_get_history(scripts)
93+
.map_err(Error::Electrum)?
94+
.into_iter()
95+
.map(|txs| {
96+
txs.into_iter()
97+
.map(|tx| {
98+
let tx_height = match tx.height {
99+
none if none <= 0 => None,
100+
height => {
101+
txid_to_height.insert(tx.tx_hash, height as u32);
102+
Some(height as u32)
103+
}
104+
};
105+
(tx.tx_hash, tx_height)
106+
})
107+
.collect()
108+
})
109+
.collect();
110+
111+
script_req.satisfy(txids_per_script)?
112+
}
113+
114+
Request::Conftime(conftime_req) => {
115+
// collect up to chunk_size heights to fetch from electrum
116+
let needs_block_height = {
117+
let mut needs_block_height_iter = conftime_req
118+
.request()
119+
.filter_map(|txid| txid_to_height.get(txid).cloned())
120+
.filter(|height| block_times.get(height).is_none());
121+
let mut needs_block_height = HashSet::new();
122+
123+
while needs_block_height.len() < chunk_size {
124+
match needs_block_height_iter.next() {
125+
Some(height) => needs_block_height.insert(height),
126+
None => break,
127+
};
128+
}
129+
needs_block_height
130+
};
131+
132+
let new_block_headers = self
133+
.client
134+
.batch_block_header(needs_block_height.iter().cloned())?;
135+
136+
for (height, header) in needs_block_height.into_iter().zip(new_block_headers) {
137+
block_times.insert(height, header.time);
138+
}
139+
140+
let conftimes = conftime_req
141+
.request()
142+
.take(chunk_size)
143+
.map(|txid| {
144+
let confirmation_time = txid_to_height
145+
.get(txid)
146+
.map(|height| {
147+
let timestamp =
148+
*block_times.get(height).ok_or_else(electrum_goof)?;
149+
Result::<_, Error>::Ok(BlockTime {
150+
height: *height,
151+
timestamp: timestamp.into(),
152+
})
153+
})
154+
.transpose()?;
155+
Ok(confirmation_time)
156+
})
157+
.collect::<Result<_, Error>>()?;
158+
159+
conftime_req.satisfy(conftimes)?
160+
}
161+
Request::Tx(tx_req) => {
162+
let needs_full = tx_req.request().take(chunk_size);
163+
tx_cache.save_txs(needs_full.clone())?;
164+
let full_transactions = needs_full
165+
.map(|txid| tx_cache.get(*txid).ok_or_else(electrum_goof))
166+
.collect::<Result<Vec<_>, _>>()?;
167+
let input_txs = full_transactions.iter().flat_map(|tx| {
168+
tx.input
169+
.iter()
170+
.filter(|input| !input.previous_output.is_null())
171+
.map(|input| &input.previous_output.txid)
172+
});
173+
tx_cache.save_txs(input_txs)?;
174+
175+
let full_details = full_transactions
176+
.into_iter()
177+
.map(|tx| {
178+
let prev_outputs = tx
179+
.input
180+
.iter()
181+
.map(|input| {
182+
if input.previous_output.is_null() {
183+
return Ok(None);
184+
}
185+
let prev_tx = tx_cache
186+
.get(input.previous_output.txid)
187+
.ok_or_else(electrum_goof)?;
188+
let txout = prev_tx
189+
.output
190+
.get(input.previous_output.vout as usize)
191+
.ok_or_else(electrum_goof)?;
192+
Ok(Some(txout.clone()))
193+
})
194+
.collect::<Result<Vec<_>, Error>>()?;
195+
Ok((prev_outputs, tx))
196+
})
197+
.collect::<Result<Vec<_>, Error>>()?;
198+
199+
tx_req.satisfy(full_details)?
200+
}
201+
Request::Finish(batch_update) => break batch_update,
202+
}
203+
};
204+
205+
database.commit_batch(batch_update)?;
206+
Ok(())
78207
}
79208

80209
fn get_tx(&self, txid: &Txid) -> Result<Option<Transaction>, Error> {
@@ -101,43 +230,48 @@ impl Blockchain for ElectrumBlockchain {
101230
}
102231
}
103232

104-
impl ElectrumLikeSync for Client {
105-
fn els_batch_script_get_history<'s, I: IntoIterator<Item = &'s Script> + Clone>(
106-
&self,
107-
scripts: I,
108-
) -> Result<Vec<Vec<ElsGetHistoryRes>>, Error> {
109-
self.batch_script_get_history(scripts)
110-
.map(|v| {
111-
v.into_iter()
112-
.map(|v| {
113-
v.into_iter()
114-
.map(
115-
|electrum_client::GetHistoryRes {
116-
height, tx_hash, ..
117-
}| ElsGetHistoryRes {
118-
height,
119-
tx_hash,
120-
},
121-
)
122-
.collect()
123-
})
124-
.collect()
125-
})
126-
.map_err(Error::Electrum)
233+
struct TxCache<'a, 'b, D> {
234+
db: &'a D,
235+
client: &'b Client,
236+
cache: HashMap<Txid, Transaction>,
237+
}
238+
239+
impl<'a, 'b, D: Database> TxCache<'a, 'b, D> {
240+
fn new(db: &'a D, client: &'b Client) -> Self {
241+
TxCache {
242+
db,
243+
client,
244+
cache: HashMap::default(),
245+
}
127246
}
247+
fn save_txs<'c>(&mut self, txids: impl Iterator<Item = &'c Txid>) -> Result<(), Error> {
248+
let mut need_fetch = vec![];
249+
for txid in txids {
250+
if self.cache.get(txid).is_some() {
251+
continue;
252+
} else if let Some(transaction) = self.db.get_raw_tx(txid)? {
253+
self.cache.insert(*txid, transaction);
254+
} else {
255+
need_fetch.push(txid);
256+
}
257+
}
128258

129-
fn els_batch_transaction_get<'s, I: IntoIterator<Item = &'s Txid> + Clone>(
130-
&self,
131-
txids: I,
132-
) -> Result<Vec<Transaction>, Error> {
133-
self.batch_transaction_get(txids).map_err(Error::Electrum)
259+
if !need_fetch.is_empty() {
260+
let txs = self
261+
.client
262+
.batch_transaction_get(need_fetch.clone())
263+
.map_err(Error::Electrum)?;
264+
for (tx, _txid) in txs.into_iter().zip(need_fetch) {
265+
debug_assert_eq!(*_txid, tx.txid());
266+
self.cache.insert(tx.txid(), tx);
267+
}
268+
}
269+
270+
Ok(())
134271
}
135272

136-
fn els_batch_block_header<I: IntoIterator<Item = u32> + Clone>(
137-
&self,
138-
heights: I,
139-
) -> Result<Vec<BlockHeader>, Error> {
140-
self.batch_block_header(heights).map_err(Error::Electrum)
273+
fn get(&self, txid: Txid) -> Option<Transaction> {
274+
self.cache.get(&txid).map(Clone::clone)
141275
}
142276
}
143277

src/blockchain/esplora/api.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
//! structs from the esplora API
2+
//!
3+
//! see: <https://github.com/Blockstream/esplora/blob/master/API.md>
4+
use crate::BlockTime;
5+
use bitcoin::{OutPoint, Script, Transaction, TxIn, TxOut, Txid};
6+
7+
#[derive(serde::Deserialize, Clone, Debug)]
8+
pub struct PrevOut {
9+
pub value: u64,
10+
pub scriptpubkey: Script,
11+
}
12+
13+
#[derive(serde::Deserialize, Clone, Debug)]
14+
pub struct Vin {
15+
pub txid: Txid,
16+
pub vout: u32,
17+
// None if coinbase
18+
pub prevout: Option<PrevOut>,
19+
pub scriptsig: Script,
20+
#[serde(deserialize_with = "deserialize_witness")]
21+
pub witness: Vec<Vec<u8>>,
22+
pub sequence: u32,
23+
pub is_coinbase: bool,
24+
}
25+
26+
#[derive(serde::Deserialize, Clone, Debug)]
27+
pub struct Vout {
28+
pub value: u64,
29+
pub scriptpubkey: Script,
30+
}
31+
32+
#[derive(serde::Deserialize, Clone, Debug)]
33+
pub struct TxStatus {
34+
pub confirmed: bool,
35+
pub block_height: Option<u32>,
36+
pub block_time: Option<u64>,
37+
}
38+
39+
#[derive(serde::Deserialize, Clone, Debug)]
40+
pub struct Tx {
41+
pub txid: Txid,
42+
pub version: i32,
43+
pub locktime: u32,
44+
pub vin: Vec<Vin>,
45+
pub vout: Vec<Vout>,
46+
pub status: TxStatus,
47+
pub fee: u64,
48+
}
49+
50+
impl Tx {
51+
pub fn to_tx(&self) -> Transaction {
52+
Transaction {
53+
version: self.version,
54+
lock_time: self.locktime,
55+
input: self
56+
.vin
57+
.iter()
58+
.cloned()
59+
.map(|vin| TxIn {
60+
previous_output: OutPoint {
61+
txid: vin.txid,
62+
vout: vin.vout,
63+
},
64+
script_sig: vin.scriptsig,
65+
sequence: vin.sequence,
66+
witness: vin.witness,
67+
})
68+
.collect(),
69+
output: self
70+
.vout
71+
.iter()
72+
.cloned()
73+
.map(|vout| TxOut {
74+
value: vout.value,
75+
script_pubkey: vout.scriptpubkey,
76+
})
77+
.collect(),
78+
}
79+
}
80+
81+
pub fn confirmation_time(&self) -> Option<BlockTime> {
82+
match self.status {
83+
TxStatus {
84+
confirmed: true,
85+
block_height: Some(height),
86+
block_time: Some(timestamp),
87+
} => Some(BlockTime { timestamp, height }),
88+
_ => None,
89+
}
90+
}
91+
92+
pub fn previous_outputs(&self) -> Vec<Option<TxOut>> {
93+
self.vin
94+
.iter()
95+
.cloned()
96+
.map(|vin| {
97+
vin.prevout.map(|po| TxOut {
98+
script_pubkey: po.scriptpubkey,
99+
value: po.value,
100+
})
101+
})
102+
.collect()
103+
}
104+
}
105+
106+
fn deserialize_witness<'de, D>(d: D) -> Result<Vec<Vec<u8>>, D::Error>
107+
where
108+
D: serde::de::Deserializer<'de>,
109+
{
110+
use crate::serde::Deserialize;
111+
use bitcoin::hashes::hex::FromHex;
112+
let list = Vec::<String>::deserialize(d)?;
113+
list.into_iter()
114+
.map(|hex_str| Vec::<u8>::from_hex(&hex_str))
115+
.collect::<Result<Vec<Vec<u8>>, _>>()
116+
.map_err(serde::de::Error::custom)
117+
}

0 commit comments

Comments
 (0)