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 height with the time it was fetched, used to
174	/// short-circuit repeat `tip()` calls within `TIP_CACHE_TTL`.
175	tip_cache: RwLock<Option<(BlockHeight, 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(&self) -> anyhow::Result<BlockHeight> {
377		if let Some((height, fetched_at)) = *self.tip_cache.read().await {
378			if fetched_at.elapsed() < TIP_CACHE_TTL {
379				return Ok(height);
380			}
381		}
382		let height = self.fetch_tip().await?;
383		*self.tip_cache.write().await = Some((height, Instant::now()));
384		Ok(height)
385	}
386
387	/// Drop the cached tip and fee-rate values, forcing the next call to
388	/// `tip()` or `update_fee_rates()` to round-trip the backend. Useful
389	/// in tests that fabricate chain changes faster than the TTL so the
390	/// next observation is deterministic without sleeping.
391	pub async fn invalidate_caches(&self) {
392		*self.tip_cache.write().await = None;
393		*self.fee_rates_fetched_at.write().await = None;
394	}
395
396	pub async fn tip_ref(&self) -> anyhow::Result<BlockRef> {
397		self.block_ref(self.tip().await?).await
398	}
399
400	/// The current tip, always round-tripping the backend instead of serving
401	/// the `TIP_CACHE_TTL` cache. Used by the tip watcher, which only fetches
402	/// when there is reason to believe the tip changed.
403	pub(crate) async fn tip_ref_uncached(&self) -> anyhow::Result<BlockRef> {
404		self.block_ref(self.fetch_tip().await?).await
405	}
406
407	/// Starts a [TipWatcher] tracking the chain tip of this source.
408	///
409	/// When this source has a ZMQ endpoint configured, block notifications
410	/// wake the watcher and `poll_interval` becomes the reconcile interval;
411	/// otherwise the tip is polled at `poll_interval`.
412	pub async fn tip_watcher(
413		self: &Arc<Self>,
414		poll_interval: Duration,
415	) -> anyhow::Result<TipWatcher> {
416		#[cfg(all(feature = "bitcoind-rpc", not(target_arch = "wasm32")))]
417		if let Some(zmq) = self.zmq_endpoint() {
418			return TipWatcher::start_zmq(self.clone(), zmq, poll_interval).await;
419		}
420		TipWatcher::start_poll(self.clone(), poll_interval).await
421	}
422
423	pub async fn block_ref(&self, height: BlockHeight) -> anyhow::Result<BlockRef> {
424		match self.inner() {
425			#[cfg(feature = "bitcoind-rpc")]
426			ChainSourceClient::Bitcoind { rpc, .. } => {
427				let hash = rpc.get_block_hash(height as u64).await?;
428				Ok(BlockRef { height, hash })
429			},
430			ChainSourceClient::Esplora(client) => {
431				let hash = client.get_block_hash(height).await?;
432				Ok(BlockRef { height, hash })
433			},
434		}
435	}
436
437	pub async fn block(&self, hash: BlockHash) -> anyhow::Result<Option<Block>> {
438		match self.inner() {
439			#[cfg(feature = "bitcoind-rpc")]
440			ChainSourceClient::Bitcoind { rpc, .. } => {
441				match rpc.get_block(&hash).await {
442					Ok(block) => Ok(Some(block)),
443					Err(e) if is_not_found(&e) => Ok(None),
444					Err(e) => Err(e.into()),
445				}
446			},
447			ChainSourceClient::Esplora(client) => {
448				Ok(client.get_block_by_hash(&hash).await?)
449			},
450		}
451	}
452
453	/// Retrieves basic CPFP ancestry information of the given transaction. Confirmed transactions
454	/// are ignored as they are not relevant to CPFP.
455	pub async fn mempool_ancestor_info(&self, txid: Txid) -> anyhow::Result<MempoolAncestorInfo> {
456		let mut result = MempoolAncestorInfo::new(txid);
457
458		// TODO: Determine if any line of descendant transactions increase the effective fee rate
459		//		 of the target txid.
460		match self.inner() {
461			#[cfg(feature = "bitcoind-rpc")]
462			ChainSourceClient::Bitcoind { rpc, .. } => {
463				let entry: rpc::json::GetMempoolEntryResult = rpc.call_raw(
464					"getmempoolentry", &[serde_json::to_value(txid).expect("serializable")],
465				).await?;
466				let err = || anyhow!("missing weight parameter from getmempoolentry");
467
468				result.total_fee = entry.fees.ancestor;
469				result.total_weight = Weight::from_wu(entry.weight.ok_or_else(err)?) +
470					Weight::from_vb(entry.ancestor_size).ok_or_else(err)?;
471			},
472			ChainSourceClient::Esplora(client) => {
473				// We should first verify the transaction is in the mempool to maintain the same
474				// behavior as Bitcoin Core
475				let status = self.tx_status(txid).await?;
476				if !matches!(status, TxStatus::Mempool) {
477					return Err(anyhow!("{} is not in the mempool, status is {:?}", txid, status));
478				}
479
480				let mut info_map: HashMap<Txid, esplora_client::Tx> = HashMap::new();
481				let mut set = HashSet::from([txid]);
482				while !set.is_empty() {
483					// Start requests asynchronously
484					let requests = set.iter().filter_map(|txid| if info_map.contains_key(txid) {
485						None
486					} else {
487						Some((txid, client.get_tx_info(&txid)))
488					}).collect::<Vec<_>>();
489
490					// Collect txids to be added to the set
491					let mut next_set = HashSet::new();
492
493					// Process each request, ignoring parents of confirmed transactions
494					for (txid, request) in requests {
495						let info = request.await?
496							.ok_or_else(|| anyhow!("unable to retrieve tx info for {}", txid))?;
497						if !info.status.confirmed {
498							for vin in info.vin.iter() {
499								next_set.insert(vin.txid);
500							}
501						}
502						info_map.insert(*txid, info);
503					}
504					set = next_set;
505				}
506				// Calculate the total weight and fee of the unconfirmed ancestry
507				for info in info_map.into_values().filter(|info| !info.status.confirmed) {
508					result.total_fee += info.fee();
509					result.total_weight += info.weight();
510				}
511			},
512		}
513		// Now calculate the effective fee rate of the package
514		Ok(result)
515	}
516
517	/// For each provided outpoint, fetches the ID of any confirmed or unconfirmed in which the
518	/// outpoint is spent.
519	pub async fn txs_spending_inputs<T: IntoIterator<Item = OutPoint>>(
520		&self,
521		outpoints: T,
522		#[cfg_attr(not(feature = "bitcoind-rpc"), allow(unused_variables))]
523		block_scan_start: BlockHeight,
524	) -> anyhow::Result<TxsSpendingInputsResult> {
525		let mut res = TxsSpendingInputsResult::new();
526		match self.inner() {
527			#[cfg(feature = "bitcoind-rpc")]
528			ChainSourceClient::Bitcoind { sync, .. } => {
529				// We must offset the height to account for the fact we iterate using next_block()
530				let start = block_scan_start.saturating_sub(1);
531				let block_ref = self.block_ref(start).await?;
532				let cp = CheckPoint::new(BlockId {
533					height: block_ref.height,
534					hash: block_ref.hash,
535				});
536
537				debug!("Scanning blocks for spent outpoints with bitcoind, starting at block height {}...", block_scan_start);
538				let outpoint_set = outpoints.into_iter().collect::<HashSet<_>>();
539
540				// `bdk_bitcoind_rpc::Emitter` is sync-only upstream, so the
541				// scan loop runs inside `spawn_blocking` with the sync companion.
542				let sync_client = sync.clone();
543				let cp_for_blocking = cp.clone();
544				res = tokio::task::spawn_blocking(move || -> anyhow::Result<TxsSpendingInputsResult> {
545					let mut res = res;
546					let mut emitter = bdk_bitcoind_rpc::Emitter::new(
547						&sync_client,
548						cp_for_blocking.clone(),
549						cp_for_blocking.height(),
550						bdk_bitcoind_rpc::NO_EXPECTED_MEMPOOL_TXS,
551					);
552					while let Some(em) = emitter.next_block()? {
553						if em.block_height() % 1000 == 0 {
554							info!("Scanned for spent outpoints until block height {}", em.block_height());
555						}
556						for tx in &em.block.txdata {
557							for txin in tx.input.iter() {
558								if outpoint_set.contains(&txin.previous_output) {
559									res.add(
560										txin.previous_output.clone(),
561										tx.compute_txid(),
562										TxStatus::Confirmed(BlockRef {
563											height: em.block_height(),
564											hash: em.block.block_hash().clone(),
565										}),
566									);
567									if res.map.len() == outpoint_set.len() {
568										return Ok(res);
569									}
570								}
571							}
572						}
573					}
574
575					debug!("Finished scanning blocks for spent outpoints, now checking the mempool...");
576					let mempool = emitter.mempool()?;
577					for (tx, _last_seen) in &mempool.update {
578						for txin in tx.input.iter() {
579							if outpoint_set.contains(&txin.previous_output) {
580								res.add(
581									txin.previous_output.clone(),
582									tx.compute_txid(),
583									TxStatus::Mempool,
584								);
585								if res.map.len() == outpoint_set.len() {
586									return Ok(res);
587								}
588							}
589						}
590					}
591					debug!("Finished checking the mempool for spent outpoints");
592					Ok(res)
593				}).await.context("Emitter scan task panicked")??;
594			},
595			ChainSourceClient::Esplora(client) => {
596				for outpoint in outpoints {
597					let output_status = client.get_output_status(&outpoint.txid, outpoint.vout.into()).await?;
598
599					if let Some(output_status) = output_status {
600						if output_status.spent {
601							let tx_status = {
602								let status = output_status.status.expect("Status should be valid if an outpoint is spent");
603								if status.confirmed {
604									TxStatus::Confirmed(BlockRef {
605										height: status.block_height.expect("Confirmed transaction missing block_height"),
606										hash: status.block_hash.expect("Confirmed transaction missing block_hash"),
607									})
608								} else {
609									TxStatus::Mempool
610								}
611							};
612							let txid = output_status.txid.expect("Txid should be valid if an outpoint is spent");
613							res.add(outpoint, txid, tx_status);
614						}
615					}
616				}
617			},
618		}
619
620		Ok(res)
621	}
622
623	pub async fn broadcast_tx(&self, tx: &Transaction) -> anyhow::Result<()> {
624		match self.inner() {
625			#[cfg(feature = "bitcoind-rpc")]
626			ChainSourceClient::Bitcoind { rpc, .. } => {
627				match rpc.send_raw_transaction(tx, None).await {
628					Ok(_) => Ok(()),
629					Err(e) if is_in_utxo_set(&e) => Ok(()),
630					Err(e) => Err(e.into()),
631				}
632			},
633			ChainSourceClient::Esplora(client) => {
634				client.broadcast(tx).await?;
635				Ok(())
636			},
637		}
638	}
639
640	pub async fn broadcast_package(&self, txs: &[impl Borrow<Transaction>]) -> Result<(), BroadcastError> {
641		let package_order = txs.iter()
642			.map(|t| t.borrow().compute_txid())
643			.collect::<Vec<_>>();
644		match self.inner() {
645			#[cfg(feature = "bitcoind-rpc")]
646			ChainSourceClient::Bitcoind { rpc, .. } => {
647				let hexes: Vec<String> = txs.iter()
648					.map(|t| bitcoin::consensus::encode::serialize_hex(t.borrow()))
649					.collect();
650				let res: rpc::SubmitPackageResult = rpc.call_raw("submitpackage", &[hexes.into()])
651					.await
652					.map_err(|e| BroadcastError::Other(e.to_string()))?;
653				if res.package_msg != "success" {
654					return Err(classify_submit_package_errors(
655						&res.package_msg,
656						res.tx_results.values().map(|t| (t.txid, t.error.as_deref())),
657						&package_order,
658					));
659				}
660				Ok(())
661			},
662			ChainSourceClient::Esplora(client) => {
663				let txs = txs.iter().map(|t| t.borrow().clone()).collect::<Vec<_>>();
664				let res = client.submit_package(&txs, None, None)
665					.await
666					.map_err(|e| BroadcastError::Other(e.to_string()))?;
667				if res.package_msg != "success" {
668					return Err(classify_submit_package_errors(
669						&res.package_msg,
670						res.tx_results.values().map(|t| (t.txid, t.error.as_deref())),
671						&package_order,
672					));
673				}
674
675				Ok(())
676			},
677		}
678	}
679
680	pub async fn get_tx(&self, txid: &Txid) -> anyhow::Result<Option<Transaction>> {
681		match self.inner() {
682			#[cfg(feature = "bitcoind-rpc")]
683			ChainSourceClient::Bitcoind { rpc, .. } => {
684				match rpc.get_raw_transaction_verbosity_zero(txid).await {
685					Ok(tx) => Ok(Some(tx.0)),
686					Err(e) if is_not_found(&e) => Ok(None),
687					Err(e) => Err(e.into()),
688				}
689			},
690			ChainSourceClient::Esplora(client) => {
691				Ok(client.get_tx(txid).await?)
692			},
693		}
694	}
695
696	/// Returns the block height the tx is confirmed in, if any.
697	pub async fn tx_confirmed(&self, txid: Txid) -> anyhow::Result<Option<BlockHeight>> {
698		Ok(self.tx_status(txid).await?.confirmed_height())
699	}
700
701	/// Returns the status of the given transaction, including the block height if it is confirmed
702	pub async fn tx_status(&self, txid: Txid) -> anyhow::Result<TxStatus> {
703		match self.inner() {
704			#[cfg(feature = "bitcoind-rpc")]
705			ChainSourceClient::Bitcoind { rpc, .. } => Ok(bitcoind_tx_status(rpc, txid).await?),
706			ChainSourceClient::Esplora(esplora) => {
707				match esplora.get_tx_info(&txid).await? {
708					Some(info) => match (info.status.block_height, info.status.block_hash) {
709						(Some(block_height), Some(block_hash)) => Ok(TxStatus::Confirmed(BlockRef {
710							height: block_height,
711							hash: block_hash,
712						} )),
713						_ => Ok(TxStatus::Mempool),
714					},
715					None => Ok(TxStatus::NotFound),
716				}
717			},
718		}
719	}
720
721	#[allow(unused)]
722	pub async fn txout_value(&self, outpoint: &OutPoint) -> anyhow::Result<Amount> {
723		let tx = match self.inner() {
724			#[cfg(feature = "bitcoind-rpc")]
725			ChainSourceClient::Bitcoind { rpc, .. } => {
726				rpc.get_raw_transaction_verbosity_zero(&outpoint.txid).await
727					.with_context(|| format!("tx {} unknown", outpoint.txid))?
728					.0
729			},
730			ChainSourceClient::Esplora(client) => {
731				client.get_tx(&outpoint.txid).await?
732					.with_context(|| format!("tx {} unknown", outpoint.txid))?
733			},
734		};
735		Ok(tx.output.get(outpoint.vout as usize).context("outpoint vout out of range")?.value)
736	}
737
738	/// Whether `outpoint` has been spent by a transaction that is confirmed, i.e.
739	/// whether any transaction spending it can still be mined.
740	///
741	/// A spend sitting only in the mempool reports `false`: it can still be
742	/// replaced, so it decides nothing.
743	///
744	/// The caller must know `outpoint`'s own transaction is confirmed. `gettxout`
745	/// reads the confirmed utxo set, so it cannot tell an output spent on-chain
746	/// apart from one whose transaction has yet to be mined.
747	pub async fn outpoint_spent_confirmed(&self, outpoint: OutPoint) -> anyhow::Result<bool> {
748		match self.inner() {
749			#[cfg(feature = "bitcoind-rpc")]
750			ChainSourceClient::Bitcoind { rpc, .. } => {
751				// `include_mempool: false` keeps a mempool-only spend out of the
752				// answer: the output stays in the confirmed set until its spender is
753				// mined.
754				let utxo = rpc.try_get_tx_out(outpoint, false).await
755					.with_context(|| format!("gettxout {} failed", outpoint))?;
756				Ok(utxo.is_none())
757			},
758			ChainSourceClient::Esplora(client) => {
759				let status = client.get_output_status(&outpoint.txid, outpoint.vout as u64).await
760					.with_context(|| format!("outspend lookup for {} failed", outpoint))?;
761				Ok(status.is_some_and(|s| {
762					s.spent && s.status.is_some_and(|s| s.confirmed)
763				}))
764			},
765		}
766	}
767
768	/// Gets the current fee rates from the chain source, falling back to user-specified values if
769	/// necessary.
770	///
771	/// No-ops if a previous successful call ran within `FEE_RATES_CACHE_TTL`.
772	/// The fallback path overwrites the cached rates but deliberately does
773	/// not advance the cache timestamp, so the next call retries the backend
774	/// instead of serving the fallback for another full TTL.
775	pub async fn update_fee_rates(&self, fallback_fee: Option<FeeRate>) -> anyhow::Result<()> {
776		if let Some(fetched_at) = *self.fee_rates_fetched_at.read().await {
777			if fetched_at.elapsed() < FEE_RATES_CACHE_TTL {
778				return Ok(());
779			}
780		}
781		let (fee_rates, used_fallback) = match (self.fetch_fee_rates().await, fallback_fee) {
782			(Ok(fee_rates), _) => (fee_rates, false),
783			(Err(e), None) => return Err(e),
784			(Err(e), Some(fallback)) => {
785				warn!("Error getting fee rates, falling back to {} sat/kvB: {}",
786					fallback.to_btc_per_kvb(), e,
787				);
788				(FeeRates { fast: fallback, regular: fallback, slow: fallback }, true)
789			}
790		};
791
792		*self.fee_rates.write().await = fee_rates;
793		if !used_fallback {
794			*self.fee_rates_fetched_at.write().await = Some(Instant::now());
795		}
796		Ok(())
797	}
798}
799
800impl TipSource for ChainSource {
801	async fn tip_ref(&self) -> anyhow::Result<BlockRef> {
802		ChainSource::tip_ref_uncached(self).await
803	}
804}
805
806// ----- bitcoind-rpc feature-gated helpers ---------------------------------
807
808/// Inspect upstream `bitcoind-async-client` JSON-RPC errors for the
809/// "transaction not found" code. Mirrors the sync-side `BitcoinRpcErrorExt`
810/// in `bitcoin_ext::rpc`.
811#[cfg(feature = "bitcoind-rpc")]
812fn is_not_found(e: &BitcoindClientError) -> bool {
813	matches!(e, BitcoindClientError::Server(c, _) if *c == RPC_INVALID_ADDRESS_OR_KEY)
814}
815
816/// Inspect upstream errors for the "already in utxo set" code.
817#[cfg(feature = "bitcoind-rpc")]
818fn is_in_utxo_set(e: &BitcoindClientError) -> bool {
819	matches!(e, BitcoindClientError::Server(c, _) if *c == RPC_VERIFY_ALREADY_IN_UTXO_SET)
820}
821
822/// Two-step `getrawtransaction` + `getblockheader` to determine whether a
823/// txid is confirmed, in the mempool, or unknown.
824#[cfg(feature = "bitcoind-rpc")]
825async fn bitcoind_tx_status(
826	rpc: &BitcoindClient, txid: Txid,
827) -> Result<TxStatus, BitcoindClientError> {
828	let res: Result<rpc::GetRawTransactionResult, _> = rpc.call_raw(
829		"getrawtransaction",
830		&[serde_json::to_value(txid).expect("serializable"), true.into()],
831	).await;
832	let info = match res {
833		Ok(info) => info,
834		Err(e) if is_not_found(&e) => return Ok(TxStatus::NotFound),
835		Err(e) => return Err(e),
836	};
837	let Some(hash) = info.blockhash else {
838		return Ok(TxStatus::Mempool);
839	};
840	let header: rpc::json::GetBlockHeaderResult = rpc.call_raw(
841		"getblockheader",
842		&[serde_json::to_value(hash).expect("serializable"), true.into()],
843	).await?;
844	if header.confirmations > 0 {
845		Ok(TxStatus::Confirmed(BlockRef {
846			height: header.height as BlockHeight,
847			hash: header.hash,
848		}))
849	} else {
850		Ok(TxStatus::Mempool)
851	}
852}
853
854/// The [FeeRates] struct represents the fee rates for transactions categorized by speed or urgency.
855#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
856pub struct FeeRates {
857	/// The fee for fast transactions (higher cost, lower time delay).
858	pub fast: FeeRate,
859	/// The fee for standard-priority transactions.
860	pub regular: FeeRate,
861	/// The fee for slower transactions (lower cost, higher time delay).
862	pub slow: FeeRate,
863}
864
865/// Contains the fee information for an unconfirmed transaction found in the mempool.
866#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
867pub struct MempoolAncestorInfo {
868	/// The ID of the transaction that was queried.
869	pub txid: Txid,
870	/// The total fee of this transaction and all of its unconfirmed ancestors. If the transaction
871	/// is to be replaced, the total fees of the published package MUST exceed this.
872	pub total_fee: Amount,
873	/// The total weight of this transaction and all of its unconfirmed ancestors.
874	pub total_weight: Weight,
875}
876
877impl MempoolAncestorInfo {
878	pub fn new(txid: Txid) -> Self {
879		Self {
880			txid,
881			total_fee: Amount::ZERO,
882			total_weight: Weight::ZERO,
883		}
884	}
885
886	pub fn effective_fee_rate(&self) -> Option<FeeRate> {
887		FeeRate::from_amount_and_weight_ceil(self.total_fee, self.total_weight)
888	}
889}
890
891#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
892pub struct TxsSpendingInputsResult {
893	pub map: HashMap<OutPoint, (Txid, TxStatus)>,
894}
895
896impl TxsSpendingInputsResult {
897	pub fn new() -> Self {
898		Self { map: HashMap::new() }
899	}
900
901	pub fn add(&mut self, outpoint: OutPoint, txid: Txid, status: TxStatus) {
902		self.map.insert(outpoint, (txid, status));
903	}
904
905	pub fn get(&self, outpoint: &OutPoint) -> Option<&(Txid, TxStatus)> {
906		self.map.get(outpoint)
907	}
908
909	pub fn confirmed_txids(&self) -> impl Iterator<Item = (Txid, BlockRef)> + '_ {
910		self.map
911			.iter()
912			.filter_map(|(_, (txid, status))| {
913				match status {
914					TxStatus::Confirmed(block) => Some((*txid, *block)),
915					_ => None,
916				}
917			})
918	}
919
920	pub fn mempool_txids(&self) -> impl Iterator<Item = Txid> + '_ {
921		self.map
922			.iter()
923			.filter(|(_, (_, status))| matches!(status, TxStatus::Mempool))
924			.map(|(_, (txid, _))| *txid)
925	}
926}
927
928/// Classified failure modes when broadcasting a transaction package.
929///
930/// The reject reasons covered by the typed variants are stable Bitcoin Core mempool policy
931/// constants (`txn-already-known`, `bad-txns-inputs-missingorspent`, `insufficient fee, rejecting
932/// replacement`). Esplora forwards bitcoind's reject reasons verbatim, so the same matching works
933/// for both backends.
934#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
935pub enum BroadcastError {
936	/// The transaction is already in the mempool. Treated as success for retry-safety.
937	#[error("transaction already known to the mempool")]
938	AlreadyKnown,
939	/// Inputs are missing or already spent — typically a conflicting replacement is in the mempool.
940	#[error("transaction inputs are missing or already spent")]
941	MissingOrSpentInputs,
942	/// The replacement fee is insufficient under RBF policy.
943	#[error("insufficient fee, rejecting replacement")]
944	InsufficientReplacementFee,
945	/// Any other failure (unrecognized reject reason, RPC/transport error, etc.).
946	#[error("{0}")]
947	Other(String),
948}
949
950impl BroadcastError {
951	/// True if the error means the transaction (or an equivalent one) is already known to the
952	/// network — i.e., not a sign that our transaction is invalid.
953	pub fn is_mempool_conflict(&self) -> bool {
954		matches!(
955			self,
956			BroadcastError::AlreadyKnown
957				| BroadcastError::MissingOrSpentInputs
958				| BroadcastError::InsufficientReplacementFee,
959		)
960	}
961}
962
963fn classify_submit_package_errors<'a>(
964	package_msg: &str,
965	tx_results: impl Iterator<Item = (Txid, Option<&'a str>)>,
966	package_order: &[Txid],
967) -> BroadcastError {
968	// `submitpackage` returns tx_results keyed (and thus iterated) by wtxid, not in
969	// package order. Within a package, rejections only cascade downstream: when an
970	// ancestor is rejected, every descendant necessarily fails with
971	// bad-txns-inputs-missingorspent because the output it spends never came into
972	// existence.
973	let mut results: Vec<(Txid, Option<&'a str>)> = tx_results.collect();
974	results.sort_by_key(|(txid, _)| {
975		package_order.iter().position(|t| t == txid).unwrap_or(usize::MAX)
976	});
977
978	let mut saw_already_known = false;
979	let mut root_cause = None;
980	for (_, err) in &results {
981		if let Some(err) = err {
982			if err.contains("txn-already-known") {
983				// Effectively success for this tx; keep looking for a real failure.
984				saw_already_known = true;
985				continue;
986			}
987			root_cause = Some(*err);
988			break;
989		} else {
990			continue;
991		}
992	}
993
994	match root_cause {
995		Some(e) if e.contains("bad-txns-inputs-missingorspent") => {
996			BroadcastError::MissingOrSpentInputs
997		},
998		Some(e) if e.contains("insufficient fee, rejecting replacement") => {
999			BroadcastError::InsufficientReplacementFee
1000		},
1001		Some(_) => {
1002			let combined = results.iter()
1003				.map(|(txid, e)| format!("tx {}: {}", txid, e.unwrap_or("(no error)")))
1004				.collect::<Vec<_>>()
1005				.join(", ");
1006			BroadcastError::Other(format!("msg: '{}', errors: [{}]", package_msg, combined))
1007		},
1008		None if saw_already_known => BroadcastError::AlreadyKnown,
1009		None => BroadcastError::Other(format!("msg: '{}', no tx errors", package_msg)),
1010	}
1011}
1012
1013#[cfg(test)]
1014mod test {
1015	use super::*;
1016	use std::str::FromStr;
1017
1018	#[test]
1019	fn classify_package_errors_attributes_root_cause_in_package_order() {
1020		let parent = Txid::from_str(
1021			"1111111111111111111111111111111111111111111111111111111111111111").unwrap();
1022		let child = Txid::from_str(
1023			"2222222222222222222222222222222222222222222222222222222222222222").unwrap();
1024		let order = [parent, child];
1025
1026		// Only the child fails: its own (non-package) input is spent. This is the
1027		// genuine dead-CPFP case and must classify as MissingOrSpentInputs.
1028		let res = classify_submit_package_errors("transaction failed", [
1029			(parent, None),
1030			(child, Some("bad-txns-inputs-missingorspent")),
1031		].into_iter(), &order);
1032		assert_eq!(res, BroadcastError::MissingOrSpentInputs);
1033
1034		// The parent fails for an unrelated reason; the child's missingorspent is only
1035		// the cascade of the parent never existing. The parent's error is the root
1036		// cause, so this must NOT classify as MissingOrSpentInputs. Results are fed in
1037		// wtxid order (child first) to mimic submitpackage's map ordering.
1038		let res = classify_submit_package_errors("transaction failed", [
1039			(child, Some("bad-txns-inputs-missingorspent")),
1040			(parent, Some("version")),
1041		].into_iter(), &order);
1042		assert!(matches!(res, BroadcastError::Other(_)), "got {:?}", res);
1043
1044		// The parent being already known is success for the parent, not the root
1045		// cause: the child's failure must win over it.
1046		let res = classify_submit_package_errors("transaction failed", [
1047			(parent, Some("txn-already-known")),
1048			(child, Some("bad-txns-inputs-missingorspent")),
1049		].into_iter(), &order);
1050		assert_eq!(res, BroadcastError::MissingOrSpentInputs);
1051
1052		// Everything already known: the package is effectively in the mempool.
1053		let res = classify_submit_package_errors("transaction failed", [
1054			(parent, Some("txn-already-known")),
1055			(child, Some("txn-already-known")),
1056		].into_iter(), &order);
1057		assert_eq!(res, BroadcastError::AlreadyKnown);
1058
1059		// RBF rejection on the child with a clean parent.
1060		let res = classify_submit_package_errors("transaction failed", [
1061			(parent, None),
1062			(child, Some("insufficient fee, rejecting replacement")),
1063		].into_iter(), &order);
1064		assert_eq!(res, BroadcastError::InsufficientReplacementFee);
1065
1066		// No per-tx errors at all: fall back to the package message.
1067		let res = classify_submit_package_errors("package-mempool-limits", [
1068			(parent, None),
1069			(child, None),
1070		].into_iter(), &order);
1071		assert!(matches!(res, BroadcastError::Other(ref s) if s.contains("package-mempool-limits")));
1072	}
1073}