1#![deny(clippy::pedantic)]
2#![allow(clippy::cast_possible_truncation)]
3#![allow(clippy::missing_errors_doc)]
4#![allow(clippy::missing_panics_doc)]
5#![allow(clippy::module_name_repetitions)]
6#![allow(clippy::must_use_candidate)]
7
8#[cfg(feature = "uniffi")]
9::uniffi::setup_scaffolding!();
10
11pub mod api;
12#[cfg(feature = "cli")]
13mod cli;
14
15mod backup;
16
17pub mod client_db;
18mod deposit;
21pub mod events;
22use events::SendPaymentEvent;
23#[cfg(feature = "uniffi")]
24pub mod ffi;
25mod pegin_monitor;
27mod withdraw;
28
29use std::collections::{BTreeMap, BTreeSet};
30use std::future;
31use std::sync::Arc;
32use std::time::{Duration, SystemTime};
33
34use anyhow::{Context as AnyhowContext, anyhow, bail, ensure};
35use async_stream::{stream, try_stream};
36use backup::WalletModuleBackup;
37use bitcoin::address::NetworkUnchecked;
38use bitcoin::secp256k1::{All, SECP256K1, Secp256k1};
39use bitcoin::{Address, Network, ScriptBuf};
40use client_db::{DbKeyPrefix, PegInTweakIndexKey, SupportsSafeDepositKey, TweakIdx};
41use fedimint_api_client::api::{DynModuleApi, FederationResult};
42use fedimint_bitcoind::{BitcoindTracked, DynBitcoindRpc, IBitcoindRpc, create_esplora_rpc};
43use fedimint_client_module::module::init::{
44 ClientModuleInit, ClientModuleInitArgs, ClientModuleRecoverArgs,
45};
46use fedimint_client_module::module::recovery::RecoveryProgress;
47use fedimint_client_module::module::{ClientContext, ClientModule, IClientModule, OutPointRange};
48use fedimint_client_module::oplog::UpdateStreamOrOutcome;
49use fedimint_client_module::sm::{Context, DynState, ModuleNotifier, State, StateTransition};
50use fedimint_client_module::transaction::{
51 ClientOutput, ClientOutputBundle, ClientOutputSM, FeeQuote, FeeQuoteRequest, TransactionBuilder,
52};
53use fedimint_client_module::{DynGlobalClientContext, sm_enum_variant_translation};
54use fedimint_core::core::{Decoder, IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
55use fedimint_core::db::{
56 AutocommitError, Database, DatabaseTransaction, IDatabaseTransactionOpsCoreTyped,
57};
58use fedimint_core::encoding::{Decodable, Encodable};
59use fedimint_core::envs::{BitcoinRpcConfig, is_running_in_test_env};
60use fedimint_core::module::{
61 Amounts, ApiAuth, ApiVersion, CommonModuleInit, ModuleCommon, ModuleConsensusVersion,
62 ModuleInit, MultiApiVersion,
63};
64use fedimint_core::task::{MaybeSend, MaybeSync, TaskGroup, sleep};
65use fedimint_core::util::backoff_util::background_backoff;
66use fedimint_core::util::{BoxStream, backoff_util, retry};
67use fedimint_core::{
68 BitcoinHash, OutPoint, TransactionId, apply, async_trait_maybe_send, push_db_pair_items,
69 runtime, secp256k1,
70};
71use fedimint_derive_secret::{ChildId, DerivableSecret};
72use fedimint_logging::LOG_CLIENT_MODULE_WALLET;
73pub use fedimint_wallet_common as common;
74use fedimint_wallet_common::config::{FeeConsensus, WalletClientConfig};
75use fedimint_wallet_common::tweakable::Tweakable;
76pub use fedimint_wallet_common::*;
77use futures::{Stream, StreamExt};
78use rand::{Rng, thread_rng};
79use secp256k1::Keypair;
80use serde::{Deserialize, Serialize};
81use strum::IntoEnumIterator;
82use tokio::sync::watch;
83use tracing::{debug, instrument};
84
85use crate::api::WalletFederationApi;
86use crate::backup::{FEDERATION_RECOVER_MAX_GAP, RecoveryStateV2, WalletRecovery};
87use crate::client_db::{
88 ClaimedPegInData, ClaimedPegInKey, ClaimedPegInPrefix, NextPegInTweakIndexKey,
89 PegInPoolCursorKey, PegInTweakIndexData, PegInTweakIndexPrefix, RecoveryFinalizedKey,
90 RecoveryStateKey, SupportsSafeDepositPrefix,
91};
92use crate::deposit::DepositStateMachine;
93use crate::withdraw::{CreatedWithdrawState, WithdrawStateMachine, WithdrawStates};
94
95const WALLET_TWEAK_CHILD_ID: ChildId = ChildId(0);
96
97#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
98pub struct BitcoinTransactionData {
99 pub btc_transaction: bitcoin::Transaction,
102 pub out_idx: u32,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
107pub enum DepositStateV1 {
108 WaitingForTransaction,
109 WaitingForConfirmation(BitcoinTransactionData),
110 Confirmed(BitcoinTransactionData),
111 Claimed(BitcoinTransactionData),
112 Failed(String),
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
116#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
117pub enum DepositStateV2 {
118 WaitingForTransaction,
119 WaitingForConfirmation {
120 #[serde(with = "bitcoin::amount::serde::as_sat")]
121 btc_deposited: bitcoin::Amount,
122 btc_out_point: bitcoin::OutPoint,
123 },
124 Confirmed {
125 #[serde(with = "bitcoin::amount::serde::as_sat")]
126 btc_deposited: bitcoin::Amount,
127 btc_out_point: bitcoin::OutPoint,
128 },
129 Claimed {
130 #[serde(with = "bitcoin::amount::serde::as_sat")]
131 btc_deposited: bitcoin::Amount,
132 btc_out_point: bitcoin::OutPoint,
133 },
134 Failed(String),
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct DepositAddressInfo {
140 pub operation_id: OperationId,
141 pub address: Address,
142 pub tweak_idx: TweakIdx,
143}
144
145#[allow(clippy::enum_variant_names)]
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum MaybeNewAddress {
153 NewAddress(DepositAddressInfo),
155 TooManyUnusedAddresses(Vec<DepositAddressInfo>),
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum AllocateDepositOutcome {
168 Fresh,
170 Reused { original_tweak_idx: TweakIdx },
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
177#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
178pub enum WithdrawState {
179 Created,
180 Succeeded(bitcoin::Txid),
181 Failed(String),
182 }
186
187async fn next_withdraw_state<S>(stream: &mut S) -> Option<WithdrawStates>
188where
189 S: Stream<Item = WalletClientStates> + Unpin,
190{
191 loop {
192 if let WalletClientStates::Withdraw(ds) = stream.next().await? {
193 return Some(ds.state);
194 }
195 tokio::task::yield_now().await;
196 }
197}
198
199#[derive(Debug, Clone, Default)]
200pub struct WalletClientInit(pub Option<DynBitcoindRpc>);
202
203const SLICE_SIZE: u64 = 1000;
204
205impl WalletClientInit {
206 pub fn new(rpc: DynBitcoindRpc) -> Self {
207 Self(Some(rpc))
208 }
209
210 async fn recover_from_slices(
211 &self,
212 args: &ClientModuleRecoverArgs<Self>,
213 ) -> anyhow::Result<Option<fedimint_core::Amount>> {
214 let data = WalletClientModuleData {
215 cfg: args.cfg().clone(),
216 module_root_secret: args.module_root_secret().clone(),
217 };
218
219 let total_items = args.module_api().fetch_recovery_count().await?;
220
221 let mut state = RecoveryStateV2::new();
222
223 state.refill_pending_pool_up_to(&data, TweakIdx(FEDERATION_RECOVER_MAX_GAP));
224
225 for start in (0..total_items).step_by(SLICE_SIZE as usize) {
226 let end = std::cmp::min(start + SLICE_SIZE, total_items);
227
228 let items = args.module_api().fetch_recovery_slice(start, end).await?;
229
230 for item in &items {
231 match item {
232 RecoveryItem::Input { outpoint, script } => {
233 state.handle_item(*outpoint, script, &data);
234 }
235 }
236 }
237
238 args.update_recovery_progress(RecoveryProgress {
239 complete: end.try_into().unwrap_or(u32::MAX),
240 total: total_items.try_into().unwrap_or(u32::MAX),
241 });
242 }
243
244 let mut dbtx = args.db().begin_transaction().await;
245
246 for tweak_idx in 0..state.new_start_idx().0 {
247 let operation_id = data.derive_peg_in_script(TweakIdx(tweak_idx)).3;
248
249 let claimed = state
250 .claimed_outpoints
251 .get(&TweakIdx(tweak_idx))
252 .cloned()
253 .unwrap_or_default();
254
255 dbtx.insert_new_entry(
256 &PegInTweakIndexKey(TweakIdx(tweak_idx)),
257 &PegInTweakIndexData {
258 operation_id,
259 creation_time: fedimint_core::time::now(),
260 last_check_time: None,
261 next_check_time: Some(fedimint_core::time::now()),
262 claimed,
263 },
264 )
265 .await;
266 }
267
268 dbtx.insert_new_entry(&NextPegInTweakIndexKey, &state.new_start_idx())
269 .await;
270
271 dbtx.commit_tx().await;
272
273 Ok(None)
277 }
278}
279
280impl ModuleInit for WalletClientInit {
281 type Common = WalletCommonInit;
282
283 async fn dump_database(
284 &self,
285 dbtx: &mut DatabaseTransaction<'_>,
286 prefix_names: Vec<String>,
287 ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
288 let mut wallet_client_items: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> =
289 BTreeMap::new();
290 let filtered_prefixes = DbKeyPrefix::iter().filter(|f| {
291 prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase())
292 });
293
294 for table in filtered_prefixes {
295 match table {
296 DbKeyPrefix::NextPegInTweakIndex => {
297 if let Some(index) = dbtx.get_value(&NextPegInTweakIndexKey).await {
298 wallet_client_items
299 .insert("NextPegInTweakIndex".to_string(), Box::new(index));
300 }
301 }
302 DbKeyPrefix::PegInTweakIndex => {
303 push_db_pair_items!(
304 dbtx,
305 PegInTweakIndexPrefix,
306 PegInTweakIndexKey,
307 PegInTweakIndexData,
308 wallet_client_items,
309 "Peg-In Tweak Index"
310 );
311 }
312 DbKeyPrefix::ClaimedPegIn => {
313 push_db_pair_items!(
314 dbtx,
315 ClaimedPegInPrefix,
316 ClaimedPegInKey,
317 ClaimedPegInData,
318 wallet_client_items,
319 "Claimed Peg-In"
320 );
321 }
322 DbKeyPrefix::RecoveryFinalized => {
323 if let Some(val) = dbtx.get_value(&RecoveryFinalizedKey).await {
324 wallet_client_items.insert("RecoveryFinalized".to_string(), Box::new(val));
325 }
326 }
327 DbKeyPrefix::SupportsSafeDeposit => {
328 push_db_pair_items!(
329 dbtx,
330 SupportsSafeDepositPrefix,
331 SupportsSafeDepositKey,
332 (),
333 wallet_client_items,
334 "Supports Safe Deposit"
335 );
336 }
337 DbKeyPrefix::PegInPoolCursor => {
338 if let Some(cursor) = dbtx.get_value(&PegInPoolCursorKey).await {
339 wallet_client_items.insert("PegInPoolCursor".to_string(), Box::new(cursor));
340 }
341 }
342 DbKeyPrefix::RecoveryState
343 | DbKeyPrefix::ExternalReservedStart
344 | DbKeyPrefix::CoreInternalReservedStart
345 | DbKeyPrefix::CoreInternalReservedEnd => {}
346 }
347 }
348
349 Box::new(wallet_client_items.into_iter())
350 }
351}
352
353#[apply(async_trait_maybe_send!)]
354impl ClientModuleInit for WalletClientInit {
355 type Module = WalletClientModule;
356
357 fn supported_api_versions(&self) -> MultiApiVersion {
358 MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
359 .expect("no version conflicts")
360 }
361
362 async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
363 let data = WalletClientModuleData {
364 cfg: args.cfg().clone(),
365 module_root_secret: args.module_root_secret().clone(),
366 };
367
368 let db = args.db().clone();
369
370 let rpc_config = WalletClientModule::get_rpc_config(args.cfg());
371
372 let btc_rpc = if let Some(user_rpc) = args.user_bitcoind_rpc() {
379 user_rpc.clone()
380 } else if let Some(factory) = args.user_bitcoind_rpc_no_chain_id() {
381 if let Some(rpc) = factory(rpc_config.url.clone()).await {
382 rpc
383 } else {
384 self.0
385 .clone()
386 .unwrap_or(create_esplora_rpc(&rpc_config.url)?)
387 }
388 } else {
389 self.0
390 .clone()
391 .unwrap_or(create_esplora_rpc(&rpc_config.url)?)
392 };
393 let btc_rpc = BitcoindTracked::new(btc_rpc, "wallet-client").into_dyn();
394
395 let module_api = args.module_api().clone();
396
397 let (pegin_claimed_sender, pegin_claimed_receiver) = watch::channel(());
398 let (pegin_monitor_wakeup_sender, pegin_monitor_wakeup_receiver) = watch::channel(());
399
400 Ok(WalletClientModule {
401 db,
402 data,
403 module_api,
404 notifier: args.notifier().clone(),
405 rpc: btc_rpc,
406 client_ctx: args.context(),
407 pegin_monitor_wakeup_sender,
408 pegin_monitor_wakeup_receiver,
409 pegin_claimed_receiver,
410 pegin_claimed_sender,
411 task_group: args.task_group().clone(),
412 client_span: args.client_span().clone(),
413 admin_auth: args.admin_auth().cloned(),
414 })
415 }
416
417 async fn recover(
422 &self,
423 args: &ClientModuleRecoverArgs<Self>,
424 snapshot: Option<&<Self::Module as ClientModule>::Backup>,
425 ) -> anyhow::Result<Option<fedimint_core::Amount>> {
426 if args
429 .db()
430 .begin_transaction_nc()
431 .await
432 .get_value(&RecoveryStateKey)
433 .await
434 .is_some()
435 {
436 return args
437 .recover_from_history::<WalletRecovery>(self, snapshot)
438 .await;
439 }
440
441 if args.module_api().fetch_recovery_count().await.is_ok() {
443 self.recover_from_slices(args).await
444 } else {
445 args.recover_from_history::<WalletRecovery>(self, snapshot)
446 .await
447 }
448 }
449
450 fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
451 Some(
452 DbKeyPrefix::iter()
453 .map(|p| p as u8)
454 .chain(
455 DbKeyPrefix::ExternalReservedStart as u8
456 ..=DbKeyPrefix::CoreInternalReservedEnd as u8,
457 )
458 .collect(),
459 )
460 }
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct WalletOperationMeta {
465 pub variant: WalletOperationMetaVariant,
466 pub extra_meta: serde_json::Value,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
470#[serde(rename_all = "snake_case")]
471pub enum WalletOperationMetaVariant {
472 Deposit {
473 address: Address<NetworkUnchecked>,
474 #[serde(default)]
479 tweak_idx: Option<TweakIdx>,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
481 expires_at: Option<SystemTime>,
482 },
483 Withdraw {
484 address: Address<NetworkUnchecked>,
485 #[serde(with = "bitcoin::amount::serde::as_sat")]
486 amount: bitcoin::Amount,
487 fee: PegOutFees,
488 change: Vec<OutPoint>,
489 },
490
491 RbfWithdraw {
492 rbf: Rbf,
493 change: Vec<OutPoint>,
494 },
495}
496
497#[derive(Debug, Clone)]
499pub struct WalletClientModuleData {
500 cfg: WalletClientConfig,
501 module_root_secret: DerivableSecret,
502}
503
504impl WalletClientModuleData {
505 fn derive_deposit_address(
506 &self,
507 idx: TweakIdx,
508 ) -> (Keypair, secp256k1::PublicKey, Address, OperationId) {
509 let idx = ChildId(idx.0);
510
511 let secret_tweak_key = self
512 .module_root_secret
513 .child_key(WALLET_TWEAK_CHILD_ID)
514 .child_key(idx)
515 .to_secp_key(fedimint_core::secp256k1::SECP256K1);
516
517 let public_tweak_key = secret_tweak_key.public_key();
518
519 let address = self
520 .cfg
521 .peg_in_descriptor
522 .tweak(&public_tweak_key, bitcoin::secp256k1::SECP256K1)
523 .address(self.cfg.network.0)
524 .unwrap();
525
526 let operation_id = OperationId(public_tweak_key.x_only_public_key().0.serialize());
528
529 (secret_tweak_key, public_tweak_key, address, operation_id)
530 }
531
532 fn derive_peg_in_script(
533 &self,
534 idx: TweakIdx,
535 ) -> (ScriptBuf, bitcoin::Address, Keypair, OperationId) {
536 let (secret_tweak_key, _, address, operation_id) = self.derive_deposit_address(idx);
537
538 (
539 self.cfg
540 .peg_in_descriptor
541 .tweak(&secret_tweak_key.public_key(), SECP256K1)
542 .script_pubkey(),
543 address,
544 secret_tweak_key,
545 operation_id,
546 )
547 }
548}
549
550#[derive(Debug)]
551#[cfg_attr(feature = "uniffi", derive(uniffi::Object))]
552pub struct WalletClientModule {
553 data: WalletClientModuleData,
554 db: Database,
555 module_api: DynModuleApi,
556 notifier: ModuleNotifier<WalletClientStates>,
557 rpc: DynBitcoindRpc,
558 client_ctx: ClientContext<Self>,
559 pegin_monitor_wakeup_sender: watch::Sender<()>,
561 pegin_monitor_wakeup_receiver: watch::Receiver<()>,
562 pegin_claimed_sender: watch::Sender<()>,
564 pegin_claimed_receiver: watch::Receiver<()>,
565 task_group: TaskGroup,
566 client_span: tracing::Span,
567 admin_auth: Option<ApiAuth>,
568}
569
570#[apply(async_trait_maybe_send!)]
571impl ClientModule for WalletClientModule {
572 type Init = WalletClientInit;
573 type Common = WalletModuleTypes;
574 type Backup = WalletModuleBackup;
575 type ModuleStateMachineContext = WalletClientContext;
576 type States = WalletClientStates;
577
578 fn context(&self) -> Self::ModuleStateMachineContext {
579 WalletClientContext {
580 rpc: self.rpc.clone(),
581 wallet_descriptor: self.cfg().peg_in_descriptor.clone(),
582 wallet_decoder: self.decoder(),
583 secp: Secp256k1::default(),
584 client_ctx: self.client_ctx.clone(),
585 }
586 }
587
588 async fn start(&self) {
589 self.task_group
590 .spawn_cancellable_with_span(self.client_span.clone(), "peg-in monitor", {
591 let client_ctx = self.client_ctx.clone();
592 let db = self.db.clone();
593 let btc_rpc = self.rpc.clone();
594 let module_api = self.module_api.clone();
595 let data = self.data.clone();
596 let pegin_claimed_sender = self.pegin_claimed_sender.clone();
597 let pegin_monitor_wakeup_receiver = self.pegin_monitor_wakeup_receiver.clone();
598 pegin_monitor::run_peg_in_monitor(
599 client_ctx,
600 db,
601 btc_rpc,
602 module_api,
603 data,
604 pegin_claimed_sender,
605 pegin_monitor_wakeup_receiver,
606 )
607 });
608
609 self.task_group.spawn_cancellable_with_span(
610 self.client_span.clone(),
611 "supports-safe-deposit-version",
612 {
613 let db = self.db.clone();
614 let module_api = self.module_api.clone();
615
616 poll_supports_safe_deposit_version(db, module_api)
617 },
618 );
619 }
620
621 fn supports_backup(&self) -> bool {
622 true
623 }
624
625 async fn backup(&self) -> anyhow::Result<backup::WalletModuleBackup> {
626 let session_count = self.client_ctx.global_api().session_count().await?;
628
629 let mut dbtx = self.db.begin_transaction_nc().await;
630 let next_pegin_tweak_idx = dbtx
631 .get_value(&NextPegInTweakIndexKey)
632 .await
633 .unwrap_or_default();
634 let claimed = dbtx
635 .find_by_prefix(&PegInTweakIndexPrefix)
636 .await
637 .filter_map(|(k, v)| async move {
638 if v.claimed.is_empty() {
639 None
640 } else {
641 Some(k.0)
642 }
643 })
644 .collect()
645 .await;
646 Ok(backup::WalletModuleBackup::new_v1(
647 session_count,
648 next_pegin_tweak_idx,
649 claimed,
650 ))
651 }
652
653 fn input_fee(
654 &self,
655 _amount: &Amounts,
656 _input: &<Self::Common as ModuleCommon>::Input,
657 ) -> Option<Amounts> {
658 Some(Amounts::new_bitcoin(self.cfg().fee_consensus.peg_in_abs))
659 }
660
661 fn output_fee(
662 &self,
663 _amount: &Amounts,
664 _output: &<Self::Common as ModuleCommon>::Output,
665 ) -> Option<Amounts> {
666 Some(Amounts::new_bitcoin(self.cfg().fee_consensus.peg_out_abs))
667 }
668
669 async fn handle_rpc(
670 &self,
671 method: String,
672 request: serde_json::Value,
673 ) -> BoxStream<'_, anyhow::Result<serde_json::Value>> {
674 Box::pin(try_stream! {
675 match method.as_str() {
676 "get_wallet_summary" => {
677 let _req: WalletSummaryRequest = serde_json::from_value(request)?;
678 let wallet_summary = self.get_wallet_summary()
679 .await
680 .expect("Failed to fetch wallet summary");
681 let result = serde_json::to_value(&wallet_summary)
682 .expect("Serialization error");
683 yield result;
684 }
685 "get_block_count_local" => {
686 let block_count = self.get_block_count_local().await
687 .expect("Failed to fetch block count");
688 yield serde_json::to_value(block_count)?;
689 }
690 "peg_in" => {
691 let req: PegInRequest = serde_json::from_value(request)?;
692 let response = self.peg_in(req)
693 .await
694 .map_err(|e| anyhow::anyhow!("peg_in failed: {e}"))?;
695 let result = serde_json::to_value(&response)?;
696 yield result;
697 },
698 "peg_out" => {
699 let req: PegOutRequest = serde_json::from_value(request)?;
700 let response = self.peg_out(req)
701 .await
702 .map_err(|e| anyhow::anyhow!("peg_out failed: {e}"))?;
703 let result = serde_json::to_value(&response)?;
704 yield result;
705 },
706 "subscribe_deposit" => {
707 let req: SubscribeDepositRequest = serde_json::from_value(request)?;
708 for await state in self.subscribe_deposit(req.operation_id).await?.into_stream() {
709 yield serde_json::to_value(state)?;
710 }
711 },
712 "subscribe_withdraw" => {
713 let req: SubscribeWithdrawRequest = serde_json::from_value(request)?;
714 for await state in self.subscribe_withdraw_updates(req.operation_id).await?.into_stream(){
715 yield serde_json::to_value(state)?;
716 }
717 }
718 _ => {
719 Err(anyhow::format_err!("Unknown method: {method}"))?;
720 }
721 }
722 })
723 }
724
725 #[cfg(feature = "cli")]
726 async fn handle_cli_command(
727 &self,
728 args: &[std::ffi::OsString],
729 ) -> anyhow::Result<serde_json::Value> {
730 cli::handle_cli_command(self, args).await
731 }
732}
733
734#[derive(Deserialize)]
735struct WalletSummaryRequest {}
736
737#[derive(Debug, Clone)]
738pub struct WalletClientContext {
739 rpc: DynBitcoindRpc,
740 wallet_descriptor: PegInDescriptor,
741 wallet_decoder: Decoder,
742 secp: Secp256k1<All>,
743 pub client_ctx: ClientContext<WalletClientModule>,
744}
745
746#[derive(Debug, Clone, Serialize, Deserialize)]
747pub struct PegInRequest {
748 pub extra_meta: serde_json::Value,
749}
750
751#[cfg(feature = "uniffi")]
752uniffi::custom_type!(PegInRequest, String, {
753 lower: |v| serde_json::to_string(&v).expect("PegInRequest serialization cannot fail"),
754 try_lift: |s| serde_json::from_str::<PegInRequest>(&s).map_err(|e| anyhow!("Failed to parse PegInRequest: {e}")),
755});
756
757#[derive(Deserialize)]
758struct SubscribeDepositRequest {
759 operation_id: OperationId,
760}
761
762#[derive(Deserialize)]
763struct SubscribeWithdrawRequest {
764 operation_id: OperationId,
765}
766
767#[derive(Debug, Clone, Serialize, Deserialize)]
768#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
769pub struct PegInResponse {
770 pub deposit_address: Address<NetworkUnchecked>,
771 pub operation_id: OperationId,
772}
773
774#[derive(Debug, Clone, Serialize, Deserialize)]
775#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
776pub struct PegOutRequest {
777 pub amount_sat: u64,
778 pub destination_address: Address<NetworkUnchecked>,
779 pub extra_meta: serde_json::Value,
780}
781
782#[derive(Debug, Clone, Serialize, Deserialize)]
783#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
784pub struct PegOutResponse {
785 pub operation_id: OperationId,
786}
787
788impl Context for WalletClientContext {
789 const KIND: Option<ModuleKind> = Some(KIND);
790}
791
792impl WalletClientModule {
793 fn cfg(&self) -> &WalletClientConfig {
794 &self.data.cfg
795 }
796
797 fn get_rpc_config(cfg: &WalletClientConfig) -> BitcoinRpcConfig {
798 match BitcoinRpcConfig::get_defaults_from_env_vars() {
799 Ok(rpc_config) => {
800 if rpc_config.kind == "bitcoind" {
803 cfg.default_bitcoin_rpc.clone()
804 } else {
805 rpc_config
806 }
807 }
808 _ => cfg.default_bitcoin_rpc.clone(),
809 }
810 }
811
812 pub fn get_network(&self) -> Network {
813 self.cfg().network.0
814 }
815
816 pub fn get_finality_delay(&self) -> u32 {
817 self.cfg().finality_delay
818 }
819
820 pub fn get_fee_consensus(&self) -> FeeConsensus {
821 self.cfg().fee_consensus
822 }
823
824 async fn allocate_deposit_address_inner(
825 &self,
826 dbtx: &mut DatabaseTransaction<'_>,
827 ) -> DepositAddressInfo {
828 dbtx.ensure_isolated().expect("Must be isolated db");
829
830 let tweak_idx = get_next_peg_in_tweak_child_id(dbtx).await;
831 let (_secret_tweak_key, _, address, operation_id) =
832 self.data.derive_deposit_address(tweak_idx);
833
834 let now = fedimint_core::time::now();
835
836 dbtx.insert_new_entry(
837 &PegInTweakIndexKey(tweak_idx),
838 &PegInTweakIndexData {
839 creation_time: now,
840 next_check_time: Some(now),
841 last_check_time: None,
842 operation_id,
843 claimed: vec![],
844 },
845 )
846 .await;
847
848 DepositAddressInfo {
849 operation_id,
850 address,
851 tweak_idx,
852 }
853 }
854
855 pub async fn get_withdraw_fees(
862 &self,
863 address: &bitcoin::Address,
864 amount: bitcoin::Amount,
865 ) -> anyhow::Result<PegOutFees> {
866 self.module_api
867 .fetch_peg_out_fees(address, amount)
868 .await?
869 .context("Federation didn't return peg-out fees")
870 }
871
872 pub async fn send_fee_quote(&self, amount: bitcoin::Amount) -> anyhow::Result<FeeQuote> {
887 let amount = fedimint_core::Amount::from_sats(amount.to_sat());
888 self.client_ctx
889 .fee_quote(
890 OperationId::new_random(),
891 FeeQuoteRequest {
892 input_amount: Amounts::ZERO,
893 output_amount: Amounts::new_bitcoin(amount),
894 input_fee: Amounts::ZERO,
895 output_fee: Amounts::new_bitcoin(self.cfg().fee_consensus.peg_out_abs),
896 },
897 )
898 .await
899 }
900
901 pub async fn get_wallet_summary(&self) -> anyhow::Result<WalletSummary> {
903 Ok(self.module_api.fetch_wallet_summary().await?)
904 }
905
906 pub async fn get_block_count_local(&self) -> anyhow::Result<u32> {
907 Ok(self.module_api.fetch_block_count_local().await?)
908 }
909
910 pub fn create_withdraw_output(
911 &self,
912 operation_id: OperationId,
913 address: bitcoin::Address,
914 amount: bitcoin::Amount,
915 fees: PegOutFees,
916 ) -> anyhow::Result<ClientOutputBundle<WalletOutput, WalletClientStates>> {
917 let output = WalletOutput::new_v0_peg_out(address, amount, fees);
918
919 let amount = output.maybe_v0_ref().expect("v0 output").amount().into();
920
921 let sm_gen = move |out_point_range: OutPointRange| {
922 assert_eq!(out_point_range.count(), 1);
923 let out_idx = out_point_range.start_idx();
924 vec![WalletClientStates::Withdraw(WithdrawStateMachine {
925 operation_id,
926 state: WithdrawStates::Created(CreatedWithdrawState {
927 fm_outpoint: OutPoint {
928 txid: out_point_range.txid(),
929 out_idx,
930 },
931 }),
932 })]
933 };
934
935 Ok(ClientOutputBundle::new(
936 vec![ClientOutput::<WalletOutput> {
937 output,
938 amounts: Amounts::new_bitcoin(amount),
939 }],
940 vec![ClientOutputSM::<WalletClientStates> {
941 state_machines: Arc::new(sm_gen),
942 }],
943 ))
944 }
945
946 pub async fn peg_in(&self, req: PegInRequest) -> anyhow::Result<PegInResponse> {
947 let deposit_address = self.safe_allocate_deposit_address(req.extra_meta).await?;
948
949 Ok(PegInResponse {
950 deposit_address: Address::from_script(
951 &deposit_address.address.script_pubkey(),
952 self.get_network(),
953 )?
954 .as_unchecked()
955 .clone(),
956 operation_id: deposit_address.operation_id,
957 })
958 }
959
960 pub async fn peg_out(&self, req: PegOutRequest) -> anyhow::Result<PegOutResponse> {
961 let amount = bitcoin::Amount::from_sat(req.amount_sat);
962 let destination = req
963 .destination_address
964 .require_network(self.get_network())?;
965
966 let fees = self.get_withdraw_fees(&destination, amount).await?;
967 let operation_id = self
968 .withdraw(&destination, amount, fees, req.extra_meta)
969 .await
970 .context("Failed to initiate withdraw")?;
971
972 Ok(PegOutResponse { operation_id })
973 }
974
975 pub fn create_rbf_withdraw_output(
976 &self,
977 operation_id: OperationId,
978 rbf: &Rbf,
979 ) -> anyhow::Result<ClientOutputBundle<WalletOutput, WalletClientStates>> {
980 let output = WalletOutput::new_v0_rbf(rbf.fees, rbf.txid);
981
982 let amount = output.maybe_v0_ref().expect("v0 output").amount().into();
983
984 let sm_gen = move |out_point_range: OutPointRange| {
985 assert_eq!(out_point_range.count(), 1);
986 let out_idx = out_point_range.start_idx();
987 vec![WalletClientStates::Withdraw(WithdrawStateMachine {
988 operation_id,
989 state: WithdrawStates::Created(CreatedWithdrawState {
990 fm_outpoint: OutPoint {
991 txid: out_point_range.txid(),
992 out_idx,
993 },
994 }),
995 })]
996 };
997
998 Ok(ClientOutputBundle::new(
999 vec![ClientOutput::<WalletOutput> {
1000 output,
1001 amounts: Amounts::new_bitcoin(amount),
1002 }],
1003 vec![ClientOutputSM::<WalletClientStates> {
1004 state_machines: Arc::new(sm_gen),
1005 }],
1006 ))
1007 }
1008
1009 pub async fn btc_tx_has_no_size_limit(&self) -> FederationResult<bool> {
1010 Ok(self.module_api.module_consensus_version().await? >= ModuleConsensusVersion::new(2, 2))
1011 }
1012
1013 pub async fn supports_safe_deposit(&self) -> bool {
1022 let mut dbtx = self.db.begin_transaction().await;
1023
1024 let already_verified_supports_safe_deposit =
1025 dbtx.get_value(&SupportsSafeDepositKey).await.is_some();
1026
1027 already_verified_supports_safe_deposit || {
1028 match self.module_api.module_consensus_version().await {
1029 Ok(module_consensus_version) => {
1030 let supported_version =
1031 SAFE_DEPOSIT_MODULE_CONSENSUS_VERSION <= module_consensus_version;
1032
1033 if supported_version {
1034 dbtx.insert_new_entry(&SupportsSafeDepositKey, &()).await;
1035 dbtx.commit_tx().await;
1036 }
1037
1038 supported_version
1039 }
1040 Err(_) => false,
1041 }
1042 }
1043 }
1044
1045 pub async fn safe_allocate_deposit_address<M>(
1053 &self,
1054 extra_meta: M,
1055 ) -> anyhow::Result<DepositAddressInfo>
1056 where
1057 M: Serialize + MaybeSend + MaybeSync,
1058 {
1059 ensure!(
1060 self.supports_safe_deposit().await,
1061 "Wallet module consensus version doesn't support safe deposits",
1062 );
1063
1064 self.allocate_deposit_address_expert_only(extra_meta).await
1065 }
1066
1067 pub async fn allocate_deposit_address_expert_only<M>(
1085 &self,
1086 extra_meta: M,
1087 ) -> anyhow::Result<DepositAddressInfo>
1088 where
1089 M: Serialize + MaybeSend + MaybeSync,
1090 {
1091 let extra_meta_value =
1092 serde_json::to_value(extra_meta).expect("Failed to serialize extra meta");
1093 let deposit_address = self
1094 .db
1095 .autocommit(
1096 move |dbtx, _| {
1097 let extra_meta_value_inner = extra_meta_value.clone();
1098 Box::pin(async move {
1099 let deposit_address = self.allocate_deposit_address_inner(dbtx).await;
1100
1101 self.client_ctx
1102 .manual_operation_start_dbtx(
1103 dbtx,
1104 deposit_address.operation_id,
1105 WalletCommonInit::KIND.as_str(),
1106 WalletOperationMeta {
1107 variant: WalletOperationMetaVariant::Deposit {
1108 address: deposit_address.address.clone().into_unchecked(),
1109 tweak_idx: Some(deposit_address.tweak_idx),
1110 expires_at: None,
1111 },
1112 extra_meta: extra_meta_value_inner,
1113 },
1114 vec![],
1115 )
1116 .await?;
1117
1118 debug!(
1119 target: LOG_CLIENT_MODULE_WALLET,
1120 tweak_idx = %deposit_address.tweak_idx,
1121 address = %deposit_address.address,
1122 "Derived a new deposit address"
1123 );
1124
1125 self.rpc
1127 .watch_script_history(&deposit_address.address.script_pubkey())
1128 .await?;
1129
1130 let sender = self.pegin_monitor_wakeup_sender.clone();
1131 dbtx.on_commit(move || {
1132 sender.send_replace(());
1133 });
1134
1135 Ok(deposit_address)
1136 })
1137 },
1138 Some(100),
1139 )
1140 .await
1141 .map_err(|e| match e {
1142 AutocommitError::CommitFailed {
1143 last_error,
1144 attempts,
1145 } => anyhow!("Failed to commit after {attempts} attempts: {last_error}"),
1146 AutocommitError::ClosureError { error, .. } => error,
1147 })?;
1148
1149 Ok(deposit_address)
1150 }
1151
1152 pub async fn allocate_deposit_address_pooled_stateless(
1190 &self,
1191 max_gap_size: usize,
1192 ) -> anyhow::Result<MaybeNewAddress> {
1193 let max_gap_size_u64 = u64::try_from(max_gap_size).unwrap_or(u64::MAX);
1194 let extra_meta_value = serde_json::Value::Null;
1195 let result = self
1196 .db
1197 .autocommit(
1198 move |dbtx, _| {
1199 let extra_meta_value_inner = extra_meta_value.clone();
1200 Box::pin(async move {
1201 let unused = self.unused_pooled_deposit_addresses(dbtx).await;
1202
1203 if max_gap_size_u64 <= unused.len() as u64 && !unused.is_empty() {
1204 let addresses = unused
1205 .into_iter()
1206 .map(|(tweak_idx, data)| {
1207 let (_script, address, _key, operation_id) =
1208 self.data.derive_peg_in_script(tweak_idx);
1209
1210 debug_assert_eq!(operation_id, data.operation_id);
1211
1212 DepositAddressInfo {
1213 operation_id,
1214 address,
1215 tweak_idx,
1216 }
1217 })
1218 .collect();
1219
1220 return Ok::<_, anyhow::Error>(
1221 MaybeNewAddress::TooManyUnusedAddresses(addresses),
1222 );
1223 }
1224
1225 let deposit_address = self.allocate_deposit_address_inner(dbtx).await;
1226
1227 self.client_ctx
1228 .manual_operation_start_dbtx(
1229 dbtx,
1230 deposit_address.operation_id,
1231 WalletCommonInit::KIND.as_str(),
1232 WalletOperationMeta {
1233 variant: WalletOperationMetaVariant::Deposit {
1234 address: deposit_address.address.clone().into_unchecked(),
1235 tweak_idx: Some(deposit_address.tweak_idx),
1236 expires_at: None,
1237 },
1238 extra_meta: extra_meta_value_inner,
1239 },
1240 vec![],
1241 )
1242 .await?;
1243
1244 debug!(
1245 target: LOG_CLIENT_MODULE_WALLET,
1246 tweak_idx = %deposit_address.tweak_idx,
1247 address = %deposit_address.address,
1248 "Derived a new pooled deposit address"
1249 );
1250
1251 self.rpc
1252 .watch_script_history(&deposit_address.address.script_pubkey())
1253 .await?;
1254
1255 let sender = self.pegin_monitor_wakeup_sender.clone();
1256 dbtx.on_commit(move || {
1257 sender.send_replace(());
1258 });
1259
1260 Ok(MaybeNewAddress::NewAddress(deposit_address))
1261 })
1262 },
1263 Some(100),
1264 )
1265 .await
1266 .map_err(|e| match e {
1267 AutocommitError::CommitFailed {
1268 last_error,
1269 attempts,
1270 } => anyhow!("Failed to commit after {attempts} attempts: {last_error}"),
1271 AutocommitError::ClosureError { error, .. } => error,
1272 })?;
1273
1274 Ok(result)
1275 }
1276
1277 async fn unused_pooled_deposit_addresses(
1278 &self,
1279 dbtx: &mut DatabaseTransaction<'_>,
1280 ) -> Vec<(TweakIdx, PegInTweakIndexData)> {
1281 let mut unused: Vec<(TweakIdx, PegInTweakIndexData)> = dbtx
1286 .find_by_prefix_sorted_descending(&PegInTweakIndexPrefix)
1287 .await
1288 .take_while(|(_, d)| std::future::ready(d.claimed.is_empty()))
1289 .map(|(k, v)| (k.0, v))
1290 .collect()
1291 .await;
1292
1293 unused.sort_by_key(|(t, d)| (d.creation_time, *t));
1296 unused
1297 }
1298
1299 #[allow(clippy::too_many_lines)]
1322 pub async fn allocate_deposit_address_pooled(
1323 &self,
1324 max_gap_size: usize,
1325 ) -> anyhow::Result<(DepositAddressInfo, AllocateDepositOutcome)> {
1326 let stateless = self
1327 .allocate_deposit_address_pooled_stateless(max_gap_size)
1328 .await?;
1329
1330 let reused_addresses = match stateless {
1331 MaybeNewAddress::NewAddress(deposit_address) => {
1332 return Ok((deposit_address, AllocateDepositOutcome::Fresh));
1333 }
1334 MaybeNewAddress::TooManyUnusedAddresses(addresses) => addresses,
1335 };
1336
1337 let result = self
1338 .db
1339 .autocommit(
1340 move |dbtx, _| {
1341 let reused_addresses = reused_addresses.clone();
1342 Box::pin(async move {
1343 let cursor = dbtx
1344 .get_value(&PegInPoolCursorKey)
1345 .await
1346 .unwrap_or(TweakIdx(0));
1347
1348 let pick_pos = reused_addresses
1349 .iter()
1350 .position(|a| cursor <= a.tweak_idx)
1351 .unwrap_or(0);
1352 let reused_address = reused_addresses[pick_pos].clone();
1353
1354 let existing_tweak_idx = reused_address.tweak_idx;
1355 let existing = dbtx
1356 .get_value(&PegInTweakIndexKey(reused_address.tweak_idx))
1357 .await
1358 .with_context(|| {
1359 format!(
1360 "Pooled address disappeared while reusing {}",
1361 reused_address.tweak_idx
1362 )
1363 })?;
1364
1365 ensure!(
1366 existing.claimed.is_empty(),
1367 "Pooled address was used while reusing {}",
1368 reused_address.tweak_idx
1369 );
1370
1371 dbtx.insert_entry(&PegInPoolCursorKey, &reused_address.tweak_idx.next())
1372 .await;
1373
1374 let now = fedimint_core::time::now();
1382 dbtx.insert_entry(
1383 &PegInTweakIndexKey(reused_address.tweak_idx),
1384 &PegInTweakIndexData {
1385 creation_time: now,
1386 last_check_time: None,
1387 next_check_time: Some(now),
1388 operation_id: existing.operation_id,
1389 claimed: existing.claimed,
1390 },
1391 )
1392 .await;
1393
1394 let sender = self.pegin_monitor_wakeup_sender.clone();
1395 dbtx.on_commit(move || {
1396 sender.send_replace(());
1397 });
1398
1399 Ok::<_, anyhow::Error>((
1400 reused_address,
1401 AllocateDepositOutcome::Reused {
1402 original_tweak_idx: existing_tweak_idx,
1403 },
1404 ))
1405 })
1406 },
1407 Some(100),
1408 )
1409 .await
1410 .map_err(|e| match e {
1411 AutocommitError::CommitFailed {
1412 last_error,
1413 attempts,
1414 } => anyhow!("Failed to commit after {attempts} attempts: {last_error}"),
1415 AutocommitError::ClosureError { error, .. } => error,
1416 })?;
1417
1418 Ok(result)
1419 }
1420
1421 pub async fn subscribe_deposit(
1427 &self,
1428 operation_id: OperationId,
1429 ) -> anyhow::Result<UpdateStreamOrOutcome<DepositStateV2>> {
1430 let operation = self
1431 .client_ctx
1432 .get_operation(operation_id)
1433 .await
1434 .with_context(|| anyhow!("Operation not found: {}", operation_id.fmt_short()))?;
1435
1436 if operation.operation_module_kind() != WalletCommonInit::KIND.as_str() {
1437 bail!("Operation is not a wallet operation");
1438 }
1439
1440 let operation_meta = operation.meta::<WalletOperationMeta>();
1441
1442 let WalletOperationMetaVariant::Deposit {
1443 address, tweak_idx, ..
1444 } = operation_meta.variant
1445 else {
1446 bail!("Operation is not a deposit operation");
1447 };
1448
1449 let address = address.require_network(self.cfg().network.0)?;
1450
1451 let Some(tweak_idx) = tweak_idx else {
1453 let outcome_v1 = operation
1457 .outcome::<DepositStateV1>()
1458 .context("Old pending deposit, can't subscribe to updates")?;
1459
1460 let outcome_v2 = match outcome_v1 {
1461 DepositStateV1::Claimed(tx_info) => DepositStateV2::Claimed {
1462 btc_deposited: tx_info.btc_transaction.output[tx_info.out_idx as usize].value,
1463 btc_out_point: bitcoin::OutPoint {
1464 txid: tx_info.btc_transaction.compute_txid(),
1465 vout: tx_info.out_idx,
1466 },
1467 },
1468 DepositStateV1::Failed(error) => DepositStateV2::Failed(error),
1469 _ => bail!("Non-final outcome in operation log"),
1470 };
1471
1472 return Ok(UpdateStreamOrOutcome::Outcome(outcome_v2));
1473 };
1474
1475 Ok(self.client_ctx.outcome_or_updates(
1476 &operation,
1477 operation_id,
1478 |state| match state {
1479 DepositStateV2::WaitingForTransaction
1480 | DepositStateV2::WaitingForConfirmation { .. }
1481 | DepositStateV2::Confirmed { .. } => false,
1482 DepositStateV2::Claimed { .. } | DepositStateV2::Failed(_) => true,
1483 },
1484 {
1485 let stream_rpc = self.rpc.clone();
1486 let stream_client_ctx = self.client_ctx.clone();
1487 let stream_script_pub_key = address.script_pubkey();
1488 move || {
1489
1490 stream! {
1491 yield DepositStateV2::WaitingForTransaction;
1492
1493 retry(
1494 "subscribe script history",
1495 background_backoff(),
1496 || stream_rpc.watch_script_history(&stream_script_pub_key)
1497 ).await.expect("Will never give up");
1498 let (btc_out_point, btc_deposited) = retry(
1499 "fetch history",
1500 background_backoff(),
1501 || async {
1502 let history = stream_rpc.get_script_history(&stream_script_pub_key).await?;
1503 history.first().and_then(|tx| {
1504 let (out_idx, amount) = tx.output
1505 .iter()
1506 .enumerate()
1507 .find_map(|(idx, output)| (output.script_pubkey == stream_script_pub_key).then_some((idx, output.value)))?;
1508 let txid = tx.compute_txid();
1509
1510 Some((
1511 bitcoin::OutPoint {
1512 txid,
1513 vout: out_idx as u32,
1514 },
1515 amount
1516 ))
1517 }).context("No deposit transaction found")
1518 }
1519 ).await.expect("Will never give up");
1520
1521 yield DepositStateV2::WaitingForConfirmation {
1522 btc_deposited,
1523 btc_out_point
1524 };
1525
1526 let claim_data = stream_client_ctx.module_db().wait_key_exists(&ClaimedPegInKey {
1527 peg_in_index: tweak_idx,
1528 btc_out_point,
1529 }).await;
1530
1531 yield DepositStateV2::Confirmed {
1532 btc_deposited,
1533 btc_out_point
1534 };
1535
1536 match stream_client_ctx.await_primary_module_outputs(operation_id, claim_data.change).await {
1537 Ok(()) => yield DepositStateV2::Claimed {
1538 btc_deposited,
1539 btc_out_point
1540 },
1541 Err(e) => yield DepositStateV2::Failed(e.to_string())
1542 }
1543 }
1544 }}))
1545 }
1546
1547 pub async fn list_peg_in_tweak_idxes(&self) -> BTreeMap<TweakIdx, PegInTweakIndexData> {
1548 self.client_ctx
1549 .module_db()
1550 .clone()
1551 .begin_transaction_nc()
1552 .await
1553 .find_by_prefix(&PegInTweakIndexPrefix)
1554 .await
1555 .map(|(key, data)| (key.0, data))
1556 .collect()
1557 .await
1558 }
1559
1560 pub async fn find_tweak_idx_by_address(
1561 &self,
1562 address: bitcoin::Address<NetworkUnchecked>,
1563 ) -> anyhow::Result<TweakIdx> {
1564 let data = self.data.clone();
1565 let Some((tweak_idx, _)) = self
1566 .db
1567 .begin_transaction_nc()
1568 .await
1569 .find_by_prefix(&PegInTweakIndexPrefix)
1570 .await
1571 .filter(|(k, _)| {
1572 let (_, derived_address, _tweak_key, _) = data.derive_peg_in_script(k.0);
1573 future::ready(derived_address.into_unchecked() == address)
1574 })
1575 .next()
1576 .await
1577 else {
1578 bail!("Address not found in the list of derived keys");
1579 };
1580
1581 Ok(tweak_idx.0)
1582 }
1583 pub async fn find_tweak_idx_by_operation_id(
1584 &self,
1585 operation_id: OperationId,
1586 ) -> anyhow::Result<TweakIdx> {
1587 Ok(self
1588 .client_ctx
1589 .module_db()
1590 .clone()
1591 .begin_transaction_nc()
1592 .await
1593 .find_by_prefix(&PegInTweakIndexPrefix)
1594 .await
1595 .filter(|(_k, v)| future::ready(v.operation_id == operation_id))
1596 .next()
1597 .await
1598 .ok_or_else(|| anyhow::format_err!("OperationId not found"))?
1599 .0
1600 .0)
1601 }
1602
1603 pub async fn get_pegin_tweak_idx(
1604 &self,
1605 tweak_idx: TweakIdx,
1606 ) -> anyhow::Result<PegInTweakIndexData> {
1607 self.client_ctx
1608 .module_db()
1609 .clone()
1610 .begin_transaction_nc()
1611 .await
1612 .get_value(&PegInTweakIndexKey(tweak_idx))
1613 .await
1614 .ok_or_else(|| anyhow::format_err!("TweakIdx not found"))
1615 }
1616
1617 pub async fn get_claimed_pegins(
1618 &self,
1619 dbtx: &mut DatabaseTransaction<'_>,
1620 tweak_idx: TweakIdx,
1621 ) -> Vec<(
1622 bitcoin::OutPoint,
1623 TransactionId,
1624 Vec<fedimint_core::OutPoint>,
1625 )> {
1626 let outpoints = dbtx
1627 .get_value(&PegInTweakIndexKey(tweak_idx))
1628 .await
1629 .map(|v| v.claimed)
1630 .unwrap_or_default();
1631
1632 let mut res = vec![];
1633
1634 for outpoint in outpoints {
1635 let claimed_peg_in_data = dbtx
1636 .get_value(&ClaimedPegInKey {
1637 peg_in_index: tweak_idx,
1638 btc_out_point: outpoint,
1639 })
1640 .await
1641 .expect("Must have a corresponding claim record");
1642 res.push((
1643 outpoint,
1644 claimed_peg_in_data.claim_txid,
1645 claimed_peg_in_data.change,
1646 ));
1647 }
1648
1649 res
1650 }
1651
1652 pub async fn recheck_pegin_address_by_op_id(
1654 &self,
1655 operation_id: OperationId,
1656 ) -> anyhow::Result<()> {
1657 let tweak_idx = self.find_tweak_idx_by_operation_id(operation_id).await?;
1658
1659 self.recheck_pegin_address(tweak_idx).await
1660 }
1661
1662 pub async fn recheck_pegin_address_by_address(
1664 &self,
1665 address: bitcoin::Address<NetworkUnchecked>,
1666 ) -> anyhow::Result<()> {
1667 self.recheck_pegin_address(self.find_tweak_idx_by_address(address).await?)
1668 .await
1669 }
1670
1671 pub async fn recheck_pegin_address(&self, tweak_idx: TweakIdx) -> anyhow::Result<()> {
1673 self.db
1674 .autocommit(
1675 |dbtx, _| {
1676 Box::pin(async {
1677 let db_key = PegInTweakIndexKey(tweak_idx);
1678 let db_val = dbtx
1679 .get_value(&db_key)
1680 .await
1681 .ok_or_else(|| anyhow::format_err!("DBKey not found"))?;
1682
1683 dbtx.insert_entry(
1684 &db_key,
1685 &PegInTweakIndexData {
1686 next_check_time: Some(fedimint_core::time::now()),
1687 ..db_val
1688 },
1689 )
1690 .await;
1691
1692 let sender = self.pegin_monitor_wakeup_sender.clone();
1693 dbtx.on_commit(move || {
1694 sender.send_replace(());
1695 });
1696
1697 Ok::<_, anyhow::Error>(())
1698 })
1699 },
1700 Some(100),
1701 )
1702 .await?;
1703
1704 Ok(())
1705 }
1706
1707 pub async fn await_num_deposits_by_operation_id(
1709 &self,
1710 operation_id: OperationId,
1711 num_deposits: usize,
1712 ) -> anyhow::Result<()> {
1713 let tweak_idx = self.find_tweak_idx_by_operation_id(operation_id).await?;
1714 self.await_num_deposits(tweak_idx, num_deposits).await
1715 }
1716
1717 pub async fn await_num_deposits_by_address(
1718 &self,
1719 address: bitcoin::Address<NetworkUnchecked>,
1720 num_deposits: usize,
1721 ) -> anyhow::Result<()> {
1722 self.await_num_deposits(self.find_tweak_idx_by_address(address).await?, num_deposits)
1723 .await
1724 }
1725
1726 #[instrument(target = LOG_CLIENT_MODULE_WALLET, skip_all, fields(tweak_idx=?tweak_idx, num_deposists=num_deposits))]
1727 pub async fn await_num_deposits(
1728 &self,
1729 tweak_idx: TweakIdx,
1730 num_deposits: usize,
1731 ) -> anyhow::Result<()> {
1732 let operation_id = self.get_pegin_tweak_idx(tweak_idx).await?.operation_id;
1733
1734 let mut receiver = self.pegin_claimed_receiver.clone();
1735 let mut backoff = backoff_util::aggressive_backoff();
1736
1737 loop {
1738 let pegins = self
1739 .get_claimed_pegins(
1740 &mut self.client_ctx.module_db().begin_transaction_nc().await,
1741 tweak_idx,
1742 )
1743 .await;
1744
1745 if pegins.len() < num_deposits {
1746 debug!(target: LOG_CLIENT_MODULE_WALLET, has=pegins.len(), "Not enough deposits");
1747 self.recheck_pegin_address(tweak_idx).await?;
1748 runtime::sleep(backoff.next().unwrap_or_default()).await;
1749 receiver.changed().await?;
1750 continue;
1751 }
1752
1753 debug!(target: LOG_CLIENT_MODULE_WALLET, has=pegins.len(), "Enough deposits detected");
1754
1755 for (_outpoint, transaction_id, change) in pegins {
1756 if transaction_id == TransactionId::from_byte_array([0; 32]) && change.is_empty() {
1757 debug!(target: LOG_CLIENT_MODULE_WALLET, "Deposited amount was too low, skipping");
1758 continue;
1759 }
1760
1761 debug!(target: LOG_CLIENT_MODULE_WALLET, out_points=?change, "Ensuring deposists claimed");
1762 let tx_subscriber = self.client_ctx.transaction_updates(operation_id).await;
1763
1764 if let Err(e) = tx_subscriber.await_tx_accepted(transaction_id).await {
1765 bail!("{e}");
1766 }
1767
1768 debug!(target: LOG_CLIENT_MODULE_WALLET, out_points=?change, "Ensuring outputs claimed");
1769 self.client_ctx
1770 .await_primary_module_outputs(operation_id, change)
1771 .await
1772 .expect("Cannot fail if tx was accepted and federation is honest");
1773 }
1774
1775 return Ok(());
1776 }
1777 }
1778
1779 pub async fn withdraw<M: Serialize + MaybeSend + MaybeSync>(
1784 &self,
1785 address: &bitcoin::Address,
1786 amount: bitcoin::Amount,
1787 fee: PegOutFees,
1788 extra_meta: M,
1789 ) -> anyhow::Result<OperationId> {
1790 {
1791 let operation_id = OperationId(thread_rng().r#gen());
1792
1793 let withdraw_output =
1794 self.create_withdraw_output(operation_id, address.clone(), amount, fee)?;
1795 let tx_builder = TransactionBuilder::new()
1796 .with_outputs(self.client_ctx.make_client_outputs(withdraw_output));
1797
1798 let extra_meta =
1799 serde_json::to_value(extra_meta).expect("Failed to serialize extra meta");
1800 self.client_ctx
1801 .finalize_and_submit_transaction(
1802 operation_id,
1803 WalletCommonInit::KIND.as_str(),
1804 {
1805 let address = address.clone();
1806 move |change_range: OutPointRange| WalletOperationMeta {
1807 variant: WalletOperationMetaVariant::Withdraw {
1808 address: address.clone().into_unchecked(),
1809 amount,
1810 fee,
1811 change: change_range.into_iter().collect(),
1812 },
1813 extra_meta: extra_meta.clone(),
1814 }
1815 },
1816 tx_builder,
1817 )
1818 .await?;
1819
1820 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
1821
1822 self.client_ctx
1823 .log_event(
1824 &mut dbtx,
1825 SendPaymentEvent {
1826 operation_id,
1827 amount: amount + fee.amount(),
1828 fee: fee.amount(),
1829 },
1830 )
1831 .await;
1832
1833 dbtx.commit_tx().await;
1834
1835 Ok(operation_id)
1836 }
1837 }
1838
1839 #[deprecated(
1844 since = "0.4.0",
1845 note = "RBF withdrawals are rejected by the federation"
1846 )]
1847 pub async fn rbf_withdraw<M: Serialize + MaybeSync + MaybeSend>(
1848 &self,
1849 rbf: Rbf,
1850 extra_meta: M,
1851 ) -> anyhow::Result<OperationId> {
1852 let operation_id = OperationId(thread_rng().r#gen());
1853
1854 let withdraw_output = self.create_rbf_withdraw_output(operation_id, &rbf)?;
1855 let tx_builder = TransactionBuilder::new()
1856 .with_outputs(self.client_ctx.make_client_outputs(withdraw_output));
1857
1858 let extra_meta = serde_json::to_value(extra_meta).expect("Failed to serialize extra meta");
1859 self.client_ctx
1860 .finalize_and_submit_transaction(
1861 operation_id,
1862 WalletCommonInit::KIND.as_str(),
1863 move |change_range: OutPointRange| WalletOperationMeta {
1864 variant: WalletOperationMetaVariant::RbfWithdraw {
1865 rbf: rbf.clone(),
1866 change: change_range.into_iter().collect(),
1867 },
1868 extra_meta: extra_meta.clone(),
1869 },
1870 tx_builder,
1871 )
1872 .await?;
1873
1874 Ok(operation_id)
1875 }
1876
1877 pub async fn subscribe_withdraw_updates(
1878 &self,
1879 operation_id: OperationId,
1880 ) -> anyhow::Result<UpdateStreamOrOutcome<WithdrawState>> {
1881 let operation = self
1882 .client_ctx
1883 .get_operation(operation_id)
1884 .await
1885 .with_context(|| anyhow!("Operation not found: {}", operation_id.fmt_short()))?;
1886
1887 if operation.operation_module_kind() != WalletCommonInit::KIND.as_str() {
1888 bail!("Operation is not a wallet operation");
1889 }
1890
1891 let operation_meta = operation.meta::<WalletOperationMeta>();
1892
1893 let (WalletOperationMetaVariant::Withdraw { change, .. }
1894 | WalletOperationMetaVariant::RbfWithdraw { change, .. }) = operation_meta.variant
1895 else {
1896 bail!("Operation is not a withdraw operation");
1897 };
1898
1899 let mut operation_stream = self.notifier.subscribe(operation_id).await;
1900 let client_ctx = self.client_ctx.clone();
1901
1902 Ok(self.client_ctx.outcome_or_updates(
1903 &operation,
1904 operation_id,
1905 |state| match state {
1906 WithdrawState::Created => false,
1907 WithdrawState::Succeeded(_) | WithdrawState::Failed(_) => true,
1908 },
1909 move || {
1910 stream! {
1911 match next_withdraw_state(&mut operation_stream).await {
1912 Some(WithdrawStates::Created(_)) => {
1913 yield WithdrawState::Created;
1914 },
1915 Some(s) => {
1916 panic!("Unexpected state {s:?}")
1917 },
1918 None => return,
1919 }
1920
1921 let _ = client_ctx
1926 .await_primary_module_outputs(operation_id, change)
1927 .await;
1928
1929
1930 match next_withdraw_state(&mut operation_stream).await {
1931 Some(WithdrawStates::Aborted(inner)) => {
1932 yield WithdrawState::Failed(inner.error);
1933 },
1934 Some(WithdrawStates::Success(inner)) => {
1935 yield WithdrawState::Succeeded(inner.txid);
1936 },
1937 Some(s) => {
1938 panic!("Unexpected state {s:?}")
1939 },
1940 None => {},
1941 }
1942 }
1943 },
1944 ))
1945 }
1946
1947 fn admin_auth(&self) -> anyhow::Result<ApiAuth> {
1948 self.admin_auth
1949 .clone()
1950 .ok_or_else(|| anyhow::format_err!("Admin auth not set"))
1951 }
1952
1953 pub async fn activate_consensus_version_voting(&self) -> anyhow::Result<()> {
1954 self.module_api
1955 .activate_consensus_version_voting(self.admin_auth()?)
1956 .await?;
1957
1958 Ok(())
1959 }
1960}
1961
1962async fn poll_supports_safe_deposit_version(db: Database, module_api: DynModuleApi) {
1965 loop {
1966 let mut dbtx = db.begin_transaction().await;
1967
1968 if dbtx.get_value(&SupportsSafeDepositKey).await.is_some() {
1969 break;
1970 }
1971
1972 module_api.wait_for_initialized_connections().await;
1973
1974 if let Ok(module_consensus_version) = module_api.module_consensus_version().await
1975 && SAFE_DEPOSIT_MODULE_CONSENSUS_VERSION <= module_consensus_version
1976 {
1977 dbtx.insert_new_entry(&SupportsSafeDepositKey, &()).await;
1978 dbtx.commit_tx().await;
1979 break;
1980 }
1981
1982 drop(dbtx);
1983
1984 if is_running_in_test_env() {
1985 sleep(Duration::from_secs(10)).await;
1987 } else {
1988 sleep(Duration::from_hours(1)).await;
1989 }
1990 }
1991}
1992
1993async fn get_next_peg_in_tweak_child_id(dbtx: &mut DatabaseTransaction<'_>) -> TweakIdx {
1995 let index = dbtx
1996 .get_value(&NextPegInTweakIndexKey)
1997 .await
1998 .unwrap_or_default();
1999 dbtx.insert_entry(&NextPegInTweakIndexKey, &(index.next()))
2000 .await;
2001 index
2002}
2003
2004#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2005pub enum WalletClientStates {
2006 Deposit(DepositStateMachine),
2007 Withdraw(WithdrawStateMachine),
2008}
2009
2010impl IntoDynInstance for WalletClientStates {
2011 type DynType = DynState;
2012
2013 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
2014 DynState::from_typed(instance_id, self)
2015 }
2016}
2017
2018impl State for WalletClientStates {
2019 type ModuleContext = WalletClientContext;
2020
2021 fn transitions(
2022 &self,
2023 context: &Self::ModuleContext,
2024 global_context: &DynGlobalClientContext,
2025 ) -> Vec<StateTransition<Self>> {
2026 match self {
2027 WalletClientStates::Deposit(sm) => {
2028 sm_enum_variant_translation!(
2029 sm.transitions(context, global_context),
2030 WalletClientStates::Deposit
2031 )
2032 }
2033 WalletClientStates::Withdraw(sm) => {
2034 sm_enum_variant_translation!(
2035 sm.transitions(context, global_context),
2036 WalletClientStates::Withdraw
2037 )
2038 }
2039 }
2040 }
2041
2042 fn operation_id(&self) -> OperationId {
2043 match self {
2044 WalletClientStates::Deposit(sm) => sm.operation_id(),
2045 WalletClientStates::Withdraw(sm) => sm.operation_id(),
2046 }
2047 }
2048}
2049
2050#[cfg(all(test, not(target_family = "wasm")))]
2051mod tests {
2052 use std::collections::BTreeSet;
2053 use std::sync::atomic::{AtomicBool, Ordering};
2054
2055 use super::*;
2056 use crate::backup::{
2057 RECOVER_NUM_IDX_ADD_TO_LAST_USED, RecoverScanOutcome, recover_scan_idxes_for_activity,
2058 };
2059
2060 #[allow(clippy::too_many_lines)] #[tokio::test(flavor = "multi_thread")]
2062 async fn sanity_test_recover_inner() {
2063 {
2064 let last_checked = AtomicBool::new(false);
2065 let last_checked = &last_checked;
2066 assert_eq!(
2067 recover_scan_idxes_for_activity(
2068 TweakIdx(0),
2069 &BTreeSet::new(),
2070 |cur_idx| async move {
2071 Ok(match cur_idx {
2072 TweakIdx(9) => {
2073 last_checked.store(true, Ordering::SeqCst);
2074 vec![]
2075 }
2076 TweakIdx(10) => panic!("Shouldn't happen"),
2077 TweakIdx(11) => {
2078 vec![0usize] }
2080 _ => vec![],
2081 })
2082 }
2083 )
2084 .await
2085 .unwrap(),
2086 RecoverScanOutcome {
2087 last_used_idx: None,
2088 new_start_idx: TweakIdx(RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2089 tweak_idxes_with_pegins: BTreeSet::from([])
2090 }
2091 );
2092 assert!(last_checked.load(Ordering::SeqCst));
2093 }
2094
2095 {
2096 let last_checked = AtomicBool::new(false);
2097 let last_checked = &last_checked;
2098 assert_eq!(
2099 recover_scan_idxes_for_activity(
2100 TweakIdx(0),
2101 &BTreeSet::from([TweakIdx(1), TweakIdx(2)]),
2102 |cur_idx| async move {
2103 Ok(match cur_idx {
2104 TweakIdx(1) => panic!("Shouldn't happen: already used (1)"),
2105 TweakIdx(2) => panic!("Shouldn't happen: already used (2)"),
2106 TweakIdx(11) => {
2107 last_checked.store(true, Ordering::SeqCst);
2108 vec![]
2109 }
2110 TweakIdx(12) => panic!("Shouldn't happen"),
2111 TweakIdx(13) => {
2112 vec![0usize] }
2114 _ => vec![],
2115 })
2116 }
2117 )
2118 .await
2119 .unwrap(),
2120 RecoverScanOutcome {
2121 last_used_idx: Some(TweakIdx(2)),
2122 new_start_idx: TweakIdx(2 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2123 tweak_idxes_with_pegins: BTreeSet::from([])
2124 }
2125 );
2126 assert!(last_checked.load(Ordering::SeqCst));
2127 }
2128
2129 {
2130 let last_checked = AtomicBool::new(false);
2131 let last_checked = &last_checked;
2132 assert_eq!(
2133 recover_scan_idxes_for_activity(
2134 TweakIdx(10),
2135 &BTreeSet::new(),
2136 |cur_idx| async move {
2137 Ok(match cur_idx {
2138 TweakIdx(10) => vec![()],
2139 TweakIdx(19) => {
2140 last_checked.store(true, Ordering::SeqCst);
2141 vec![]
2142 }
2143 TweakIdx(20) => panic!("Shouldn't happen"),
2144 _ => vec![],
2145 })
2146 }
2147 )
2148 .await
2149 .unwrap(),
2150 RecoverScanOutcome {
2151 last_used_idx: Some(TweakIdx(10)),
2152 new_start_idx: TweakIdx(10 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2153 tweak_idxes_with_pegins: BTreeSet::from([TweakIdx(10)])
2154 }
2155 );
2156 assert!(last_checked.load(Ordering::SeqCst));
2157 }
2158
2159 assert_eq!(
2160 recover_scan_idxes_for_activity(TweakIdx(0), &BTreeSet::new(), |cur_idx| async move {
2161 Ok(match cur_idx {
2162 TweakIdx(6 | 15) => vec![()],
2163 _ => vec![],
2164 })
2165 })
2166 .await
2167 .unwrap(),
2168 RecoverScanOutcome {
2169 last_used_idx: Some(TweakIdx(15)),
2170 new_start_idx: TweakIdx(15 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2171 tweak_idxes_with_pegins: BTreeSet::from([TweakIdx(6), TweakIdx(15)])
2172 }
2173 );
2174 assert_eq!(
2175 recover_scan_idxes_for_activity(TweakIdx(10), &BTreeSet::new(), |cur_idx| async move {
2176 Ok(match cur_idx {
2177 TweakIdx(8) => {
2178 vec![()] }
2180 TweakIdx(9) => {
2181 panic!("Shouldn't happen")
2182 }
2183 _ => vec![],
2184 })
2185 })
2186 .await
2187 .unwrap(),
2188 RecoverScanOutcome {
2189 last_used_idx: None,
2190 new_start_idx: TweakIdx(9 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2191 tweak_idxes_with_pegins: BTreeSet::from([])
2192 }
2193 );
2194 assert_eq!(
2195 recover_scan_idxes_for_activity(TweakIdx(10), &BTreeSet::new(), |cur_idx| async move {
2196 Ok(match cur_idx {
2197 TweakIdx(9) => panic!("Shouldn't happen"),
2198 TweakIdx(15) => vec![()],
2199 _ => vec![],
2200 })
2201 })
2202 .await
2203 .unwrap(),
2204 RecoverScanOutcome {
2205 last_used_idx: Some(TweakIdx(15)),
2206 new_start_idx: TweakIdx(15 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2207 tweak_idxes_with_pegins: BTreeSet::from([TweakIdx(15)])
2208 }
2209 );
2210 }
2211}