Skip to main content

fedimint_wallet_client/
pegin_monitor.rs

1use std::cmp;
2use std::convert::Infallible;
3use std::time::{Duration, SystemTime};
4
5use bitcoin::ScriptBuf;
6use fedimint_api_client::api::{DynModuleApi, FederationError};
7use fedimint_bitcoind::{BitcoinRpcError, DynBitcoindRpc};
8use fedimint_client_module::module::{ClientContext, OutPointRange};
9use fedimint_client_module::transaction::{ClientInput, ClientInputBundle};
10use fedimint_core::core::OperationId;
11use fedimint_core::db::{
12    AutocommitError, Database, DatabaseError, DatabaseTransaction,
13    IDatabaseTransactionOpsCoreTyped as _,
14};
15use fedimint_core::envs::is_running_in_test_env;
16use fedimint_core::module::Amounts;
17use fedimint_core::task::sleep;
18use fedimint_core::txoproof::TxOutProof;
19use fedimint_core::util::FmtCompact as _;
20use fedimint_core::{BitcoinHash, TransactionId, secp256k1, time};
21use fedimint_logging::LOG_CLIENT_MODULE_WALLET;
22use fedimint_wallet_common::WalletInput;
23use fedimint_wallet_common::txoproof::PegInProof;
24use futures::StreamExt as _;
25use secp256k1::Keypair;
26use tokio::sync::watch;
27use tracing::{debug, instrument, trace, warn};
28
29use crate::api::WalletFederationApi as _;
30use crate::client_db::{
31    ClaimedPegInData, ClaimedPegInKey, PegInTweakIndexData, PegInTweakIndexKey,
32    PegInTweakIndexPrefix, TweakIdx,
33};
34use crate::events::{DepositConfirmed, ReceivePaymentEvent};
35use crate::{WalletClientModule, WalletClientModuleData};
36
37/// A helper struct meant to combined data from all addresses/records
38/// into a single struct with all actionable data.
39#[derive(Debug, Clone)]
40struct NextActions {
41    /// Current time
42    now: SystemTime,
43    /// Index keys due for a check
44    due: Vec<(PegInTweakIndexKey, PegInTweakIndexData)>,
45    /// Nearest key that is not due yet
46    next: Option<SystemTime>,
47}
48
49impl NextActions {
50    pub fn new() -> Self {
51        Self {
52            now: time::now(),
53            due: vec![],
54            next: None,
55        }
56    }
57}
58
59impl NextActions {
60    /// Calculate next actions from the database
61    async fn from_db_state(db: &Database) -> Self {
62        db.begin_transaction_nc()
63            .await
64            .find_by_prefix(&PegInTweakIndexPrefix)
65            .await
66            .fold(NextActions::new(), |state, (key, val)| async {
67                state.fold(key, val)
68            })
69            .await
70    }
71
72    /// Combine current state with another record
73    pub fn fold(mut self, key: PegInTweakIndexKey, val: PegInTweakIndexData) -> Self {
74        if let Some(next_check_time) = val.next_check_time {
75            if next_check_time < self.now {
76                self.due.push((key, val));
77            }
78
79            self.next = match self.next {
80                Some(existing) => Some(existing.min(next_check_time)),
81                None => Some(next_check_time),
82            };
83        }
84        self
85    }
86}
87
88/// A deposit monitoring task
89///
90/// On the high level it maintains a list of derived addresses with some info
91/// like when is the next time to check for deposits on them.
92#[allow(clippy::too_many_lines)]
93pub(crate) async fn run_peg_in_monitor(
94    client_ctx: ClientContext<WalletClientModule>,
95    db: Database,
96    btc_rpc: DynBitcoindRpc,
97    module_api: DynModuleApi,
98    data: WalletClientModuleData,
99    pegin_claimed_sender: watch::Sender<()>,
100    mut wakeup_receiver: watch::Receiver<()>,
101) {
102    let min_sleep: Duration = if is_running_in_test_env() {
103        Duration::from_millis(100)
104    } else {
105        Duration::from_secs(30)
106    };
107
108    // How long to wait before the next check. Seeded to ZERO so the
109    // first iteration runs a check immediately. Recomputed at the end
110    // of each successful iteration based on DB scheduling and how the
111    // previous wakeup arrived (see `woke_via_signal` below).
112    let mut next_wakeup_duration = Duration::ZERO;
113
114    loop {
115        debug!(target: LOG_CLIENT_MODULE_WALLET, sleep_msecs=%next_wakeup_duration.as_millis(), "Sleeping before next check");
116        let woke_via_signal = tokio::select! {
117            () = sleep(next_wakeup_duration) => {
118                debug!(target: LOG_CLIENT_MODULE_WALLET, "Woken up by a scheduled wakeup");
119                false
120            },
121            res = wakeup_receiver.changed() => {
122                debug!(target: LOG_CLIENT_MODULE_WALLET, "Woken up by a signal");
123                if res.is_err() {
124                    debug!(target: LOG_CLIENT_MODULE_WALLET,  "Terminating peg-in monitor");
125                    return;
126                }
127                true
128            }
129        };
130
131        check_for_deposits(
132            &db,
133            &data,
134            &btc_rpc,
135            &module_api,
136            &client_ctx,
137            &pegin_claimed_sender,
138        )
139        .await;
140
141        let now = time::now();
142        let next_wakeup = NextActions::from_db_state(&db).await.next.unwrap_or_else(||
143            /* for simplicity just wake up every hour, even when there's no need */
144              now + Duration::from_hours(1));
145        // When this iteration was signal-driven (manual recheck, new
146        // address allocation) drop the `min_sleep` floor so the natural
147        // retry_delay_vec drives timing — relevant for fresh addresses
148        // where the natural delay is `age / 10` and would otherwise be
149        // clamped to 30s in production.
150        let effective_min_sleep = if woke_via_signal {
151            Duration::ZERO
152        } else {
153            min_sleep
154        };
155        next_wakeup_duration = next_wakeup
156            .duration_since(now)
157            .unwrap_or_default()
158            .max(effective_min_sleep);
159    }
160}
161
162async fn check_for_deposits(
163    db: &Database,
164    data: &WalletClientModuleData,
165    btc_rpc: &DynBitcoindRpc,
166    module_api: &DynModuleApi,
167    client_ctx: &ClientContext<WalletClientModule>,
168    pengin_claimed_sender: &watch::Sender<()>,
169) {
170    let due = NextActions::from_db_state(db).await.due;
171    trace!(target: LOG_CLIENT_MODULE_WALLET, ?due, "Checking for deposists");
172    for (due_key, due_val) in due {
173        check_and_claim_idx_pegins(
174            data,
175            due_key,
176            btc_rpc,
177            module_api,
178            db,
179            client_ctx,
180            due_val,
181            pengin_claimed_sender,
182        )
183        .await;
184    }
185}
186
187#[allow(clippy::too_many_arguments)]
188async fn check_and_claim_idx_pegins(
189    data: &WalletClientModuleData,
190    due_key: PegInTweakIndexKey,
191    btc_rpc: &DynBitcoindRpc,
192    module_api: &DynModuleApi,
193    db: &Database,
194    client_ctx: &ClientContext<WalletClientModule>,
195    due_val: PegInTweakIndexData,
196    pengin_claimed_sender: &watch::Sender<()>,
197) {
198    let now = time::now();
199    match check_idx_pegins(data, due_key.0, btc_rpc, module_api, db, client_ctx).await {
200        Ok(outcomes) => {
201            let next_check_time = CheckOutcome::retry_delay_vec(&outcomes, due_val.creation_time)
202                .map(|duration| now + duration);
203            db
204                .autocommit(
205                    |dbtx, _| {
206                        Box::pin(async {
207                            // Re-read inside the transaction so a concurrent
208                            // `recheck_pegin_address` that landed while we were
209                            // doing Bitcoin RPC calls isn't silently overwritten.
210                            // Peg-in entries are never deleted, only updated,
211                            // so the entry is guaranteed to still exist even
212                            // across `autocommit` retries.
213                            let current = dbtx
214                                .get_value(&due_key)
215                                .await
216                                .expect("Peg-in entries are never deleted");
217
218                            // If `next_check_time` changed under us (a recheck set
219                            // it to "now"), keep the earliest of the two so the
220                            // recheck still triggers the next monitor iteration.
221                            let merged_next_check_time = if current.next_check_time
222                                == due_val.next_check_time
223                            {
224                                next_check_time
225                            } else {
226                                match (current.next_check_time, next_check_time) {
227                                    (Some(a), Some(b)) => Some(a.min(b)),
228                                    (a, None) => a,
229                                    (None, b) => b,
230                                }
231                            };
232
233                            let claimed_now = CheckOutcome::get_claimed_now_outpoints(&outcomes);
234
235                            let claimed_sender = pengin_claimed_sender.clone();
236                            dbtx.on_commit(move || {
237                                claimed_sender.send_replace(());
238                            });
239
240                            let peg_in_tweak_index_data = PegInTweakIndexData {
241                                next_check_time: merged_next_check_time,
242                                last_check_time: Some(now),
243                                claimed: [current.claimed.clone(), claimed_now].concat(),
244                                ..current
245                            };
246                            trace!(
247                                target: LOG_CLIENT_MODULE_WALLET,
248                                tweak_idx=%due_key.0,
249                                due_in_secs=?merged_next_check_time.map(|next_check_time| next_check_time.duration_since(now).unwrap_or_default().as_secs()),
250                                data=?peg_in_tweak_index_data,
251                                "Updating"
252                            );
253                            dbtx
254                                .insert_entry(&due_key, &peg_in_tweak_index_data)
255                                .await;
256
257                            Ok::<_, Infallible>(())
258                        })
259                    },
260                    None,
261                )
262                .await
263                .expect("Autocommit retries forever and the closure cannot fail");
264        }
265        Err(err) => {
266            debug!(
267                target: LOG_CLIENT_MODULE_WALLET,
268                err = %err.fmt_compact(),
269                tweak_idx = %due_key.0,
270                "Error checking tweak_idx"
271            );
272        }
273    }
274}
275
276/// Outcome of checking a single deposit Bitcoin transaction output
277///
278/// For every address there can be multiple outcomes (`Vec<Self>`).
279#[derive(Copy, Clone, Debug)]
280enum CheckOutcome {
281    /// There's a tx pending (needs more confirmation)
282    Pending { num_blocks_needed: u64 },
283    /// A state machine was created to claim the peg-in
284    Claimed { outpoint: bitcoin::OutPoint },
285
286    /// A peg-in transaction was already claimed (state machine created) in the
287    /// past
288    AlreadyClaimed,
289}
290
291impl CheckOutcome {
292    /// Desired retry delay for a single outcome
293    ///
294    /// None means "no need to check anymore".
295    fn retry_delay(self) -> Option<Duration> {
296        match self {
297            // Check again in time proportional to the expected block confirmation time
298            CheckOutcome::Pending { num_blocks_needed } => {
299                if is_running_in_test_env() {
300                    // In tests, we basically mine all blocks right away
301                    Some(Duration::from_millis(1))
302                } else {
303                    Some(Duration::from_secs(60 * num_blocks_needed))
304                }
305            }
306            // Once anything has been claimed, there's no reason to claim again automatically,
307            // and it's undesirable due to privacy reasons.
308            // Users can possibly update the underlying record via other means to force a check on
309            // demand.
310            CheckOutcome::Claimed { .. } | CheckOutcome::AlreadyClaimed => None,
311        }
312    }
313
314    /// Desired retry delay for a bunch of outcomes.
315    ///
316    /// This time is intended to be persisted in the database.
317    ///
318    /// None means "no need to check anymore".
319    fn retry_delay_vec(outcomes: &[CheckOutcome], creation_time: SystemTime) -> Option<Duration> {
320        // If the address was allocated, but nothing was ever received or even detected
321        // on it yet, check again in time proportional to the age of the
322        // address.
323        if outcomes.is_empty() {
324            if is_running_in_test_env() {
325                // When testing we usually send deposits right away, so check more aggressively.
326                return Some(Duration::from_millis(100));
327            }
328            let now = time::now();
329            let age = now.duration_since(creation_time).unwrap_or_default();
330            return Some(age / 10);
331        }
332
333        // The delays is the minimum retry delay.
334        let mut min = None;
335
336        for outcome in outcomes {
337            min = match (min, outcome.retry_delay()) {
338                (None, time) => time,
339                (Some(min), None) => Some(min),
340                (Some(min), Some(time)) => Some(cmp::min(min, time)),
341            };
342        }
343
344        min
345    }
346
347    fn get_claimed_now_outpoints(outcomes: &[CheckOutcome]) -> Vec<bitcoin::OutPoint> {
348        let mut res = vec![];
349        for outcome in outcomes {
350            if let CheckOutcome::Claimed { outpoint } = outcome {
351                res.push(*outpoint);
352            }
353        }
354
355        res
356    }
357}
358
359/// Query via btc rpc for a history of an address derived with `tweak_idx` and
360/// claim any peg-ins that are ready.
361///
362/// Return a list of [`CheckOutcome`]s for each matching output.
363#[instrument(target = LOG_CLIENT_MODULE_WALLET, skip_all, fields(tweak_idx))]
364async fn check_idx_pegins(
365    data: &WalletClientModuleData,
366    tweak_idx: TweakIdx,
367    btc_rpc: &DynBitcoindRpc,
368    module_rpc: &DynModuleApi,
369    db: &Database,
370    client_ctx: &ClientContext<WalletClientModule>,
371) -> Result<Vec<CheckOutcome>, CheckPegInsError> {
372    let current_consensus_block_count = module_rpc.fetch_consensus_block_count().await?;
373    let (script, address, tweak_key, operation_id) = data.derive_peg_in_script(tweak_idx);
374    btc_rpc.watch_script_history(&script).await?;
375
376    let history = btc_rpc.get_script_history(&script).await?;
377
378    debug!(target: LOG_CLIENT_MODULE_WALLET, %address, num_txes=history.len(), "Got history of a peg-in address");
379
380    let mut outcomes = vec![];
381
382    for (transaction, out_idx) in filter_onchain_deposit_outputs(history.into_iter(), &script) {
383        let txid = transaction.compute_txid();
384        let outpoint = bitcoin::OutPoint {
385            txid,
386            vout: out_idx,
387        };
388
389        let claimed_peg_in_key = ClaimedPegInKey {
390            peg_in_index: tweak_idx,
391            btc_out_point: outpoint,
392        };
393
394        if db
395            .begin_transaction_nc()
396            .await
397            .get_value(&claimed_peg_in_key)
398            .await
399            .is_some()
400        {
401            debug!(target: LOG_CLIENT_MODULE_WALLET, %txid, %out_idx, "Already claimed");
402            outcomes.push(CheckOutcome::AlreadyClaimed);
403            continue;
404        }
405        let finality_delay = u64::from(data.cfg.finality_delay);
406
407        let tx_block_count =
408            if let Some(tx_block_height) = btc_rpc.get_tx_block_height(&txid).await? {
409                tx_block_height.saturating_add(1)
410            } else {
411                outcomes.push(CheckOutcome::Pending {
412                    num_blocks_needed: finality_delay,
413                });
414                debug!(target:LOG_CLIENT_MODULE_WALLET, %txid, %out_idx,"In the mempool");
415                continue;
416            };
417
418        let num_blocks_needed = tx_block_count.saturating_sub(current_consensus_block_count);
419
420        if 0 < num_blocks_needed {
421            outcomes.push(CheckOutcome::Pending { num_blocks_needed });
422            debug!(target: LOG_CLIENT_MODULE_WALLET, %txid, %out_idx, %num_blocks_needed, %finality_delay, %tx_block_count, %current_consensus_block_count, "Needs more confirmations");
423            continue;
424        }
425
426        debug!(target: LOG_CLIENT_MODULE_WALLET, %txid, %out_idx, %finality_delay, %tx_block_count, %current_consensus_block_count, "Ready to claim");
427
428        let tx_out_proof = btc_rpc.get_txout_proof(txid).await?;
429        let federation_knows_utxo = module_rpc.is_utxo_confirmed(outpoint).await?;
430
431        claim_peg_in(
432            client_ctx,
433            tweak_idx,
434            tweak_key,
435            &transaction,
436            operation_id,
437            outpoint,
438            tx_out_proof,
439            federation_knows_utxo,
440        )
441        .await?;
442        outcomes.push(CheckOutcome::Claimed { outpoint });
443    }
444    Ok(outcomes)
445}
446
447#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
448async fn claim_peg_in(
449    client_ctx: &ClientContext<WalletClientModule>,
450    tweak_idx: TweakIdx,
451    tweak_key: Keypair,
452    transaction: &bitcoin::Transaction,
453    operation_id: OperationId,
454    out_point: bitcoin::OutPoint,
455    tx_out_proof: TxOutProof,
456    federation_knows_utxo: bool,
457) -> Result<(), CheckPegInsError> {
458    /// Returns the claim transactions output range if a claim happened or
459    /// `None` otherwise if the deposit was smaller than the deposit fee.
460    async fn claim_peg_in_inner(
461        client_ctx: &ClientContext<WalletClientModule>,
462        dbtx: &mut DatabaseTransaction<'_>,
463        btc_transaction: &bitcoin::Transaction,
464        out_idx: u32,
465        tweak_key: Keypair,
466        txout_proof: TxOutProof,
467        operation_id: OperationId,
468        federation_knows_utxo: bool,
469    ) -> Option<OutPointRange> {
470        let pegin_proof = PegInProof::new(
471            txout_proof,
472            btc_transaction.clone(),
473            out_idx,
474            tweak_key.public_key(),
475        )
476        .expect("TODO: handle API returning faulty proofs");
477
478        let amount = pegin_proof.tx_output().value.into();
479        let wallet_input = if federation_knows_utxo {
480            WalletInput::new_v1(&pegin_proof)
481        } else {
482            WalletInput::new_v0(pegin_proof)
483        };
484
485        let client_input = ClientInput::<WalletInput> {
486            input: wallet_input,
487            keys: vec![tweak_key],
488            amounts: Amounts::new_bitcoin(amount),
489        };
490
491        if amount <= client_ctx.self_ref().cfg().fee_consensus.peg_in_abs {
492            warn!(target: LOG_CLIENT_MODULE_WALLET, "We won't claim a deposit lower than the deposit fee");
493            return None;
494        }
495
496        let txid = btc_transaction.compute_txid();
497
498        client_ctx
499            .log_event(
500                dbtx,
501                DepositConfirmed {
502                    txid,
503                    out_idx,
504                    amount,
505                },
506            )
507            .await;
508
509        client_ctx
510            .log_event(
511                dbtx,
512                ReceivePaymentEvent {
513                    operation_id,
514                    amount,
515                    txid,
516                },
517            )
518            .await;
519
520        Some(
521            client_ctx
522                .claim_inputs(
523                    dbtx,
524                    ClientInputBundle::new_no_sm(vec![client_input]),
525                    operation_id,
526                )
527                .await
528                .expect("Cannot claim input, additional funding needed"),
529        )
530    }
531
532    let tx_out_proof = &tx_out_proof;
533
534    debug!(target: LOG_CLIENT_MODULE_WALLET, %out_point, "Claiming a peg-in");
535
536    client_ctx
537        .module_db()
538        .autocommit(
539            |dbtx, _| {
540                Box::pin(async {
541                    let maybe_change_range = claim_peg_in_inner(
542                        client_ctx,
543                        dbtx,
544                        transaction,
545                        out_point.vout,
546                        tweak_key,
547                        tx_out_proof.clone(),
548                        operation_id,
549                        federation_knows_utxo,
550                    )
551                    .await;
552
553                    let claimed_pegin_data = if let Some(change_range) = maybe_change_range {
554                        ClaimedPegInData {
555                            claim_txid: change_range.txid(),
556                            change: change_range.into_iter().collect(),
557                        }
558                    } else {
559                        ClaimedPegInData {
560                            claim_txid: TransactionId::from_byte_array([0; 32]),
561                            change: vec![],
562                        }
563                    };
564
565                    dbtx.insert_entry(
566                        &ClaimedPegInKey {
567                            peg_in_index: tweak_idx,
568                            btc_out_point: out_point,
569                        },
570                        &claimed_pegin_data,
571                    )
572                    .await;
573
574                    Ok::<_, Infallible>(())
575                })
576            },
577            Some(100),
578        )
579        .await
580        .map_err(|e| match e {
581            AutocommitError::CommitFailed {
582                last_error,
583                attempts,
584            } => CheckPegInsError::ClaimCommit {
585                attempts,
586                source: last_error,
587            },
588            AutocommitError::ClosureError { error, .. } => match error {},
589        })?;
590
591    Ok(())
592}
593
594pub(crate) fn filter_onchain_deposit_outputs<'a>(
595    tx_iter: impl Iterator<Item = bitcoin::Transaction> + 'a,
596    out_script: &'a ScriptBuf,
597) -> impl Iterator<Item = (bitcoin::Transaction, u32)> + 'a {
598    tx_iter.flat_map(move |tx| {
599        tx.output
600            .clone()
601            .into_iter()
602            .enumerate()
603            .filter_map(move |(out_idx, tx_out)| {
604                if &tx_out.script_pubkey == out_script {
605                    Some((tx.clone(), out_idx as u32))
606                } else {
607                    None
608                }
609            })
610    })
611}
612
613/// A failure to check one peg-in address and claim its confirmed deposits.
614#[derive(Debug, thiserror::Error)]
615enum CheckPegInsError {
616    /// The federation could not report its block count or whether it knows a
617    /// deposit.
618    #[error(transparent)]
619    Federation(#[from] FederationError),
620
621    /// The Bitcoin backend could not report the address's history or a
622    /// deposit's proof.
623    #[error(transparent)]
624    BitcoinRpc(#[from] BitcoinRpcError),
625
626    /// The claim of a confirmed deposit could not be committed.
627    #[error("Failed to commit after {attempts} attempts")]
628    ClaimCommit {
629        attempts: usize,
630        #[source]
631        source: DatabaseError,
632    },
633}