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,
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 async fn recover(
423 &self,
424 args: &ClientModuleRecoverArgs<Self>,
425 snapshot: Option<&<Self::Module as ClientModule>::Backup>,
426 ) -> anyhow::Result<Option<fedimint_core::Amount>> {
427 if args
430 .db()
431 .begin_transaction_nc()
432 .await
433 .get_value(&RecoveryStateKey)
434 .await
435 .is_some()
436 {
437 return args
438 .recover_from_history::<WalletRecovery>(self, snapshot)
439 .await;
440 }
441
442 if args.module_api().fetch_recovery_count().await.is_ok() {
444 self.recover_from_slices(args).await
445 } else {
446 args.recover_from_history::<WalletRecovery>(self, snapshot)
447 .await
448 }
449 }
450
451 fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
452 Some(
453 DbKeyPrefix::iter()
454 .map(|p| p as u8)
455 .chain(
456 DbKeyPrefix::ExternalReservedStart as u8
457 ..=DbKeyPrefix::CoreInternalReservedEnd as u8,
458 )
459 .collect(),
460 )
461 }
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct WalletOperationMeta {
466 pub variant: WalletOperationMetaVariant,
467 pub extra_meta: serde_json::Value,
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize)]
471#[serde(rename_all = "snake_case")]
472pub enum WalletOperationMetaVariant {
473 Deposit {
474 address: Address<NetworkUnchecked>,
475 #[serde(default)]
480 tweak_idx: Option<TweakIdx>,
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 expires_at: Option<SystemTime>,
483 },
484 Withdraw {
485 address: Address<NetworkUnchecked>,
486 #[serde(with = "bitcoin::amount::serde::as_sat")]
487 amount: bitcoin::Amount,
488 fee: PegOutFees,
489 change: Vec<OutPoint>,
490 },
491
492 RbfWithdraw {
493 rbf: Rbf,
494 change: Vec<OutPoint>,
495 },
496}
497
498#[derive(Debug, Clone)]
500pub struct WalletClientModuleData {
501 cfg: WalletClientConfig,
502 module_root_secret: DerivableSecret,
503}
504
505impl WalletClientModuleData {
506 fn derive_deposit_address(
507 &self,
508 idx: TweakIdx,
509 ) -> (Keypair, secp256k1::PublicKey, Address, OperationId) {
510 let idx = ChildId(idx.0);
511
512 let secret_tweak_key = self
513 .module_root_secret
514 .child_key(WALLET_TWEAK_CHILD_ID)
515 .child_key(idx)
516 .to_secp_key(fedimint_core::secp256k1::SECP256K1);
517
518 let public_tweak_key = secret_tweak_key.public_key();
519
520 let address = self
521 .cfg
522 .peg_in_descriptor
523 .tweak(&public_tweak_key, bitcoin::secp256k1::SECP256K1)
524 .address(self.cfg.network.0)
525 .unwrap();
526
527 let operation_id = OperationId(public_tweak_key.x_only_public_key().0.serialize());
529
530 (secret_tweak_key, public_tweak_key, address, operation_id)
531 }
532
533 fn derive_peg_in_script(
534 &self,
535 idx: TweakIdx,
536 ) -> (ScriptBuf, bitcoin::Address, Keypair, OperationId) {
537 let (secret_tweak_key, _, address, operation_id) = self.derive_deposit_address(idx);
538
539 (
540 self.cfg
541 .peg_in_descriptor
542 .tweak(&secret_tweak_key.public_key(), SECP256K1)
543 .script_pubkey(),
544 address,
545 secret_tweak_key,
546 operation_id,
547 )
548 }
549}
550
551#[derive(Debug)]
552#[cfg_attr(feature = "uniffi", derive(uniffi::Object))]
553pub struct WalletClientModule {
554 data: WalletClientModuleData,
555 db: Database,
556 module_api: DynModuleApi,
557 notifier: ModuleNotifier<WalletClientStates>,
558 rpc: DynBitcoindRpc,
559 client_ctx: ClientContext<Self>,
560 pegin_monitor_wakeup_sender: watch::Sender<()>,
562 pegin_monitor_wakeup_receiver: watch::Receiver<()>,
563 pegin_claimed_sender: watch::Sender<()>,
565 pegin_claimed_receiver: watch::Receiver<()>,
566 task_group: TaskGroup,
567 client_span: tracing::Span,
568 admin_auth: Option<ApiAuth>,
569}
570
571#[apply(async_trait_maybe_send!)]
572impl ClientModule for WalletClientModule {
573 type Init = WalletClientInit;
574 type Common = WalletModuleTypes;
575 type Backup = WalletModuleBackup;
576 type ModuleStateMachineContext = WalletClientContext;
577 type States = WalletClientStates;
578
579 fn context(&self) -> Self::ModuleStateMachineContext {
580 WalletClientContext {
581 rpc: self.rpc.clone(),
582 wallet_descriptor: self.cfg().peg_in_descriptor.clone(),
583 wallet_decoder: self.decoder(),
584 secp: Secp256k1::default(),
585 client_ctx: self.client_ctx.clone(),
586 }
587 }
588
589 async fn start(&self) {
590 self.task_group
591 .spawn_cancellable_with_span(self.client_span.clone(), "peg-in monitor", {
592 let client_ctx = self.client_ctx.clone();
593 let db = self.db.clone();
594 let btc_rpc = self.rpc.clone();
595 let module_api = self.module_api.clone();
596 let data = self.data.clone();
597 let pegin_claimed_sender = self.pegin_claimed_sender.clone();
598 let pegin_monitor_wakeup_receiver = self.pegin_monitor_wakeup_receiver.clone();
599 pegin_monitor::run_peg_in_monitor(
600 client_ctx,
601 db,
602 btc_rpc,
603 module_api,
604 data,
605 pegin_claimed_sender,
606 pegin_monitor_wakeup_receiver,
607 )
608 });
609
610 self.task_group.spawn_cancellable_with_span(
611 self.client_span.clone(),
612 "supports-safe-deposit-version",
613 {
614 let db = self.db.clone();
615 let module_api = self.module_api.clone();
616
617 poll_supports_safe_deposit_version(db, module_api)
618 },
619 );
620 }
621
622 fn supports_backup(&self) -> bool {
623 true
624 }
625
626 async fn backup(&self) -> anyhow::Result<backup::WalletModuleBackup> {
627 let session_count = self.client_ctx.global_api().session_count().await?;
629
630 let mut dbtx = self.db.begin_transaction_nc().await;
631 let next_pegin_tweak_idx = dbtx
632 .get_value(&NextPegInTweakIndexKey)
633 .await
634 .unwrap_or_default();
635 let claimed = dbtx
636 .find_by_prefix(&PegInTweakIndexPrefix)
637 .await
638 .filter_map(|(k, v)| async move {
639 if v.claimed.is_empty() {
640 None
641 } else {
642 Some(k.0)
643 }
644 })
645 .collect()
646 .await;
647 Ok(backup::WalletModuleBackup::new_v1(
648 session_count,
649 next_pegin_tweak_idx,
650 claimed,
651 ))
652 }
653
654 fn input_fee(
655 &self,
656 _amount: &Amounts,
657 _input: &<Self::Common as ModuleCommon>::Input,
658 ) -> Option<Amounts> {
659 Some(Amounts::new_bitcoin(self.cfg().fee_consensus.peg_in_abs))
660 }
661
662 fn output_fee(
663 &self,
664 _amount: &Amounts,
665 _output: &<Self::Common as ModuleCommon>::Output,
666 ) -> Option<Amounts> {
667 Some(Amounts::new_bitcoin(self.cfg().fee_consensus.peg_out_abs))
668 }
669
670 async fn handle_rpc(
671 &self,
672 method: String,
673 request: serde_json::Value,
674 ) -> BoxStream<'_, anyhow::Result<serde_json::Value>> {
675 Box::pin(try_stream! {
676 match method.as_str() {
677 "get_wallet_summary" => {
678 let _req: WalletSummaryRequest = serde_json::from_value(request)?;
679 let wallet_summary = self.get_wallet_summary()
680 .await
681 .expect("Failed to fetch wallet summary");
682 let result = serde_json::to_value(&wallet_summary)
683 .expect("Serialization error");
684 yield result;
685 }
686 "get_block_count_local" => {
687 let block_count = self.get_block_count_local().await
688 .expect("Failed to fetch block count");
689 yield serde_json::to_value(block_count)?;
690 }
691 "peg_in" => {
692 let req: PegInRequest = serde_json::from_value(request)?;
693 let response = self.peg_in(req)
694 .await
695 .map_err(|e| anyhow::anyhow!("peg_in failed: {e}"))?;
696 let result = serde_json::to_value(&response)?;
697 yield result;
698 },
699 "peg_out" => {
700 let req: PegOutRequest = serde_json::from_value(request)?;
701 let response = self.peg_out(req)
702 .await
703 .map_err(|e| anyhow::anyhow!("peg_out failed: {e}"))?;
704 let result = serde_json::to_value(&response)?;
705 yield result;
706 },
707 "subscribe_deposit" => {
708 let req: SubscribeDepositRequest = serde_json::from_value(request)?;
709 for await state in self.subscribe_deposit(req.operation_id).await?.into_stream() {
710 yield serde_json::to_value(state)?;
711 }
712 },
713 "subscribe_withdraw" => {
714 let req: SubscribeWithdrawRequest = serde_json::from_value(request)?;
715 for await state in self.subscribe_withdraw_updates(req.operation_id).await?.into_stream(){
716 yield serde_json::to_value(state)?;
717 }
718 }
719 _ => {
720 Err(anyhow::format_err!("Unknown method: {method}"))?;
721 }
722 }
723 })
724 }
725
726 #[cfg(feature = "cli")]
727 async fn handle_cli_command(
728 &self,
729 args: &[std::ffi::OsString],
730 ) -> anyhow::Result<serde_json::Value> {
731 cli::handle_cli_command(self, args).await
732 }
733}
734
735#[derive(Deserialize)]
736struct WalletSummaryRequest {}
737
738#[derive(Debug, Clone)]
739pub struct WalletClientContext {
740 rpc: DynBitcoindRpc,
741 wallet_descriptor: PegInDescriptor,
742 wallet_decoder: Decoder,
743 secp: Secp256k1<All>,
744 pub client_ctx: ClientContext<WalletClientModule>,
745}
746
747#[derive(Debug, Clone, Serialize, Deserialize)]
748pub struct PegInRequest {
749 pub extra_meta: serde_json::Value,
750}
751
752#[cfg(feature = "uniffi")]
753uniffi::custom_type!(PegInRequest, String, {
754 lower: |v| serde_json::to_string(&v).expect("PegInRequest serialization cannot fail"),
755 try_lift: |s| serde_json::from_str::<PegInRequest>(&s).map_err(|e| anyhow!("Failed to parse PegInRequest: {e}")),
756});
757
758#[derive(Deserialize)]
759struct SubscribeDepositRequest {
760 operation_id: OperationId,
761}
762
763#[derive(Deserialize)]
764struct SubscribeWithdrawRequest {
765 operation_id: OperationId,
766}
767
768#[derive(Debug, Clone, Serialize, Deserialize)]
769#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
770pub struct PegInResponse {
771 pub deposit_address: Address<NetworkUnchecked>,
772 pub operation_id: OperationId,
773}
774
775#[derive(Debug, Clone, Serialize, Deserialize)]
776#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
777pub struct PegOutRequest {
778 pub amount_sat: u64,
779 pub destination_address: Address<NetworkUnchecked>,
780 pub extra_meta: serde_json::Value,
781}
782
783#[derive(Debug, Clone, Serialize, Deserialize)]
784#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
785pub struct PegOutResponse {
786 pub operation_id: OperationId,
787}
788
789impl Context for WalletClientContext {
790 const KIND: Option<ModuleKind> = Some(KIND);
791}
792
793impl WalletClientModule {
794 fn cfg(&self) -> &WalletClientConfig {
795 &self.data.cfg
796 }
797
798 fn get_rpc_config(cfg: &WalletClientConfig) -> BitcoinRpcConfig {
799 match BitcoinRpcConfig::get_defaults_from_env_vars() {
800 Ok(rpc_config) => {
801 if rpc_config.kind == "bitcoind" {
804 cfg.default_bitcoin_rpc.clone()
805 } else {
806 rpc_config
807 }
808 }
809 _ => cfg.default_bitcoin_rpc.clone(),
810 }
811 }
812
813 pub fn get_network(&self) -> Network {
814 self.cfg().network.0
815 }
816
817 pub fn get_finality_delay(&self) -> u32 {
818 self.cfg().finality_delay
819 }
820
821 pub fn get_fee_consensus(&self) -> FeeConsensus {
822 self.cfg().fee_consensus
823 }
824
825 async fn allocate_deposit_address_inner(
826 &self,
827 dbtx: &mut DatabaseTransaction<'_>,
828 ) -> DepositAddressInfo {
829 dbtx.ensure_isolated().expect("Must be isolated db");
830
831 let tweak_idx = get_next_peg_in_tweak_child_id(dbtx).await;
832 let (_secret_tweak_key, _, address, operation_id) =
833 self.data.derive_deposit_address(tweak_idx);
834
835 let now = fedimint_core::time::now();
836
837 dbtx.insert_new_entry(
838 &PegInTweakIndexKey(tweak_idx),
839 &PegInTweakIndexData {
840 creation_time: now,
841 next_check_time: Some(now),
842 last_check_time: None,
843 operation_id,
844 claimed: vec![],
845 },
846 )
847 .await;
848
849 DepositAddressInfo {
850 operation_id,
851 address,
852 tweak_idx,
853 }
854 }
855
856 pub async fn get_withdraw_fees(
863 &self,
864 address: &bitcoin::Address,
865 amount: bitcoin::Amount,
866 ) -> anyhow::Result<PegOutFees> {
867 self.module_api
868 .fetch_peg_out_fees(address, amount)
869 .await?
870 .context("Federation didn't return peg-out fees")
871 }
872
873 pub async fn send_fee_quote(&self, amount: bitcoin::Amount) -> anyhow::Result<FeeQuote> {
888 let amount = fedimint_core::Amount::from_sats(amount.to_sat());
889 self.client_ctx
890 .fee_quote(
891 OperationId::new_random(),
892 FeeQuoteRequest {
893 input_amount: Amounts::ZERO,
894 output_amount: Amounts::new_bitcoin(amount),
895 input_fee: Amounts::ZERO,
896 output_fee: Amounts::new_bitcoin(self.cfg().fee_consensus.peg_out_abs),
897 },
898 )
899 .await
900 }
901
902 pub async fn max_withdrawable_amount(
936 &self,
937 address: &bitcoin::Address,
938 balance: fedimint_core::Amount,
939 ) -> anyhow::Result<(bitcoin::Amount, PegOutFees)> {
940 let max_fees = self
943 .get_withdraw_fees(
944 address,
945 bitcoin::Amount::from_sat(balance.sats_round_down()),
946 )
947 .await?;
948 let max_fee_msats = fedimint_core::Amount::from_sats(max_fees.amount().to_sat());
949
950 let max = max_affordable_send_amount(
951 balance,
952 fedimint_core::Amount::from_sats(address.script_pubkey().minimal_non_dust().to_sat()),
953 balance,
954 |amount: fedimint_core::Amount| {
958 fedimint_core::Amount::from_sats(amount.msats.div_ceil(1000)) + max_fee_msats
959 },
960 |funded: fedimint_core::Amount| {
961 self.send_fee_quote(bitcoin::Amount::from_sat(funded.msats / 1000))
962 },
963 )
964 .await
965 .ok_or_else(|| anyhow!("Balance is too low to withdraw any amount after fees"))?;
966
967 let amount = bitcoin::Amount::from_sat(max.msats.div_ceil(1000));
970
971 let fees = self.get_withdraw_fees(address, amount).await?;
976
977 Ok((amount, fees))
978 }
979
980 pub async fn get_wallet_summary(&self) -> anyhow::Result<WalletSummary> {
982 Ok(self.module_api.fetch_wallet_summary().await?)
983 }
984
985 pub async fn get_block_count_local(&self) -> anyhow::Result<u32> {
986 Ok(self.module_api.fetch_block_count_local().await?)
987 }
988
989 pub fn create_withdraw_output(
990 &self,
991 operation_id: OperationId,
992 address: bitcoin::Address,
993 amount: bitcoin::Amount,
994 fees: PegOutFees,
995 ) -> anyhow::Result<ClientOutputBundle<WalletOutput, WalletClientStates>> {
996 let output = WalletOutput::new_v0_peg_out(address, amount, fees);
997
998 let amount = output.maybe_v0_ref().expect("v0 output").amount().into();
999
1000 let sm_gen = move |out_point_range: OutPointRange| {
1001 assert_eq!(out_point_range.count(), 1);
1002 let out_idx = out_point_range.start_idx();
1003 vec![WalletClientStates::Withdraw(WithdrawStateMachine {
1004 operation_id,
1005 state: WithdrawStates::Created(CreatedWithdrawState {
1006 fm_outpoint: OutPoint {
1007 txid: out_point_range.txid(),
1008 out_idx,
1009 },
1010 }),
1011 })]
1012 };
1013
1014 Ok(ClientOutputBundle::new(
1015 vec![ClientOutput::<WalletOutput> {
1016 output,
1017 amounts: Amounts::new_bitcoin(amount),
1018 }],
1019 vec![ClientOutputSM::<WalletClientStates> {
1020 state_machines: Arc::new(sm_gen),
1021 }],
1022 ))
1023 }
1024
1025 pub async fn peg_in(&self, req: PegInRequest) -> anyhow::Result<PegInResponse> {
1026 let deposit_address = self.safe_allocate_deposit_address(req.extra_meta).await?;
1027
1028 Ok(PegInResponse {
1029 deposit_address: Address::from_script(
1030 &deposit_address.address.script_pubkey(),
1031 self.get_network(),
1032 )?
1033 .as_unchecked()
1034 .clone(),
1035 operation_id: deposit_address.operation_id,
1036 })
1037 }
1038
1039 pub async fn peg_out(&self, req: PegOutRequest) -> anyhow::Result<PegOutResponse> {
1040 let amount = bitcoin::Amount::from_sat(req.amount_sat);
1041 let destination = req
1042 .destination_address
1043 .require_network(self.get_network())?;
1044
1045 let fees = self.get_withdraw_fees(&destination, amount).await?;
1046 let operation_id = self
1047 .withdraw(&destination, amount, fees, req.extra_meta)
1048 .await
1049 .context("Failed to initiate withdraw")?;
1050
1051 Ok(PegOutResponse { operation_id })
1052 }
1053
1054 pub fn create_rbf_withdraw_output(
1055 &self,
1056 operation_id: OperationId,
1057 rbf: &Rbf,
1058 ) -> anyhow::Result<ClientOutputBundle<WalletOutput, WalletClientStates>> {
1059 let output = WalletOutput::new_v0_rbf(rbf.fees, rbf.txid);
1060
1061 let amount = output.maybe_v0_ref().expect("v0 output").amount().into();
1062
1063 let sm_gen = move |out_point_range: OutPointRange| {
1064 assert_eq!(out_point_range.count(), 1);
1065 let out_idx = out_point_range.start_idx();
1066 vec![WalletClientStates::Withdraw(WithdrawStateMachine {
1067 operation_id,
1068 state: WithdrawStates::Created(CreatedWithdrawState {
1069 fm_outpoint: OutPoint {
1070 txid: out_point_range.txid(),
1071 out_idx,
1072 },
1073 }),
1074 })]
1075 };
1076
1077 Ok(ClientOutputBundle::new(
1078 vec![ClientOutput::<WalletOutput> {
1079 output,
1080 amounts: Amounts::new_bitcoin(amount),
1081 }],
1082 vec![ClientOutputSM::<WalletClientStates> {
1083 state_machines: Arc::new(sm_gen),
1084 }],
1085 ))
1086 }
1087
1088 pub async fn btc_tx_has_no_size_limit(&self) -> FederationResult<bool> {
1089 Ok(self.module_api.module_consensus_version().await? >= ModuleConsensusVersion::new(2, 2))
1090 }
1091
1092 pub async fn supports_safe_deposit(&self) -> bool {
1101 let mut dbtx = self.db.begin_transaction().await;
1102
1103 let already_verified_supports_safe_deposit =
1104 dbtx.get_value(&SupportsSafeDepositKey).await.is_some();
1105
1106 already_verified_supports_safe_deposit || {
1107 match self.module_api.module_consensus_version().await {
1108 Ok(module_consensus_version) => {
1109 let supported_version =
1110 SAFE_DEPOSIT_MODULE_CONSENSUS_VERSION <= module_consensus_version;
1111
1112 if supported_version {
1113 dbtx.insert_new_entry(&SupportsSafeDepositKey, &()).await;
1114 dbtx.commit_tx().await;
1115 }
1116
1117 supported_version
1118 }
1119 Err(_) => false,
1120 }
1121 }
1122 }
1123
1124 pub async fn safe_allocate_deposit_address<M>(
1132 &self,
1133 extra_meta: M,
1134 ) -> anyhow::Result<DepositAddressInfo>
1135 where
1136 M: Serialize + MaybeSend + MaybeSync,
1137 {
1138 ensure!(
1139 self.supports_safe_deposit().await,
1140 "Wallet module consensus version doesn't support safe deposits",
1141 );
1142
1143 self.allocate_deposit_address_expert_only(extra_meta).await
1144 }
1145
1146 pub async fn allocate_deposit_address_expert_only<M>(
1164 &self,
1165 extra_meta: M,
1166 ) -> anyhow::Result<DepositAddressInfo>
1167 where
1168 M: Serialize + MaybeSend + MaybeSync,
1169 {
1170 let extra_meta_value =
1171 serde_json::to_value(extra_meta).expect("Failed to serialize extra meta");
1172 let deposit_address = self
1173 .db
1174 .autocommit(
1175 move |dbtx, _| {
1176 let extra_meta_value_inner = extra_meta_value.clone();
1177 Box::pin(async move {
1178 let deposit_address = self.allocate_deposit_address_inner(dbtx).await;
1179
1180 self.client_ctx
1181 .manual_operation_start_dbtx(
1182 dbtx,
1183 deposit_address.operation_id,
1184 WalletCommonInit::KIND.as_str(),
1185 WalletOperationMeta {
1186 variant: WalletOperationMetaVariant::Deposit {
1187 address: deposit_address.address.clone().into_unchecked(),
1188 tweak_idx: Some(deposit_address.tweak_idx),
1189 expires_at: None,
1190 },
1191 extra_meta: extra_meta_value_inner,
1192 },
1193 vec![],
1194 )
1195 .await?;
1196
1197 debug!(
1198 target: LOG_CLIENT_MODULE_WALLET,
1199 tweak_idx = %deposit_address.tweak_idx,
1200 address = %deposit_address.address,
1201 "Derived a new deposit address"
1202 );
1203
1204 self.rpc
1206 .watch_script_history(&deposit_address.address.script_pubkey())
1207 .await?;
1208
1209 let sender = self.pegin_monitor_wakeup_sender.clone();
1210 dbtx.on_commit(move || {
1211 sender.send_replace(());
1212 });
1213
1214 Ok(deposit_address)
1215 })
1216 },
1217 Some(100),
1218 )
1219 .await
1220 .map_err(|e| match e {
1221 AutocommitError::CommitFailed {
1222 last_error,
1223 attempts,
1224 } => anyhow!("Failed to commit after {attempts} attempts: {last_error}"),
1225 AutocommitError::ClosureError { error, .. } => error,
1226 })?;
1227
1228 Ok(deposit_address)
1229 }
1230
1231 pub async fn allocate_deposit_address_pooled_stateless(
1269 &self,
1270 max_gap_size: usize,
1271 ) -> anyhow::Result<MaybeNewAddress> {
1272 let max_gap_size_u64 = u64::try_from(max_gap_size).unwrap_or(u64::MAX);
1273 let extra_meta_value = serde_json::Value::Null;
1274 let result = self
1275 .db
1276 .autocommit(
1277 move |dbtx, _| {
1278 let extra_meta_value_inner = extra_meta_value.clone();
1279 Box::pin(async move {
1280 let unused = self.unused_pooled_deposit_addresses(dbtx).await;
1281
1282 if max_gap_size_u64 <= unused.len() as u64 && !unused.is_empty() {
1283 let addresses = unused
1284 .into_iter()
1285 .map(|(tweak_idx, data)| {
1286 let (_script, address, _key, operation_id) =
1287 self.data.derive_peg_in_script(tweak_idx);
1288
1289 debug_assert_eq!(operation_id, data.operation_id);
1290
1291 DepositAddressInfo {
1292 operation_id,
1293 address,
1294 tweak_idx,
1295 }
1296 })
1297 .collect();
1298
1299 return Ok::<_, anyhow::Error>(
1300 MaybeNewAddress::TooManyUnusedAddresses(addresses),
1301 );
1302 }
1303
1304 let deposit_address = self.allocate_deposit_address_inner(dbtx).await;
1305
1306 self.client_ctx
1307 .manual_operation_start_dbtx(
1308 dbtx,
1309 deposit_address.operation_id,
1310 WalletCommonInit::KIND.as_str(),
1311 WalletOperationMeta {
1312 variant: WalletOperationMetaVariant::Deposit {
1313 address: deposit_address.address.clone().into_unchecked(),
1314 tweak_idx: Some(deposit_address.tweak_idx),
1315 expires_at: None,
1316 },
1317 extra_meta: extra_meta_value_inner,
1318 },
1319 vec![],
1320 )
1321 .await?;
1322
1323 debug!(
1324 target: LOG_CLIENT_MODULE_WALLET,
1325 tweak_idx = %deposit_address.tweak_idx,
1326 address = %deposit_address.address,
1327 "Derived a new pooled deposit address"
1328 );
1329
1330 self.rpc
1331 .watch_script_history(&deposit_address.address.script_pubkey())
1332 .await?;
1333
1334 let sender = self.pegin_monitor_wakeup_sender.clone();
1335 dbtx.on_commit(move || {
1336 sender.send_replace(());
1337 });
1338
1339 Ok(MaybeNewAddress::NewAddress(deposit_address))
1340 })
1341 },
1342 Some(100),
1343 )
1344 .await
1345 .map_err(|e| match e {
1346 AutocommitError::CommitFailed {
1347 last_error,
1348 attempts,
1349 } => anyhow!("Failed to commit after {attempts} attempts: {last_error}"),
1350 AutocommitError::ClosureError { error, .. } => error,
1351 })?;
1352
1353 Ok(result)
1354 }
1355
1356 async fn unused_pooled_deposit_addresses(
1357 &self,
1358 dbtx: &mut DatabaseTransaction<'_>,
1359 ) -> Vec<(TweakIdx, PegInTweakIndexData)> {
1360 let mut unused: Vec<(TweakIdx, PegInTweakIndexData)> = dbtx
1365 .find_by_prefix_sorted_descending(&PegInTweakIndexPrefix)
1366 .await
1367 .take_while(|(_, d)| std::future::ready(d.claimed.is_empty()))
1368 .map(|(k, v)| (k.0, v))
1369 .collect()
1370 .await;
1371
1372 unused.sort_by_key(|(t, d)| (d.creation_time, *t));
1375 unused
1376 }
1377
1378 #[allow(clippy::too_many_lines)]
1401 pub async fn allocate_deposit_address_pooled(
1402 &self,
1403 max_gap_size: usize,
1404 ) -> anyhow::Result<(DepositAddressInfo, AllocateDepositOutcome)> {
1405 let stateless = self
1406 .allocate_deposit_address_pooled_stateless(max_gap_size)
1407 .await?;
1408
1409 let reused_addresses = match stateless {
1410 MaybeNewAddress::NewAddress(deposit_address) => {
1411 return Ok((deposit_address, AllocateDepositOutcome::Fresh));
1412 }
1413 MaybeNewAddress::TooManyUnusedAddresses(addresses) => addresses,
1414 };
1415
1416 let result = self
1417 .db
1418 .autocommit(
1419 move |dbtx, _| {
1420 let reused_addresses = reused_addresses.clone();
1421 Box::pin(async move {
1422 let cursor = dbtx
1423 .get_value(&PegInPoolCursorKey)
1424 .await
1425 .unwrap_or(TweakIdx(0));
1426
1427 let pick_pos = reused_addresses
1428 .iter()
1429 .position(|a| cursor <= a.tweak_idx)
1430 .unwrap_or(0);
1431 let reused_address = reused_addresses[pick_pos].clone();
1432
1433 let existing_tweak_idx = reused_address.tweak_idx;
1434 let existing = dbtx
1435 .get_value(&PegInTweakIndexKey(reused_address.tweak_idx))
1436 .await
1437 .with_context(|| {
1438 format!(
1439 "Pooled address disappeared while reusing {}",
1440 reused_address.tweak_idx
1441 )
1442 })?;
1443
1444 ensure!(
1445 existing.claimed.is_empty(),
1446 "Pooled address was used while reusing {}",
1447 reused_address.tweak_idx
1448 );
1449
1450 dbtx.insert_entry(&PegInPoolCursorKey, &reused_address.tweak_idx.next())
1451 .await;
1452
1453 let now = fedimint_core::time::now();
1461 dbtx.insert_entry(
1462 &PegInTweakIndexKey(reused_address.tweak_idx),
1463 &PegInTweakIndexData {
1464 creation_time: now,
1465 last_check_time: None,
1466 next_check_time: Some(now),
1467 operation_id: existing.operation_id,
1468 claimed: existing.claimed,
1469 },
1470 )
1471 .await;
1472
1473 let sender = self.pegin_monitor_wakeup_sender.clone();
1474 dbtx.on_commit(move || {
1475 sender.send_replace(());
1476 });
1477
1478 Ok::<_, anyhow::Error>((
1479 reused_address,
1480 AllocateDepositOutcome::Reused {
1481 original_tweak_idx: existing_tweak_idx,
1482 },
1483 ))
1484 })
1485 },
1486 Some(100),
1487 )
1488 .await
1489 .map_err(|e| match e {
1490 AutocommitError::CommitFailed {
1491 last_error,
1492 attempts,
1493 } => anyhow!("Failed to commit after {attempts} attempts: {last_error}"),
1494 AutocommitError::ClosureError { error, .. } => error,
1495 })?;
1496
1497 Ok(result)
1498 }
1499
1500 pub async fn subscribe_deposit(
1506 &self,
1507 operation_id: OperationId,
1508 ) -> anyhow::Result<UpdateStreamOrOutcome<DepositStateV2>> {
1509 let operation = self
1510 .client_ctx
1511 .get_operation(operation_id)
1512 .await
1513 .with_context(|| anyhow!("Operation not found: {}", operation_id.fmt_short()))?;
1514
1515 if operation.operation_module_kind() != WalletCommonInit::KIND.as_str() {
1516 bail!("Operation is not a wallet operation");
1517 }
1518
1519 let operation_meta = operation.meta::<WalletOperationMeta>();
1520
1521 let WalletOperationMetaVariant::Deposit {
1522 address, tweak_idx, ..
1523 } = operation_meta.variant
1524 else {
1525 bail!("Operation is not a deposit operation");
1526 };
1527
1528 let address = address.require_network(self.cfg().network.0)?;
1529
1530 let Some(tweak_idx) = tweak_idx else {
1532 let outcome_v1 = operation
1536 .outcome::<DepositStateV1>()
1537 .context("Old pending deposit, can't subscribe to updates")?;
1538
1539 let outcome_v2 = match outcome_v1 {
1540 DepositStateV1::Claimed(tx_info) => DepositStateV2::Claimed {
1541 btc_deposited: tx_info.btc_transaction.output[tx_info.out_idx as usize].value,
1542 btc_out_point: bitcoin::OutPoint {
1543 txid: tx_info.btc_transaction.compute_txid(),
1544 vout: tx_info.out_idx,
1545 },
1546 },
1547 DepositStateV1::Failed(error) => DepositStateV2::Failed(error),
1548 _ => bail!("Non-final outcome in operation log"),
1549 };
1550
1551 return Ok(UpdateStreamOrOutcome::Outcome(outcome_v2));
1552 };
1553
1554 Ok(self.client_ctx.outcome_or_updates(
1555 &operation,
1556 operation_id,
1557 |state| match state {
1558 DepositStateV2::WaitingForTransaction
1559 | DepositStateV2::WaitingForConfirmation { .. }
1560 | DepositStateV2::Confirmed { .. } => false,
1561 DepositStateV2::Claimed { .. } | DepositStateV2::Failed(_) => true,
1562 },
1563 {
1564 let stream_rpc = self.rpc.clone();
1565 let stream_client_ctx = self.client_ctx.clone();
1566 let stream_script_pub_key = address.script_pubkey();
1567 move || {
1568
1569 stream! {
1570 yield DepositStateV2::WaitingForTransaction;
1571
1572 retry(
1573 "subscribe script history",
1574 background_backoff(),
1575 || stream_rpc.watch_script_history(&stream_script_pub_key)
1576 ).await.expect("Will never give up");
1577 let (btc_out_point, btc_deposited) = retry(
1578 "fetch history",
1579 background_backoff(),
1580 || async {
1581 let history = stream_rpc.get_script_history(&stream_script_pub_key).await?;
1582 history.first().and_then(|tx| {
1583 let (out_idx, amount) = tx.output
1584 .iter()
1585 .enumerate()
1586 .find_map(|(idx, output)| (output.script_pubkey == stream_script_pub_key).then_some((idx, output.value)))?;
1587 let txid = tx.compute_txid();
1588
1589 Some((
1590 bitcoin::OutPoint {
1591 txid,
1592 vout: out_idx as u32,
1593 },
1594 amount
1595 ))
1596 }).context("No deposit transaction found")
1597 }
1598 ).await.expect("Will never give up");
1599
1600 yield DepositStateV2::WaitingForConfirmation {
1601 btc_deposited,
1602 btc_out_point
1603 };
1604
1605 let claim_data = stream_client_ctx.module_db().wait_key_exists(&ClaimedPegInKey {
1606 peg_in_index: tweak_idx,
1607 btc_out_point,
1608 }).await;
1609
1610 yield DepositStateV2::Confirmed {
1611 btc_deposited,
1612 btc_out_point
1613 };
1614
1615 match stream_client_ctx.await_primary_module_outputs(operation_id, claim_data.change).await {
1616 Ok(()) => yield DepositStateV2::Claimed {
1617 btc_deposited,
1618 btc_out_point
1619 },
1620 Err(e) => yield DepositStateV2::Failed(e.to_string())
1621 }
1622 }
1623 }}))
1624 }
1625
1626 pub async fn list_peg_in_tweak_idxes(&self) -> BTreeMap<TweakIdx, PegInTweakIndexData> {
1627 self.client_ctx
1628 .module_db()
1629 .clone()
1630 .begin_transaction_nc()
1631 .await
1632 .find_by_prefix(&PegInTweakIndexPrefix)
1633 .await
1634 .map(|(key, data)| (key.0, data))
1635 .collect()
1636 .await
1637 }
1638
1639 pub async fn find_tweak_idx_by_address(
1640 &self,
1641 address: bitcoin::Address<NetworkUnchecked>,
1642 ) -> anyhow::Result<TweakIdx> {
1643 let data = self.data.clone();
1644 let Some((tweak_idx, _)) = self
1645 .db
1646 .begin_transaction_nc()
1647 .await
1648 .find_by_prefix(&PegInTweakIndexPrefix)
1649 .await
1650 .filter(|(k, _)| {
1651 let (_, derived_address, _tweak_key, _) = data.derive_peg_in_script(k.0);
1652 future::ready(derived_address.into_unchecked() == address)
1653 })
1654 .next()
1655 .await
1656 else {
1657 bail!("Address not found in the list of derived keys");
1658 };
1659
1660 Ok(tweak_idx.0)
1661 }
1662 pub async fn find_tweak_idx_by_operation_id(
1663 &self,
1664 operation_id: OperationId,
1665 ) -> anyhow::Result<TweakIdx> {
1666 Ok(self
1667 .client_ctx
1668 .module_db()
1669 .clone()
1670 .begin_transaction_nc()
1671 .await
1672 .find_by_prefix(&PegInTweakIndexPrefix)
1673 .await
1674 .filter(|(_k, v)| future::ready(v.operation_id == operation_id))
1675 .next()
1676 .await
1677 .ok_or_else(|| anyhow::format_err!("OperationId not found"))?
1678 .0
1679 .0)
1680 }
1681
1682 pub async fn get_pegin_tweak_idx(
1683 &self,
1684 tweak_idx: TweakIdx,
1685 ) -> anyhow::Result<PegInTweakIndexData> {
1686 self.client_ctx
1687 .module_db()
1688 .clone()
1689 .begin_transaction_nc()
1690 .await
1691 .get_value(&PegInTweakIndexKey(tweak_idx))
1692 .await
1693 .ok_or_else(|| anyhow::format_err!("TweakIdx not found"))
1694 }
1695
1696 pub async fn get_claimed_pegins(
1697 &self,
1698 dbtx: &mut DatabaseTransaction<'_>,
1699 tweak_idx: TweakIdx,
1700 ) -> Vec<(
1701 bitcoin::OutPoint,
1702 TransactionId,
1703 Vec<fedimint_core::OutPoint>,
1704 )> {
1705 let outpoints = dbtx
1706 .get_value(&PegInTweakIndexKey(tweak_idx))
1707 .await
1708 .map(|v| v.claimed)
1709 .unwrap_or_default();
1710
1711 let mut res = vec![];
1712
1713 for outpoint in outpoints {
1714 let claimed_peg_in_data = dbtx
1715 .get_value(&ClaimedPegInKey {
1716 peg_in_index: tweak_idx,
1717 btc_out_point: outpoint,
1718 })
1719 .await
1720 .expect("Must have a corresponding claim record");
1721 res.push((
1722 outpoint,
1723 claimed_peg_in_data.claim_txid,
1724 claimed_peg_in_data.change,
1725 ));
1726 }
1727
1728 res
1729 }
1730
1731 pub async fn recheck_pegin_address_by_op_id(
1733 &self,
1734 operation_id: OperationId,
1735 ) -> anyhow::Result<()> {
1736 let tweak_idx = self.find_tweak_idx_by_operation_id(operation_id).await?;
1737
1738 self.recheck_pegin_address(tweak_idx).await
1739 }
1740
1741 pub async fn recheck_pegin_address_by_address(
1743 &self,
1744 address: bitcoin::Address<NetworkUnchecked>,
1745 ) -> anyhow::Result<()> {
1746 self.recheck_pegin_address(self.find_tweak_idx_by_address(address).await?)
1747 .await
1748 }
1749
1750 pub async fn recheck_pegin_address(&self, tweak_idx: TweakIdx) -> anyhow::Result<()> {
1752 self.db
1753 .autocommit(
1754 |dbtx, _| {
1755 Box::pin(async {
1756 let db_key = PegInTweakIndexKey(tweak_idx);
1757 let db_val = dbtx
1758 .get_value(&db_key)
1759 .await
1760 .ok_or_else(|| anyhow::format_err!("DBKey not found"))?;
1761
1762 dbtx.insert_entry(
1763 &db_key,
1764 &PegInTweakIndexData {
1765 next_check_time: Some(fedimint_core::time::now()),
1766 ..db_val
1767 },
1768 )
1769 .await;
1770
1771 let sender = self.pegin_monitor_wakeup_sender.clone();
1772 dbtx.on_commit(move || {
1773 sender.send_replace(());
1774 });
1775
1776 Ok::<_, anyhow::Error>(())
1777 })
1778 },
1779 Some(100),
1780 )
1781 .await?;
1782
1783 Ok(())
1784 }
1785
1786 pub async fn await_num_deposits_by_operation_id(
1788 &self,
1789 operation_id: OperationId,
1790 num_deposits: usize,
1791 ) -> anyhow::Result<()> {
1792 let tweak_idx = self.find_tweak_idx_by_operation_id(operation_id).await?;
1793 self.await_num_deposits(tweak_idx, num_deposits).await
1794 }
1795
1796 pub async fn await_num_deposits_by_address(
1797 &self,
1798 address: bitcoin::Address<NetworkUnchecked>,
1799 num_deposits: usize,
1800 ) -> anyhow::Result<()> {
1801 self.await_num_deposits(self.find_tweak_idx_by_address(address).await?, num_deposits)
1802 .await
1803 }
1804
1805 #[instrument(target = LOG_CLIENT_MODULE_WALLET, skip_all, fields(tweak_idx=?tweak_idx, num_deposists=num_deposits))]
1806 pub async fn await_num_deposits(
1807 &self,
1808 tweak_idx: TweakIdx,
1809 num_deposits: usize,
1810 ) -> anyhow::Result<()> {
1811 let operation_id = self.get_pegin_tweak_idx(tweak_idx).await?.operation_id;
1812
1813 let mut receiver = self.pegin_claimed_receiver.clone();
1814 let mut backoff = backoff_util::aggressive_backoff();
1815
1816 loop {
1817 let pegins = self
1818 .get_claimed_pegins(
1819 &mut self.client_ctx.module_db().begin_transaction_nc().await,
1820 tweak_idx,
1821 )
1822 .await;
1823
1824 if pegins.len() < num_deposits {
1825 debug!(target: LOG_CLIENT_MODULE_WALLET, has=pegins.len(), "Not enough deposits");
1826 self.recheck_pegin_address(tweak_idx).await?;
1827 runtime::sleep(backoff.next().unwrap_or_default()).await;
1828 receiver.changed().await?;
1829 continue;
1830 }
1831
1832 debug!(target: LOG_CLIENT_MODULE_WALLET, has=pegins.len(), "Enough deposits detected");
1833
1834 for (_outpoint, transaction_id, change) in pegins {
1835 if transaction_id == TransactionId::from_byte_array([0; 32]) && change.is_empty() {
1836 debug!(target: LOG_CLIENT_MODULE_WALLET, "Deposited amount was too low, skipping");
1837 continue;
1838 }
1839
1840 debug!(target: LOG_CLIENT_MODULE_WALLET, out_points=?change, "Ensuring deposists claimed");
1841 let tx_subscriber = self.client_ctx.transaction_updates(operation_id).await;
1842
1843 if let Err(e) = tx_subscriber.await_tx_accepted(transaction_id).await {
1844 bail!("{e}");
1845 }
1846
1847 debug!(target: LOG_CLIENT_MODULE_WALLET, out_points=?change, "Ensuring outputs claimed");
1848 self.client_ctx
1849 .await_primary_module_outputs(operation_id, change)
1850 .await
1851 .expect("Cannot fail if tx was accepted and federation is honest");
1852 }
1853
1854 return Ok(());
1855 }
1856 }
1857
1858 pub async fn withdraw<M: Serialize + MaybeSend + MaybeSync>(
1863 &self,
1864 address: &bitcoin::Address,
1865 amount: bitcoin::Amount,
1866 fee: PegOutFees,
1867 extra_meta: M,
1868 ) -> anyhow::Result<OperationId> {
1869 {
1870 let operation_id = OperationId(thread_rng().r#gen());
1871
1872 let withdraw_output =
1873 self.create_withdraw_output(operation_id, address.clone(), amount, fee)?;
1874 let tx_builder = TransactionBuilder::new()
1875 .with_outputs(self.client_ctx.make_client_outputs(withdraw_output));
1876
1877 let extra_meta =
1878 serde_json::to_value(extra_meta).expect("Failed to serialize extra meta");
1879 self.client_ctx
1880 .finalize_and_submit_transaction(
1881 operation_id,
1882 WalletCommonInit::KIND.as_str(),
1883 {
1884 let address = address.clone();
1885 move |change_range: OutPointRange| WalletOperationMeta {
1886 variant: WalletOperationMetaVariant::Withdraw {
1887 address: address.clone().into_unchecked(),
1888 amount,
1889 fee,
1890 change: change_range.into_iter().collect(),
1891 },
1892 extra_meta: extra_meta.clone(),
1893 }
1894 },
1895 tx_builder,
1896 )
1897 .await?;
1898
1899 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
1900
1901 self.client_ctx
1902 .log_event(
1903 &mut dbtx,
1904 SendPaymentEvent {
1905 operation_id,
1906 amount: amount + fee.amount(),
1907 fee: fee.amount(),
1908 },
1909 )
1910 .await;
1911
1912 dbtx.commit_tx().await;
1913
1914 Ok(operation_id)
1915 }
1916 }
1917
1918 #[deprecated(
1923 since = "0.4.0",
1924 note = "RBF withdrawals are rejected by the federation"
1925 )]
1926 pub async fn rbf_withdraw<M: Serialize + MaybeSync + MaybeSend>(
1927 &self,
1928 rbf: Rbf,
1929 extra_meta: M,
1930 ) -> anyhow::Result<OperationId> {
1931 let operation_id = OperationId(thread_rng().r#gen());
1932
1933 let withdraw_output = self.create_rbf_withdraw_output(operation_id, &rbf)?;
1934 let tx_builder = TransactionBuilder::new()
1935 .with_outputs(self.client_ctx.make_client_outputs(withdraw_output));
1936
1937 let extra_meta = serde_json::to_value(extra_meta).expect("Failed to serialize extra meta");
1938 self.client_ctx
1939 .finalize_and_submit_transaction(
1940 operation_id,
1941 WalletCommonInit::KIND.as_str(),
1942 move |change_range: OutPointRange| WalletOperationMeta {
1943 variant: WalletOperationMetaVariant::RbfWithdraw {
1944 rbf: rbf.clone(),
1945 change: change_range.into_iter().collect(),
1946 },
1947 extra_meta: extra_meta.clone(),
1948 },
1949 tx_builder,
1950 )
1951 .await?;
1952
1953 Ok(operation_id)
1954 }
1955
1956 pub async fn subscribe_withdraw_updates(
1957 &self,
1958 operation_id: OperationId,
1959 ) -> anyhow::Result<UpdateStreamOrOutcome<WithdrawState>> {
1960 let operation = self
1961 .client_ctx
1962 .get_operation(operation_id)
1963 .await
1964 .with_context(|| anyhow!("Operation not found: {}", operation_id.fmt_short()))?;
1965
1966 if operation.operation_module_kind() != WalletCommonInit::KIND.as_str() {
1967 bail!("Operation is not a wallet operation");
1968 }
1969
1970 let operation_meta = operation.meta::<WalletOperationMeta>();
1971
1972 let (WalletOperationMetaVariant::Withdraw { change, .. }
1973 | WalletOperationMetaVariant::RbfWithdraw { change, .. }) = operation_meta.variant
1974 else {
1975 bail!("Operation is not a withdraw operation");
1976 };
1977
1978 let mut operation_stream = self.notifier.subscribe(operation_id).await;
1979 let client_ctx = self.client_ctx.clone();
1980
1981 Ok(self.client_ctx.outcome_or_updates(
1982 &operation,
1983 operation_id,
1984 |state| match state {
1985 WithdrawState::Created => false,
1986 WithdrawState::Succeeded(_) | WithdrawState::Failed(_) => true,
1987 },
1988 move || {
1989 stream! {
1990 match next_withdraw_state(&mut operation_stream).await {
1991 Some(WithdrawStates::Created(_)) => {
1992 yield WithdrawState::Created;
1993 },
1994 Some(s) => {
1995 panic!("Unexpected state {s:?}")
1996 },
1997 None => return,
1998 }
1999
2000 let _ = client_ctx
2005 .await_primary_module_outputs(operation_id, change)
2006 .await;
2007
2008
2009 match next_withdraw_state(&mut operation_stream).await {
2010 Some(WithdrawStates::Aborted(inner)) => {
2011 yield WithdrawState::Failed(inner.error);
2012 },
2013 Some(WithdrawStates::Success(inner)) => {
2014 yield WithdrawState::Succeeded(inner.txid);
2015 },
2016 Some(s) => {
2017 panic!("Unexpected state {s:?}")
2018 },
2019 None => {},
2020 }
2021 }
2022 },
2023 ))
2024 }
2025
2026 fn admin_auth(&self) -> anyhow::Result<ApiAuth> {
2027 self.admin_auth
2028 .clone()
2029 .ok_or_else(|| anyhow::format_err!("Admin auth not set"))
2030 }
2031
2032 pub async fn activate_consensus_version_voting(&self) -> anyhow::Result<()> {
2033 self.module_api
2034 .activate_consensus_version_voting(self.admin_auth()?)
2035 .await?;
2036
2037 Ok(())
2038 }
2039}
2040
2041async fn poll_supports_safe_deposit_version(db: Database, module_api: DynModuleApi) {
2044 loop {
2045 let mut dbtx = db.begin_transaction().await;
2046
2047 if dbtx.get_value(&SupportsSafeDepositKey).await.is_some() {
2048 break;
2049 }
2050
2051 module_api.wait_for_initialized_connections().await;
2052
2053 if let Ok(module_consensus_version) = module_api.module_consensus_version().await
2054 && SAFE_DEPOSIT_MODULE_CONSENSUS_VERSION <= module_consensus_version
2055 {
2056 dbtx.insert_new_entry(&SupportsSafeDepositKey, &()).await;
2057 dbtx.commit_tx().await;
2058 break;
2059 }
2060
2061 drop(dbtx);
2062
2063 if is_running_in_test_env() {
2064 sleep(Duration::from_secs(10)).await;
2066 } else {
2067 sleep(Duration::from_hours(1)).await;
2068 }
2069 }
2070}
2071
2072async fn get_next_peg_in_tweak_child_id(dbtx: &mut DatabaseTransaction<'_>) -> TweakIdx {
2074 let index = dbtx
2075 .get_value(&NextPegInTweakIndexKey)
2076 .await
2077 .unwrap_or_default();
2078 dbtx.insert_entry(&NextPegInTweakIndexKey, &(index.next()))
2079 .await;
2080 index
2081}
2082
2083#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2084pub enum WalletClientStates {
2085 Deposit(DepositStateMachine),
2086 Withdraw(WithdrawStateMachine),
2087}
2088
2089impl IntoDynInstance for WalletClientStates {
2090 type DynType = DynState;
2091
2092 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
2093 DynState::from_typed(instance_id, self)
2094 }
2095}
2096
2097impl State for WalletClientStates {
2098 type ModuleContext = WalletClientContext;
2099
2100 fn transitions(
2101 &self,
2102 context: &Self::ModuleContext,
2103 global_context: &DynGlobalClientContext,
2104 ) -> Vec<StateTransition<Self>> {
2105 match self {
2106 WalletClientStates::Deposit(sm) => {
2107 sm_enum_variant_translation!(
2108 sm.transitions(context, global_context),
2109 WalletClientStates::Deposit
2110 )
2111 }
2112 WalletClientStates::Withdraw(sm) => {
2113 sm_enum_variant_translation!(
2114 sm.transitions(context, global_context),
2115 WalletClientStates::Withdraw
2116 )
2117 }
2118 }
2119 }
2120
2121 fn operation_id(&self) -> OperationId {
2122 match self {
2123 WalletClientStates::Deposit(sm) => sm.operation_id(),
2124 WalletClientStates::Withdraw(sm) => sm.operation_id(),
2125 }
2126 }
2127}
2128
2129#[cfg(all(test, not(target_family = "wasm")))]
2130mod tests {
2131 use std::collections::BTreeSet;
2132 use std::sync::atomic::{AtomicBool, Ordering};
2133
2134 use super::*;
2135 use crate::backup::{
2136 RECOVER_NUM_IDX_ADD_TO_LAST_USED, RecoverScanOutcome, recover_scan_idxes_for_activity,
2137 };
2138
2139 #[allow(clippy::too_many_lines)] #[tokio::test(flavor = "multi_thread")]
2141 async fn sanity_test_recover_inner() {
2142 {
2143 let last_checked = AtomicBool::new(false);
2144 let last_checked = &last_checked;
2145 assert_eq!(
2146 recover_scan_idxes_for_activity(
2147 TweakIdx(0),
2148 &BTreeSet::new(),
2149 |cur_idx| async move {
2150 Ok(match cur_idx {
2151 TweakIdx(9) => {
2152 last_checked.store(true, Ordering::SeqCst);
2153 vec![]
2154 }
2155 TweakIdx(10) => panic!("Shouldn't happen"),
2156 TweakIdx(11) => {
2157 vec![0usize] }
2159 _ => vec![],
2160 })
2161 }
2162 )
2163 .await
2164 .unwrap(),
2165 RecoverScanOutcome {
2166 last_used_idx: None,
2167 new_start_idx: TweakIdx(RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2168 tweak_idxes_with_pegins: BTreeSet::from([])
2169 }
2170 );
2171 assert!(last_checked.load(Ordering::SeqCst));
2172 }
2173
2174 {
2175 let last_checked = AtomicBool::new(false);
2176 let last_checked = &last_checked;
2177 assert_eq!(
2178 recover_scan_idxes_for_activity(
2179 TweakIdx(0),
2180 &BTreeSet::from([TweakIdx(1), TweakIdx(2)]),
2181 |cur_idx| async move {
2182 Ok(match cur_idx {
2183 TweakIdx(1) => panic!("Shouldn't happen: already used (1)"),
2184 TweakIdx(2) => panic!("Shouldn't happen: already used (2)"),
2185 TweakIdx(11) => {
2186 last_checked.store(true, Ordering::SeqCst);
2187 vec![]
2188 }
2189 TweakIdx(12) => panic!("Shouldn't happen"),
2190 TweakIdx(13) => {
2191 vec![0usize] }
2193 _ => vec![],
2194 })
2195 }
2196 )
2197 .await
2198 .unwrap(),
2199 RecoverScanOutcome {
2200 last_used_idx: Some(TweakIdx(2)),
2201 new_start_idx: TweakIdx(2 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2202 tweak_idxes_with_pegins: BTreeSet::from([])
2203 }
2204 );
2205 assert!(last_checked.load(Ordering::SeqCst));
2206 }
2207
2208 {
2209 let last_checked = AtomicBool::new(false);
2210 let last_checked = &last_checked;
2211 assert_eq!(
2212 recover_scan_idxes_for_activity(
2213 TweakIdx(10),
2214 &BTreeSet::new(),
2215 |cur_idx| async move {
2216 Ok(match cur_idx {
2217 TweakIdx(10) => vec![()],
2218 TweakIdx(19) => {
2219 last_checked.store(true, Ordering::SeqCst);
2220 vec![]
2221 }
2222 TweakIdx(20) => panic!("Shouldn't happen"),
2223 _ => vec![],
2224 })
2225 }
2226 )
2227 .await
2228 .unwrap(),
2229 RecoverScanOutcome {
2230 last_used_idx: Some(TweakIdx(10)),
2231 new_start_idx: TweakIdx(10 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2232 tweak_idxes_with_pegins: BTreeSet::from([TweakIdx(10)])
2233 }
2234 );
2235 assert!(last_checked.load(Ordering::SeqCst));
2236 }
2237
2238 assert_eq!(
2239 recover_scan_idxes_for_activity(TweakIdx(0), &BTreeSet::new(), |cur_idx| async move {
2240 Ok(match cur_idx {
2241 TweakIdx(6 | 15) => vec![()],
2242 _ => vec![],
2243 })
2244 })
2245 .await
2246 .unwrap(),
2247 RecoverScanOutcome {
2248 last_used_idx: Some(TweakIdx(15)),
2249 new_start_idx: TweakIdx(15 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2250 tweak_idxes_with_pegins: BTreeSet::from([TweakIdx(6), TweakIdx(15)])
2251 }
2252 );
2253 assert_eq!(
2254 recover_scan_idxes_for_activity(TweakIdx(10), &BTreeSet::new(), |cur_idx| async move {
2255 Ok(match cur_idx {
2256 TweakIdx(8) => {
2257 vec![()] }
2259 TweakIdx(9) => {
2260 panic!("Shouldn't happen")
2261 }
2262 _ => vec![],
2263 })
2264 })
2265 .await
2266 .unwrap(),
2267 RecoverScanOutcome {
2268 last_used_idx: None,
2269 new_start_idx: TweakIdx(9 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2270 tweak_idxes_with_pegins: BTreeSet::from([])
2271 }
2272 );
2273 assert_eq!(
2274 recover_scan_idxes_for_activity(TweakIdx(10), &BTreeSet::new(), |cur_idx| async move {
2275 Ok(match cur_idx {
2276 TweakIdx(9) => panic!("Shouldn't happen"),
2277 TweakIdx(15) => vec![()],
2278 _ => vec![],
2279 })
2280 })
2281 .await
2282 .unwrap(),
2283 RecoverScanOutcome {
2284 last_used_idx: Some(TweakIdx(15)),
2285 new_start_idx: TweakIdx(15 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2286 tweak_idxes_with_pegins: BTreeSet::from([TweakIdx(15)])
2287 }
2288 );
2289 }
2290}