Skip to main content

bark/
chain.rs

1
2
3use std::borrow::Borrow;
4use std::collections::{HashMap, HashSet};
5use std::str::FromStr as _;
6use std::sync::Arc;
7use std::time::Duration;
8
9use anyhow::Context;
10use bark_runtime::Instant;
11use bdk_core::{BlockId, CheckPoint};
12use bdk_esplora::esplora_client;
13use bitcoin::constants::genesis_block;
14use bitcoin::{
15	Amount, Block, BlockHash, FeeRate, Network, OutPoint, Transaction, Txid, Weight,
16};
17use log::{debug, info, warn};
18use tokio::sync::RwLock;
19
20use bitcoin_ext::{BlockHeight, BlockRef, FeeRateExt, TxStatus};
21use bitcoin_ext::rpc;
22#[cfg(feature = "bitcoind-rpc")]
23use bitcoin_ext::rpc::{
24	BitcoinAsyncRpcExt, BitcoinRpcClient, RPC_INVALID_ADDRESS_OR_KEY,
25	RPC_VERIFY_ALREADY_IN_UTXO_SET,
26};
27#[cfg(feature = "bitcoind-rpc")]
28use bitcoind_async_client::Client as BitcoindClient;
29#[cfg(feature = "bitcoind-rpc")]
30use bitcoind_async_client::error::ClientError as BitcoindClientError;
31#[cfg(feature = "bitcoind-rpc")]
32use bitcoind_async_client::traits::{Broadcaster, Reader};
33
34use crate::daemon::tip_watcher::{TipSource, TipWatcher};
35
36const FEE_RATE_TARGET_CONF_FAST: u16 = 1;
37const FEE_RATE_TARGET_CONF_REGULAR: u16 = 3;
38const FEE_RATE_TARGET_CONF_SLOW: u16 = 6;
39
40/// Coalesce bursts of `tip()` calls within the same `Wallet::sync()` cycle
41/// (parallel sub-syncs each fetch tip, and the exit progress state machine
42/// fetches it twice per iteration). Short enough to be invisible to tests
43/// and UI; long enough to dedupe within a single sync burst.
44const TIP_CACHE_TTL: Duration = Duration::from_secs(1);
45
46/// Fee estimates change on the scale of minutes, so refreshing more often
47/// than this buys nothing while costing one HTTP round trip per sync tick.
48const FEE_RATES_CACHE_TTL: Duration = Duration::from_secs(30);
49
50#[cfg(feature = "bitcoind-rpc")]
51const MIN_BITCOIND_VERSION: usize = 290000;
52
53/// Configuration for the onchain data source.
54///
55/// [ChainSource] selects which backend to use for blockchain data and transaction broadcasting:
56/// - Bitcoind: uses a Bitcoin Core node via JSON-RPC
57/// - Esplora: uses the HTTP API endpoint of [esplora-electrs](https://github.com/Blockstream/electrs)
58///
59/// Typical usage is to construct a ChainSource from configuration and pass it to
60/// [ChainSource::new] along with the expected [Network].
61///
62/// Notes:
63/// - For [ChainSourceSpec::Bitcoind], authentication must be provided (cookie file or user/pass)
64///   and the node must run with `txindex=1`.
65#[derive(Clone, Debug)]
66pub enum ChainSourceSpec {
67	Bitcoind {
68		/// RPC URL of the Bitcoin Core node (e.g. <http://127.0.0.1:8332>).
69		url: String,
70		/// Authentication method for JSON-RPC (cookie file or user/pass).
71		auth: rpc::Auth,
72		/// ZMQ endpoint of the node (e.g. `tcp://127.0.0.1:28332`), used to get
73		/// notified of new blocks. When unset, the chain tip is polled instead.
74		zmq: Option<String>,
75	},
76	Esplora {
77		/// Base URL of the esplora-electrs instance (e.g. <https://esplora.signet.2nd.dev>).
78		url: String,
79	},
80}
81
82impl ChainSourceSpec {
83	pub(crate) fn url(&self) -> &String {
84		match self {
85			ChainSourceSpec::Bitcoind { url, .. } => url,
86			ChainSourceSpec::Esplora { url } => url,
87		}
88	}
89}
90
91pub enum ChainSourceClient {
92	/// Native bitcoind backend.
93	///
94	/// Carries an async client for everything the wallet does asynchronously
95	/// and a sync companion for `bdk_bitcoind_rpc::Emitter`, which is sync-only
96	/// upstream and runs inside `tokio::task::spawn_blocking`.
97	#[cfg(feature = "bitcoind-rpc")]
98	Bitcoind {
99		rpc: BitcoindClient,
100		sync: BitcoinRpcClient,
101	},
102	Esplora(esplora_client::AsyncClient),
103}
104
105impl ChainSourceClient {
106	async fn check_network(&self, expected: Network) -> anyhow::Result<()> {
107		match self {
108			#[cfg(feature = "bitcoind-rpc")]
109			ChainSourceClient::Bitcoind { rpc, .. } => {
110				let network = rpc.network().await?;
111				if expected != network {
112					bail!("Network mismatch: expected {:?}, got {:?}", expected, network);
113				}
114			},
115			ChainSourceClient::Esplora(client) => {
116				let res = client.client().get(format!("{}/block-height/0", client.url()))
117					.send().await?.text().await?;
118				let genesis_hash = BlockHash::from_str(&res)
119					.context("bad response from server (not a blockhash). Esplora client possibly misconfigured")?;
120				if genesis_hash != genesis_block(expected).block_hash() {
121					bail!("Network mismatch: expected {:?}, got {:?}", expected, genesis_hash);
122				}
123			},
124		};
125
126		Ok(())
127	}
128}
129
130/// Client for interacting with the configured on-chain backend.
131///
132/// [ChainSource] abstracts over multiple backends using [ChainSourceSpec] to provide:
133/// - Chain queries (tip, block headers/blocks, transaction status and fetching)
134/// - Mempool-related utilities (ancestor fee/weight, spending lookups)
135/// - Broadcasting single transactions or packages (RBF/CPFP workflows)
136/// - Fee estimation and caching with optional fallback values
137///
138/// Behavior notes:
139/// - [ChainSource::update_fee_rates] refreshes internal fee estimates; if backend estimates
140///   fail and a fallback fee is provided, it will be used for all tiers.
141/// - [ChainSource::fee_rates] returns the last cached [FeeRates].
142///
143/// Examples:
144///
145/// ```rust
146/// # async fn func() {
147/// use bark::chain::{ChainSource, ChainSourceSpec};
148/// use bdk_bitcoind_rpc::bitcoincore_rpc::Auth;
149/// use bitcoin::{FeeRate, Network};
150///
151/// let spec = ChainSourceSpec::Bitcoind {
152///     url: "http://localhost:8332".into(),
153///     auth: Auth::UserPass("user".into(), "password".into()),
154///     zmq: None,
155/// };
156/// let network = Network::Bitcoin;
157/// let fallback_fee = FeeRate::from_sat_per_vb(5);
158/// #[cfg(feature = "socks5-proxy")]
159/// let socks5 = Some("socks5h://127.0.0.1:9050");
160///
161/// let instance = ChainSource::new(spec, network, fallback_fee, socks5).await.unwrap();
162/// # }
163/// ```
164pub struct ChainSource {
165	inner: ChainSourceClient,
166	network: Network,
167	/// The ZMQ endpoint of the bitcoind backend, if one was configured.
168	zmq_endpoint: Option<String>,
169	fee_rates: RwLock<FeeRates>,
170	/// `None` until the first successful (or fallback) `update_fee_rates`.
171	/// `Some(t)` makes subsequent calls within `FEE_RATES_CACHE_TTL` a no-op.
172	fee_rates_fetched_at: RwLock<Option<Instant>>,
173	/// Last observed tip with the time it was fetched, used to short-circuit
174	/// repeat `tip_ref()` / `tip()` calls within `TIP_CACHE_TTL`.
175	tip_cache: RwLock<Option<(BlockRef, Instant)>>,
176}
177
178impl ChainSource {
179	/// Checks that the version of the chain source is compatible with Bark.
180	///
181	/// For bitcoind, it checks if the version is at least 29.0
182	/// This is the first version for which 0 fee-anchors are considered standard
183	pub async fn require_version(&self) -> anyhow::Result<()> {
184		#[cfg(feature = "bitcoind-rpc")]
185		if let ChainSourceClient::Bitcoind { rpc, .. } = self.inner() {
186			#[derive(Debug, serde::Deserialize)]
187			struct NetworkInfo { version: usize }
188			let info: NetworkInfo = rpc.call_raw("getnetworkinfo", &[]).await?;
189			if info.version < MIN_BITCOIND_VERSION {
190				bail!("Bitcoin Core version is too old, you can participate in rounds but won't be able to unilaterally exit. Please upgrade to 29.0 or higher.");
191			}
192		}
193
194		Ok(())
195	}
196
197	pub(crate) fn inner(&self) -> &ChainSourceClient {
198		&self.inner
199	}
200
201	/// Gets a cached copy of the calculated network [FeeRates]
202	pub async fn fee_rates(&self) -> FeeRates {
203		self.fee_rates.read().await.clone()
204	}
205
206	/// Gets the network that the [ChainSource] was validated against.
207	pub fn network(&self) -> Network {
208		self.network
209	}
210
211	/// Creates a new instance of the object with the specified chain source, network, and optional
212	/// fallback fee rate.
213	///
214	/// This function initializes the internal chain source client based on the provided `chain_source`:
215	/// - If `chain_source` is of type [ChainSourceSpec::Bitcoind], it creates a Bitcoin Core RPC client
216	///   using the provided URL and authentication parameters.
217	/// - If `chain_source` is of type [ChainSourceSpec::Esplora], it creates an Esplora client with the
218	///   given URL.
219	///
220	/// Both clients are initialized asynchronously, and any errors encountered during their
221	/// creation will be returned as part of the [anyhow::Result].
222	///
223	/// Additionally, the function performs a network consistency check to ensure the specified
224	/// network (e.g., `mainnet` or `signet`) matches the network configuration of the initialized
225	/// chain source client.
226	///
227	/// The `fallback_fee` parameter is optional. If provided, it is used as the default fee rate
228	/// for transactions. If not specified, the `FeeRate::BROADCAST_MIN` is used as the default fee
229	/// rate.
230	///
231	/// # Arguments
232	///
233	/// * `chain_source` - Specifies the backend to use for blockchain data.
234	/// * `network` - The Bitcoin network to operate on (e.g., `mainnet`, `testnet`, `regtest`).
235	/// * `fallback_fee` - An optional fallback fee rate to use for transaction fee estimation. If
236	///   not provided, a default fee rate of [FeeRate::BROADCAST_MIN] will be used.
237	///
238	/// # Returns
239	///
240	/// * `Ok(Self)` - If the object is successfully created with all necessary configurations.
241	/// * `Err(anyhow::Error)` - If there is an error in initializing the chain source client or
242	///   verifying the network.
243	pub async fn new(
244		spec: ChainSourceSpec,
245		network: Network,
246		fallback_fee: Option<FeeRate>,
247		#[cfg(feature = "socks5-proxy")] proxy: Option<&str>,
248	) -> anyhow::Result<Self> {
249		let (inner, zmq_endpoint) = match spec {
250			#[cfg(feature = "bitcoind-rpc")]
251			ChainSourceSpec::Bitcoind { url, auth, zmq } => {
252				// `bdk_bitcoind_rpc::Emitter` is sync-only upstream, so we keep
253				// a sync companion to drive it inside `spawn_blocking`. The async
254				// client is used everywhere else. `BitcoinRpcClient` (rather
255				// than the bare `bitcoincore_rpc::Client`) is required so the
256				// `spawn_blocking` closure can take an owned, `Clone` value.
257				//
258				// The sync companion currently does not honour `socks5-proxy`;
259				// SOCKS5 is supported on the Esplora backend, where it is the
260				// realistic Tor-via-bitcoind use case.
261				let sync = BitcoinRpcClient::new(&url, auth.clone())
262					.context("failed to create sync bitcoind rpc client")?;
263				let async_auth = match auth {
264					rpc::Auth::None => bail!(
265						"bitcoind RPC auth is required (cookie file or user/pass)",
266					),
267					rpc::Auth::UserPass(u, p) => bitcoind_async_client::Auth::UserPass(u, p),
268					rpc::Auth::CookieFile(p) => bitcoind_async_client::Auth::CookieFile(p),
269				};
270				let rpc = BitcoindClient::new(url, async_auth, None, None, None)
271					.context("failed to create async bitcoind rpc client")?;
272				rpc.require_txindex().await?;
273				(ChainSourceClient::Bitcoind { rpc, sync }, zmq)
274			},
275			#[cfg(not(feature = "bitcoind-rpc"))]
276			ChainSourceSpec::Bitcoind { .. } => bail!(
277				"bitcoind RPC backend is not available: this build was compiled without \
278				 the `bitcoind-rpc` feature (notably the wasm-web build)",
279			),
280			ChainSourceSpec::Esplora { url } => (ChainSourceClient::Esplora({
281				let url = crate::utils::url_with_default_https_scheme(&url);
282				// the esplora client doesn't deal well with trailing slash in url
283				let url = url.strip_suffix("/").unwrap_or(&url);
284				#[cfg(feature = "socks5-proxy")]
285				let mut builder = esplora_client::Builder::new(url);
286				#[cfg(not(feature = "socks5-proxy"))]
287				let builder = esplora_client::Builder::new(url);
288				#[cfg(feature = "socks5-proxy")]
289				if let Some(proxy) = proxy {
290					builder = builder.proxy(proxy);
291				}
292				builder.build_async()
293					.with_context(|| format!("failed to create esplora client for url {}", url))?
294			}), None),
295		};
296
297		inner.check_network(network).await?;
298
299		let fee = fallback_fee.unwrap_or(FeeRate::BROADCAST_MIN);
300		let fee_rates = RwLock::new(FeeRates { fast: fee, regular: fee, slow: fee });
301
302		Ok(Self {
303			inner,
304			network,
305			zmq_endpoint,
306			fee_rates,
307			fee_rates_fetched_at: RwLock::new(None),
308			tip_cache: RwLock::new(None),
309		})
310	}
311
312	async fn fetch_fee_rates(&self) -> anyhow::Result<FeeRates> {
313		match self.inner() {
314			#[cfg(feature = "bitcoind-rpc")]
315			ChainSourceClient::Bitcoind { rpc, .. } => {
316				let get_fee_rate = async |target: u16| -> anyhow::Result<FeeRate> {
317					let fee: rpc::json::EstimateSmartFeeResult = rpc.call_raw(
318						"estimatesmartfee",
319						&[
320							target.into(),
321							serde_json::to_value(rpc::json::EstimateMode::Economical)
322								.expect("serializable"),
323						],
324					).await?;
325					if let Some(fee_rate) = fee.fee_rate {
326						Ok(FeeRate::from_amount_per_kvb_ceil(fee_rate))
327					} else {
328						Err(anyhow!("No rate returned from estimate_smart_fee for a {} confirmation target", target))
329					}
330				};
331				Ok(FeeRates {
332					fast: get_fee_rate(FEE_RATE_TARGET_CONF_FAST).await?,
333					regular: get_fee_rate(FEE_RATE_TARGET_CONF_REGULAR).await.expect("should exist"),
334					slow: get_fee_rate(FEE_RATE_TARGET_CONF_SLOW).await.expect("should exist"),
335				})
336			},
337			ChainSourceClient::Esplora(client) => {
338				// The API should return rates for targets 1-25, 144 and 1008
339				let estimates = client.get_fee_estimates().await?;
340				let get_fee_rate = |target| {
341					let fee = estimates.get(&target).with_context(||
342						format!("No rate returned from get_fee_estimates for a {} confirmation target", target)
343					)?;
344					FeeRate::from_sat_per_vb_decimal_checked_ceil(*fee).with_context(||
345						format!("Invalid rate returned from get_fee_estimates {} for a {} confirmation target", fee, target)
346					)
347				};
348				Ok(FeeRates {
349					fast: get_fee_rate(FEE_RATE_TARGET_CONF_FAST)?,
350					regular: get_fee_rate(FEE_RATE_TARGET_CONF_REGULAR)?,
351					slow: get_fee_rate(FEE_RATE_TARGET_CONF_SLOW)?,
352				})
353			}
354		}
355	}
356
357	/// The ZMQ endpoint of the bitcoind backend, if one was configured.
358	///
359	/// Always `None` for the Esplora backend.
360	pub fn zmq_endpoint(&self) -> Option<&str> {
361		self.zmq_endpoint.as_deref()
362	}
363
364	async fn fetch_tip(&self) -> anyhow::Result<BlockHeight> {
365		match self.inner() {
366			#[cfg(feature = "bitcoind-rpc")]
367			ChainSourceClient::Bitcoind { rpc, .. } => {
368				Ok(rpc.get_block_count().await? as BlockHeight)
369			},
370			ChainSourceClient::Esplora(client) => {
371				Ok(client.get_height().await?)
372			},
373		}
374	}
375
376	pub async fn tip_ref(&self) -> anyhow::Result<BlockRef> {
377		if let Some((block_ref, fetched_at)) = *self.tip_cache.read().await {
378			if fetched_at.elapsed() < TIP_CACHE_TTL {
379				return Ok(block_ref);
380			}
381		}
382		let block_ref = self.tip_ref_uncached().await?;
383		self.record_observed_tip(block_ref).await;
384		Ok(block_ref)
385	}
386
387	pub async fn tip(&self) -> anyhow::Result<BlockHeight> {
388		Ok(self.tip_ref().await?.height)
389	}
390
391	/// Store an observed tip as the current `tip_cache` entry.
392	async fn record_observed_tip(&self, block_ref: BlockRef) {
393		*self.tip_cache.write().await = Some((block_ref, Instant::now()));
394	}
395
396	/// Drop the cached tip and fee-rate values, forcing the next call to
397	/// `tip()` or `update_fee_rates()` to round-trip the backend. Useful
398	/// in tests that fabricate chain changes faster than the TTL so the
399	/// next observation is deterministic without sleeping.
400	pub async fn invalidate_caches(&self) {
401		*self.tip_cache.write().await = None;
402		*self.fee_rates_fetched_at.write().await = None;
403	}
404
405	/// The current tip, always round-tripping the backend instead of serving
406	/// the `TIP_CACHE_TTL` cache. Used by the tip watcher, which only fetches
407	/// when there is reason to believe the tip changed.
408	pub(crate) async fn tip_ref_uncached(&self) -> anyhow::Result<BlockRef> {
409		self.block_ref(self.fetch_tip().await?).await
410	}
411
412	/// Starts a [TipWatcher] tracking the chain tip of this source.
413	///
414	/// When this source has a ZMQ endpoint configured, block notifications
415	/// wake the watcher and `poll_interval` becomes the reconcile interval;
416	/// otherwise the tip is polled at `poll_interval`.
417	pub async fn tip_watcher(
418		self: &Arc<Self>,
419		poll_interval: Duration,
420	) -> anyhow::Result<TipWatcher> {
421		#[cfg(all(feature = "bitcoind-rpc", not(target_arch = "wasm32")))]
422		if let Some(zmq) = self.zmq_endpoint() {
423			return TipWatcher::start_zmq(self.clone(), zmq, poll_interval).await;
424		}
425		TipWatcher::start_poll(self.clone(), poll_interval).await
426	}
427
428	pub async fn block_ref(&self, height: BlockHeight) -> anyhow::Result<BlockRef> {
429		match self.inner() {
430			#[cfg(feature = "bitcoind-rpc")]
431			ChainSourceClient::Bitcoind { rpc, .. } => {
432				let hash = rpc.get_block_hash(height as u64).await?;
433				Ok(BlockRef { height, hash })
434			},
435			ChainSourceClient::Esplora(client) => {
436				let hash = client.get_block_hash(height).await?;
437				Ok(BlockRef { height, hash })
438			},
439		}
440	}
441
442	pub async fn block(&self, hash: BlockHash) -> anyhow::Result<Option<Block>> {
443		match self.inner() {
444			#[cfg(feature = "bitcoind-rpc")]
445			ChainSourceClient::Bitcoind { rpc, .. } => {
446				match rpc.get_block(&hash).await {
447					Ok(block) => Ok(Some(block)),
448					Err(e) if is_not_found(&e) => Ok(None),
449					Err(e) => Err(e.into()),
450				}
451			},
452			ChainSourceClient::Esplora(client) => {
453				Ok(client.get_block_by_hash(&hash).await?)
454			},
455		}
456	}
457
458	/// Retrieves basic CPFP ancestry information of the given transaction. Confirmed transactions
459	/// are ignored as they are not relevant to CPFP.
460	pub async fn mempool_ancestor_info(&self, txid: Txid) -> anyhow::Result<MempoolAncestorInfo> {
461		let mut result = MempoolAncestorInfo::new(txid);
462
463		// TODO: Determine if any line of descendant transactions increase the effective fee rate
464		//		 of the target txid.
465		match self.inner() {
466			#[cfg(feature = "bitcoind-rpc")]
467			ChainSourceClient::Bitcoind { rpc, .. } => {
468				let entry: rpc::json::GetMempoolEntryResult = rpc.call_raw(
469					"getmempoolentry", &[serde_json::to_value(txid).expect("serializable")],
470				).await?;
471				let err = || anyhow!("missing weight parameter from getmempoolentry");
472
473				result.total_fee = entry.fees.ancestor;
474				result.total_weight = Weight::from_wu(entry.weight.ok_or_else(err)?) +
475					Weight::from_vb(entry.ancestor_size).ok_or_else(err)?;
476			},
477			ChainSourceClient::Esplora(client) => {
478				// We should first verify the transaction is in the mempool to maintain the same
479				// behavior as Bitcoin Core
480				let status = self.tx_status(txid).await?;
481				if !matches!(status, TxStatus::Mempool) {
482					return Err(anyhow!("{} is not in the mempool, status is {:?}", txid, status));
483				}
484
485				let mut info_map: HashMap<Txid, esplora_client::Tx> = HashMap::new();
486				let mut set = HashSet::from([txid]);
487				while !set.is_empty() {
488					// Start requests asynchronously
489					let requests = set.iter().filter_map(|txid| if info_map.contains_key(txid) {
490						None
491					} else {
492						Some((txid, client.get_tx_info(&txid)))
493					}).collect::<Vec<_>>();
494
495					// Collect txids to be added to the set
496					let mut next_set = HashSet::new();
497
498					// Process each request, ignoring parents of confirmed transactions
499					for (txid, request) in requests {
500						let info = request.await?
501							.ok_or_else(|| anyhow!("unable to retrieve tx info for {}", txid))?;
502						if !info.status.confirmed {
503							for vin in info.vin.iter() {
504								next_set.insert(vin.txid);
505							}
506						}
507						info_map.insert(*txid, info);
508					}
509					set = next_set;
510				}
511				// Calculate the total weight and fee of the unconfirmed ancestry
512				for info in info_map.into_values().filter(|info| !info.status.confirmed) {
513					result.total_fee += info.fee();
514					result.total_weight += info.weight();
515				}
516			},
517		}
518		// Now calculate the effective fee rate of the package
519		Ok(result)
520	}
521
522	/// For each provided outpoint, fetches the ID of any confirmed or unconfirmed in which the
523	/// outpoint is spent.
524	pub async fn txs_spending_inputs<T: IntoIterator<Item = OutPoint>>(
525		&self,
526		outpoints: T,
527		#[cfg_attr(not(feature = "bitcoind-rpc"), allow(unused_variables))]
528		block_scan_start: BlockHeight,
529	) -> anyhow::Result<TxsSpendingInputsResult> {
530		let mut res = TxsSpendingInputsResult::new();
531		match self.inner() {
532			#[cfg(feature = "bitcoind-rpc")]
533			ChainSourceClient::Bitcoind { sync, .. } => {
534				// We must offset the height to account for the fact we iterate using next_block()
535				let start = block_scan_start.saturating_sub(1);
536				let block_ref = self.block_ref(start).await?;
537				let cp = CheckPoint::new(BlockId {
538					height: block_ref.height,
539					hash: block_ref.hash,
540				});
541
542				debug!("Scanning blocks for spent outpoints with bitcoind, starting at block height {}...", block_scan_start);
543				let outpoint_set = outpoints.into_iter().collect::<HashSet<_>>();
544
545				// `bdk_bitcoind_rpc::Emitter` is sync-only upstream, so the
546				// scan loop runs inside `spawn_blocking` with the sync companion.
547				let sync_client = sync.clone();
548				let cp_for_blocking = cp.clone();
549				res = tokio::task::spawn_blocking(move || -> anyhow::Result<TxsSpendingInputsResult> {
550					let mut res = res;
551					let mut emitter = bdk_bitcoind_rpc::Emitter::new(
552						&sync_client,
553						cp_for_blocking.clone(),
554						cp_for_blocking.height(),
555						bdk_bitcoind_rpc::NO_EXPECTED_MEMPOOL_TXS,
556					);
557					while let Some(em) = emitter.next_block()? {
558						if em.block_height() % 1000 == 0 {
559							info!("Scanned for spent outpoints until block height {}", em.block_height());
560						}
561						for tx in &em.block.txdata {
562							for txin in tx.input.iter() {
563								if outpoint_set.contains(&txin.previous_output) {
564									res.add(
565										txin.previous_output.clone(),
566										tx.compute_txid(),
567										TxStatus::Confirmed(BlockRef {
568											height: em.block_height(),
569											hash: em.block.block_hash().clone(),
570										}),
571									);
572									if res.map.len() == outpoint_set.len() {
573										return Ok(res);
574									}
575								}
576							}
577						}
578					}
579
580					debug!("Finished scanning blocks for spent outpoints, now checking the mempool...");
581					let mempool = emitter.mempool()?;
582					for (tx, _last_seen) in &mempool.update {
583						for txin in tx.input.iter() {
584							if outpoint_set.contains(&txin.previous_output) {
585								res.add(
586									txin.previous_output.clone(),
587									tx.compute_txid(),
588									TxStatus::Mempool,
589								);
590								if res.map.len() == outpoint_set.len() {
591									return Ok(res);
592								}
593							}
594						}
595					}
596					debug!("Finished checking the mempool for spent outpoints");
597					Ok(res)
598				}).await.context("Emitter scan task panicked")??;
599			},
600			ChainSourceClient::Esplora(client) => {
601				for outpoint in outpoints {
602					let output_status = client.get_output_status(&outpoint.txid, outpoint.vout.into()).await?;
603
604					if let Some(output_status) = output_status {
605						if output_status.spent {
606							let tx_status = {
607								let status = output_status.status.expect("Status should be valid if an outpoint is spent");
608								if status.confirmed {
609									TxStatus::Confirmed(BlockRef {
610										height: status.block_height.expect("Confirmed transaction missing block_height"),
611										hash: status.block_hash.expect("Confirmed transaction missing block_hash"),
612									})
613								} else {
614									TxStatus::Mempool
615								}
616							};
617							let txid = output_status.txid.expect("Txid should be valid if an outpoint is spent");
618							res.add(outpoint, txid, tx_status);
619						}
620					}
621				}
622			},
623		}
624
625		Ok(res)
626	}
627
628	pub async fn broadcast_tx(&self, tx: &Transaction) -> anyhow::Result<()> {
629		match self.inner() {
630			#[cfg(feature = "bitcoind-rpc")]
631			ChainSourceClient::Bitcoind { rpc, .. } => {
632				match rpc.send_raw_transaction(tx, None).await {
633					Ok(_) => Ok(()),
634					Err(e) if is_in_utxo_set(&e) => Ok(()),
635					Err(e) => Err(e.into()),
636				}
637			},
638			ChainSourceClient::Esplora(client) => {
639				client.broadcast(tx).await?;
640				Ok(())
641			},
642		}
643	}
644
645	pub async fn broadcast_package(&self, txs: &[impl Borrow<Transaction>]) -> Result<(), BroadcastError> {
646		let package_order = txs.iter()
647			.map(|t| t.borrow().compute_txid())
648			.collect::<Vec<_>>();
649		match self.inner() {
650			#[cfg(feature = "bitcoind-rpc")]
651			ChainSourceClient::Bitcoind { rpc, .. } => {
652				let hexes: Vec<String> = txs.iter()
653					.map(|t| bitcoin::consensus::encode::serialize_hex(t.borrow()))
654					.collect();
655				let res: rpc::SubmitPackageResult = rpc.call_raw("submitpackage", &[hexes.into()])
656					.await
657					.map_err(|e| BroadcastError::Other(e.to_string()))?;
658				if res.package_msg != "success" {
659					return Err(classify_submit_package_errors(
660						&res.package_msg,
661						res.tx_results.values().map(|t| (t.txid, t.error.as_deref())),
662						&package_order,
663					));
664				}
665				Ok(())
666			},
667			ChainSourceClient::Esplora(client) => {
668				let txs = txs.iter().map(|t| t.borrow().clone()).collect::<Vec<_>>();
669				let res = client.submit_package(&txs, None, None)
670					.await
671					.map_err(|e| BroadcastError::Other(e.to_string()))?;
672				if res.package_msg != "success" {
673					return Err(classify_submit_package_errors(
674						&res.package_msg,
675						res.tx_results.values().map(|t| (t.txid, t.error.as_deref())),
676						&package_order,
677					));
678				}
679
680				Ok(())
681			},
682		}
683	}
684
685	pub async fn get_tx(&self, txid: &Txid) -> anyhow::Result<Option<Transaction>> {
686		match self.inner() {
687			#[cfg(feature = "bitcoind-rpc")]
688			ChainSourceClient::Bitcoind { rpc, .. } => {
689				match rpc.get_raw_transaction_verbosity_zero(txid).await {
690					Ok(tx) => Ok(Some(tx.0)),
691					Err(e) if is_not_found(&e) => Ok(None),
692					Err(e) => Err(e.into()),
693				}
694			},
695			ChainSourceClient::Esplora(client) => {
696				Ok(client.get_tx(txid).await?)
697			},
698		}
699	}
700
701	/// Returns the block height the tx is confirmed in, if any.
702	pub async fn tx_confirmed(&self, txid: Txid) -> anyhow::Result<Option<BlockHeight>> {
703		Ok(self.tx_status(txid).await?.confirmed_height())
704	}
705
706	/// Returns the status of the given transaction, including the block height if it is confirmed
707	pub async fn tx_status(&self, txid: Txid) -> anyhow::Result<TxStatus> {
708		match self.inner() {
709			#[cfg(feature = "bitcoind-rpc")]
710			ChainSourceClient::Bitcoind { rpc, .. } => Ok(bitcoind_tx_status(rpc, txid).await?),
711			ChainSourceClient::Esplora(esplora) => {
712				match esplora.get_tx_info(&txid).await? {
713					Some(info) => match (info.status.block_height, info.status.block_hash) {
714						(Some(block_height), Some(block_hash)) => Ok(TxStatus::Confirmed(BlockRef {
715							height: block_height,
716							hash: block_hash,
717						} )),
718						_ => Ok(TxStatus::Mempool),
719					},
720					None => Ok(TxStatus::NotFound),
721				}
722			},
723		}
724	}
725
726	#[allow(unused)]
727	pub async fn txout_value(&self, outpoint: &OutPoint) -> anyhow::Result<Amount> {
728		let tx = match self.inner() {
729			#[cfg(feature = "bitcoind-rpc")]
730			ChainSourceClient::Bitcoind { rpc, .. } => {
731				rpc.get_raw_transaction_verbosity_zero(&outpoint.txid).await
732					.with_context(|| format!("tx {} unknown", outpoint.txid))?
733					.0
734			},
735			ChainSourceClient::Esplora(client) => {
736				client.get_tx(&outpoint.txid).await?
737					.with_context(|| format!("tx {} unknown", outpoint.txid))?
738			},
739		};
740		Ok(tx.output.get(outpoint.vout as usize).context("outpoint vout out of range")?.value)
741	}
742
743	/// Whether `outpoint` has been spent by a transaction that is confirmed, i.e.
744	/// whether any transaction spending it can still be mined.
745	///
746	/// A spend sitting only in the mempool reports `false`: it can still be
747	/// replaced, so it decides nothing.
748	///
749	/// The caller must know `outpoint`'s own transaction is confirmed. `gettxout`
750	/// reads the confirmed utxo set, so it cannot tell an output spent on-chain
751	/// apart from one whose transaction has yet to be mined.
752	pub async fn outpoint_spent_confirmed(&self, outpoint: OutPoint) -> anyhow::Result<bool> {
753		match self.inner() {
754			#[cfg(feature = "bitcoind-rpc")]
755			ChainSourceClient::Bitcoind { rpc, .. } => {
756				// `include_mempool: false` keeps a mempool-only spend out of the
757				// answer: the output stays in the confirmed set until its spender is
758				// mined.
759				let utxo = rpc.try_get_tx_out(outpoint, false).await
760					.with_context(|| format!("gettxout {} failed", outpoint))?;
761				Ok(utxo.is_none())
762			},
763			ChainSourceClient::Esplora(client) => {
764				let status = client.get_output_status(&outpoint.txid, outpoint.vout as u64).await
765					.with_context(|| format!("outspend lookup for {} failed", outpoint))?;
766				Ok(status.is_some_and(|s| {
767					s.spent && s.status.is_some_and(|s| s.confirmed)
768				}))
769			},
770		}
771	}
772
773	/// Gets the current fee rates from the chain source, falling back to user-specified values if
774	/// necessary.
775	///
776	/// No-ops if a previous successful call ran within `FEE_RATES_CACHE_TTL`.
777	/// The fallback path overwrites the cached rates but deliberately does
778	/// not advance the cache timestamp, so the next call retries the backend
779	/// instead of serving the fallback for another full TTL.
780	pub async fn update_fee_rates(&self, fallback_fee: Option<FeeRate>) -> anyhow::Result<()> {
781		if let Some(fetched_at) = *self.fee_rates_fetched_at.read().await {
782			if fetched_at.elapsed() < FEE_RATES_CACHE_TTL {
783				return Ok(());
784			}
785		}
786		let (fee_rates, used_fallback) = match (self.fetch_fee_rates().await, fallback_fee) {
787			(Ok(fee_rates), _) => (fee_rates, false),
788			(Err(e), None) => return Err(e),
789			(Err(e), Some(fallback)) => {
790				warn!("Error getting fee rates, falling back to {} sat/kvB: {}",
791					fallback.to_btc_per_kvb(), e,
792				);
793				(FeeRates { fast: fallback, regular: fallback, slow: fallback }, true)
794			}
795		};
796
797		*self.fee_rates.write().await = fee_rates;
798		if !used_fallback {
799			*self.fee_rates_fetched_at.write().await = Some(Instant::now());
800		}
801		Ok(())
802	}
803}
804
805impl TipSource for ChainSource {
806	async fn tip_ref(&self) -> anyhow::Result<BlockRef> {
807		let block_ref = ChainSource::tip_ref_uncached(self).await?;
808		// The tip watcher observes new blocks before any subscriber
809		// reacts. Refresh `tip_cache` here so callers of `tip()` cannot
810		// serve a height older than what the watcher already knows.
811		self.record_observed_tip(block_ref).await;
812		Ok(block_ref)
813	}
814}
815
816// ----- bitcoind-rpc feature-gated helpers ---------------------------------
817
818/// Inspect upstream `bitcoind-async-client` JSON-RPC errors for the
819/// "transaction not found" code. Mirrors the sync-side `BitcoinRpcErrorExt`
820/// in `bitcoin_ext::rpc`.
821#[cfg(feature = "bitcoind-rpc")]
822fn is_not_found(e: &BitcoindClientError) -> bool {
823	matches!(e, BitcoindClientError::Server(c, _) if *c == RPC_INVALID_ADDRESS_OR_KEY)
824}
825
826/// Inspect upstream errors for the "already in utxo set" code.
827#[cfg(feature = "bitcoind-rpc")]
828fn is_in_utxo_set(e: &BitcoindClientError) -> bool {
829	matches!(e, BitcoindClientError::Server(c, _) if *c == RPC_VERIFY_ALREADY_IN_UTXO_SET)
830}
831
832/// Two-step `getrawtransaction` + `getblockheader` to determine whether a
833/// txid is confirmed, in the mempool, or unknown.
834#[cfg(feature = "bitcoind-rpc")]
835async fn bitcoind_tx_status(
836	rpc: &BitcoindClient, txid: Txid,
837) -> Result<TxStatus, BitcoindClientError> {
838	let res: Result<rpc::GetRawTransactionResult, _> = rpc.call_raw(
839		"getrawtransaction",
840		&[serde_json::to_value(txid).expect("serializable"), true.into()],
841	).await;
842	let info = match res {
843		Ok(info) => info,
844		Err(e) if is_not_found(&e) => return Ok(TxStatus::NotFound),
845		Err(e) => return Err(e),
846	};
847	let Some(hash) = info.blockhash else {
848		return Ok(TxStatus::Mempool);
849	};
850	let header: rpc::json::GetBlockHeaderResult = rpc.call_raw(
851		"getblockheader",
852		&[serde_json::to_value(hash).expect("serializable"), true.into()],
853	).await?;
854	if header.confirmations > 0 {
855		Ok(TxStatus::Confirmed(BlockRef {
856			height: header.height as BlockHeight,
857			hash: header.hash,
858		}))
859	} else {
860		Ok(TxStatus::Mempool)
861	}
862}
863
864/// The [FeeRates] struct represents the fee rates for transactions categorized by speed or urgency.
865#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
866pub struct FeeRates {
867	/// The fee for fast transactions (higher cost, lower time delay).
868	pub fast: FeeRate,
869	/// The fee for standard-priority transactions.
870	pub regular: FeeRate,
871	/// The fee for slower transactions (lower cost, higher time delay).
872	pub slow: FeeRate,
873}
874
875/// Contains the fee information for an unconfirmed transaction found in the mempool.
876#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
877pub struct MempoolAncestorInfo {
878	/// The ID of the transaction that was queried.
879	pub txid: Txid,
880	/// The total fee of this transaction and all of its unconfirmed ancestors. If the transaction
881	/// is to be replaced, the total fees of the published package MUST exceed this.
882	pub total_fee: Amount,
883	/// The total weight of this transaction and all of its unconfirmed ancestors.
884	pub total_weight: Weight,
885}
886
887impl MempoolAncestorInfo {
888	pub fn new(txid: Txid) -> Self {
889		Self {
890			txid,
891			total_fee: Amount::ZERO,
892			total_weight: Weight::ZERO,
893		}
894	}
895
896	pub fn effective_fee_rate(&self) -> Option<FeeRate> {
897		FeeRate::from_amount_and_weight_ceil(self.total_fee, self.total_weight)
898	}
899}
900
901#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
902pub struct TxsSpendingInputsResult {
903	pub map: HashMap<OutPoint, (Txid, TxStatus)>,
904}
905
906impl TxsSpendingInputsResult {
907	pub fn new() -> Self {
908		Self { map: HashMap::new() }
909	}
910
911	pub fn add(&mut self, outpoint: OutPoint, txid: Txid, status: TxStatus) {
912		self.map.insert(outpoint, (txid, status));
913	}
914
915	pub fn get(&self, outpoint: &OutPoint) -> Option<&(Txid, TxStatus)> {
916		self.map.get(outpoint)
917	}
918
919	pub fn confirmed_txids(&self) -> impl Iterator<Item = (Txid, BlockRef)> + '_ {
920		self.map
921			.iter()
922			.filter_map(|(_, (txid, status))| {
923				match status {
924					TxStatus::Confirmed(block) => Some((*txid, *block)),
925					_ => None,
926				}
927			})
928	}
929
930	pub fn mempool_txids(&self) -> impl Iterator<Item = Txid> + '_ {
931		self.map
932			.iter()
933			.filter(|(_, (_, status))| matches!(status, TxStatus::Mempool))
934			.map(|(_, (txid, _))| *txid)
935	}
936}
937
938/// Classified failure modes when broadcasting a transaction package.
939///
940/// The reject reasons covered by the typed variants are stable Bitcoin Core mempool policy
941/// constants (`txn-already-known`, `bad-txns-inputs-missingorspent`, `insufficient fee, rejecting
942/// replacement`). Esplora forwards bitcoind's reject reasons verbatim, so the same matching works
943/// for both backends.
944#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
945pub enum BroadcastError {
946	/// The transaction is already in the mempool. Treated as success for retry-safety.
947	#[error("transaction already known to the mempool")]
948	AlreadyKnown,
949	/// Inputs are missing or already spent — typically a conflicting replacement is in the mempool.
950	#[error("transaction inputs are missing or already spent")]
951	MissingOrSpentInputs,
952	/// The replacement fee is insufficient under RBF policy.
953	#[error("insufficient fee, rejecting replacement")]
954	InsufficientReplacementFee,
955	/// Any other failure (unrecognized reject reason, RPC/transport error, etc.).
956	#[error("{0}")]
957	Other(String),
958}
959
960impl BroadcastError {
961	/// True if the error means the transaction (or an equivalent one) is already known to the
962	/// network — i.e., not a sign that our transaction is invalid.
963	pub fn is_mempool_conflict(&self) -> bool {
964		matches!(
965			self,
966			BroadcastError::AlreadyKnown
967				| BroadcastError::MissingOrSpentInputs
968				| BroadcastError::InsufficientReplacementFee,
969		)
970	}
971}
972
973fn classify_submit_package_errors<'a>(
974	package_msg: &str,
975	tx_results: impl Iterator<Item = (Txid, Option<&'a str>)>,
976	package_order: &[Txid],
977) -> BroadcastError {
978	// `submitpackage` returns tx_results keyed (and thus iterated) by wtxid, not in
979	// package order. Within a package, rejections only cascade downstream: when an
980	// ancestor is rejected, every descendant necessarily fails with
981	// bad-txns-inputs-missingorspent because the output it spends never came into
982	// existence.
983	let mut results: Vec<(Txid, Option<&'a str>)> = tx_results.collect();
984	results.sort_by_key(|(txid, _)| {
985		package_order.iter().position(|t| t == txid).unwrap_or(usize::MAX)
986	});
987
988	let mut saw_already_known = false;
989	let mut root_cause = None;
990	for (_, err) in &results {
991		if let Some(err) = err {
992			if err.contains("txn-already-known") {
993				// Effectively success for this tx; keep looking for a real failure.
994				saw_already_known = true;
995				continue;
996			}
997			root_cause = Some(*err);
998			break;
999		} else {
1000			continue;
1001		}
1002	}
1003
1004	match root_cause {
1005		Some(e) if e.contains("bad-txns-inputs-missingorspent") => {
1006			BroadcastError::MissingOrSpentInputs
1007		},
1008		Some(e) if e.contains("insufficient fee, rejecting replacement") => {
1009			BroadcastError::InsufficientReplacementFee
1010		},
1011		Some(_) => {
1012			let combined = results.iter()
1013				.map(|(txid, e)| format!("tx {}: {}", txid, e.unwrap_or("(no error)")))
1014				.collect::<Vec<_>>()
1015				.join(", ");
1016			BroadcastError::Other(format!("msg: '{}', errors: [{}]", package_msg, combined))
1017		},
1018		None if saw_already_known => BroadcastError::AlreadyKnown,
1019		None => BroadcastError::Other(format!("msg: '{}', no tx errors", package_msg)),
1020	}
1021}
1022
1023#[cfg(test)]
1024mod test {
1025	use super::*;
1026	use std::str::FromStr;
1027
1028	#[test]
1029	fn classify_package_errors_attributes_root_cause_in_package_order() {
1030		let parent = Txid::from_str(
1031			"1111111111111111111111111111111111111111111111111111111111111111").unwrap();
1032		let child = Txid::from_str(
1033			"2222222222222222222222222222222222222222222222222222222222222222").unwrap();
1034		let order = [parent, child];
1035
1036		// Only the child fails: its own (non-package) input is spent. This is the
1037		// genuine dead-CPFP case and must classify as MissingOrSpentInputs.
1038		let res = classify_submit_package_errors("transaction failed", [
1039			(parent, None),
1040			(child, Some("bad-txns-inputs-missingorspent")),
1041		].into_iter(), &order);
1042		assert_eq!(res, BroadcastError::MissingOrSpentInputs);
1043
1044		// The parent fails for an unrelated reason; the child's missingorspent is only
1045		// the cascade of the parent never existing. The parent's error is the root
1046		// cause, so this must NOT classify as MissingOrSpentInputs. Results are fed in
1047		// wtxid order (child first) to mimic submitpackage's map ordering.
1048		let res = classify_submit_package_errors("transaction failed", [
1049			(child, Some("bad-txns-inputs-missingorspent")),
1050			(parent, Some("version")),
1051		].into_iter(), &order);
1052		assert!(matches!(res, BroadcastError::Other(_)), "got {:?}", res);
1053
1054		// The parent being already known is success for the parent, not the root
1055		// cause: the child's failure must win over it.
1056		let res = classify_submit_package_errors("transaction failed", [
1057			(parent, Some("txn-already-known")),
1058			(child, Some("bad-txns-inputs-missingorspent")),
1059		].into_iter(), &order);
1060		assert_eq!(res, BroadcastError::MissingOrSpentInputs);
1061
1062		// Everything already known: the package is effectively in the mempool.
1063		let res = classify_submit_package_errors("transaction failed", [
1064			(parent, Some("txn-already-known")),
1065			(child, Some("txn-already-known")),
1066		].into_iter(), &order);
1067		assert_eq!(res, BroadcastError::AlreadyKnown);
1068
1069		// RBF rejection on the child with a clean parent.
1070		let res = classify_submit_package_errors("transaction failed", [
1071			(parent, None),
1072			(child, Some("insufficient fee, rejecting replacement")),
1073		].into_iter(), &order);
1074		assert_eq!(res, BroadcastError::InsufficientReplacementFee);
1075
1076		// No per-tx errors at all: fall back to the package message.
1077		let res = classify_submit_package_errors("package-mempool-limits", [
1078			(parent, None),
1079			(child, None),
1080		].into_iter(), &order);
1081		assert!(matches!(res, BroadcastError::Other(ref s) if s.contains("package-mempool-limits")));
1082	}
1083}