Skip to main content

fedimint_wallet_client/
lib.rs

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