Skip to main content

fedimint_wallet_client/
lib.rs

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