Skip to main content

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}