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