Skip to main content

fedimint_walletv2_client/
lib.rs

1#![deny(clippy::pedantic)]
2#![allow(clippy::missing_errors_doc)]
3#![allow(clippy::missing_panics_doc)]
4#![allow(clippy::must_use_candidate)]
5#![allow(clippy::module_name_repetitions)]
6
7pub use fedimint_walletv2_common as common;
8
9mod api;
10#[cfg(feature = "cli")]
11mod cli;
12pub mod db;
13pub mod events;
14mod receive_sm;
15mod send_sm;
16
17use std::collections::{BTreeMap, BTreeSet};
18use std::sync::Arc;
19use std::time::Duration;
20
21use anyhow::anyhow;
22use api::WalletFederationApi;
23use bitcoin::address::NetworkUnchecked;
24use bitcoin::{Address, ScriptBuf};
25use db::{NextOutputIndexKey, ValidAddressIndexKey, ValidAddressIndexPrefix};
26use events::{ReceivePaymentEvent, SendPaymentEvent};
27use fedimint_api_client::api::{DynModuleApi, FederationResult};
28use fedimint_client::DynGlobalClientContext;
29use fedimint_client::transaction::{
30    ClientInput, ClientInputBundle, ClientInputSM, ClientOutput, ClientOutputBundle,
31    ClientOutputSM, FeeQuote, FeeQuoteRequest, TransactionBuilder, max_affordable_send_amount,
32};
33use fedimint_client_module::db::ClientModuleMigrationFn;
34use fedimint_client_module::module::init::{ClientModuleInit, ClientModuleInitArgs};
35use fedimint_client_module::module::recovery::NoModuleBackup;
36use fedimint_client_module::module::{ClientContext, ClientModule, OutPointRange};
37use fedimint_client_module::sm::{Context, DynState, ModuleNotifier, State, StateTransition};
38use fedimint_client_module::sm_enum_variant_translation;
39use fedimint_core::core::{IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
40use fedimint_core::db::{
41    Database, DatabaseTransaction, DatabaseVersion, IDatabaseTransactionOpsCoreTyped,
42};
43use fedimint_core::encoding::{Decodable, Encodable};
44use fedimint_core::module::{
45    AmountUnit, Amounts, ApiVersion, CommonModuleInit, ModuleCommon, ModuleInit, MultiApiVersion,
46};
47use fedimint_core::task::{TaskGroup, TaskHandle, sleep};
48use fedimint_core::{Amount, OutPoint, TransactionId, apply, async_trait_maybe_send};
49use fedimint_derive_secret::{ChildId, DerivableSecret};
50use fedimint_eventlog::{Event, EventLogId};
51use fedimint_logging::LOG_CLIENT_MODULE_WALLETV2;
52use fedimint_walletv2_common::config::WalletClientConfig;
53use fedimint_walletv2_common::{
54    KIND, OutputInfo, StandardScript, TxInfo, WalletCommonInit, WalletInput, WalletInputV0,
55    WalletModuleTypes, WalletOutput, WalletOutputV0, descriptor, is_potential_receive,
56};
57use futures::StreamExt;
58use receive_sm::{ReceiveSMCommon, ReceiveSMState, ReceiveStateMachine};
59use secp256k1::Keypair;
60use send_sm::{SendSMCommon, SendSMState, SendStateMachine};
61use serde::{Deserialize, Serialize};
62use strum::IntoEnumIterator as _;
63use thiserror::Error;
64use tracing::{debug, warn};
65
66/// Number of output info entries to scan per batch.
67const SLICE_SIZE: u64 = 1000;
68
69/// Number of event log entries to read per batch.
70const EVENT_LOG_PAGE_SIZE: u64 = 1000;
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub enum WalletOperationMeta {
74    Send(SendMeta),
75    Receive(ReceiveMeta),
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct SendMeta {
80    pub change_outpoint_range: OutPointRange,
81    pub address: Address<NetworkUnchecked>,
82    pub value: bitcoin::Amount,
83    pub fee: bitcoin::Amount,
84    #[serde(default)]
85    pub custom_meta: serde_json::Value,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ReceiveMeta {
90    pub change_outpoint_range: OutPointRange,
91    pub value: bitcoin::Amount,
92    pub fee: bitcoin::Amount,
93    pub address: Option<Address<NetworkUnchecked>>,
94    pub outpoint: Option<bitcoin::OutPoint>,
95}
96
97/// The final state of an operation sending bitcoin onchain.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub enum FinalSendOperationState {
100    /// The transaction was successful.
101    Success(bitcoin::Txid),
102    /// The funding transaction was aborted.
103    Aborted,
104    /// A programming error has occurred or the federation is malicious.
105    Failure,
106}
107
108/// The final state of an operation receiving bitcoin onchain.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub enum FinalReceiveOperationState {
111    /// The federation accepted the claiming transaction.
112    Success,
113    /// The federation rejected the claiming transaction.
114    Aborted,
115}
116
117#[derive(Debug, Clone)]
118pub struct WalletClientModule {
119    root_secret: DerivableSecret,
120    cfg: WalletClientConfig,
121    notifier: ModuleNotifier<WalletClientStateMachines>,
122    client_ctx: ClientContext<Self>,
123    db: Database,
124    module_api: DynModuleApi,
125}
126
127#[derive(Debug, Clone)]
128pub struct WalletClientContext {
129    pub client_ctx: ClientContext<WalletClientModule>,
130}
131
132impl Context for WalletClientContext {
133    const KIND: Option<ModuleKind> = Some(KIND);
134}
135
136#[apply(async_trait_maybe_send!)]
137impl ClientModule for WalletClientModule {
138    type Init = WalletClientInit;
139    type Common = WalletModuleTypes;
140    type Backup = NoModuleBackup;
141    type ModuleStateMachineContext = WalletClientContext;
142    type States = WalletClientStateMachines;
143
144    fn context(&self) -> Self::ModuleStateMachineContext {
145        WalletClientContext {
146            client_ctx: self.client_ctx.clone(),
147        }
148    }
149
150    fn input_fee(
151        &self,
152        amount: &Amounts,
153        _input: &<Self::Common as ModuleCommon>::Input,
154    ) -> Option<Amounts> {
155        amount
156            .get(&AmountUnit::BITCOIN)
157            .map(|a| Amounts::new_bitcoin(self.cfg.fee_consensus.fee(*a)))
158    }
159
160    fn output_fee(
161        &self,
162        amount: &Amounts,
163        _output: &<Self::Common as ModuleCommon>::Output,
164    ) -> Option<Amounts> {
165        amount
166            .get(&AmountUnit::BITCOIN)
167            .map(|a| Amounts::new_bitcoin(self.cfg.fee_consensus.fee(*a)))
168    }
169
170    #[cfg(feature = "cli")]
171    async fn handle_cli_command(
172        &self,
173        args: &[std::ffi::OsString],
174    ) -> anyhow::Result<serde_json::Value> {
175        cli::handle_cli_command(self, args).await
176    }
177}
178
179#[derive(Debug, Clone, Default)]
180pub struct WalletClientInit;
181
182impl ModuleInit for WalletClientInit {
183    type Common = WalletCommonInit;
184
185    async fn dump_database(
186        &self,
187        _dbtx: &mut DatabaseTransaction<'_>,
188        _prefix_names: Vec<String>,
189    ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
190        Box::new(BTreeMap::new().into_iter())
191    }
192}
193
194#[apply(async_trait_maybe_send!)]
195impl ClientModuleInit for WalletClientInit {
196    type Module = WalletClientModule;
197
198    fn supported_api_versions(&self) -> MultiApiVersion {
199        MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
200            .expect("no version conflicts")
201    }
202
203    async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
204        let module = WalletClientModule {
205            root_secret: args.module_root_secret().clone(),
206            cfg: args.cfg().clone(),
207            notifier: args.notifier().clone(),
208            client_ctx: args.context(),
209            db: args.db().clone(),
210            module_api: args.module_api().clone(),
211        };
212
213        module.spawn_output_scanner(args.task_group(), args.client_span());
214
215        Ok(module)
216    }
217
218    fn get_database_migrations(&self) -> BTreeMap<DatabaseVersion, ClientModuleMigrationFn> {
219        BTreeMap::new()
220    }
221
222    fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
223        Some(db::DbKeyPrefix::iter().map(|p| p as u8).collect())
224    }
225}
226
227impl WalletClientModule {
228    /// Returns the Bitcoin network for this federation.
229    pub fn get_network(&self) -> bitcoin::Network {
230        self.cfg.network
231    }
232
233    /// Fetch the total value of bitcoin controlled by the federation.
234    pub async fn total_value(&self) -> FederationResult<bitcoin::Amount> {
235        self.module_api
236            .federation_wallet()
237            .await
238            .map(|tx_out| tx_out.map_or(bitcoin::Amount::ZERO, |tx_out| tx_out.value))
239    }
240
241    /// Fetch the consensus block count of the federation.
242    pub async fn block_count(&self) -> FederationResult<u64> {
243        self.module_api.consensus_block_count().await
244    }
245
246    /// Fetch the current consensus feerate.
247    pub async fn feerate(&self) -> FederationResult<Option<u64>> {
248        self.module_api.consensus_feerate().await
249    }
250
251    /// Fetch information on the chain of pending bitcoin transactions.
252    pub async fn pending_tx_chain(&self) -> FederationResult<Vec<TxInfo>> {
253        self.module_api.pending_tx_chain().await
254    }
255
256    /// Display log of bitcoin transactions.
257    pub async fn tx_chain(&self) -> FederationResult<Vec<TxInfo>> {
258        self.module_api.tx_chain().await
259    }
260
261    /// Fetch the current fee required to send an onchain payment.
262    pub async fn send_fee(&self) -> Result<bitcoin::Amount, SendError> {
263        self.module_api
264            .send_fee()
265            .await
266            .map_err(|e| SendError::FederationError(e.to_string()))?
267            .ok_or(SendError::NoConsensusFeerateAvailable)
268    }
269
270    /// Computes the federation fee an onchain send of an output worth `amount`
271    /// (the payment amount plus the on-chain miner fee) would incur, without
272    /// submitting anything.
273    ///
274    /// A send submits a single wallet output worth `amount`; the primary module
275    /// balances it by spending ecash to fund the output and minting any change.
276    /// This quotes the fee of that transaction — the wallet output fee, the
277    /// mint input fees on the funding notes, any mint change output fees,
278    /// and sub-denomination dust — via the shared, module-agnostic fee
279    /// quote.
280    ///
281    /// The on-chain Bitcoin miner fee is deliberately excluded: it is part of
282    /// the output `amount` (see [`Self::send_fee`]), not the on-federation
283    /// transaction fee.
284    pub async fn send_fee_quote(&self, amount: bitcoin::Amount) -> anyhow::Result<FeeQuote> {
285        let amount = Amount::from_sats(amount.to_sat());
286        self.client_ctx
287            .fee_quote(
288                OperationId::new_random(),
289                FeeQuoteRequest {
290                    input_amount: Amounts::ZERO,
291                    output_amount: Amounts::new_bitcoin(amount),
292                    input_fee: Amounts::ZERO,
293                    output_fee: Amounts::new_bitcoin(self.cfg.fee_consensus.fee(amount)),
294                },
295            )
296            .await
297    }
298
299    /// Finds the largest value that can be sent on chain in full out of
300    /// `balance` — the amount a "send everything" sweep should use.
301    ///
302    /// Sending `value` costs `value + fee` (the on-chain miner fee is carried
303    /// inside the wallet output, see [`Self::send`]) *plus* the federation fee
304    /// of funding that output — the wallet output fee, the mint input fees on
305    /// the funding notes, any mint change output fees and sub-denomination
306    /// dust — as quoted by [`Self::send_fee_quote`]. This returns the largest
307    /// `value` satisfying
308    ///
309    /// ```text
310    /// value + fee + send_fee_quote(value + fee).total() <= balance
311    /// ```
312    ///
313    /// `balance` is the client's current Bitcoin balance (e.g. from
314    /// `Client::get_balance_for_btc`). `fee` is the on-chain fee from
315    /// [`Self::send_fee`]; pass the *same* value on to [`Self::send`], since
316    /// the required feerate rises with each pending federation transaction and
317    /// a value computed against a stale fee would be rejected.
318    ///
319    /// The maximum is found by binary search over the real fee quote (see
320    /// [`max_affordable_send_amount`]) rather than by subtracting a single
321    /// quote: the federation fee is charged per note, so note selection,
322    /// denomination rounding, change and dust move it in steps as the value
323    /// crosses thresholds, and a quote taken at the full balance would fail
324    /// outright — funding it is the very thing that is unaffordable.
325    ///
326    /// The quote is point-in-time and moves with the balance; [`Self::send`]
327    /// remains the source of truth. Note that it cannot account for the
328    /// federation's own on-chain constraints — a send whose change UTXO would
329    /// fall below the dust limit is still rejected by the guardians.
330    ///
331    /// Returns an error if the balance cannot cover even the dust limit plus
332    /// fees.
333    pub async fn max_sendable_amount(
334        &self,
335        balance: Amount,
336        fee: bitcoin::Amount,
337    ) -> anyhow::Result<bitcoin::Amount> {
338        let fee_msats = Amount::from_sats(fee.to_sat());
339
340        let max = max_affordable_send_amount(
341            balance,
342            Amount::from_sats(self.cfg.dust_limit.to_sat()),
343            balance,
344            // The solver searches millisatoshis, but a send funds a whole
345            // number of satoshis. Rounding the probe up to the next satoshi
346            // keeps the predicate conservative and makes the value handed to
347            // the quote below an exact satoshi multiple.
348            |value: Amount| Amount::from_sats(value.msats.div_ceil(1000)) + fee_msats,
349            |funded: Amount| self.send_fee_quote(bitcoin::Amount::from_sat(funded.msats / 1000)),
350        )
351        .await
352        .ok_or_else(|| anyhow!("Balance is too low to send any amount on chain after fees"))?;
353
354        // `gross_up` rounded up to whole satoshis, so the largest affordable
355        // amount already sits on a satoshi boundary; no value is lost here.
356        Ok(bitcoin::Amount::from_sat(max.msats.div_ceil(1000)))
357    }
358
359    /// Fetch the current fee required to claim an onchain deposit (peg-in).
360    pub async fn receive_fee(&self) -> Result<bitcoin::Amount, ReceiveError> {
361        self.module_api
362            .receive_fee()
363            .await
364            .map_err(|e| ReceiveError::FederationError(e.to_string()))?
365            .ok_or(ReceiveError::NoConsensusFeerateAvailable)
366    }
367
368    /// Send an onchain payment with the given fee.
369    pub async fn send(
370        &self,
371        address: Address<NetworkUnchecked>,
372        value: bitcoin::Amount,
373        fee: Option<bitcoin::Amount>,
374        custom_meta: serde_json::Value,
375    ) -> Result<OperationId, SendError> {
376        if !address.is_valid_for_network(self.cfg.network) {
377            return Err(SendError::WrongNetwork);
378        }
379
380        if value < self.cfg.dust_limit {
381            return Err(SendError::DustValue);
382        }
383
384        let fee = match fee {
385            Some(value) => value,
386            None => self
387                .module_api
388                .send_fee()
389                .await
390                .map_err(|e| SendError::FederationError(e.to_string()))?
391                .ok_or(SendError::NoConsensusFeerateAvailable)?,
392        };
393
394        let operation_id = OperationId::new_random();
395
396        let destination = StandardScript::from_address(&address.clone().assume_checked())
397            .ok_or(SendError::UnsupportedAddress)?;
398
399        let client_output = ClientOutput::<WalletOutput> {
400            output: WalletOutput::V0(WalletOutputV0 {
401                destination,
402                value,
403                fee,
404            }),
405            amounts: Amounts::new_bitcoin(Amount::from_sats((value + fee).to_sat())),
406        };
407
408        let client_output_sm = ClientOutputSM::<WalletClientStateMachines> {
409            state_machines: Arc::new(move |range: OutPointRange| {
410                vec![WalletClientStateMachines::Send(SendStateMachine {
411                    common: SendSMCommon {
412                        operation_id,
413                        outpoint: OutPoint {
414                            txid: range.txid(),
415                            out_idx: 0,
416                        },
417                        value,
418                        fee,
419                    },
420                    state: SendSMState::Funding,
421                })]
422            }),
423        };
424
425        let client_output_bundle = self.client_ctx.make_client_outputs(ClientOutputBundle::new(
426            vec![client_output],
427            vec![client_output_sm],
428        ));
429
430        let address_clone = address.clone();
431
432        self.client_ctx
433            .finalize_and_submit_transaction(
434                operation_id,
435                WalletCommonInit::KIND.as_str(),
436                move |change_outpoint_range| {
437                    WalletOperationMeta::Send(SendMeta {
438                        change_outpoint_range,
439                        address: address_clone.clone(),
440                        value,
441                        fee,
442                        custom_meta: custom_meta.clone(),
443                    })
444                },
445                TransactionBuilder::new().with_outputs(client_output_bundle),
446            )
447            .await
448            .map_err(|_| SendError::InsufficientFunds)?;
449
450        let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
451
452        self.client_ctx
453            .log_event(
454                &mut dbtx,
455                SendPaymentEvent {
456                    operation_id,
457                    address,
458                    value,
459                    fee,
460                },
461            )
462            .await;
463
464        dbtx.commit_tx().await;
465
466        Ok(operation_id)
467    }
468
469    /// Await the final state of the send operation.
470    pub async fn await_final_send_operation_state(
471        &self,
472        operation_id: OperationId,
473    ) -> anyhow::Result<FinalSendOperationState> {
474        let operation = self.client_ctx.get_operation(operation_id).await?;
475        let mut stream = self.notifier.subscribe(operation_id).await;
476
477        let mut stream = self
478            .client_ctx
479            .outcome_or_updates(&operation, operation_id, |_| true, move || {
480                async_stream::stream! {
481                    loop {
482                        if let Some(WalletClientStateMachines::Send(state)) = stream.next().await {
483                            match state.state {
484                                SendSMState::Funding => {}
485                                SendSMState::Success(txid) => {
486                                    yield FinalSendOperationState::Success(txid);
487                                    return;
488                                }
489                                SendSMState::Aborted(..) => {
490                                    yield FinalSendOperationState::Aborted;
491                                    return;
492                                }
493                                SendSMState::Failure => {
494                                    yield FinalSendOperationState::Failure;
495                                    return;
496                                }
497                            }
498                        }
499                    }
500                }
501            })
502            .into_stream();
503
504        let mut final_state = None;
505
506        while let Some(state) = stream.next().await {
507            final_state = Some(state);
508        }
509
510        Ok(final_state.expect("Stream contains one final state"))
511    }
512
513    /// Await the final state of the receive operation.
514    pub async fn await_final_receive_operation_state(
515        &self,
516        operation_id: OperationId,
517    ) -> anyhow::Result<FinalReceiveOperationState> {
518        let operation = self.client_ctx.get_operation(operation_id).await?;
519        let mut stream = self.notifier.subscribe(operation_id).await;
520
521        let mut stream = self
522            .client_ctx
523            .outcome_or_updates(&operation, operation_id, |_| true, move || {
524                async_stream::stream! {
525                    loop {
526                        if let Some(WalletClientStateMachines::Receive(state)) = stream.next().await {
527                            match state.state {
528                                ReceiveSMState::Funding => {}
529                                ReceiveSMState::Success => {
530                                    yield FinalReceiveOperationState::Success;
531                                    return;
532                                }
533                                ReceiveSMState::Aborted(..) => {
534                                    yield FinalReceiveOperationState::Aborted;
535                                    return;
536                                }
537                            }
538                        }
539                    }
540                }
541            })
542            .into_stream();
543
544        let mut final_state = None;
545
546        while let Some(state) = stream.next().await {
547            final_state = Some(state);
548        }
549
550        Ok(final_state.expect("Stream contains one final state"))
551    }
552
553    /// Returns the highest valid receive address index that the background
554    /// scanner has derived so far, or `None` if it has not derived one yet.
555    async fn valid_index(&self) -> Option<u64> {
556        self.db
557            .begin_transaction_nc()
558            .await
559            .find_by_prefix_sorted_descending(&ValidAddressIndexPrefix)
560            .await
561            .next()
562            .await
563            .map(|entry| entry.0.0)
564    }
565
566    /// Returns the next unused receive address.
567    ///
568    /// To wait for a payment to this address race-free, read the client's
569    /// current event log position (via the global `get_next_event_log_id`)
570    /// *before* calling this, then pass that position to
571    /// [`Self::await_receive`]; it will only consider payments received
572    /// after that position.
573    ///
574    /// If the background scanner has already derived a valid address index this
575    /// returns immediately. Otherwise it blocks, letting the scanner grind
576    /// until it finds the next valid index, and returns once one is
577    /// available.
578    pub async fn receive(&self) -> Address {
579        loop {
580            if let Some(index) = self.valid_index().await {
581                return self.derive_address(index);
582            }
583
584            sleep(Duration::from_secs(1)).await;
585        }
586    }
587
588    /// Block until the next on-chain payment recorded at or after `position` is
589    /// received and successfully claimed by the federation.
590    ///
591    /// Returns the peg-in's final state together with the event log position
592    /// just past it, so that a subsequent call can resume from there to wait
593    /// for the following receive.
594    ///
595    /// A peg-in attempt may be aborted (rejected by the federation), in which
596    /// case the still-unspent output is reprocessed into a new receive
597    /// operation; this keeps waiting until one succeeds.
598    pub async fn await_receive(
599        &self,
600        position: EventLogId,
601    ) -> anyhow::Result<(FinalReceiveOperationState, EventLogId)> {
602        let mut position = position;
603
604        loop {
605            let (operation_id, next_position) = self.next_receive_operation(position).await;
606
607            position = next_position;
608
609            let state = self
610                .await_final_receive_operation_state(operation_id)
611                .await?;
612
613            // A successful peg-in is terminal; an aborted one is retried as a
614            // new receive operation, so keep waiting.
615            if state == FinalReceiveOperationState::Success {
616                // Reaching `Success` only means the peg-in claim transaction was
617                // accepted into consensus. The ecash it mints is issued
618                // asynchronously by the primary module, so wait for those
619                // outputs before returning; otherwise the freshly claimed funds
620                // may not yet be reflected in the client's balance.
621                let operation = self.client_ctx.get_operation(operation_id).await?;
622
623                if let WalletOperationMeta::Receive(ReceiveMeta {
624                    change_outpoint_range,
625                    ..
626                }) = operation.meta::<WalletOperationMeta>()
627                {
628                    self.client_ctx
629                        .await_primary_module_outputs(
630                            operation_id,
631                            change_outpoint_range.into_iter().collect(),
632                        )
633                        .await?;
634                }
635
636                return Ok((state, position));
637            }
638        }
639    }
640
641    /// Scan the event log from `position` for the next [`ReceivePaymentEvent`],
642    /// blocking until one is found, and return its operation id together with
643    /// the event log position just past it.
644    async fn next_receive_operation(&self, position: EventLogId) -> (OperationId, EventLogId) {
645        let mut position = position;
646
647        loop {
648            let events = self
649                .client_ctx
650                .get_event_log(Some(position), EVENT_LOG_PAGE_SIZE)
651                .await;
652
653            for entry in &events {
654                position = entry.id().saturating_add(1);
655
656                if entry.module_kind() == Some(&KIND)
657                    && entry.kind == ReceivePaymentEvent::KIND
658                    && let Some(event) = entry.to_event::<ReceivePaymentEvent>()
659                {
660                    return (event.operation_id, position);
661                }
662            }
663
664            if events.is_empty() {
665                // Caught up with the log; wait for new events to be written.
666                sleep(Duration::from_secs(1)).await;
667            }
668        }
669    }
670
671    fn derive_address(&self, index: u64) -> Address {
672        descriptor(
673            &self.cfg.bitcoin_pks,
674            &self.derive_tweak(index).public_key().consensus_hash(),
675        )
676        .address(self.cfg.network)
677    }
678
679    fn derive_tweak(&self, index: u64) -> Keypair {
680        self.root_secret
681            .child_key(ChildId(index))
682            .to_secp_key(secp256k1::SECP256K1)
683    }
684
685    /// Find the next valid index starting from (and including) `start_index`.
686    ///
687    /// Only ~1/65536 indices are valid, so the search is CPU-bound and may scan
688    /// many indices before finding one. The scan runs in bounded batches and
689    /// yields to the executor between them, so it does not stall the runtime —
690    /// important on wasm, which is single-threaded. It stops and returns `None`
691    /// once the task group begins shutting down.
692    async fn next_valid_index(&self, start_index: u64, handle: &TaskHandle) -> Option<u64> {
693        /// Indices to scan per batch before yielding to the executor.
694        const SCAN_BATCH: u64 = 256;
695
696        let pks_hash = self.cfg.bitcoin_pks.consensus_hash();
697
698        let mut index = start_index;
699
700        while !handle.is_shutting_down() {
701            for _ in 0..SCAN_BATCH {
702                if is_potential_receive(&self.derive_address(index).script_pubkey(), &pks_hash) {
703                    return Some(index);
704                }
705
706                index += 1;
707            }
708
709            // Hand control back to the executor between batches.
710            sleep(Duration::ZERO).await;
711        }
712
713        None
714    }
715
716    /// Issue ecash for an unspent output with a given fee.
717    ///
718    /// Returns `None` if the output value cannot cover the fee, or if the
719    /// remainder is too small to fund the claim transaction's fees.
720    async fn receive_output(
721        &self,
722        output_index: u64,
723        value: bitcoin::Amount,
724        address_index: u64,
725        fee: bitcoin::Amount,
726        outpoint: Option<bitcoin::OutPoint>,
727    ) -> Option<(OperationId, TransactionId)> {
728        let operation_id = OperationId::new_random();
729
730        let client_input = ClientInput::<WalletInput> {
731            input: WalletInput::V0(WalletInputV0 {
732                output_index,
733                fee,
734                tweak: self.derive_tweak(address_index).public_key(),
735            }),
736            keys: vec![self.derive_tweak(address_index)],
737            amounts: Amounts::new_bitcoin(Amount::from_sats(value.checked_sub(fee)?.to_sat())),
738        };
739
740        let client_input_sm = ClientInputSM::<WalletClientStateMachines> {
741            state_machines: Arc::new(move |range: OutPointRange| {
742                vec![WalletClientStateMachines::Receive(ReceiveStateMachine {
743                    common: ReceiveSMCommon {
744                        operation_id,
745                        txid: range.txid(),
746                        value,
747                        fee,
748                    },
749                    state: ReceiveSMState::Funding,
750                })]
751            }),
752        };
753
754        let client_input_bundle = self.client_ctx.make_client_inputs(ClientInputBundle::new(
755            vec![client_input],
756            vec![client_input_sm],
757        ));
758
759        let address = self.derive_address(address_index).as_unchecked().clone();
760
761        let meta_address = address.clone();
762        let range = self
763            .client_ctx
764            .finalize_and_submit_transaction(
765                operation_id,
766                WalletCommonInit::KIND.as_str(),
767                move |change_outpoint_range| {
768                    WalletOperationMeta::Receive(ReceiveMeta {
769                        change_outpoint_range,
770                        value,
771                        fee,
772                        address: Some(meta_address.clone()),
773                        outpoint,
774                    })
775                },
776                TransactionBuilder::new().with_inputs(client_input_bundle),
777            )
778            .await
779            .ok()?;
780
781        let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
782
783        self.client_ctx
784            .log_event(
785                &mut dbtx,
786                ReceivePaymentEvent {
787                    operation_id,
788                    value,
789                    fee,
790                    address,
791                    outpoint,
792                },
793            )
794            .await;
795
796        dbtx.commit_tx().await;
797
798        Some((operation_id, range.txid()))
799    }
800
801    fn spawn_output_scanner(&self, task_group: &TaskGroup, client_span: &tracing::Span) {
802        let module = self.clone();
803        let handle = task_group.make_handle();
804
805        task_group.spawn_cancellable_with_span(client_span.clone(), "output-scanner", async move {
806            let mut dbtx = module.db.begin_transaction().await;
807
808            if dbtx
809                .find_by_prefix(&ValidAddressIndexPrefix)
810                .await
811                .next()
812                .await
813                .is_none()
814            {
815                let Some(index) = module.next_valid_index(0, &handle).await else {
816                    return;
817                };
818
819                dbtx.insert_new_entry(&ValidAddressIndexKey(index), &())
820                    .await;
821            }
822
823            dbtx.commit_tx().await;
824
825            loop {
826                match module.check_outputs(&handle).await {
827                    Ok(skip_wait) => {
828                        if skip_wait {
829                            continue;
830                        }
831                    }
832                    Err(e) => {
833                        warn!(target: LOG_CLIENT_MODULE_WALLETV2, "Failed to fetch outputs: {e}");
834                    }
835                }
836
837                sleep(fedimint_walletv2_common::sleep_duration()).await;
838            }
839        });
840    }
841
842    async fn check_outputs(&self, handle: &TaskHandle) -> anyhow::Result<bool> {
843        let mut dbtx = self.db.begin_transaction_nc().await;
844
845        let next_output_index = dbtx.get_value(&NextOutputIndexKey).await.unwrap_or(0);
846
847        let mut valid_indices: Vec<u64> = dbtx
848            .find_by_prefix(&ValidAddressIndexPrefix)
849            .await
850            .map(|entry| entry.0.0)
851            .collect()
852            .await;
853
854        let mut address_map: BTreeMap<ScriptBuf, u64> = valid_indices
855            .iter()
856            .map(|&i| (self.derive_address(i).script_pubkey(), i))
857            .collect();
858
859        let outputs = self
860            .module_api
861            .output_info_slice(next_output_index, next_output_index + SLICE_SIZE)
862            .await?;
863
864        let returned_num = outputs.len();
865        let mut matched_num: usize = 0;
866
867        for output in &outputs {
868            if let Some(&address_index) = address_map.get(&output.script) {
869                matched_num += 1;
870
871                // Claim before extending the valid index list: the index search
872                // below is CPU-bound and can take longer than a short-lived
873                // client process (e.g. a cli invocation) lives. The claim is
874                // quick and the extension can be retried on the next scan.
875                if !output.spent && !self.process_unspent_output(output, address_index).await? {
876                    return Ok(false);
877                }
878
879                let next_address_index = valid_indices
880                    .last()
881                    .copied()
882                    .expect("we have at least one address index");
883
884                // If we used the highest valid index, add the next valid one
885                if address_index == next_address_index {
886                    let Some(index) = self.next_valid_index(next_address_index + 1, handle).await
887                    else {
888                        return Ok(false);
889                    };
890
891                    let mut dbtx = self.db.begin_transaction().await;
892
893                    dbtx.insert_entry(&ValidAddressIndexKey(index), &()).await;
894
895                    dbtx.commit_tx_result().await?;
896
897                    valid_indices.push(index);
898
899                    address_map.insert(self.derive_address(index).script_pubkey(), index);
900                }
901            }
902
903            let mut dbtx = self.db.begin_transaction().await;
904
905            dbtx.insert_entry(&NextOutputIndexKey, &(output.index + 1))
906                .await;
907
908            dbtx.commit_tx_result().await?;
909        }
910
911        debug!(
912            target: LOG_CLIENT_MODULE_WALLETV2,
913            next_output_index,
914            returned_num,
915            matched_num,
916            valid_indices_num = valid_indices.len(),
917            "Scanning for outputs"
918        );
919
920        Ok(!outputs.is_empty())
921    }
922
923    async fn process_unspent_output(
924        &self,
925        output: &OutputInfo,
926        address_index: u64,
927    ) -> anyhow::Result<bool> {
928        debug!(
929            target: LOG_CLIENT_MODULE_WALLETV2,
930            output_index = output.index,
931            value_sat = output.value.to_sat(),
932            address_index,
933            outpoint = ?output.outpoint,
934            "Discovered unspent walletv2 receive output"
935        );
936
937        // In order to not overpay on fees we choose to wait,
938        // the congestion will clear up within a few blocks.
939        let pending_tx_chain_len = self.module_api.pending_tx_chain().await?.len();
940        if 3 <= pending_tx_chain_len {
941            debug!(
942                target: LOG_CLIENT_MODULE_WALLETV2,
943                output_index = output.index,
944                pending_tx_chain_len,
945                "Delaying walletv2 receive claim because pending transaction chain is full"
946            );
947            return Ok(false);
948        }
949
950        let receive_fee = self
951            .module_api
952            .receive_fee()
953            .await?
954            .ok_or(anyhow!("No consensus feerate is available"))?;
955
956        if let Some((operation_id, txid)) = self
957            .receive_output(
958                output.index,
959                output.value,
960                address_index,
961                receive_fee,
962                output.outpoint,
963            )
964            .await
965        {
966            debug!(
967                target: LOG_CLIENT_MODULE_WALLETV2,
968                output_index = output.index,
969                ?operation_id,
970                %txid,
971                "Waiting for walletv2 receive claim acceptance"
972            );
973            self.client_ctx
974                .transaction_updates(operation_id)
975                .await
976                .await_tx_accepted(txid)
977                .await
978                .map_err(|e| anyhow!("Claim transaction was rejected: {e}"))?;
979            debug!(
980                target: LOG_CLIENT_MODULE_WALLETV2,
981                output_index = output.index,
982                ?operation_id,
983                %txid,
984                "Walletv2 receive claim accepted"
985            );
986        } else {
987            debug!(
988                target: LOG_CLIENT_MODULE_WALLETV2,
989                output_index = output.index,
990                value_sat = output.value.to_sat(),
991                fee_sat = receive_fee.to_sat(),
992                "Skipping walletv2 receive claim; value cannot cover the claim fees"
993            );
994        }
995
996        Ok(true)
997    }
998}
999
1000#[derive(Error, Debug, Clone, Eq, PartialEq)]
1001pub enum SendError {
1002    #[error("Address is from a different network than the federation.")]
1003    WrongNetwork,
1004    #[error("The value is too small")]
1005    DustValue,
1006    #[error("Federation returned an error: {0}")]
1007    FederationError(String),
1008    #[error("No consensus feerate is available at this time")]
1009    NoConsensusFeerateAvailable,
1010    #[error("The client does not have sufficient funds to send the payment")]
1011    InsufficientFunds,
1012    #[error("Unsupported address type")]
1013    UnsupportedAddress,
1014}
1015
1016#[derive(Error, Debug, Clone, Eq, PartialEq)]
1017pub enum ReceiveError {
1018    #[error("Federation returned an error: {0}")]
1019    FederationError(String),
1020    #[error("No consensus feerate is available at this time")]
1021    NoConsensusFeerateAvailable,
1022}
1023
1024#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
1025pub enum WalletClientStateMachines {
1026    Send(send_sm::SendStateMachine),
1027    Receive(receive_sm::ReceiveStateMachine),
1028}
1029
1030impl State for WalletClientStateMachines {
1031    type ModuleContext = WalletClientContext;
1032
1033    fn transitions(
1034        &self,
1035        context: &Self::ModuleContext,
1036        global_context: &DynGlobalClientContext,
1037    ) -> Vec<StateTransition<Self>> {
1038        match self {
1039            WalletClientStateMachines::Send(sm) => sm_enum_variant_translation!(
1040                sm.transitions(context, global_context),
1041                WalletClientStateMachines::Send
1042            ),
1043            WalletClientStateMachines::Receive(sm) => sm_enum_variant_translation!(
1044                sm.transitions(context, global_context),
1045                WalletClientStateMachines::Receive
1046            ),
1047        }
1048    }
1049
1050    fn operation_id(&self) -> OperationId {
1051        match self {
1052            WalletClientStateMachines::Send(sm) => sm.operation_id(),
1053            WalletClientStateMachines::Receive(sm) => sm.operation_id(),
1054        }
1055    }
1056}
1057
1058impl IntoDynInstance for WalletClientStateMachines {
1059    type DynType = DynState;
1060
1061    fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
1062        DynState::from_typed(instance_id, self)
1063    }
1064}