Skip to main content

fedimint_wallet_client/
lib.rs

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