fedimint_walletv2_server/pending.rs
1//! The pending receive scanner: a guardian-local, non-consensus view of the
2//! receive outputs in its mempool and in the blocks just below its chain tip,
3//! served to clients so they can render peg-in progress.
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::time::Duration;
7
8use anyhow::Context;
9use bitcoin::hashes::sha256;
10use bitcoin::{Transaction, Txid};
11use fedimint_core::envs::{is_env_var_set, is_running_in_test_env};
12use fedimint_core::task::TaskGroup;
13use fedimint_core::util::FmtCompact as _;
14use fedimint_logging::LOG_MODULE_WALLETV2;
15use fedimint_server_core::bitcoin_rpc::ServerBitcoinRpcMonitor;
16use fedimint_walletv2_common::{
17 BLOCK_COUNT_VOTE_DELAY, MempoolVisibility, PendingOutput, PendingOutputs, is_potential_receive,
18};
19use tokio::sync::watch;
20use tracing::debug;
21
22use crate::envs::FM_WALLETV2_DISABLE_MEMPOOL_SCAN_ENV;
23
24/// Number of blocks below the local chain tip that the pending receive scanner
25/// reports on.
26///
27/// Only the first [`BLOCK_COUNT_VOTE_DELAY`] blocks of this window are
28/// strictly necessary, since deeper outputs are already in the consensus output
29/// log. The window deliberately extends past that so a peg-in keeps being
30/// reported for a while after it becomes final: a client needs a moment to
31/// notice the consensus entry and start its claim, and without the overlap its
32/// progress display would briefly fall back to reporting nothing at all.
33pub(crate) const MAX_PENDING_DEPTH: u64 = 2 * BLOCK_COUNT_VOTE_DELAY;
34
35/// Filtered receive outputs of the blocks in the pending window, keyed by
36/// height and tagged with the hash they were derived from.
37type BlockCache = BTreeMap<u64, (bitcoin::BlockHash, Vec<PendingOutput>)>;
38
39/// Filtered receive outputs of the transactions currently in the mempool.
40///
41/// Every transaction is kept, including those with no matching outputs, so that
42/// a scan only fetches transactions it has never seen. On a busy mainnet node
43/// this holds on the order of tens of thousands of empty entries, which is a
44/// few megabytes and far cheaper than refetching the mempool each scan.
45type MempoolCache = BTreeMap<Txid, Vec<PendingOutput>>;
46
47/// State the pending receive scanner carries between runs.
48#[derive(Default)]
49struct PendingCache {
50 blocks: BlockCache,
51 mempool: MempoolCache,
52}
53
54/// Returns the receive outputs of `tx` that pass the probabilistic filter.
55fn filtered_receive_outputs(
56 tx: &Transaction,
57 pks_hash: &sha256::Hash,
58 height: Option<u64>,
59) -> Vec<PendingOutput> {
60 let txid = tx.compute_txid();
61
62 tx.output
63 .iter()
64 .enumerate()
65 .filter(|(_, tx_out)| is_potential_receive(&tx_out.script_pubkey, pks_hash))
66 .map(|(vout, tx_out)| PendingOutput {
67 script: tx_out.script_pubkey.clone(),
68 outpoint: bitcoin::OutPoint {
69 txid,
70 vout: u32::try_from(vout)
71 .expect("Bitcoin transaction has more than u32::MAX outputs"),
72 },
73 value: tx_out.value,
74 height,
75 })
76 .collect()
77}
78
79/// Collects the receive outputs of the most recently mined blocks.
80///
81/// Scans the last [`MAX_PENDING_DEPTH`] blocks below the guardian's local chain
82/// tip. The window is relative to the tip rather than to the consensus block
83/// count, so the work stays bounded even if the federation's consensus block
84/// count is lagging badly, and so a peg-in keeps being reported for a while
85/// after it becomes final.
86///
87/// Blocks are only fetched when the hash at a height is absent from `cache` or
88/// differs from the cached one, which keeps the steady state cost at one block
89/// fetch per new block and makes reorgs within the window self-repairing.
90async fn scan_pending_blocks(
91 btc_rpc: &ServerBitcoinRpcMonitor,
92 pks_hash: &sha256::Hash,
93 block_count: u64,
94 cache: &mut BlockCache,
95) -> anyhow::Result<()> {
96 let start = block_count.saturating_sub(MAX_PENDING_DEPTH);
97
98 // Drop the blocks that have fallen out of the window as the chain advanced.
99 cache.retain(|height, _| (start..block_count).contains(height));
100
101 for height in start..block_count {
102 let block_hash = btc_rpc.get_block_hash(height).await?;
103
104 if cache
105 .get(&height)
106 .is_some_and(|(cached_hash, _)| *cached_hash == block_hash)
107 {
108 continue;
109 }
110
111 let block = btc_rpc.get_block(&block_hash).await?;
112
113 let outputs = block
114 .txdata
115 .iter()
116 .flat_map(|tx| filtered_receive_outputs(tx, pks_hash, Some(height)))
117 .collect::<Vec<PendingOutput>>();
118
119 cache.insert(height, (block_hash, outputs));
120 }
121
122 Ok(())
123}
124
125/// How long one pass may spend fetching mempool transactions before it
126/// publishes what it has and starts the next pass.
127///
128/// Fetches are sequential, so a cold cache over a large mempool would otherwise
129/// hold back the confirmations already gathered from the block scan until
130/// every transaction had been fetched. Kept short under test so that a test can
131/// observe a pass running out of budget without a large mempool.
132fn mempool_scan_budget() -> Duration {
133 if is_running_in_test_env() {
134 Duration::from_millis(100)
135 } else {
136 Duration::from_secs(5)
137 }
138}
139
140/// How one pass over the guardian's mempool ended.
141///
142/// Clients only learn the resulting [`MempoolVisibility`]; the scanner also
143/// needs to know *why* a pass stopped short, because a pass that ran out of
144/// budget should continue right away while one that hit a failing backend
145/// should wait before trying again.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147enum MempoolScan {
148 /// The backend cannot enumerate a mempool, the scan is turned off, or
149 /// listing the mempool failed.
150 Unavailable,
151 /// Fetching a transaction failed, so the pass gave up partway through.
152 Failed,
153 /// The pass spent its budget with transactions still left to fetch.
154 OutOfBudget,
155 /// Every transaction the backend listed is now in the cache.
156 Complete,
157}
158
159impl MempoolScan {
160 fn visibility(self) -> MempoolVisibility {
161 match self {
162 Self::Unavailable => MempoolVisibility::Unavailable,
163 Self::Failed | Self::OutOfBudget => MempoolVisibility::Partial,
164 Self::Complete => MempoolVisibility::Complete,
165 }
166 }
167}
168
169/// Collects the receive outputs of the transactions in the node's mempool.
170///
171/// A pass never fails outright, because the mempool is only ever the weakest
172/// half of the pending view: losing it must not cost the mined half, which is
173/// the stronger signal and is gathered from a backend that may well still be
174/// working. Instead it reports how far it got.
175///
176/// A pass stops fetching once `budget` has elapsed, after fetching at least one
177/// transaction so that every pass makes progress.
178///
179/// Only transactions absent from `cache` are fetched, and entries that have
180/// left the mempool are dropped, so eviction and replacement are handled by
181/// rebuilding the retained set rather than by tracking them explicitly. A
182/// transaction that disappears between being listed and being fetched is
183/// skipped rather than treated as an error, since that is the ordinary outcome
184/// of it being mined mid-scan.
185///
186/// `cache` is never emptied on failure. Refilling it costs one fetch per
187/// mempool transaction, so discarding it because a single call failed would
188/// turn a transient error into repeated bursts of RPC work; whether its
189/// contents may be *reported* is what the returned [`MempoolScan`] decides. A
190/// pass that stops short still reports its cache: it was pruned against a fresh
191/// listing, so every entry in it is real and the set is merely short of a few
192/// transactions.
193async fn scan_pending_mempool(
194 btc_rpc: &ServerBitcoinRpcMonitor,
195 pks_hash: &sha256::Hash,
196 budget: Duration,
197 cache: &mut MempoolCache,
198) -> MempoolScan {
199 let deadline = fedimint_core::time::now() + budget;
200
201 let txids = match btc_rpc.get_mempool_txids().await {
202 Ok(Some(txids)) => txids,
203 // The backend has no mempool to enumerate, as esplora never does.
204 Ok(None) => return MempoolScan::Unavailable,
205 // Without a fresh listing the cache cannot be pruned, so it may hold
206 // transactions that have since been mined or evicted. Report nothing
207 // from it until a listing succeeds and prunes it again.
208 Err(err) => {
209 debug!(
210 target: LOG_MODULE_WALLETV2,
211 err = %err.fmt_compact(),
212 "Error listing the mempool, reporting confirmations only"
213 );
214
215 return MempoolScan::Unavailable;
216 }
217 };
218
219 let txids: BTreeSet<Txid> = txids.into_iter().collect();
220
221 cache.retain(|txid, _| txids.contains(txid));
222
223 let missing: Vec<Txid> = txids
224 .into_iter()
225 .filter(|txid| !cache.contains_key(txid))
226 .collect();
227
228 let mut missing = missing.into_iter().peekable();
229
230 while let Some(txid) = missing.next() {
231 match btc_rpc.get_mempool_tx(&txid).await {
232 Ok(Some(tx)) => {
233 cache.insert(txid, filtered_receive_outputs(&tx, pks_hash, None));
234 }
235 // The transaction left the mempool between being listed and being
236 // fetched, so its absence from the cache is correct.
237 Ok(None) => {}
238 // A node that has the transaction but declines to return it answers
239 // with code `-5`, which the backend already reports as `Ok(None)`,
240 // so an error here is the backend itself failing and every
241 // remaining fetch would fail the same way. Abandon the pass rather
242 // than walk the rest of the mempool collecting the same error, and
243 // report what was gathered: everything fetched so far stays cached,
244 // so the next pass resumes from here instead of starting over.
245 Err(err) => {
246 debug!(
247 target: LOG_MODULE_WALLETV2,
248 %txid,
249 err = %err.fmt_compact(),
250 "Error fetching a mempool transaction, reporting a partial mempool"
251 );
252
253 return MempoolScan::Failed;
254 }
255 }
256
257 // Checked after a fetch rather than before, so that even a budget
258 // shorter than a single fetch makes progress on every pass.
259 if missing.peek().is_some() && fedimint_core::time::now() >= deadline {
260 return MempoolScan::OutOfBudget;
261 }
262 }
263
264 MempoolScan::Complete
265}
266
267/// Collects every receive output a guardian can see but the federation has not
268/// yet recorded, from the recent blocks and, unless `mempool_budget` is `None`
269/// because the mempool scan is turned off, the mempool.
270///
271/// Also returns how the mempool pass ended, so the caller can tell whether to
272/// continue straight away.
273async fn scan_pending_receives(
274 btc_rpc: &ServerBitcoinRpcMonitor,
275 pks_hash: &sha256::Hash,
276 mempool_budget: Option<Duration>,
277 cache: &mut PendingCache,
278) -> anyhow::Result<(PendingOutputs, MempoolScan)> {
279 let status = btc_rpc
280 .status()
281 .context("Bitcoin backend is not connected")?;
282
283 scan_pending_blocks(btc_rpc, pks_hash, status.block_count, &mut cache.blocks).await?;
284
285 // The mempool pass never fails the whole scan. Propagating would discard
286 // the mined progress gathered above, which is both the stronger signal and
287 // still available: with an esplora fallback the block scan can succeed
288 // while the mempool read, which only bitcoind can serve, does not.
289 let mempool = match mempool_budget {
290 Some(budget) => scan_pending_mempool(btc_rpc, pks_hash, budget, &mut cache.mempool).await,
291 None => MempoolScan::Unavailable,
292 };
293
294 let mined = cache
295 .blocks
296 .values()
297 .flat_map(|(_, outputs)| outputs.iter().cloned());
298
299 // The cache survives a pass that could not report from it, so that a
300 // transient failure does not force a refetch of the whole mempool next
301 // time; what is reported is decided here instead of by emptying it.
302 let unmined = (mempool != MempoolScan::Unavailable)
303 .then(|| cache.mempool.values().flatten().cloned())
304 .into_iter()
305 .flatten();
306
307 // A transaction mined between the two scans appears in both; the mined
308 // entry is the stronger signal, so it wins on the client side by way of the
309 // per-outpoint merge preferring a known height.
310 let outputs = mined.chain(unmined).collect::<Vec<PendingOutput>>();
311
312 debug!(
313 target: LOG_MODULE_WALLETV2,
314 block_count = status.block_count,
315 ?mempool,
316 mempool_txs_num = cache.mempool.len(),
317 pending_outputs_num = outputs.len(),
318 "Scanned for pending receives"
319 );
320
321 let pending = PendingOutputs {
322 block_count: status.block_count,
323 outputs,
324 mempool_visibility: mempool.visibility(),
325 };
326
327 Ok((pending, mempool))
328}
329
330/// Maintains [`crate::Wallet::pending_outputs`] by scanning both the blocks
331/// just below the guardian's local chain tip and its mempool.
332///
333/// This deliberately runs outside of consensus. Most of this window sits
334/// above the consensus block count precisely because the federation does
335/// not consider those blocks final yet, and each guardian sees a slightly
336/// different tip, so nothing derived here may ever influence consensus
337/// state. It exists only so clients can render peg-in progress rather than
338/// showing nothing for the hour it takes a deposit to become claimable.
339pub(crate) fn spawn_pending_receive_scanner(
340 btc_rpc: ServerBitcoinRpcMonitor,
341 pks_hash: sha256::Hash,
342 sender: watch::Sender<PendingOutputs>,
343 task_group: &TaskGroup,
344) {
345 // Read once rather than per scan: it selects how this guardian behaves
346 // for its whole run, and a guardian that has opted out should not be
347 // paying an env lookup on every chain tip either.
348 let mempool_budget =
349 (!is_env_var_set(FM_WALLETV2_DISABLE_MEMPOOL_SCAN_ENV)).then(mempool_scan_budget);
350
351 task_group.spawn_cancellable("scan_pending_receives", async move {
352 // Carried across scans so that each one only fetches blocks and
353 // mempool transactions it has not already seen.
354 let mut cache = PendingCache::default();
355
356 // Follow the monitor's own refresh cadence rather than an
357 // independent timer, so we never scan a chain tip that is already
358 // stale by up to a full update interval.
359 let mut status = btc_rpc.subscribe_status();
360
361 loop {
362 let scan = scan_pending_receives(&btc_rpc, &pks_hash, mempool_budget, &mut cache).await;
363
364 let out_of_budget = match scan {
365 Ok((pending, mempool)) => {
366 sender.send_replace(pending);
367
368 mempool == MempoolScan::OutOfBudget
369 }
370 Err(err) => {
371 // This view is advisory, so a guardian that cannot
372 // reach its bitcoin backend degrades to reporting
373 // nothing pending rather than failing.
374 debug!(
375 target: LOG_MODULE_WALLETV2,
376 err = %err.fmt_compact(),
377 "Error scanning for pending receives"
378 );
379
380 sender.send_replace(PendingOutputs::default());
381
382 false
383 }
384 };
385
386 // A pass that ran out of budget has published what it gathered
387 // and still has mempool transactions left to fetch, so carry on
388 // now rather than a full monitor interval later. Every such
389 // pass fetches at least one transaction, so this terminates
390 // once the cache has caught up with the mempool.
391 //
392 // Yield first: the bitcoind backend's RPC futures run
393 // synchronously via `block_in_place` and never return `Pending`,
394 // so without this a catch-up would run every slice inside a
395 // single poll and the enclosing `cancel_on_shutdown` could not
396 // observe shutdown in between.
397 if out_of_budget {
398 tokio::task::yield_now().await;
399 continue;
400 }
401
402 if status.changed().await.is_err() {
403 break;
404 }
405 }
406 });
407}