esplora-client = { version = "0.12.0" }
bdk_chain = { path = "../chain" }
bdk_testenv = { path = "../testenv" }
-tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
+tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "time"] }
[features]
default = ["std", "async-https", "blocking-https"]
let spk = spk.spk;
async move {
let mut last_seen = None;
+ let mut seen_cursors = HashSet::new();
let mut spk_txs = Vec::new();
loop {
let txs = client.scripthash_txs(&spk, last_seen).await?;
if tx_count < 25 {
break;
}
+ // Paging from a cursor we have already paged from means the server is
+ // not paging forward (it repeats or cycles), so we would loop forever.
+ if !seen_cursors.insert(last_seen) {
+ return Err(Box::new(esplora_client::Error::InvalidResponse));
+ }
}
let got_txids = spk_txs.iter().map(|tx| tx.txid).collect::<HashSet<_>>();
let evicted_txids = expected_txids
let spk = spk.spk;
std::thread::spawn(move || -> Result<TxsOfSpkIndex, Error> {
let mut last_txid = None;
+ let mut seen_cursors = HashSet::new();
let mut spk_txs = Vec::new();
loop {
let txs = client.scripthash_txs(&spk, last_txid)?;
if tx_count < 25 {
break;
}
+ // Paging from a cursor we have already paged from means the server is
+ // not paging forward (it repeats or cycles), so we would loop forever.
+ if !seen_cursors.insert(last_txid) {
+ return Err(Box::new(esplora_client::Error::InvalidResponse));
+ }
}
let got_txids = spk_txs.iter().map(|tx| tx.txid).collect::<HashSet<_>>();
let evicted_txids = expected_txids
Ok(())
}
+
+// Ensure `sync` gives up on a server that keeps returning the same page of transactions.
+//
+// The per-spk pagination loop advances by asking for the transactions following the last txid of
+// the previous page. A server that answers every request with the same full page leaves the cursor
+// where it was, which used to make the loop request that page forever. See bitcoindevkit/bdk#2339.
+#[tokio::test]
+async fn sync_terminates_when_server_page_does_not_advance() {
+ let base_url = common::spawn_non_advancing_server();
+ let client = Builder::new(&base_url)
+ .build_async()
+ .expect("must build client");
+
+ let request = SyncRequest::builder().spks([common::get_test_spk()]);
+ let result = tokio::time::timeout(Duration::from_secs(10), client.sync(request, 1))
+ .await
+ .expect("`sync` must terminate");
+ assert!(
+ matches!(
+ result.unwrap_err().as_ref(),
+ esplora_client::Error::InvalidResponse
+ ),
+ "`sync` must reject a page that does not advance the cursor"
+ );
+}
Ok(())
}
+
+// Ensure `sync` gives up on a server that keeps returning the same page of transactions.
+//
+// The per-spk pagination loop advances by asking for the transactions following the last txid of
+// the previous page. A server that answers every request with the same full page leaves the cursor
+// where it was, which used to make the loop request that page forever. See bitcoindevkit/bdk#2339.
+#[test]
+fn sync_terminates_when_server_page_does_not_advance() {
+ let base_url = common::spawn_non_advancing_server();
+ let client = Builder::new(&base_url).build_blocking();
+
+ let (result_tx, result_rx) = std::sync::mpsc::channel();
+ std::thread::spawn(move || {
+ let request = SyncRequest::builder().spks([common::get_test_spk()]);
+ let _ = result_tx.send(client.sync(request, 1).map(|_| ()));
+ });
+
+ let result = result_rx
+ .recv_timeout(Duration::from_secs(10))
+ .expect("`sync` must terminate");
+ assert!(
+ matches!(
+ result.unwrap_err().as_ref(),
+ esplora_client::Error::InvalidResponse
+ ),
+ "`sync` must reject a page that does not advance the cursor"
+ );
+}
use bdk_core::bitcoin::key::{Secp256k1, UntweakedPublicKey};
use bdk_core::bitcoin::{Address, ScriptBuf};
+use std::io::{BufRead, BufReader, Write};
+use std::net::TcpListener;
use std::str::FromStr;
const PK_BYTES: &[u8] = &[
.map(|s| Address::from_str(s).unwrap().assume_checked())
.collect()
}
+
+/// Number of transactions per page requested by `bdk_esplora`.
+const PAGE_SIZE: u32 = 25;
+
+/// A full page of transactions, always ending on the same txid.
+fn non_advancing_page() -> String {
+ let txs = (0..PAGE_SIZE)
+ .map(|i| {
+ serde_json::json!({
+ "txid": format!("{i:064x}"),
+ "version": 2,
+ "locktime": 0,
+ "vin": [],
+ "vout": [],
+ "size": 100,
+ "weight": 400,
+ "status": {
+ "confirmed": false,
+ "block_height": null,
+ "block_hash": null,
+ "block_time": null
+ },
+ "fee": 0
+ })
+ })
+ .collect::<Vec<_>>();
+ serde_json::to_string(&txs).expect("page must serialize")
+}
+
+/// Spawns a stub Esplora server which answers every request with the same full page of
+/// transactions, emulating a server that never pages forward.
+///
+/// Returns the base URL to point an Esplora client at. The listener is served until the test
+/// binary exits.
+pub fn spawn_non_advancing_server() -> String {
+ let listener = TcpListener::bind("127.0.0.1:0").expect("must bind to loopback");
+ let base_url = format!(
+ "http://{}",
+ listener.local_addr().expect("must have local addr")
+ );
+ let body = non_advancing_page();
+
+ std::thread::spawn(move || {
+ for stream in listener.incoming() {
+ let Ok(mut stream) = stream else { continue };
+ // Every path gets the same answer, so there is nothing to parse. We still consume the
+ // request head, otherwise closing the connection could abort the client's write.
+ let mut reader = BufReader::new(stream.try_clone().expect("must clone stream"));
+ let mut line = String::new();
+ while reader.read_line(&mut line).unwrap_or(0) > 0 {
+ if line == "\r\n" || line == "\n" {
+ break;
+ }
+ line.clear();
+ }
+ let _ = write!(
+ stream,
+ "HTTP/1.1 200 OK\r\n\
+ content-type: application/json\r\n\
+ content-length: {}\r\n\
+ connection: close\r\n\
+ \r\n\
+ {body}",
+ body.len()
+ );
+ }
+ });
+
+ base_url
+}