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#[derive(Debug, Clone)]
40struct NextActions {
41 now: SystemTime,
43 due: Vec<(PegInTweakIndexKey, PegInTweakIndexData)>,
45 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 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 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#[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 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 now + Duration::from_hours(1));
145 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 let current = dbtx
214 .get_value(&due_key)
215 .await
216 .expect("Peg-in entries are never deleted");
217
218 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#[derive(Copy, Clone, Debug)]
280enum CheckOutcome {
281 Pending { num_blocks_needed: u64 },
283 Claimed { outpoint: bitcoin::OutPoint },
285
286 AlreadyClaimed,
289}
290
291impl CheckOutcome {
292 fn retry_delay(self) -> Option<Duration> {
296 match self {
297 CheckOutcome::Pending { num_blocks_needed } => {
299 if is_running_in_test_env() {
300 Some(Duration::from_millis(1))
302 } else {
303 Some(Duration::from_secs(60 * num_blocks_needed))
304 }
305 }
306 CheckOutcome::Claimed { .. } | CheckOutcome::AlreadyClaimed => None,
311 }
312 }
313
314 fn retry_delay_vec(outcomes: &[CheckOutcome], creation_time: SystemTime) -> Option<Duration> {
320 if outcomes.is_empty() {
324 if is_running_in_test_env() {
325 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 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#[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 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#[derive(Debug, thiserror::Error)]
615enum CheckPegInsError {
616 #[error(transparent)]
619 Federation(#[from] FederationError),
620
621 #[error(transparent)]
624 BitcoinRpc(#[from] BitcoinRpcError),
625
626 #[error("Failed to commit after {attempts} attempts")]
628 ClaimCommit {
629 attempts: usize,
630 #[source]
631 source: DatabaseError,
632 },
633}