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