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