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