Skip to main content

fedimint_wallet_client/
lib.rs

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;
18/// Legacy, state-machine based peg-ins, replaced by `pegin_monitor`
19/// but retained for time being to ensure existing peg-ins complete.
20mod deposit;
21/// Error types of the wallet client.
22pub mod error;
23pub mod events;
24use events::SendPaymentEvent;
25#[cfg(feature = "uniffi")]
26pub mod ffi;
27/// Peg-in monitor: a task monitoring deposit addresses for peg-ins.
28mod pegin_monitor;
29mod withdraw;
30
31use std::collections::{BTreeMap, BTreeSet};
32use std::future;
33use std::sync::Arc;
34use std::time::{Duration, SystemTime};
35
36use async_stream::{stream, try_stream};
37use backup::WalletModuleBackup;
38use bitcoin::address::NetworkUnchecked;
39use bitcoin::secp256k1::{All, SECP256K1, Secp256k1};
40use bitcoin::{Address, Network, ScriptBuf};
41use client_db::{DbKeyPrefix, PegInTweakIndexKey, SupportsSafeDepositKey, TweakIdx};
42use fedimint_api_client::api::{DynModuleApi, FederationResult};
43use fedimint_bitcoind::{
44    BitcoinRpcError, BitcoindTracked, DynBitcoindRpc, IBitcoindRpc, create_esplora_rpc,
45};
46use fedimint_client_module::error::{ClientModuleError, TransactionSubmitError};
47use fedimint_client_module::module::init::{
48    ClientModuleInit, ClientModuleInitArgs, ClientModuleRecoverArgs, RecoveryMode,
49};
50use fedimint_client_module::module::recovery::RecoveryProgress;
51use fedimint_client_module::module::{ClientContext, ClientModule, IClientModule, OutPointRange};
52use fedimint_client_module::oplog::UpdateStreamOrOutcome;
53use fedimint_client_module::sm::{Context, DynState, ModuleNotifier, State, StateTransition};
54use fedimint_client_module::transaction::{
55    ClientOutput, ClientOutputBundle, ClientOutputSM, FeeQuote, FeeQuoteRequest,
56    TransactionBuilder, max_affordable_send_amount,
57};
58use fedimint_client_module::{DynGlobalClientContext, sm_enum_variant_translation};
59use fedimint_core::core::{Decoder, IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
60use fedimint_core::db::{
61    Committable, Database, DatabaseError, DatabaseTransaction, IDatabaseTransactionOpsCoreTyped,
62};
63use fedimint_core::encoding::{Decodable, Encodable};
64use fedimint_core::envs::{BitcoinRpcConfig, is_running_in_test_env};
65use fedimint_core::module::{
66    Amounts, ApiAuth, ApiVersion, CommonModuleInit, ModuleCommon, ModuleConsensusVersion,
67    ModuleInit, MultiApiVersion,
68};
69use fedimint_core::task::{MaybeSend, MaybeSync, TaskGroup, sleep};
70use fedimint_core::util::backoff_util::background_backoff;
71use fedimint_core::util::{BoxStream, FmtCompact as _, backoff_util, retry};
72use fedimint_core::{
73    BitcoinHash, OutPoint, TransactionId, apply, async_trait_maybe_send, push_db_pair_items,
74    runtime, secp256k1,
75};
76use fedimint_derive_secret::{ChildId, DerivableSecret};
77use fedimint_logging::LOG_CLIENT_MODULE_WALLET;
78pub use fedimint_wallet_common as common;
79use fedimint_wallet_common::config::{FeeConsensus, WalletClientConfig};
80use fedimint_wallet_common::tweakable::Tweakable;
81pub use fedimint_wallet_common::*;
82use futures::{Stream, StreamExt, TryStreamExt as _};
83use rand::{Rng, thread_rng};
84use secp256k1::Keypair;
85use serde::{Deserialize, Serialize};
86use strum::IntoEnumIterator;
87use tokio::sync::watch;
88use tracing::{debug, instrument, warn};
89
90use crate::api::WalletFederationApi;
91use crate::backup::{FEDERATION_RECOVER_MAX_GAP, RecoveryStateV2, WalletRecovery};
92use crate::client_db::{
93    ClaimedPegInData, ClaimedPegInKey, ClaimedPegInPrefix, NextPegInTweakIndexKey,
94    PegInPoolCursorKey, PegInTweakIndexData, PegInTweakIndexPrefix, RecoveryFinalizedKey,
95    RecoveryStateKey, SupportsSafeDepositPrefix,
96};
97use crate::deposit::DepositStateMachine;
98pub use crate::error::{
99    ConsensusVersionVotingError, DepositAddressError, MaxWithdrawableAmountError, PegInError,
100    PegOutError, SubscribeDepositError, SubscribeWithdrawError, WithdrawFeesError,
101};
102use crate::withdraw::{CreatedWithdrawState, WithdrawStateMachine, WithdrawStates};
103
104const WALLET_TWEAK_CHILD_ID: ChildId = ChildId(0);
105
106#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
107pub struct BitcoinTransactionData {
108    /// The bitcoin transaction is saved as soon as we see it so the transaction
109    /// can be re-transmitted if it's evicted from the mempool.
110    pub btc_transaction: bitcoin::Transaction,
111    /// Index of the deposit output
112    pub out_idx: u32,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
116pub enum DepositStateV1 {
117    WaitingForTransaction,
118    WaitingForConfirmation(BitcoinTransactionData),
119    Confirmed(BitcoinTransactionData),
120    Claimed(BitcoinTransactionData),
121    Failed(String),
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
125#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
126pub enum DepositStateV2 {
127    WaitingForTransaction,
128    WaitingForConfirmation {
129        #[serde(with = "bitcoin::amount::serde::as_sat")]
130        btc_deposited: bitcoin::Amount,
131        btc_out_point: bitcoin::OutPoint,
132    },
133    Confirmed {
134        #[serde(with = "bitcoin::amount::serde::as_sat")]
135        btc_deposited: bitcoin::Amount,
136        btc_out_point: bitcoin::OutPoint,
137    },
138    Claimed {
139        #[serde(with = "bitcoin::amount::serde::as_sat")]
140        btc_deposited: bitcoin::Amount,
141        btc_out_point: bitcoin::OutPoint,
142    },
143    Failed(String),
144}
145
146/// Deposit address allocated by this client.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct DepositAddressInfo {
149    pub operation_id: OperationId,
150    pub address: Address,
151    pub tweak_idx: TweakIdx,
152}
153
154/// Result of [`WalletClientModule::allocate_deposit_address_pooled_stateless`].
155///
156/// Callers that need a custom address-picking strategy can use
157/// [`MaybeNewAddress::TooManyUnusedAddresses`] to see all currently reusable
158/// addresses instead of relying on this crate's round-robin policy.
159#[allow(clippy::enum_variant_names)]
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum MaybeNewAddress {
162    /// A new tweak/operation was created on this call.
163    NewAddress(DepositAddressInfo),
164    /// The unused-address gap is full. No new address was allocated.
165    ///
166    /// Reusable unused addresses are ordered by `creation_time` ascending.
167    TooManyUnusedAddresses(Vec<DepositAddressInfo>),
168}
169
170/// Outcome of [`WalletClientModule::allocate_deposit_address_pooled`], so
171/// callers can decide whether to perform per-operation initialization (notes,
172/// fee bookkeeping, metadata writes) — for `Reused` returns, that work was
173/// already done at the time of the original `Fresh` allocation and must not be
174/// repeated.
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum AllocateDepositOutcome {
177    /// A new tweak/operation was created on this call.
178    Fresh,
179    /// An existing unused address was returned. The carried `TweakIdx` is the
180    /// same as the one returned in the tuple and refers to the original
181    /// allocation; it's exposed here as well for explicit diagnostic use.
182    Reused { original_tweak_idx: TweakIdx },
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
186#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
187pub enum WithdrawState {
188    Created,
189    Succeeded(bitcoin::Txid),
190    Failed(String),
191    // TODO: track refund
192    // Refunded,
193    // RefundFailed(String),
194}
195
196async fn next_withdraw_state<S>(stream: &mut S) -> Option<WithdrawStates>
197where
198    S: Stream<Item = WalletClientStates> + Unpin,
199{
200    loop {
201        if let WalletClientStates::Withdraw(ds) = stream.next().await? {
202            return Some(ds.state);
203        }
204        tokio::task::yield_now().await;
205    }
206}
207
208#[derive(Debug, Clone, Default)]
209// TODO: should probably move to DB
210pub struct WalletClientInit(pub Option<DynBitcoindRpc>);
211
212const SLICE_SIZE: u64 = 1000;
213
214impl WalletClientInit {
215    pub fn new(rpc: DynBitcoindRpc) -> Self {
216        Self(Some(rpc))
217    }
218
219    async fn recover_from_slices(
220        &self,
221        args: &ClientModuleRecoverArgs<Self>,
222        total_items: u64,
223    ) -> Option<fedimint_core::Amount> {
224        let data = WalletClientModuleData {
225            cfg: args.cfg().clone(),
226            module_root_secret: args.module_root_secret().clone(),
227        };
228
229        let mut state = RecoveryStateV2::new();
230
231        state.refill_pending_pool_up_to(&data, TweakIdx(FEDERATION_RECOVER_MAX_GAP));
232
233        for start in (0..total_items).step_by(SLICE_SIZE as usize) {
234            let end = std::cmp::min(start + SLICE_SIZE, total_items);
235
236            let items = args.module_api().fetch_recovery_slice(start, end).await;
237
238            for item in &items {
239                match item {
240                    RecoveryItem::Input { outpoint, script } => {
241                        state.handle_item(*outpoint, script, &data);
242                    }
243                }
244            }
245
246            args.update_recovery_progress(RecoveryProgress {
247                complete: end.try_into().unwrap_or(u32::MAX),
248                total: total_items.try_into().unwrap_or(u32::MAX),
249            });
250        }
251
252        let mut dbtx = args.db().begin_transaction().await;
253
254        for tweak_idx in 0..state.new_start_idx().0 {
255            let operation_id = data.derive_peg_in_script(TweakIdx(tweak_idx)).3;
256
257            let claimed = state
258                .claimed_outpoints
259                .get(&TweakIdx(tweak_idx))
260                .cloned()
261                .unwrap_or_default();
262
263            dbtx.insert_new_entry(
264                &PegInTweakIndexKey(TweakIdx(tweak_idx)),
265                &PegInTweakIndexData {
266                    operation_id,
267                    creation_time: fedimint_core::time::now(),
268                    last_check_time: None,
269                    next_check_time: Some(fedimint_core::time::now()),
270                    claimed,
271                },
272            )
273            .await;
274        }
275
276        dbtx.insert_new_entry(&NextPegInTweakIndexKey, &state.new_start_idx())
277            .await;
278
279        dbtx.commit_tx().await;
280
281        // The wallet only discovers which on-chain outputs belonged to the
282        // client during recovery; their value isn't known until the deposits
283        // are later claimed, so no amount is reported here.
284        None
285    }
286}
287
288impl ModuleInit for WalletClientInit {
289    type Common = WalletCommonInit;
290
291    async fn dump_database(
292        &self,
293        dbtx: &mut DatabaseTransaction<'_>,
294        prefix_names: Vec<String>,
295    ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
296        let mut wallet_client_items: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> =
297            BTreeMap::new();
298        let filtered_prefixes = DbKeyPrefix::iter().filter(|f| {
299            prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase())
300        });
301
302        for table in filtered_prefixes {
303            match table {
304                DbKeyPrefix::NextPegInTweakIndex => {
305                    if let Some(index) = dbtx.get_value(&NextPegInTweakIndexKey).await {
306                        wallet_client_items
307                            .insert("NextPegInTweakIndex".to_string(), Box::new(index));
308                    }
309                }
310                DbKeyPrefix::PegInTweakIndex => {
311                    push_db_pair_items!(
312                        dbtx,
313                        PegInTweakIndexPrefix,
314                        PegInTweakIndexKey,
315                        PegInTweakIndexData,
316                        wallet_client_items,
317                        "Peg-In Tweak Index"
318                    );
319                }
320                DbKeyPrefix::ClaimedPegIn => {
321                    push_db_pair_items!(
322                        dbtx,
323                        ClaimedPegInPrefix,
324                        ClaimedPegInKey,
325                        ClaimedPegInData,
326                        wallet_client_items,
327                        "Claimed Peg-In"
328                    );
329                }
330                DbKeyPrefix::RecoveryFinalized => {
331                    if let Some(val) = dbtx.get_value(&RecoveryFinalizedKey).await {
332                        wallet_client_items.insert("RecoveryFinalized".to_string(), Box::new(val));
333                    }
334                }
335                DbKeyPrefix::SupportsSafeDeposit => {
336                    push_db_pair_items!(
337                        dbtx,
338                        SupportsSafeDepositPrefix,
339                        SupportsSafeDepositKey,
340                        (),
341                        wallet_client_items,
342                        "Supports Safe Deposit"
343                    );
344                }
345                DbKeyPrefix::PegInPoolCursor => {
346                    if let Some(cursor) = dbtx.get_value(&PegInPoolCursorKey).await {
347                        wallet_client_items.insert("PegInPoolCursor".to_string(), Box::new(cursor));
348                    }
349                }
350                DbKeyPrefix::RecoveryState
351                | DbKeyPrefix::ExternalReservedStart
352                | DbKeyPrefix::CoreInternalReservedStart
353                | DbKeyPrefix::CoreInternalReservedEnd => {}
354            }
355        }
356
357        Box::new(wallet_client_items.into_iter())
358    }
359}
360
361#[apply(async_trait_maybe_send!)]
362impl ClientModuleInit for WalletClientInit {
363    type Module = WalletClientModule;
364
365    fn supported_api_versions(&self) -> MultiApiVersion {
366        MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
367            .expect("no version conflicts")
368    }
369
370    async fn init(
371        &self,
372        args: &ClientModuleInitArgs<Self>,
373    ) -> Result<Self::Module, ClientModuleError> {
374        let data = WalletClientModuleData {
375            cfg: args.cfg().clone(),
376            module_root_secret: args.module_root_secret().clone(),
377        };
378
379        let db = args.db().clone();
380
381        let rpc_config = WalletClientModule::get_rpc_config(args.cfg());
382
383        // Priority:
384        // 1. user-provided bitcoind RPC from ClientBuilder::with_bitcoind_rpc
385        // 2. user-provided no-chain-id factory from
386        //    ClientBuilder::with_bitcoind_rpc_no_chain_id
387        // 3. WalletClientInit constructor
388        // 4. create from config (esplora)
389        let btc_rpc = if let Some(user_rpc) = args.user_bitcoind_rpc() {
390            user_rpc.clone()
391        } else if let Some(factory) = args.user_bitcoind_rpc_no_chain_id() {
392            if let Some(rpc) = factory(rpc_config.url.clone()).await {
393                rpc
394            } else {
395                self.0.clone().unwrap_or(
396                    create_esplora_rpc(&rpc_config.url).map_err(ClientModuleError::other)?,
397                )
398            }
399        } else {
400            self.0
401                .clone()
402                .unwrap_or(create_esplora_rpc(&rpc_config.url).map_err(ClientModuleError::other)?)
403        };
404        let btc_rpc = BitcoindTracked::new(btc_rpc, "wallet-client").into_dyn();
405
406        let module_api = args.module_api().clone();
407
408        let (pegin_claimed_sender, pegin_claimed_receiver) = watch::channel(());
409        let (pegin_monitor_wakeup_sender, pegin_monitor_wakeup_receiver) = watch::channel(());
410
411        Ok(WalletClientModule {
412            db,
413            data,
414            module_api,
415            notifier: args.notifier().clone(),
416            rpc: btc_rpc,
417            client_ctx: args.context(),
418            pegin_monitor_wakeup_sender,
419            pegin_monitor_wakeup_receiver,
420            pegin_claimed_receiver,
421            pegin_claimed_sender,
422            task_group: args.task_group().clone(),
423            client_span: args.client_span().clone(),
424            admin_auth: args.admin_auth().cloned(),
425        })
426    }
427
428    fn recovery_mode(&self) -> RecoveryMode {
429        RecoveryMode::Unusable
430    }
431
432    /// Wallet recovery
433    ///
434    /// Uses slice-based recovery if supported by the federation, otherwise
435    /// falls back to session-based history recovery.
436    async fn recover(
437        &self,
438        args: &ClientModuleRecoverArgs<Self>,
439        snapshot: Option<&<Self::Module as ClientModule>::Backup>,
440    ) -> Result<Option<fedimint_core::Amount>, ClientModuleError> {
441        // Check if V1 (session-based) recovery state exists (resuming interrupted
442        // recovery)
443        if args
444            .db()
445            .begin_transaction_nc()
446            .await
447            .get_value(&RecoveryStateKey)
448            .await
449            .is_some()
450        {
451            return args
452                .recover_from_history::<WalletRecovery>(self, snapshot)
453                .await;
454        }
455
456        // Determine which method to use based on endpoint availability. The
457        // count fetched here is reused by the slice recovery so that it is not
458        // requested a second time (and cannot transiently fail there).
459        match args.module_api().fetch_recovery_count().await {
460            Ok(total_items) => Ok(self.recover_from_slices(args, total_items).await),
461            Err(_) => {
462                args.recover_from_history::<WalletRecovery>(self, snapshot)
463                    .await
464            }
465        }
466    }
467
468    fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
469        Some(
470            DbKeyPrefix::iter()
471                .map(|p| p as u8)
472                .chain(
473                    DbKeyPrefix::ExternalReservedStart as u8
474                        ..=DbKeyPrefix::CoreInternalReservedEnd as u8,
475                )
476                .collect(),
477        )
478    }
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct WalletOperationMeta {
483    pub variant: WalletOperationMetaVariant,
484    pub extra_meta: serde_json::Value,
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize)]
488#[serde(rename_all = "snake_case")]
489pub enum WalletOperationMetaVariant {
490    Deposit {
491        address: Address<NetworkUnchecked>,
492        /// Added in 0.4.2, can be `None` for old deposits or `Some` for ones
493        /// using the pegin monitor. The value is the child index of the key
494        /// used to generate the address, so we can re-generate the secret key
495        /// from our root secret.
496        #[serde(default)]
497        tweak_idx: Option<TweakIdx>,
498        #[serde(default, skip_serializing_if = "Option::is_none")]
499        expires_at: Option<SystemTime>,
500    },
501    Withdraw {
502        address: Address<NetworkUnchecked>,
503        #[serde(with = "bitcoin::amount::serde::as_sat")]
504        amount: bitcoin::Amount,
505        fee: PegOutFees,
506        change: Vec<OutPoint>,
507    },
508
509    RbfWithdraw {
510        rbf: Rbf,
511        change: Vec<OutPoint>,
512    },
513}
514
515/// The non-resource, just plain-data parts of [`WalletClientModule`]
516#[derive(Debug, Clone)]
517pub struct WalletClientModuleData {
518    cfg: WalletClientConfig,
519    module_root_secret: DerivableSecret,
520}
521
522impl WalletClientModuleData {
523    fn derive_deposit_address(
524        &self,
525        idx: TweakIdx,
526    ) -> (Keypair, secp256k1::PublicKey, Address, OperationId) {
527        let idx = ChildId(idx.0);
528
529        let secret_tweak_key = self
530            .module_root_secret
531            .child_key(WALLET_TWEAK_CHILD_ID)
532            .child_key(idx)
533            .to_secp_key(fedimint_core::secp256k1::SECP256K1);
534
535        let public_tweak_key = secret_tweak_key.public_key();
536
537        let address = self
538            .cfg
539            .peg_in_descriptor
540            .tweak(&public_tweak_key, bitcoin::secp256k1::SECP256K1)
541            .address(self.cfg.network.0)
542            .unwrap();
543
544        // TODO: make hash?
545        let operation_id = OperationId(public_tweak_key.x_only_public_key().0.serialize());
546
547        (secret_tweak_key, public_tweak_key, address, operation_id)
548    }
549
550    fn derive_peg_in_script(
551        &self,
552        idx: TweakIdx,
553    ) -> (ScriptBuf, bitcoin::Address, Keypair, OperationId) {
554        let (secret_tweak_key, _, address, operation_id) = self.derive_deposit_address(idx);
555
556        (
557            self.cfg
558                .peg_in_descriptor
559                .tweak(&secret_tweak_key.public_key(), SECP256K1)
560                .script_pubkey(),
561            address,
562            secret_tweak_key,
563            operation_id,
564        )
565    }
566}
567
568#[derive(Debug)]
569#[cfg_attr(feature = "uniffi", derive(uniffi::Object))]
570pub struct WalletClientModule {
571    data: WalletClientModuleData,
572    db: Database,
573    module_api: DynModuleApi,
574    notifier: ModuleNotifier<WalletClientStates>,
575    rpc: DynBitcoindRpc,
576    client_ctx: ClientContext<Self>,
577    /// Updated to wake up pegin monitor
578    pegin_monitor_wakeup_sender: watch::Sender<()>,
579    pegin_monitor_wakeup_receiver: watch::Receiver<()>,
580    /// Called every time a peg-in was claimed
581    pegin_claimed_sender: watch::Sender<()>,
582    pegin_claimed_receiver: watch::Receiver<()>,
583    task_group: TaskGroup,
584    client_span: tracing::Span,
585    admin_auth: Option<ApiAuth>,
586}
587
588#[apply(async_trait_maybe_send!)]
589impl ClientModule for WalletClientModule {
590    type Init = WalletClientInit;
591    type Common = WalletModuleTypes;
592    type Backup = WalletModuleBackup;
593    type ModuleStateMachineContext = WalletClientContext;
594    type States = WalletClientStates;
595
596    fn context(&self) -> Self::ModuleStateMachineContext {
597        WalletClientContext {
598            rpc: self.rpc.clone(),
599            wallet_descriptor: self.cfg().peg_in_descriptor.clone(),
600            wallet_decoder: self.decoder(),
601            secp: Secp256k1::default(),
602            client_ctx: self.client_ctx.clone(),
603        }
604    }
605
606    async fn start(&self) {
607        self.task_group
608            .spawn_cancellable_with_span(self.client_span.clone(), "peg-in monitor", {
609                let client_ctx = self.client_ctx.clone();
610                let db = self.db.clone();
611                let btc_rpc = self.rpc.clone();
612                let module_api = self.module_api.clone();
613                let data = self.data.clone();
614                let pegin_claimed_sender = self.pegin_claimed_sender.clone();
615                let pegin_monitor_wakeup_receiver = self.pegin_monitor_wakeup_receiver.clone();
616                pegin_monitor::run_peg_in_monitor(
617                    client_ctx,
618                    db,
619                    btc_rpc,
620                    module_api,
621                    data,
622                    pegin_claimed_sender,
623                    pegin_monitor_wakeup_receiver,
624                )
625            });
626
627        self.task_group.spawn_cancellable_with_span(
628            self.client_span.clone(),
629            "supports-safe-deposit-version",
630            {
631                let db = self.db.clone();
632                let module_api = self.module_api.clone();
633
634                poll_supports_safe_deposit_version(db, module_api)
635            },
636        );
637    }
638
639    fn supports_backup(&self) -> bool {
640        true
641    }
642
643    async fn backup(&self) -> Result<backup::WalletModuleBackup, ClientModuleError> {
644        // fetch consensus height first
645        let session_count = self
646            .client_ctx
647            .global_api()
648            .session_count()
649            .await
650            .map_err(ClientModuleError::other)?;
651
652        let mut dbtx = self.db.begin_transaction_nc().await;
653        let next_pegin_tweak_idx = dbtx
654            .get_value(&NextPegInTweakIndexKey)
655            .await
656            .unwrap_or_default();
657        let claimed = dbtx
658            .find_by_prefix(&PegInTweakIndexPrefix)
659            .await
660            .filter_map(|(k, v)| async move {
661                if v.claimed.is_empty() {
662                    None
663                } else {
664                    Some(k.0)
665                }
666            })
667            .collect()
668            .await;
669        Ok(backup::WalletModuleBackup::new_v1(
670            session_count,
671            next_pegin_tweak_idx,
672            claimed,
673        ))
674    }
675
676    fn input_fee(
677        &self,
678        _amount: &Amounts,
679        _input: &<Self::Common as ModuleCommon>::Input,
680    ) -> Option<Amounts> {
681        Some(Amounts::new_bitcoin(self.cfg().fee_consensus.peg_in_abs))
682    }
683
684    fn output_fee(
685        &self,
686        _amount: &Amounts,
687        _output: &<Self::Common as ModuleCommon>::Output,
688    ) -> Option<Amounts> {
689        Some(Amounts::new_bitcoin(self.cfg().fee_consensus.peg_out_abs))
690    }
691
692    async fn handle_rpc(
693        &self,
694        method: String,
695        request: serde_json::Value,
696    ) -> BoxStream<'_, Result<serde_json::Value, ClientModuleError>> {
697        let stream: BoxStream<'_, Result<serde_json::Value, RpcError>> = Box::pin(try_stream! {
698            match method.as_str() {
699                "get_wallet_summary" => {
700                    let _req: WalletSummaryRequest = serde_json::from_value(request)?;
701                    let wallet_summary = self.get_wallet_summary()
702                        .await
703                        .expect("Failed to fetch wallet summary");
704                    let result = serde_json::to_value(&wallet_summary)
705                        .expect("Serialization error");
706                    yield result;
707                }
708                "get_block_count_local" => {
709                    let block_count = self.get_block_count_local().await
710                        .expect("Failed to fetch block count");
711                    yield serde_json::to_value(block_count)?;
712                }
713                "peg_in" => {
714                    let req: PegInRequest = serde_json::from_value(request)?;
715                    let response = self.peg_in(req)
716                        .await
717                        .map_err(RpcError::PegIn)?;
718                    let result = serde_json::to_value(&response)?;
719                    yield result;
720                },
721                "peg_out" => {
722                    let req: PegOutRequest = serde_json::from_value(request)?;
723                    let response = self.peg_out(req)
724                        .await
725                        .map_err(RpcError::PegOut)?;
726                    let result = serde_json::to_value(&response)?;
727                    yield result;
728                },
729                "subscribe_deposit" => {
730                    let req: SubscribeDepositRequest = serde_json::from_value(request)?;
731                    for await state in self.subscribe_deposit(req.operation_id).await?.into_stream() {
732                        yield serde_json::to_value(state)?;
733                    }
734                },
735                "subscribe_withdraw" => {
736                    let req: SubscribeWithdrawRequest = serde_json::from_value(request)?;
737                    for await state in self.subscribe_withdraw_updates(req.operation_id).await?.into_stream(){
738                        yield serde_json::to_value(state)?;
739                    }
740                }
741                _ => {
742                    Err(RpcError::UnknownMethod { method: method.clone() })?;
743                }
744            }
745        });
746        Box::pin(stream.map_err(ClientModuleError::other))
747    }
748
749    #[cfg(feature = "cli")]
750    async fn handle_cli_command(
751        &self,
752        args: &[std::ffi::OsString],
753    ) -> Result<serde_json::Value, ClientModuleError> {
754        cli::handle_cli_command(self, args)
755            .await
756            .map_err(ClientModuleError::other)
757    }
758}
759
760/// A failure of a wallet module RPC request.
761#[derive(Debug, thiserror::Error)]
762enum RpcError {
763    /// The request's parameters do not fit the method, or its response could
764    /// not be serialized.
765    #[error(transparent)]
766    Json(#[from] serde_json::Error),
767
768    /// The peg-in could not be started.
769    #[error("peg_in failed")]
770    PegIn(#[source] DepositAddressError),
771
772    /// The peg-out could not be started.
773    #[error("peg_out failed")]
774    PegOut(#[source] PegOutError),
775
776    /// The deposit's updates could not be followed.
777    #[error(transparent)]
778    SubscribeDeposit(#[from] SubscribeDepositError),
779
780    /// The withdrawal's updates could not be followed.
781    #[error(transparent)]
782    SubscribeWithdraw(#[from] SubscribeWithdrawError),
783
784    /// The request names a method the module does not have.
785    #[error("Unknown method: {method}")]
786    UnknownMethod { method: String },
787}
788
789#[derive(Deserialize)]
790struct WalletSummaryRequest {}
791
792#[derive(Debug, Clone)]
793pub struct WalletClientContext {
794    rpc: DynBitcoindRpc,
795    wallet_descriptor: PegInDescriptor,
796    wallet_decoder: Decoder,
797    secp: Secp256k1<All>,
798    pub client_ctx: ClientContext<WalletClientModule>,
799}
800
801#[derive(Debug, Clone, Serialize, Deserialize)]
802pub struct PegInRequest {
803    pub extra_meta: serde_json::Value,
804}
805
806#[cfg(feature = "uniffi")]
807uniffi::custom_type!(PegInRequest, String, {
808    lower: |v| serde_json::to_string(&v).expect("PegInRequest serialization cannot fail"),
809    try_lift: |s| serde_json::from_str::<PegInRequest>(&s)
810        .map_err(|e| uniffi::deps::anyhow::anyhow!("Failed to parse PegInRequest: {e}")),
811});
812
813#[derive(Deserialize)]
814struct SubscribeDepositRequest {
815    operation_id: OperationId,
816}
817
818#[derive(Deserialize)]
819struct SubscribeWithdrawRequest {
820    operation_id: OperationId,
821}
822
823#[derive(Debug, Clone, Serialize, Deserialize)]
824#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
825pub struct PegInResponse {
826    pub deposit_address: Address<NetworkUnchecked>,
827    pub operation_id: OperationId,
828}
829
830#[derive(Debug, Clone, Serialize, Deserialize)]
831#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
832pub struct PegOutRequest {
833    pub amount_sat: u64,
834    pub destination_address: Address<NetworkUnchecked>,
835    pub extra_meta: serde_json::Value,
836}
837
838#[derive(Debug, Clone, Serialize, Deserialize)]
839#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
840pub struct PegOutResponse {
841    pub operation_id: OperationId,
842}
843
844impl Context for WalletClientContext {
845    const KIND: Option<ModuleKind> = Some(KIND);
846}
847
848impl WalletClientModule {
849    fn cfg(&self) -> &WalletClientConfig {
850        &self.data.cfg
851    }
852
853    fn get_rpc_config(cfg: &WalletClientConfig) -> BitcoinRpcConfig {
854        match BitcoinRpcConfig::get_defaults_from_env_vars() {
855            Ok(rpc_config) => {
856                // TODO: Wallet client cannot support bitcoind RPC until the bitcoin dep is
857                // updated to 0.30
858                if rpc_config.kind == "bitcoind" {
859                    cfg.default_bitcoin_rpc.clone()
860                } else {
861                    rpc_config
862                }
863            }
864            _ => cfg.default_bitcoin_rpc.clone(),
865        }
866    }
867
868    pub fn get_network(&self) -> Network {
869        self.cfg().network.0
870    }
871
872    pub fn get_finality_delay(&self) -> u32 {
873        self.cfg().finality_delay
874    }
875
876    pub fn get_fee_consensus(&self) -> FeeConsensus {
877        self.cfg().fee_consensus
878    }
879
880    async fn allocate_deposit_address_inner(
881        &self,
882        dbtx: &mut DatabaseTransaction<'_>,
883    ) -> DepositAddressInfo {
884        dbtx.ensure_isolated().expect("Must be isolated db");
885
886        let tweak_idx = get_next_peg_in_tweak_child_id(dbtx).await;
887        let (_secret_tweak_key, _, address, operation_id) =
888            self.data.derive_deposit_address(tweak_idx);
889
890        let now = fedimint_core::time::now();
891
892        dbtx.insert_new_entry(
893            &PegInTweakIndexKey(tweak_idx),
894            &PegInTweakIndexData {
895                creation_time: now,
896                next_check_time: Some(now),
897                last_check_time: None,
898                operation_id,
899                claimed: vec![],
900            },
901        )
902        .await;
903
904        DepositAddressInfo {
905            operation_id,
906            address,
907            tweak_idx,
908        }
909    }
910
911    /// Fetches the fees that would need to be paid to make the withdraw request
912    /// using [`Self::withdraw`] work *right now*.
913    ///
914    /// Note that we do not receive a guarantee that these fees will be valid in
915    /// the future, thus even the next second using these fees *may* fail.
916    /// The caller should be prepared to retry with a new fee estimate.
917    pub async fn get_withdraw_fees(
918        &self,
919        address: &bitcoin::Address,
920        amount: bitcoin::Amount,
921    ) -> Result<PegOutFees, WithdrawFeesError> {
922        self.module_api
923            .fetch_peg_out_fees(address, amount)
924            .await?
925            .ok_or(WithdrawFeesError::NoQuote)
926    }
927
928    /// Computes the federation fee a peg-out of an on-chain output worth
929    /// `amount` (the withdrawal amount plus the on-chain miner fee) would
930    /// incur, without submitting anything.
931    ///
932    /// A peg-out submits a single wallet output worth `amount`; the primary
933    /// module balances it by spending ecash to fund the output and minting any
934    /// change. This quotes the fee of that transaction — the wallet (peg-out)
935    /// output fee, the mint input fees on the funding notes, any mint change
936    /// output fees, and sub-denomination dust — via the shared, module-agnostic
937    /// fee quote.
938    ///
939    /// The on-chain Bitcoin miner fee is deliberately excluded: it is part of
940    /// the output `amount` (see [`Self::get_withdraw_fees`]), not the
941    /// on-federation transaction fee.
942    pub async fn send_fee_quote(
943        &self,
944        amount: bitcoin::Amount,
945    ) -> Result<FeeQuote, TransactionSubmitError> {
946        let amount = fedimint_core::Amount::from_sats(amount.to_sat());
947        self.client_ctx
948            .fee_quote(
949                OperationId::new_random(),
950                FeeQuoteRequest {
951                    input_amount: Amounts::ZERO,
952                    output_amount: Amounts::new_bitcoin(amount),
953                    input_fee: Amounts::ZERO,
954                    output_fee: Amounts::new_bitcoin(self.cfg().fee_consensus.peg_out_abs),
955                },
956            )
957            .await
958    }
959
960    /// Finds the largest amount that can be withdrawn in full out of
961    /// `balance` — the amount a "withdraw everything" sweep should use —
962    /// together with the peg-out fees to submit it with.
963    ///
964    /// Withdrawing `amount` submits a peg-out output worth `amount +
965    /// peg_out_fees` (the on-chain miner fee is carried inside the output, see
966    /// [`WalletOutputV0::amount`]) and funding that output costs an additional
967    /// federation fee — the peg-out output fee, the mint input fees on the
968    /// funding notes, any mint change output fees and sub-denomination dust —
969    /// as quoted by [`Self::send_fee_quote`].
970    ///
971    /// Both returned values must be passed to [`Self::withdraw`] together. The
972    /// federation rebuilds the peg-out for the exact amount submitted and
973    /// rejects it unless the declared `total_weight` matches what it computed
974    /// (`WalletOutputError::TxWeightIncorrect`), so the fees are re-quoted here
975    /// at the amount being returned rather than at the balance.
976    ///
977    /// This costs exactly two [`Self::get_withdraw_fees`] round trips. The
978    /// first, at the full balance, is an upper bound on the miner fee — the
979    /// federation selects UTXOs until they cover the amount, so the weight is
980    /// monotone in it — and sizing the withdrawal against that upper bound can
981    /// only leave the result more affordable once the true, smaller fee
982    /// replaces it. The maximum itself is then found by binary search over the
983    /// real fee quote (see [`max_affordable_send_amount`]), which needs no
984    /// further round trips because [`Self::send_fee_quote`] is a local dry-run.
985    ///
986    /// Sizing against the upper bound means a sweep may leave a small
987    /// remainder behind. The quote is point-in-time: if the federation's UTXO
988    /// set or feerate moves before the peg-out is processed it is rejected, and
989    /// the caller should retry with a fresh quote.
990    ///
991    /// Returns [`MaxWithdrawableAmountError::BalanceTooLow`] if the balance
992    /// cannot cover the destination's dust limit plus fees, or
993    /// [`MaxWithdrawableAmountError::Quote`] if the fee probe itself failed.
994    pub async fn max_withdrawable_amount(
995        &self,
996        address: &bitcoin::Address,
997        balance: fedimint_core::Amount,
998    ) -> Result<(bitcoin::Amount, PegOutFees), MaxWithdrawableAmountError> {
999        // Upper bound on the miner fee: the weight only grows as the federation
1000        // has to reach for more UTXOs, so no smaller withdrawal costs more.
1001        let max_fees = self
1002            .get_withdraw_fees(
1003                address,
1004                bitcoin::Amount::from_sat(balance.sats_round_down()),
1005            )
1006            .await?;
1007        let max_fee_msats = fedimint_core::Amount::from_sats(max_fees.amount().to_sat());
1008        let dust_limit = address.script_pubkey().minimal_non_dust();
1009
1010        let max = max_affordable_send_amount(
1011            balance,
1012            fedimint_core::Amount::from_sats(dust_limit.to_sat()),
1013            balance,
1014            // A peg-out funds a whole number of sats, so round the probe up to
1015            // the next sat before adding the miner fee; the solver searches
1016            // millisatoshis.
1017            |amount: fedimint_core::Amount| {
1018                fedimint_core::Amount::from_sats(amount.msats.div_ceil(1000)) + max_fee_msats
1019            },
1020            |funded: fedimint_core::Amount| {
1021                self.send_fee_quote(bitcoin::Amount::from_sat(funded.msats / 1000))
1022            },
1023        )
1024        .await
1025        .map_err(MaxWithdrawableAmountError::Quote)?
1026        .ok_or(MaxWithdrawableAmountError::BalanceTooLow {
1027            balance,
1028            dust_limit,
1029        })?;
1030
1031        // `gross_up` rounded up to whole satoshis, so the largest affordable
1032        // amount already sits on a satoshi boundary; no value is lost here.
1033        let amount = bitcoin::Amount::from_sat(max.msats.div_ceil(1000));
1034
1035        // Re-quote at the amount actually being withdrawn. This is the fee the
1036        // federation recomputes for that amount, so the declared weight
1037        // matches; it is at most `max_fees`, which is what keeps `amount`
1038        // affordable.
1039        let fees = self.get_withdraw_fees(address, amount).await?;
1040
1041        Ok((amount, fees))
1042    }
1043
1044    /// Returns a summary of the wallet's coins
1045    pub async fn get_wallet_summary(&self) -> FederationResult<WalletSummary> {
1046        self.module_api.fetch_wallet_summary().await
1047    }
1048
1049    pub async fn get_block_count_local(&self) -> FederationResult<u32> {
1050        self.module_api.fetch_block_count_local().await
1051    }
1052
1053    pub fn create_withdraw_output(
1054        &self,
1055        operation_id: OperationId,
1056        address: bitcoin::Address,
1057        amount: bitcoin::Amount,
1058        fees: PegOutFees,
1059    ) -> ClientOutputBundle<WalletOutput, WalletClientStates> {
1060        let output = WalletOutput::new_v0_peg_out(address, amount, fees);
1061
1062        let amount = output.maybe_v0_ref().expect("v0 output").amount().into();
1063
1064        let sm_gen = move |out_point_range: OutPointRange| {
1065            assert_eq!(out_point_range.count(), 1);
1066            let out_idx = out_point_range.start_idx();
1067            vec![WalletClientStates::Withdraw(WithdrawStateMachine {
1068                operation_id,
1069                state: WithdrawStates::Created(CreatedWithdrawState {
1070                    fm_outpoint: OutPoint {
1071                        txid: out_point_range.txid(),
1072                        out_idx,
1073                    },
1074                }),
1075            })]
1076        };
1077
1078        ClientOutputBundle::new(
1079            vec![ClientOutput::<WalletOutput> {
1080                output,
1081                amounts: Amounts::new_bitcoin(amount),
1082            }],
1083            vec![ClientOutputSM::<WalletClientStates> {
1084                state_machines: Arc::new(sm_gen),
1085            }],
1086        )
1087    }
1088
1089    pub async fn peg_in(&self, req: PegInRequest) -> Result<PegInResponse, DepositAddressError> {
1090        let deposit_address = self.safe_allocate_deposit_address(req.extra_meta).await?;
1091
1092        Ok(PegInResponse {
1093            deposit_address: deposit_address.address.into_unchecked(),
1094            operation_id: deposit_address.operation_id,
1095        })
1096    }
1097
1098    pub async fn peg_out(&self, req: PegOutRequest) -> Result<PegOutResponse, PegOutError> {
1099        let amount = bitcoin::Amount::from_sat(req.amount_sat);
1100        let network = self.get_network();
1101        let destination = req
1102            .destination_address
1103            .require_network(network)
1104            .map_err(|_| PegOutError::WrongNetwork { expected: network })?;
1105
1106        let fees = self.get_withdraw_fees(&destination, amount).await?;
1107        let operation_id = self
1108            .withdraw(&destination, amount, fees, req.extra_meta)
1109            .await?;
1110
1111        Ok(PegOutResponse { operation_id })
1112    }
1113
1114    pub fn create_rbf_withdraw_output(
1115        &self,
1116        operation_id: OperationId,
1117        rbf: &Rbf,
1118    ) -> ClientOutputBundle<WalletOutput, WalletClientStates> {
1119        let output = WalletOutput::new_v0_rbf(rbf.fees, rbf.txid);
1120
1121        let amount = output.maybe_v0_ref().expect("v0 output").amount().into();
1122
1123        let sm_gen = move |out_point_range: OutPointRange| {
1124            assert_eq!(out_point_range.count(), 1);
1125            let out_idx = out_point_range.start_idx();
1126            vec![WalletClientStates::Withdraw(WithdrawStateMachine {
1127                operation_id,
1128                state: WithdrawStates::Created(CreatedWithdrawState {
1129                    fm_outpoint: OutPoint {
1130                        txid: out_point_range.txid(),
1131                        out_idx,
1132                    },
1133                }),
1134            })]
1135        };
1136
1137        ClientOutputBundle::new(
1138            vec![ClientOutput::<WalletOutput> {
1139                output,
1140                amounts: Amounts::new_bitcoin(amount),
1141            }],
1142            vec![ClientOutputSM::<WalletClientStates> {
1143                state_machines: Arc::new(sm_gen),
1144            }],
1145        )
1146    }
1147
1148    pub async fn btc_tx_has_no_size_limit(&self) -> FederationResult<bool> {
1149        Ok(self.module_api.module_consensus_version().await? >= ModuleConsensusVersion::new(2, 2))
1150    }
1151
1152    /// Returns true if the federation's wallet module consensus version
1153    /// supports processing all deposits.
1154    ///
1155    /// This method is safe to call offline, since it first attempts to read a
1156    /// key from the db that represents the client has previously been able to
1157    /// verify the wallet module consensus version. If the client has not
1158    /// verified the version, it must be online to fetch the latest wallet
1159    /// module consensus version.
1160    pub async fn supports_safe_deposit(&self) -> bool {
1161        if supports_safe_deposit_verified(&self.db).await {
1162            return true;
1163        }
1164
1165        verify_supports_safe_deposit(&self.db, &self.module_api)
1166            .await
1167            .unwrap_or(false)
1168    }
1169
1170    /// Allocates a deposit address controlled by the federation, guaranteeing
1171    /// safe handling of all deposits, including on-chain transactions exceeding
1172    /// `ALEPH_BFT_UNIT_BYTE_LIMIT`.
1173    ///
1174    /// Returns an error if the client has never been online to verify the
1175    /// federation's wallet module consensus version supports processing all
1176    /// deposits.
1177    pub async fn safe_allocate_deposit_address<M>(
1178        &self,
1179        extra_meta: M,
1180    ) -> Result<DepositAddressInfo, DepositAddressError>
1181    where
1182        M: Serialize + MaybeSend + MaybeSync,
1183    {
1184        if !self.supports_safe_deposit().await {
1185            return Err(DepositAddressError::SafeDepositUnverified);
1186        }
1187
1188        self.allocate_deposit_address_expert_only(extra_meta).await
1189    }
1190
1191    /// Allocates a deposit address that is controlled by the federation.
1192    ///
1193    /// This is an EXPERT ONLY method intended for power users such as Lightning
1194    /// gateways allocating liquidity, and we discourage exposing peg-in
1195    /// functionality to everyday users of a Fedimint wallet due to the
1196    /// following two limitations:
1197    ///
1198    /// The transaction sending to this address needs to be smaller than 40KB in
1199    /// order for the peg-in to be claimable. If the transaction is too large,
1200    /// funds will be lost.
1201    ///
1202    /// In the future, federations will also enforce a minimum peg-in amount to
1203    /// prevent accumulation of dust UTXOs. Peg-ins under this minimum cannot be
1204    /// claimed and funds will be lost.
1205    ///
1206    /// Everyday users should rely on Lightning to move funds into the
1207    /// federation.
1208    pub async fn allocate_deposit_address_expert_only<M>(
1209        &self,
1210        extra_meta: M,
1211    ) -> Result<DepositAddressInfo, DepositAddressError>
1212    where
1213        M: Serialize + MaybeSend + MaybeSync,
1214    {
1215        let extra_meta_value =
1216            serde_json::to_value(extra_meta).expect("Failed to serialize extra meta");
1217        let deposit_address = self
1218            .db
1219            .autocommit(
1220                move |dbtx, _| {
1221                    let extra_meta_value_inner = extra_meta_value.clone();
1222                    Box::pin(async move {
1223                        let deposit_address = self.allocate_deposit_address_inner(dbtx).await;
1224
1225                        self.client_ctx
1226                            .manual_operation_start_dbtx(
1227                                dbtx,
1228                                deposit_address.operation_id,
1229                                WalletCommonInit::KIND.as_str(),
1230                                WalletOperationMeta {
1231                                    variant: WalletOperationMetaVariant::Deposit {
1232                                        address: deposit_address.address.clone().into_unchecked(),
1233                                        tweak_idx: Some(deposit_address.tweak_idx),
1234                                        expires_at: None,
1235                                    },
1236                                    extra_meta: extra_meta_value_inner,
1237                                },
1238                                vec![],
1239                            )
1240                            .await?;
1241
1242                        debug!(
1243                            target: LOG_CLIENT_MODULE_WALLET,
1244                            tweak_idx = %deposit_address.tweak_idx,
1245                            address = %deposit_address.address,
1246                            "Derived a new deposit address"
1247                        );
1248
1249                        // Begin watching the script address
1250                        self.rpc
1251                            .watch_script_history(&deposit_address.address.script_pubkey())
1252                            .await?;
1253
1254                        let sender = self.pegin_monitor_wakeup_sender.clone();
1255                        dbtx.on_commit(move || {
1256                            sender.send_replace(());
1257                        });
1258
1259                        Ok(deposit_address)
1260                    })
1261                },
1262                Some(100),
1263            )
1264            .await?;
1265
1266        Ok(deposit_address)
1267    }
1268
1269    /// Allocate a deposit address, bounding the gap of consecutive unused
1270    /// addresses past the last-used one, without selecting a reusable address.
1271    ///
1272    /// "Unused" here means `PegInTweakIndexData::claimed.is_empty()` — the
1273    /// `pegin_monitor` has not yet successfully claimed any deposit on this
1274    /// address. There is a small race window where a deposit has been
1275    /// observed in the mempool / awaiting confirmations but not yet claimed,
1276    /// in which case the address still looks unused. Reusing such an address
1277    /// is benign in practice: `pegin_monitor` claims any number of deposits
1278    /// per address.
1279    ///
1280    /// Let `gap` be the count of unused tweak indices strictly greater than
1281    /// the highest used index (or the count of all allocated tweaks if none
1282    /// have been used yet). Semantics:
1283    ///   - If `gap >= max_gap_size` and there is at least one unused address,
1284    ///     returns all those addresses with
1285    ///     [`MaybeNewAddress::TooManyUnusedAddresses`].
1286    ///   - Otherwise, allocates a new address (equivalent to
1287    ///     [`Self::allocate_deposit_address_expert_only`]) and returns it with
1288    ///     [`MaybeNewAddress::NewAddress`].
1289    ///
1290    /// `max_gap_size == 0` therefore means "only allocate fresh when the
1291    /// previous address was already used". The very first call still
1292    /// allocates fresh because there are no addresses to reuse.
1293    ///
1294    /// `max_gap_size == usize::MAX` makes this method behave like
1295    /// [`Self::allocate_deposit_address_expert_only`] — always allocating a new
1296    /// address.
1297    ///
1298    /// Reusable addresses are ordered by `creation_time` ascending. This lets
1299    /// callers that need a custom selection strategy choose from all available
1300    /// candidates.
1301    ///
1302    /// Caveats inherited from
1303    /// [`Self::allocate_deposit_address_expert_only`] (40KB tx limit, future
1304    /// minimum peg-in amount) apply equally to newly allocated and reused
1305    /// addresses.
1306    pub async fn allocate_deposit_address_pooled_stateless(
1307        &self,
1308        max_gap_size: usize,
1309    ) -> Result<MaybeNewAddress, DepositAddressError> {
1310        let max_gap_size_u64 = u64::try_from(max_gap_size).unwrap_or(u64::MAX);
1311        let extra_meta_value = serde_json::Value::Null;
1312        let result = self
1313            .db
1314            .autocommit(
1315                move |dbtx, _| {
1316                    let extra_meta_value_inner = extra_meta_value.clone();
1317                    Box::pin(async move {
1318                        let unused = self.unused_pooled_deposit_addresses(dbtx).await;
1319
1320                        if max_gap_size_u64 <= unused.len() as u64 && !unused.is_empty() {
1321                            let addresses = unused
1322                                .into_iter()
1323                                .map(|(tweak_idx, data)| {
1324                                    let (_script, address, _key, operation_id) =
1325                                        self.data.derive_peg_in_script(tweak_idx);
1326
1327                                    debug_assert_eq!(operation_id, data.operation_id);
1328
1329                                    DepositAddressInfo {
1330                                        operation_id,
1331                                        address,
1332                                        tweak_idx,
1333                                    }
1334                                })
1335                                .collect();
1336
1337                            return Ok::<_, DepositAddressError>(
1338                                MaybeNewAddress::TooManyUnusedAddresses(addresses),
1339                            );
1340                        }
1341
1342                        let deposit_address = self.allocate_deposit_address_inner(dbtx).await;
1343
1344                        self.client_ctx
1345                            .manual_operation_start_dbtx(
1346                                dbtx,
1347                                deposit_address.operation_id,
1348                                WalletCommonInit::KIND.as_str(),
1349                                WalletOperationMeta {
1350                                    variant: WalletOperationMetaVariant::Deposit {
1351                                        address: deposit_address.address.clone().into_unchecked(),
1352                                        tweak_idx: Some(deposit_address.tweak_idx),
1353                                        expires_at: None,
1354                                    },
1355                                    extra_meta: extra_meta_value_inner,
1356                                },
1357                                vec![],
1358                            )
1359                            .await?;
1360
1361                        debug!(
1362                            target: LOG_CLIENT_MODULE_WALLET,
1363                            tweak_idx = %deposit_address.tweak_idx,
1364                            address = %deposit_address.address,
1365                            "Derived a new pooled deposit address"
1366                        );
1367
1368                        self.rpc
1369                            .watch_script_history(&deposit_address.address.script_pubkey())
1370                            .await?;
1371
1372                        let sender = self.pegin_monitor_wakeup_sender.clone();
1373                        dbtx.on_commit(move || {
1374                            sender.send_replace(());
1375                        });
1376
1377                        Ok(MaybeNewAddress::NewAddress(deposit_address))
1378                    })
1379                },
1380                Some(100),
1381            )
1382            .await?;
1383
1384        Ok(result)
1385    }
1386
1387    async fn unused_pooled_deposit_addresses(
1388        &self,
1389        dbtx: &mut DatabaseTransaction<'_>,
1390    ) -> Vec<(TweakIdx, PegInTweakIndexData)> {
1391        // Walk peg-in tweaks in descending order, taking unused ones
1392        // (`claimed.is_empty()`) until we hit a used one. That gives us exactly
1393        // the trailing gap — `gap + 1` reads regardless of total tweak count.
1394        // No RPC and no separately-maintained `last_used` index needed.
1395        let mut unused: Vec<(TweakIdx, PegInTweakIndexData)> = dbtx
1396            .find_by_prefix_sorted_descending(&PegInTweakIndexPrefix)
1397            .await
1398            .take_while(|(_, d)| std::future::ready(d.claimed.is_empty()))
1399            .map(|(k, v)| (k.0, v))
1400            .collect()
1401            .await;
1402
1403        // Order by `creation_time` ascending for caller selection and stable
1404        // wraparound after stateful reuse mutates `creation_time`.
1405        unused.sort_by_key(|(t, d)| (d.creation_time, *t));
1406        unused
1407    }
1408
1409    /// Allocate a deposit address, bounding the gap of consecutive unused
1410    /// addresses past the last-used one.
1411    ///
1412    /// This is a stateful wrapper over
1413    /// [`Self::allocate_deposit_address_pooled_stateless`]. If the stateless
1414    /// method returns [`MaybeNewAddress::TooManyUnusedAddresses`], this method
1415    /// picks one of those addresses with a persisted round-robin cursor and
1416    /// returns it with [`AllocateDepositOutcome::Reused`].
1417    ///
1418    /// Selection policy when reusing: round-robin over unused addresses
1419    /// ordered by `creation_time` ascending, with the cursor persisted
1420    /// per-client in the wallet module's DB. The cursor stores the next
1421    /// tweak index to consider; after picking idx X it advances to X+1, and
1422    /// when no candidate has `tweak_idx >= cursor` it wraps to the
1423    /// oldest-by-`creation_time` candidate. This gives the caller the
1424    /// predictable "cycle through the unused addresses" behavior.
1425    ///
1426    /// On reuse the `creation_time` and check-schedule fields of the
1427    /// underlying tweak entry are reset to "now", so `pegin_monitor`'s
1428    /// age-proportional polling delay restarts from zero. Without this an
1429    /// old entry would keep its long stale next-check delay and a user
1430    /// sending to it could wait a long time before the client noticed.
1431    #[allow(clippy::too_many_lines)]
1432    pub async fn allocate_deposit_address_pooled(
1433        &self,
1434        max_gap_size: usize,
1435    ) -> Result<(DepositAddressInfo, AllocateDepositOutcome), DepositAddressError> {
1436        let stateless = self
1437            .allocate_deposit_address_pooled_stateless(max_gap_size)
1438            .await?;
1439
1440        let reused_addresses = match stateless {
1441            MaybeNewAddress::NewAddress(deposit_address) => {
1442                return Ok((deposit_address, AllocateDepositOutcome::Fresh));
1443            }
1444            MaybeNewAddress::TooManyUnusedAddresses(addresses) => addresses,
1445        };
1446
1447        let result = self
1448            .db
1449            .autocommit(
1450                move |dbtx, _| {
1451                    let reused_addresses = reused_addresses.clone();
1452                    Box::pin(async move {
1453                        let cursor = dbtx
1454                            .get_value(&PegInPoolCursorKey)
1455                            .await
1456                            .unwrap_or(TweakIdx(0));
1457
1458                        let pick_pos = reused_addresses
1459                            .iter()
1460                            .position(|a| cursor <= a.tweak_idx)
1461                            .unwrap_or(0);
1462                        let reused_address = reused_addresses[pick_pos].clone();
1463
1464                        let existing_tweak_idx = reused_address.tweak_idx;
1465                        let existing = dbtx
1466                            .get_value(&PegInTweakIndexKey(reused_address.tweak_idx))
1467                            .await
1468                            .ok_or(DepositAddressError::PooledAddressDisappeared {
1469                                tweak_idx: reused_address.tweak_idx,
1470                            })?;
1471
1472                        if !existing.claimed.is_empty() {
1473                            return Err(DepositAddressError::PooledAddressUsed {
1474                                tweak_idx: reused_address.tweak_idx,
1475                            });
1476                        }
1477
1478                        dbtx.insert_entry(&PegInPoolCursorKey, &reused_address.tweak_idx.next())
1479                            .await;
1480
1481                        // Reset the monitoring schedule so the reused address
1482                        // gets checked as aggressively as a freshly-allocated
1483                        // one. Without this, an old entry would retain its
1484                        // `age/10` next-check delay from `pegin_monitor`,
1485                        // meaning a user who sends to a long-idle pool address
1486                        // could wait hours before the client polls for the
1487                        // deposit.
1488                        let now = fedimint_core::time::now();
1489                        dbtx.insert_entry(
1490                            &PegInTweakIndexKey(reused_address.tweak_idx),
1491                            &PegInTweakIndexData {
1492                                creation_time: now,
1493                                last_check_time: None,
1494                                next_check_time: Some(now),
1495                                operation_id: existing.operation_id,
1496                                claimed: existing.claimed,
1497                            },
1498                        )
1499                        .await;
1500
1501                        let sender = self.pegin_monitor_wakeup_sender.clone();
1502                        dbtx.on_commit(move || {
1503                            sender.send_replace(());
1504                        });
1505
1506                        Ok::<_, DepositAddressError>((
1507                            reused_address,
1508                            AllocateDepositOutcome::Reused {
1509                                original_tweak_idx: existing_tweak_idx,
1510                            },
1511                        ))
1512                    })
1513                },
1514                Some(100),
1515            )
1516            .await?;
1517
1518        Ok(result)
1519    }
1520
1521    /// Returns a stream of updates about an ongoing deposit operation created
1522    /// with [`WalletClientModule::allocate_deposit_address_expert_only`].
1523    /// Returns an error for old deposit operations created prior to the 0.4
1524    /// release and not driven to completion yet. This should be rare enough
1525    /// that an indeterminate state is ok here.
1526    pub async fn subscribe_deposit(
1527        &self,
1528        operation_id: OperationId,
1529    ) -> Result<UpdateStreamOrOutcome<DepositStateV2>, SubscribeDepositError> {
1530        let operation = self.client_ctx.get_operation(operation_id).await?;
1531
1532        let operation_meta = operation.meta::<WalletOperationMeta>();
1533
1534        let WalletOperationMetaVariant::Deposit {
1535            address, tweak_idx, ..
1536        } = operation_meta.variant
1537        else {
1538            return Err(SubscribeDepositError::NotADeposit);
1539        };
1540
1541        let network = self.cfg().network.0;
1542        let address = address
1543            .require_network(network)
1544            .map_err(|_| SubscribeDepositError::WrongNetwork { expected: network })?;
1545
1546        // The old deposit operations don't have tweak_idx set
1547        let Some(tweak_idx) = tweak_idx else {
1548            // In case we are dealing with an old deposit that still uses state machines we
1549            // don't have the logic here anymore to subscribe to updates. We can still read
1550            // the final state though if it reached any.
1551            let outcome_v1 = operation
1552                .outcome::<DepositStateV1>()
1553                .ok_or(SubscribeDepositError::OldPendingDeposit)?;
1554
1555            let outcome_v2 = match outcome_v1 {
1556                DepositStateV1::Claimed(tx_info) => DepositStateV2::Claimed {
1557                    btc_deposited: tx_info.btc_transaction.output[tx_info.out_idx as usize].value,
1558                    btc_out_point: bitcoin::OutPoint {
1559                        txid: tx_info.btc_transaction.compute_txid(),
1560                        vout: tx_info.out_idx,
1561                    },
1562                },
1563                DepositStateV1::Failed(error) => DepositStateV2::Failed(error),
1564                _ => return Err(SubscribeDepositError::NonFinalOutcome),
1565            };
1566
1567            return Ok(UpdateStreamOrOutcome::Outcome(outcome_v2));
1568        };
1569
1570        Ok(self.client_ctx.outcome_or_updates(
1571            &operation,
1572            operation_id,
1573            |state| match state {
1574                DepositStateV2::WaitingForTransaction
1575                | DepositStateV2::WaitingForConfirmation { .. }
1576                | DepositStateV2::Confirmed { .. } => false,
1577                DepositStateV2::Claimed { .. } | DepositStateV2::Failed(_) => true,
1578            },
1579            {
1580            let stream_rpc = self.rpc.clone();
1581            let stream_client_ctx = self.client_ctx.clone();
1582            let stream_script_pub_key = address.script_pubkey();
1583            move || {
1584
1585            stream! {
1586                yield DepositStateV2::WaitingForTransaction;
1587
1588                retry(
1589                    "subscribe script history",
1590                    background_backoff(),
1591                    || stream_rpc.watch_script_history(&stream_script_pub_key)
1592                ).await.expect("Will never give up");
1593                let (btc_out_point, btc_deposited) = retry(
1594                    "fetch history",
1595                    background_backoff(),
1596                    || async {
1597                        let history = stream_rpc.get_script_history(&stream_script_pub_key).await?;
1598                        history.first().and_then(|tx| {
1599                            let (out_idx, amount) = tx.output
1600                                .iter()
1601                                .enumerate()
1602                                .find_map(|(idx, output)| (output.script_pubkey == stream_script_pub_key).then_some((idx, output.value)))?;
1603                            let txid = tx.compute_txid();
1604
1605                            Some((
1606                                bitcoin::OutPoint {
1607                                    txid,
1608                                    vout: out_idx as u32,
1609                                },
1610                                amount
1611                            ))
1612                        }).ok_or(FetchDepositTransactionError::NotFound)
1613                    }
1614                ).await.expect("Will never give up");
1615
1616                yield DepositStateV2::WaitingForConfirmation {
1617                    btc_deposited,
1618                    btc_out_point
1619                };
1620
1621                let claim_data = stream_client_ctx.module_db().wait_key_exists(&ClaimedPegInKey {
1622                    peg_in_index: tweak_idx,
1623                    btc_out_point,
1624                }).await;
1625
1626                yield DepositStateV2::Confirmed {
1627                    btc_deposited,
1628                    btc_out_point
1629                };
1630
1631                match stream_client_ctx.await_primary_module_outputs(operation_id, claim_data.change).await {
1632                    Ok(()) => yield DepositStateV2::Claimed {
1633                        btc_deposited,
1634                        btc_out_point
1635                    },
1636                    Err(e) => yield DepositStateV2::Failed(e.fmt_compact().to_string())
1637                }
1638            }
1639        }}))
1640    }
1641
1642    pub async fn list_peg_in_tweak_idxes(&self) -> BTreeMap<TweakIdx, PegInTweakIndexData> {
1643        self.client_ctx
1644            .module_db()
1645            .clone()
1646            .begin_transaction_nc()
1647            .await
1648            .find_by_prefix(&PegInTweakIndexPrefix)
1649            .await
1650            .map(|(key, data)| (key.0, data))
1651            .collect()
1652            .await
1653    }
1654
1655    pub async fn find_tweak_idx_by_address(
1656        &self,
1657        address: bitcoin::Address<NetworkUnchecked>,
1658    ) -> Result<TweakIdx, PegInError> {
1659        let data = self.data.clone();
1660        let Some((tweak_idx, _)) = self
1661            .db
1662            .begin_transaction_nc()
1663            .await
1664            .find_by_prefix(&PegInTweakIndexPrefix)
1665            .await
1666            .filter(|(k, _)| {
1667                let (_, derived_address, _tweak_key, _) = data.derive_peg_in_script(k.0);
1668                future::ready(derived_address.into_unchecked() == address)
1669            })
1670            .next()
1671            .await
1672        else {
1673            return Err(PegInError::AddressNotDerived);
1674        };
1675
1676        Ok(tweak_idx.0)
1677    }
1678    pub async fn find_tweak_idx_by_operation_id(
1679        &self,
1680        operation_id: OperationId,
1681    ) -> Result<TweakIdx, PegInError> {
1682        Ok(self
1683            .client_ctx
1684            .module_db()
1685            .clone()
1686            .begin_transaction_nc()
1687            .await
1688            .find_by_prefix(&PegInTweakIndexPrefix)
1689            .await
1690            .filter(|(_k, v)| future::ready(v.operation_id == operation_id))
1691            .next()
1692            .await
1693            .ok_or(PegInError::NoAddressForOperation { operation_id })?
1694            .0
1695            .0)
1696    }
1697
1698    pub async fn get_pegin_tweak_idx(
1699        &self,
1700        tweak_idx: TweakIdx,
1701    ) -> Result<PegInTweakIndexData, PegInError> {
1702        self.client_ctx
1703            .module_db()
1704            .clone()
1705            .begin_transaction_nc()
1706            .await
1707            .get_value(&PegInTweakIndexKey(tweak_idx))
1708            .await
1709            .ok_or(PegInError::TweakIdxNotFound { tweak_idx })
1710    }
1711
1712    pub async fn get_claimed_pegins(
1713        &self,
1714        dbtx: &mut DatabaseTransaction<'_>,
1715        tweak_idx: TweakIdx,
1716    ) -> Vec<(
1717        bitcoin::OutPoint,
1718        TransactionId,
1719        Vec<fedimint_core::OutPoint>,
1720    )> {
1721        let outpoints = dbtx
1722            .get_value(&PegInTweakIndexKey(tweak_idx))
1723            .await
1724            .map(|v| v.claimed)
1725            .unwrap_or_default();
1726
1727        let mut res = vec![];
1728
1729        for outpoint in outpoints {
1730            let claimed_peg_in_data = dbtx
1731                .get_value(&ClaimedPegInKey {
1732                    peg_in_index: tweak_idx,
1733                    btc_out_point: outpoint,
1734                })
1735                .await
1736                .expect("Must have a corresponding claim record");
1737            res.push((
1738                outpoint,
1739                claimed_peg_in_data.claim_txid,
1740                claimed_peg_in_data.change,
1741            ));
1742        }
1743
1744        res
1745    }
1746
1747    /// Like [`Self::recheck_pegin_address`] but by `operation_id`
1748    pub async fn recheck_pegin_address_by_op_id(
1749        &self,
1750        operation_id: OperationId,
1751    ) -> Result<(), PegInError> {
1752        let tweak_idx = self.find_tweak_idx_by_operation_id(operation_id).await?;
1753
1754        self.recheck_pegin_address(tweak_idx).await
1755    }
1756
1757    /// Schedule given address for immediate re-check for deposits
1758    pub async fn recheck_pegin_address_by_address(
1759        &self,
1760        address: bitcoin::Address<NetworkUnchecked>,
1761    ) -> Result<(), PegInError> {
1762        self.recheck_pegin_address(self.find_tweak_idx_by_address(address).await?)
1763            .await
1764    }
1765
1766    /// Schedule given address for immediate re-check for deposits
1767    pub async fn recheck_pegin_address(&self, tweak_idx: TweakIdx) -> Result<(), PegInError> {
1768        self.db
1769            .autocommit(
1770                |dbtx, _| {
1771                    Box::pin(async {
1772                        let db_key = PegInTweakIndexKey(tweak_idx);
1773                        let db_val = dbtx
1774                            .get_value(&db_key)
1775                            .await
1776                            .ok_or(PegInError::TweakIdxNotFound { tweak_idx })?;
1777
1778                        dbtx.insert_entry(
1779                            &db_key,
1780                            &PegInTweakIndexData {
1781                                next_check_time: Some(fedimint_core::time::now()),
1782                                ..db_val
1783                            },
1784                        )
1785                        .await;
1786
1787                        let sender = self.pegin_monitor_wakeup_sender.clone();
1788                        dbtx.on_commit(move || {
1789                            sender.send_replace(());
1790                        });
1791
1792                        Ok::<_, PegInError>(())
1793                    })
1794                },
1795                Some(100),
1796            )
1797            .await?;
1798
1799        Ok(())
1800    }
1801
1802    /// Await for num deposit by [`OperationId`]
1803    pub async fn await_num_deposits_by_operation_id(
1804        &self,
1805        operation_id: OperationId,
1806        num_deposits: usize,
1807    ) -> Result<(), PegInError> {
1808        let tweak_idx = self.find_tweak_idx_by_operation_id(operation_id).await?;
1809        self.await_num_deposits(tweak_idx, num_deposits).await
1810    }
1811
1812    pub async fn await_num_deposits_by_address(
1813        &self,
1814        address: bitcoin::Address<NetworkUnchecked>,
1815        num_deposits: usize,
1816    ) -> Result<(), PegInError> {
1817        self.await_num_deposits(self.find_tweak_idx_by_address(address).await?, num_deposits)
1818            .await
1819    }
1820
1821    #[instrument(target = LOG_CLIENT_MODULE_WALLET, skip_all, fields(tweak_idx=?tweak_idx, num_deposists=num_deposits))]
1822    pub async fn await_num_deposits(
1823        &self,
1824        tweak_idx: TweakIdx,
1825        num_deposits: usize,
1826    ) -> Result<(), PegInError> {
1827        let operation_id = self.get_pegin_tweak_idx(tweak_idx).await?.operation_id;
1828
1829        let mut receiver = self.pegin_claimed_receiver.clone();
1830        let mut backoff = backoff_util::aggressive_backoff();
1831
1832        loop {
1833            let pegins = self
1834                .get_claimed_pegins(
1835                    &mut self.client_ctx.module_db().begin_transaction_nc().await,
1836                    tweak_idx,
1837                )
1838                .await;
1839
1840            if pegins.len() < num_deposits {
1841                debug!(target: LOG_CLIENT_MODULE_WALLET, has=pegins.len(), "Not enough deposits");
1842                self.recheck_pegin_address(tweak_idx).await?;
1843                runtime::sleep(backoff.next().unwrap_or_default()).await;
1844                receiver
1845                    .changed()
1846                    .await
1847                    .map_err(|_| PegInError::MonitorStopped)?;
1848                continue;
1849            }
1850
1851            debug!(target: LOG_CLIENT_MODULE_WALLET, has=pegins.len(), "Enough deposits detected");
1852
1853            for (_outpoint, transaction_id, change) in pegins {
1854                if transaction_id == TransactionId::from_byte_array([0; 32]) && change.is_empty() {
1855                    debug!(target: LOG_CLIENT_MODULE_WALLET, "Deposited amount was too low, skipping");
1856                    continue;
1857                }
1858
1859                debug!(target: LOG_CLIENT_MODULE_WALLET, out_points=?change, "Ensuring deposists claimed");
1860                let tx_subscriber = self.client_ctx.transaction_updates(operation_id).await;
1861
1862                if let Err(reason) = tx_subscriber.await_tx_accepted(transaction_id).await {
1863                    return Err(PegInError::TransactionRejected { reason });
1864                }
1865
1866                debug!(target: LOG_CLIENT_MODULE_WALLET, out_points=?change, "Ensuring outputs claimed");
1867                self.client_ctx
1868                    .await_primary_module_outputs(operation_id, change)
1869                    .await
1870                    .expect("Cannot fail if tx was accepted and federation is honest");
1871            }
1872
1873            return Ok(());
1874        }
1875    }
1876
1877    /// Attempt to withdraw a given `amount` of Bitcoin to a destination
1878    /// `address`. The caller has to supply the fee rate to be used which can be
1879    /// fetched using [`Self::get_withdraw_fees`] and should be
1880    /// acknowledged by the user since it can be unexpectedly high.
1881    pub async fn withdraw<M: Serialize + MaybeSend + MaybeSync>(
1882        &self,
1883        address: &bitcoin::Address,
1884        amount: bitcoin::Amount,
1885        fee: PegOutFees,
1886        extra_meta: M,
1887    ) -> Result<OperationId, TransactionSubmitError> {
1888        {
1889            let operation_id = OperationId(thread_rng().r#gen());
1890
1891            let withdraw_output =
1892                self.create_withdraw_output(operation_id, address.clone(), amount, fee);
1893            let tx_builder = TransactionBuilder::new()
1894                .with_outputs(self.client_ctx.make_client_outputs(withdraw_output));
1895
1896            let extra_meta =
1897                serde_json::to_value(extra_meta).expect("Failed to serialize extra meta");
1898            self.client_ctx
1899                .finalize_and_submit_transaction(
1900                    operation_id,
1901                    WalletCommonInit::KIND.as_str(),
1902                    {
1903                        let address = address.clone();
1904                        move |change_range: OutPointRange| WalletOperationMeta {
1905                            variant: WalletOperationMetaVariant::Withdraw {
1906                                address: address.clone().into_unchecked(),
1907                                amount,
1908                                fee,
1909                                change: change_range.into_iter().collect(),
1910                            },
1911                            extra_meta: extra_meta.clone(),
1912                        }
1913                    },
1914                    tx_builder,
1915                )
1916                .await?;
1917
1918            let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
1919
1920            self.client_ctx
1921                .log_event(
1922                    &mut dbtx,
1923                    SendPaymentEvent {
1924                        operation_id,
1925                        amount: amount + fee.amount(),
1926                        fee: fee.amount(),
1927                    },
1928                )
1929                .await;
1930
1931            dbtx.commit_tx().await;
1932
1933            Ok(operation_id)
1934        }
1935    }
1936
1937    /// Attempt to increase the fee of a onchain withdraw transaction using
1938    /// replace by fee (RBF).
1939    /// This can prevent transactions from getting stuck
1940    /// in the mempool
1941    #[deprecated(
1942        since = "0.4.0",
1943        note = "RBF withdrawals are rejected by the federation"
1944    )]
1945    pub async fn rbf_withdraw<M: Serialize + MaybeSync + MaybeSend>(
1946        &self,
1947        rbf: Rbf,
1948        extra_meta: M,
1949    ) -> Result<OperationId, TransactionSubmitError> {
1950        let operation_id = OperationId(thread_rng().r#gen());
1951
1952        let withdraw_output = self.create_rbf_withdraw_output(operation_id, &rbf);
1953        let tx_builder = TransactionBuilder::new()
1954            .with_outputs(self.client_ctx.make_client_outputs(withdraw_output));
1955
1956        let extra_meta = serde_json::to_value(extra_meta).expect("Failed to serialize extra meta");
1957        self.client_ctx
1958            .finalize_and_submit_transaction(
1959                operation_id,
1960                WalletCommonInit::KIND.as_str(),
1961                move |change_range: OutPointRange| WalletOperationMeta {
1962                    variant: WalletOperationMetaVariant::RbfWithdraw {
1963                        rbf: rbf.clone(),
1964                        change: change_range.into_iter().collect(),
1965                    },
1966                    extra_meta: extra_meta.clone(),
1967                },
1968                tx_builder,
1969            )
1970            .await?;
1971
1972        Ok(operation_id)
1973    }
1974
1975    pub async fn subscribe_withdraw_updates(
1976        &self,
1977        operation_id: OperationId,
1978    ) -> Result<UpdateStreamOrOutcome<WithdrawState>, SubscribeWithdrawError> {
1979        let operation = self.client_ctx.get_operation(operation_id).await?;
1980
1981        let operation_meta = operation.meta::<WalletOperationMeta>();
1982
1983        let (WalletOperationMetaVariant::Withdraw { change, .. }
1984        | WalletOperationMetaVariant::RbfWithdraw { change, .. }) = operation_meta.variant
1985        else {
1986            return Err(SubscribeWithdrawError::NotAWithdrawal);
1987        };
1988
1989        let mut operation_stream = self.notifier.subscribe(operation_id).await;
1990        let client_ctx = self.client_ctx.clone();
1991
1992        Ok(self.client_ctx.outcome_or_updates(
1993            &operation,
1994            operation_id,
1995            |state| match state {
1996                WithdrawState::Created => false,
1997                WithdrawState::Succeeded(_) | WithdrawState::Failed(_) => true,
1998            },
1999            move || {
2000                stream! {
2001                    match next_withdraw_state(&mut operation_stream).await {
2002                        Some(WithdrawStates::Created(_)) => {
2003                            yield WithdrawState::Created;
2004                        },
2005                        Some(s) => {
2006                            panic!("Unexpected state {s:?}")
2007                        },
2008                        None => return,
2009                    }
2010
2011                    // TODO: get rid of awaiting change here, there has to be a better way to make tests deterministic
2012
2013                        // Swallowing potential errors since the transaction failing  is handled by
2014                        // output outcome fetching already
2015                        let _ = client_ctx
2016                            .await_primary_module_outputs(operation_id, change)
2017                            .await;
2018
2019
2020                    match next_withdraw_state(&mut operation_stream).await {
2021                        Some(WithdrawStates::Aborted(inner)) => {
2022                            yield WithdrawState::Failed(inner.error);
2023                        },
2024                        Some(WithdrawStates::Success(inner)) => {
2025                            yield WithdrawState::Succeeded(inner.txid);
2026                        },
2027                        Some(s) => {
2028                            panic!("Unexpected state {s:?}")
2029                        },
2030                        None => {},
2031                    }
2032                }
2033            },
2034        ))
2035    }
2036
2037    fn admin_auth(&self) -> Result<ApiAuth, ConsensusVersionVotingError> {
2038        self.admin_auth
2039            .clone()
2040            .ok_or(ConsensusVersionVotingError::AdminAuthMissing)
2041    }
2042
2043    pub async fn activate_consensus_version_voting(
2044        &self,
2045    ) -> Result<(), ConsensusVersionVotingError> {
2046        self.module_api
2047            .activate_consensus_version_voting(self.admin_auth()?)
2048            .await?;
2049
2050        Ok(())
2051    }
2052}
2053
2054/// Why a deposit address's funding transaction was not found, so that it is
2055/// looked up again.
2056#[derive(Debug, thiserror::Error)]
2057enum FetchDepositTransactionError {
2058    /// The Bitcoin backend could not report the address's history.
2059    #[error(transparent)]
2060    BitcoinRpc(#[from] BitcoinRpcError),
2061
2062    /// The address's history holds no transaction paying to it yet.
2063    #[error("No deposit transaction found")]
2064    NotFound,
2065}
2066
2067/// Polls the federation checking if the activated module consensus version
2068/// supports safe deposits, saving the result in the db once it does.
2069async fn poll_supports_safe_deposit_version(db: Database, module_api: DynModuleApi) {
2070    loop {
2071        if supports_safe_deposit_verified(&db).await {
2072            break;
2073        }
2074
2075        module_api.wait_for_initialized_connections().await;
2076
2077        if verify_supports_safe_deposit(&db, &module_api).await == Some(true) {
2078            break;
2079        }
2080
2081        if is_running_in_test_env() {
2082            // Even in tests we don't want to spam the federation with requests about it
2083            sleep(Duration::from_secs(10)).await;
2084        } else {
2085            sleep(Duration::from_hours(1)).await;
2086        }
2087    }
2088}
2089
2090/// Whether the federation's wallet module consensus version was already
2091/// verified to support safe deposits.
2092async fn supports_safe_deposit_verified(db: &Database) -> bool {
2093    db.begin_transaction_nc()
2094        .await
2095        .get_value(&SupportsSafeDepositKey)
2096        .await
2097        .is_some()
2098}
2099
2100/// Fetches the federation's wallet module consensus version and records the
2101/// marker if it supports safe deposits.
2102///
2103/// Returns `None` if the version could not be fetched, e.g. while offline.
2104async fn verify_supports_safe_deposit(db: &Database, module_api: &DynModuleApi) -> Option<bool> {
2105    let module_consensus_version = match module_api.module_consensus_version().await {
2106        Ok(module_consensus_version) => module_consensus_version,
2107        Err(err) => {
2108            debug!(
2109                target: LOG_CLIENT_MODULE_WALLET,
2110                err = %err.fmt_compact(),
2111                "Could not fetch the wallet module consensus version"
2112            );
2113            return None;
2114        }
2115    };
2116
2117    let supported_version = SAFE_DEPOSIT_MODULE_CONSENSUS_VERSION <= module_consensus_version;
2118
2119    if supported_version {
2120        store_supports_safe_deposit(db.begin_transaction().await).await;
2121    }
2122
2123    Some(supported_version)
2124}
2125
2126/// Records in `dbtx` that the federation's wallet module consensus version
2127/// was verified to support safe deposits, and commits it.
2128///
2129/// The marker is written by both the background version poller and
2130/// [`WalletClientModule::supports_safe_deposit`], which race right after
2131/// joining a federation. The loser's commit fails with a write conflict, but
2132/// the winner stored the very same marker and nothing ever removes it, so the
2133/// conflict is treated as success. That is only sound for a fresh `dbtx` whose
2134/// sole write is the marker: any other write in it would be lost as well.
2135///
2136/// Any other commit error is logged rather than escalated: the marker only
2137/// lets future checks skip re-verifying the version, callers already have
2138/// their answer, and the next verification simply writes it again — an
2139/// optional write is not worth crashing over.
2140async fn store_supports_safe_deposit(mut dbtx: DatabaseTransaction<'_, Committable>) {
2141    if dbtx.get_value(&SupportsSafeDepositKey).await.is_some() {
2142        // The other writer won before this transaction started.
2143        return;
2144    }
2145
2146    dbtx.insert_entry(&SupportsSafeDepositKey, &()).await;
2147
2148    match dbtx.commit_tx_result().await {
2149        Ok(()) | Err(DatabaseError::WriteConflict) => {}
2150        Err(err) => {
2151            warn!(
2152                target: LOG_CLIENT_MODULE_WALLET,
2153                err = %err.fmt_compact(),
2154                "Failed to store the safe-deposit marker"
2155            );
2156        }
2157    }
2158}
2159
2160/// Returns the child index to derive the next peg-in tweak key from.
2161async fn get_next_peg_in_tweak_child_id(dbtx: &mut DatabaseTransaction<'_>) -> TweakIdx {
2162    let index = dbtx
2163        .get_value(&NextPegInTweakIndexKey)
2164        .await
2165        .unwrap_or_default();
2166    dbtx.insert_entry(&NextPegInTweakIndexKey, &(index.next()))
2167        .await;
2168    index
2169}
2170
2171#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2172pub enum WalletClientStates {
2173    Deposit(DepositStateMachine),
2174    Withdraw(WithdrawStateMachine),
2175}
2176
2177impl IntoDynInstance for WalletClientStates {
2178    type DynType = DynState;
2179
2180    fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
2181        DynState::from_typed(instance_id, self)
2182    }
2183}
2184
2185impl State for WalletClientStates {
2186    type ModuleContext = WalletClientContext;
2187
2188    fn transitions(
2189        &self,
2190        context: &Self::ModuleContext,
2191        global_context: &DynGlobalClientContext,
2192    ) -> Vec<StateTransition<Self>> {
2193        match self {
2194            WalletClientStates::Deposit(sm) => {
2195                sm_enum_variant_translation!(
2196                    sm.transitions(context, global_context),
2197                    WalletClientStates::Deposit
2198                )
2199            }
2200            WalletClientStates::Withdraw(sm) => {
2201                sm_enum_variant_translation!(
2202                    sm.transitions(context, global_context),
2203                    WalletClientStates::Withdraw
2204                )
2205            }
2206        }
2207    }
2208
2209    fn operation_id(&self) -> OperationId {
2210        match self {
2211            WalletClientStates::Deposit(sm) => sm.operation_id(),
2212            WalletClientStates::Withdraw(sm) => sm.operation_id(),
2213        }
2214    }
2215}
2216
2217#[cfg(all(test, not(target_family = "wasm")))]
2218mod tests {
2219    use std::collections::BTreeSet;
2220    use std::sync::atomic::{AtomicBool, Ordering};
2221
2222    use fedimint_core::db::mem_impl::MemDatabase;
2223    use fedimint_core::module::registry::ModuleDecoderRegistry;
2224
2225    use super::*;
2226    use crate::backup::{
2227        RECOVER_NUM_IDX_ADD_TO_LAST_USED, RecoverScanOutcome, recover_scan_idxes_for_activity,
2228    };
2229
2230    #[allow(clippy::too_many_lines)] // shut-up clippy, it's a test
2231    #[tokio::test(flavor = "multi_thread")]
2232    async fn sanity_test_recover_inner() {
2233        {
2234            let last_checked = AtomicBool::new(false);
2235            let last_checked = &last_checked;
2236            assert_eq!(
2237                recover_scan_idxes_for_activity(
2238                    TweakIdx(0),
2239                    &BTreeSet::new(),
2240                    |cur_idx| async move {
2241                        Ok(match cur_idx {
2242                            TweakIdx(9) => {
2243                                last_checked.store(true, Ordering::SeqCst);
2244                                vec![]
2245                            }
2246                            TweakIdx(10) => panic!("Shouldn't happen"),
2247                            TweakIdx(11) => {
2248                                vec![0usize] /* just for type inference */
2249                            }
2250                            _ => vec![],
2251                        })
2252                    }
2253                )
2254                .await
2255                .unwrap(),
2256                RecoverScanOutcome {
2257                    last_used_idx: None,
2258                    new_start_idx: TweakIdx(RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2259                    tweak_idxes_with_pegins: BTreeSet::from([])
2260                }
2261            );
2262            assert!(last_checked.load(Ordering::SeqCst));
2263        }
2264
2265        {
2266            let last_checked = AtomicBool::new(false);
2267            let last_checked = &last_checked;
2268            assert_eq!(
2269                recover_scan_idxes_for_activity(
2270                    TweakIdx(0),
2271                    &BTreeSet::from([TweakIdx(1), TweakIdx(2)]),
2272                    |cur_idx| async move {
2273                        Ok(match cur_idx {
2274                            TweakIdx(1) => panic!("Shouldn't happen: already used (1)"),
2275                            TweakIdx(2) => panic!("Shouldn't happen: already used (2)"),
2276                            TweakIdx(11) => {
2277                                last_checked.store(true, Ordering::SeqCst);
2278                                vec![]
2279                            }
2280                            TweakIdx(12) => panic!("Shouldn't happen"),
2281                            TweakIdx(13) => {
2282                                vec![0usize] /* just for type inference */
2283                            }
2284                            _ => vec![],
2285                        })
2286                    }
2287                )
2288                .await
2289                .unwrap(),
2290                RecoverScanOutcome {
2291                    last_used_idx: Some(TweakIdx(2)),
2292                    new_start_idx: TweakIdx(2 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2293                    tweak_idxes_with_pegins: BTreeSet::from([])
2294                }
2295            );
2296            assert!(last_checked.load(Ordering::SeqCst));
2297        }
2298
2299        {
2300            let last_checked = AtomicBool::new(false);
2301            let last_checked = &last_checked;
2302            assert_eq!(
2303                recover_scan_idxes_for_activity(
2304                    TweakIdx(10),
2305                    &BTreeSet::new(),
2306                    |cur_idx| async move {
2307                        Ok(match cur_idx {
2308                            TweakIdx(10) => vec![()],
2309                            TweakIdx(19) => {
2310                                last_checked.store(true, Ordering::SeqCst);
2311                                vec![]
2312                            }
2313                            TweakIdx(20) => panic!("Shouldn't happen"),
2314                            _ => vec![],
2315                        })
2316                    }
2317                )
2318                .await
2319                .unwrap(),
2320                RecoverScanOutcome {
2321                    last_used_idx: Some(TweakIdx(10)),
2322                    new_start_idx: TweakIdx(10 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2323                    tweak_idxes_with_pegins: BTreeSet::from([TweakIdx(10)])
2324                }
2325            );
2326            assert!(last_checked.load(Ordering::SeqCst));
2327        }
2328
2329        assert_eq!(
2330            recover_scan_idxes_for_activity(TweakIdx(0), &BTreeSet::new(), |cur_idx| async move {
2331                Ok(match cur_idx {
2332                    TweakIdx(6 | 15) => vec![()],
2333                    _ => vec![],
2334                })
2335            })
2336            .await
2337            .unwrap(),
2338            RecoverScanOutcome {
2339                last_used_idx: Some(TweakIdx(15)),
2340                new_start_idx: TweakIdx(15 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2341                tweak_idxes_with_pegins: BTreeSet::from([TweakIdx(6), TweakIdx(15)])
2342            }
2343        );
2344        assert_eq!(
2345            recover_scan_idxes_for_activity(TweakIdx(10), &BTreeSet::new(), |cur_idx| async move {
2346                Ok(match cur_idx {
2347                    TweakIdx(8) => {
2348                        vec![()] /* for type inference only */
2349                    }
2350                    TweakIdx(9) => {
2351                        panic!("Shouldn't happen")
2352                    }
2353                    _ => vec![],
2354                })
2355            })
2356            .await
2357            .unwrap(),
2358            RecoverScanOutcome {
2359                last_used_idx: None,
2360                new_start_idx: TweakIdx(9 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2361                tweak_idxes_with_pegins: BTreeSet::from([])
2362            }
2363        );
2364        assert_eq!(
2365            recover_scan_idxes_for_activity(TweakIdx(10), &BTreeSet::new(), |cur_idx| async move {
2366                Ok(match cur_idx {
2367                    TweakIdx(9) => panic!("Shouldn't happen"),
2368                    TweakIdx(15) => vec![()],
2369                    _ => vec![],
2370                })
2371            })
2372            .await
2373            .unwrap(),
2374            RecoverScanOutcome {
2375                last_used_idx: Some(TweakIdx(15)),
2376                new_start_idx: TweakIdx(15 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2377                tweak_idxes_with_pegins: BTreeSet::from([TweakIdx(15)])
2378            }
2379        );
2380    }
2381
2382    #[tokio::test]
2383    async fn store_supports_safe_deposit_tolerates_a_lost_race() {
2384        let db = Database::new(MemDatabase::new(), ModuleDecoderRegistry::default());
2385
2386        // Both opened before the competing write below, so committing either
2387        // of them must run into a write conflict.
2388        let racing_dbtx = db.begin_transaction().await;
2389        let mut sibling_dbtx = db.begin_transaction().await;
2390
2391        let mut competing_dbtx = db.begin_transaction().await;
2392        competing_dbtx
2393            .insert_entry(&SupportsSafeDepositKey, &())
2394            .await;
2395        competing_dbtx.commit_tx().await;
2396
2397        // Proves the setup: a plain commit of the stale sibling is rejected.
2398        sibling_dbtx
2399            .insert_entry(&SupportsSafeDepositKey, &())
2400            .await;
2401        assert!(matches!(
2402            sibling_dbtx.commit_tx_result().await,
2403            Err(DatabaseError::WriteConflict)
2404        ));
2405
2406        store_supports_safe_deposit(racing_dbtx).await;
2407
2408        assert!(supports_safe_deposit_verified(&db).await);
2409    }
2410}