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;
12mod db;
13pub mod events;
14mod receive_sm;
15mod send_sm;
16
17use std::collections::{BTreeMap, BTreeSet};
18use std::sync::Arc;
19
20use anyhow::anyhow;
21use api::WalletFederationApi;
22use bitcoin::address::NetworkUnchecked;
23use bitcoin::{Address, ScriptBuf};
24use db::{NextDepositIndexKey, ValidAddressIndexKey, ValidAddressIndexPrefix};
25use events::{ReceivePaymentEvent, SendPaymentEvent};
26use fedimint_api_client::api::{DynModuleApi, FederationResult};
27use fedimint_client::DynGlobalClientContext;
28use fedimint_client::transaction::{
29    ClientInput, ClientInputBundle, ClientInputSM, ClientOutput, ClientOutputBundle,
30    ClientOutputSM, TransactionBuilder,
31};
32use fedimint_client_module::db::ClientModuleMigrationFn;
33use fedimint_client_module::module::init::{ClientModuleInit, ClientModuleInitArgs};
34use fedimint_client_module::module::recovery::NoModuleBackup;
35use fedimint_client_module::module::{ClientContext, ClientModule, OutPointRange};
36use fedimint_client_module::sm::{Context, DynState, ModuleNotifier, State, StateTransition};
37use fedimint_client_module::sm_enum_variant_translation;
38use fedimint_core::core::{IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
39use fedimint_core::db::{
40    Database, DatabaseTransaction, DatabaseVersion, IDatabaseTransactionOpsCoreTyped,
41};
42use fedimint_core::encoding::{Decodable, Encodable};
43use fedimint_core::module::{
44    AmountUnit, Amounts, ApiVersion, CommonModuleInit, ModuleCommon, ModuleInit, MultiApiVersion,
45};
46use fedimint_core::task::{TaskGroup, sleep};
47use fedimint_core::{Amount, OutPoint, TransactionId, apply, async_trait_maybe_send};
48use fedimint_derive_secret::{ChildId, DerivableSecret};
49use fedimint_logging::LOG_CLIENT_MODULE_WALLETV2;
50use fedimint_walletv2_common::config::WalletClientConfig;
51use fedimint_walletv2_common::{
52    KIND, StandardScript, TxInfo, WalletCommonInit, WalletInput, WalletInputV0, WalletModuleTypes,
53    WalletOutput, WalletOutputV0, descriptor, is_potential_receive,
54};
55use futures::StreamExt;
56use receive_sm::{ReceiveSMCommon, ReceiveSMState, ReceiveStateMachine};
57use secp256k1::Keypair;
58use send_sm::{SendSMCommon, SendSMState, SendStateMachine};
59use serde::{Deserialize, Serialize};
60use strum::IntoEnumIterator as _;
61use thiserror::Error;
62use tracing::{info, warn};
63
64/// Number of deposit log entries to scan per batch.
65const DEPOSIT_RANGE_SIZE: u64 = 1000;
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub enum WalletOperationMeta {
69    Send(SendMeta),
70    Receive(ReceiveMeta),
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SendMeta {
75    pub change_outpoint_range: OutPointRange,
76    pub address: Address<NetworkUnchecked>,
77    pub amount: bitcoin::Amount,
78    pub fee: bitcoin::Amount,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ReceiveMeta {
83    pub change_outpoint_range: OutPointRange,
84    pub amount: bitcoin::Amount,
85    pub fee: bitcoin::Amount,
86}
87
88/// The final state of an operation sending bitcoin on-chain.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub enum FinalSendOperationState {
91    /// The transaction was successful.
92    Success(bitcoin::Txid),
93    /// The funding transaction was aborted.
94    Aborted,
95    /// A programming error has occurred or the federation is malicious.
96    Failure,
97}
98
99#[derive(Debug, Clone)]
100pub struct WalletClientModule {
101    root_secret: DerivableSecret,
102    cfg: WalletClientConfig,
103    notifier: ModuleNotifier<WalletClientStateMachines>,
104    client_ctx: ClientContext<Self>,
105    db: Database,
106    module_api: DynModuleApi,
107}
108
109#[derive(Debug, Clone)]
110pub struct WalletClientContext {
111    pub client_ctx: ClientContext<WalletClientModule>,
112}
113
114impl Context for WalletClientContext {
115    const KIND: Option<ModuleKind> = Some(KIND);
116}
117
118#[apply(async_trait_maybe_send!)]
119impl ClientModule for WalletClientModule {
120    type Init = WalletClientInit;
121    type Common = WalletModuleTypes;
122    type Backup = NoModuleBackup;
123    type ModuleStateMachineContext = WalletClientContext;
124    type States = WalletClientStateMachines;
125
126    fn context(&self) -> Self::ModuleStateMachineContext {
127        WalletClientContext {
128            client_ctx: self.client_ctx.clone(),
129        }
130    }
131
132    fn input_fee(
133        &self,
134        amount: &Amounts,
135        _input: &<Self::Common as ModuleCommon>::Input,
136    ) -> Option<Amounts> {
137        amount
138            .get(&AmountUnit::BITCOIN)
139            .map(|a| Amounts::new_bitcoin(self.cfg.fee_consensus.fee(*a)))
140    }
141
142    fn output_fee(
143        &self,
144        amount: &Amounts,
145        _output: &<Self::Common as ModuleCommon>::Output,
146    ) -> Option<Amounts> {
147        amount
148            .get(&AmountUnit::BITCOIN)
149            .map(|a| Amounts::new_bitcoin(self.cfg.fee_consensus.fee(*a)))
150    }
151
152    #[cfg(feature = "cli")]
153    async fn handle_cli_command(
154        &self,
155        args: &[std::ffi::OsString],
156    ) -> anyhow::Result<serde_json::Value> {
157        cli::handle_cli_command(self, args).await
158    }
159}
160
161#[derive(Debug, Clone, Default)]
162pub struct WalletClientInit;
163
164impl ModuleInit for WalletClientInit {
165    type Common = WalletCommonInit;
166
167    async fn dump_database(
168        &self,
169        _dbtx: &mut DatabaseTransaction<'_>,
170        _prefix_names: Vec<String>,
171    ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
172        Box::new(BTreeMap::new().into_iter())
173    }
174}
175
176#[apply(async_trait_maybe_send!)]
177impl ClientModuleInit for WalletClientInit {
178    type Module = WalletClientModule;
179
180    fn supported_api_versions(&self) -> MultiApiVersion {
181        MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
182            .expect("no version conflicts")
183    }
184
185    async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
186        let module = WalletClientModule {
187            root_secret: args.module_root_secret().clone(),
188            cfg: args.cfg().clone(),
189            notifier: args.notifier().clone(),
190            client_ctx: args.context(),
191            db: args.db().clone(),
192            module_api: args.module_api().clone(),
193        };
194
195        module.spawn_deposit_scanner(args.task_group());
196
197        Ok(module)
198    }
199
200    fn get_database_migrations(&self) -> BTreeMap<DatabaseVersion, ClientModuleMigrationFn> {
201        BTreeMap::new()
202    }
203
204    fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
205        Some(db::DbKeyPrefix::iter().map(|p| p as u8).collect())
206    }
207}
208
209impl WalletClientModule {
210    /// Returns the Bitcoin network for this federation.
211    pub fn get_network(&self) -> bitcoin::Network {
212        self.cfg.network
213    }
214
215    /// Fetch the total value of bitcoin controlled by the federation.
216    pub async fn total_value(&self) -> FederationResult<bitcoin::Amount> {
217        self.module_api
218            .federation_wallet()
219            .await
220            .map(|tx_out| tx_out.map_or(bitcoin::Amount::ZERO, |tx_out| tx_out.value))
221    }
222
223    /// Fetch the consensus block count of the federation.
224    pub async fn block_count(&self) -> FederationResult<u64> {
225        self.module_api.consensus_block_count().await
226    }
227
228    /// Fetch the current consensus feerate.
229    pub async fn feerate(&self) -> FederationResult<Option<u64>> {
230        self.module_api.consensus_feerate().await
231    }
232
233    /// Fetch information on the chain of pending bitcoin transactions.
234    async fn pending_tx_chain(&self) -> FederationResult<Vec<TxInfo>> {
235        self.module_api.pending_tx_chain().await
236    }
237
238    /// Display log of bitcoin transactions.
239    async fn tx_chain(&self, n: usize) -> FederationResult<Vec<TxInfo>> {
240        self.module_api.tx_chain(n).await
241    }
242
243    /// Fetch the current fee required to send an on-chain payment.
244    pub async fn send_fee(&self) -> Result<bitcoin::Amount, SendError> {
245        self.module_api
246            .send_fee()
247            .await
248            .map_err(|e| SendError::FederationError(e.to_string()))?
249            .ok_or(SendError::NoConsensusFeerateAvailable)
250    }
251
252    /// Send an on-chain payment with the given fee.
253    pub async fn send(
254        &self,
255        address: Address<NetworkUnchecked>,
256        amount: bitcoin::Amount,
257        fee: Option<bitcoin::Amount>,
258    ) -> Result<OperationId, SendError> {
259        if !address.is_valid_for_network(self.cfg.network) {
260            return Err(SendError::WrongNetwork);
261        }
262
263        if amount < self.cfg.dust_limit {
264            return Err(SendError::DustAmount);
265        }
266
267        let fee = match fee {
268            Some(value) => value,
269            None => self
270                .module_api
271                .send_fee()
272                .await
273                .map_err(|e| SendError::FederationError(e.to_string()))?
274                .ok_or(SendError::NoConsensusFeerateAvailable)?,
275        };
276
277        let operation_id = OperationId::new_random();
278
279        let client_output = ClientOutput::<WalletOutput> {
280            output: WalletOutput::V0(WalletOutputV0 {
281                destination: StandardScript::from_address(&address.clone().assume_checked())
282                    .ok_or(SendError::UnsupportedAddress)?,
283                value: amount,
284                fee,
285            }),
286            amounts: Amounts::new_bitcoin(Amount::from_sats((amount + fee).to_sat())),
287        };
288
289        let client_output_sm = ClientOutputSM::<WalletClientStateMachines> {
290            state_machines: Arc::new(move |range: OutPointRange| {
291                vec![WalletClientStateMachines::Send(SendStateMachine {
292                    common: SendSMCommon {
293                        operation_id,
294                        outpoint: OutPoint {
295                            txid: range.txid(),
296                            out_idx: 0,
297                        },
298                        amount,
299                        fee,
300                    },
301                    state: SendSMState::Funding,
302                })]
303            }),
304        };
305
306        let client_output_bundle = self.client_ctx.make_client_outputs(ClientOutputBundle::new(
307            vec![client_output],
308            vec![client_output_sm],
309        ));
310
311        self.client_ctx
312            .finalize_and_submit_transaction(
313                operation_id,
314                WalletCommonInit::KIND.as_str(),
315                move |change_outpoint_range| {
316                    WalletOperationMeta::Send(SendMeta {
317                        change_outpoint_range,
318                        address: address.clone(),
319                        amount,
320                        fee,
321                    })
322                },
323                TransactionBuilder::new().with_outputs(client_output_bundle),
324            )
325            .await
326            .map_err(|_| SendError::InsufficientFunds)?;
327
328        let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
329
330        self.client_ctx
331            .log_event(
332                &mut dbtx,
333                SendPaymentEvent {
334                    operation_id,
335                    amount,
336                    fee,
337                },
338            )
339            .await;
340
341        dbtx.commit_tx().await;
342
343        Ok(operation_id)
344    }
345
346    /// Await the final state of the send operation.
347    pub async fn await_final_send_operation_state(
348        &self,
349        operation_id: OperationId,
350    ) -> FinalSendOperationState {
351        let mut stream = self.notifier.subscribe(operation_id).await;
352
353        loop {
354            let Some(WalletClientStateMachines::Send(state)) = stream.next().await else {
355                panic!("stream must produce a terminal send state");
356            };
357
358            match state.state {
359                SendSMState::Funding => {}
360                SendSMState::Success(txid) => return FinalSendOperationState::Success(txid),
361                SendSMState::Aborted(..) => return FinalSendOperationState::Aborted,
362                SendSMState::Failure => return FinalSendOperationState::Failure,
363            }
364        }
365    }
366
367    /// Returns the next unused address.
368    pub async fn receive(&self) -> Address {
369        if let Some(entry) = self
370            .db
371            .begin_transaction_nc()
372            .await
373            .find_by_prefix_sorted_descending(&ValidAddressIndexPrefix)
374            .await
375            .next()
376            .await
377        {
378            self.derive_address(entry.0.0)
379        } else {
380            self.derive_address(self.next_valid_index(0))
381        }
382    }
383
384    fn derive_address(&self, index: u64) -> Address {
385        descriptor(
386            &self.cfg.bitcoin_pks,
387            &self.derive_tweak(index).public_key().consensus_hash(),
388        )
389        .address(self.cfg.network)
390    }
391
392    fn derive_tweak(&self, index: u64) -> Keypair {
393        self.root_secret
394            .child_key(ChildId(index))
395            .to_secp_key(secp256k1::SECP256K1)
396    }
397
398    /// Find the next valid index starting from (and including) `start_index`.
399    #[allow(clippy::maybe_infinite_iter)]
400    fn next_valid_index(&self, start_index: u64) -> u64 {
401        let pks_hash = self.cfg.bitcoin_pks.consensus_hash();
402
403        (start_index..)
404            .find(|i| is_potential_receive(&self.derive_address(*i).script_pubkey(), &pks_hash))
405            .expect("Will always find a valid index")
406    }
407
408    /// Issue ecash for an unspent deposit with a given fee.
409    async fn receive_deposit(
410        &self,
411        deposit_index: u64,
412        amount: bitcoin::Amount,
413        address_index: u64,
414        fee: bitcoin::Amount,
415    ) -> (OperationId, TransactionId) {
416        let operation_id = OperationId::new_random();
417
418        let client_input = ClientInput::<WalletInput> {
419            input: WalletInput::V0(WalletInputV0 {
420                deposit_index,
421                fee,
422                tweak: self.derive_tweak(address_index).public_key(),
423            }),
424            keys: vec![self.derive_tweak(address_index)],
425            amounts: Amounts::new_bitcoin(Amount::from_sats((amount - fee).to_sat())),
426        };
427
428        let client_input_sm = ClientInputSM::<WalletClientStateMachines> {
429            state_machines: Arc::new(move |range: OutPointRange| {
430                vec![WalletClientStateMachines::Receive(ReceiveStateMachine {
431                    common: ReceiveSMCommon {
432                        operation_id,
433                        txid: range.txid(),
434                        amount,
435                        fee,
436                    },
437                    state: ReceiveSMState::Funding,
438                })]
439            }),
440        };
441
442        let client_input_bundle = self.client_ctx.make_client_inputs(ClientInputBundle::new(
443            vec![client_input],
444            vec![client_input_sm],
445        ));
446
447        let range = self
448            .client_ctx
449            .finalize_and_submit_transaction(
450                operation_id,
451                WalletCommonInit::KIND.as_str(),
452                move |change_outpoint_range| {
453                    WalletOperationMeta::Receive(ReceiveMeta {
454                        change_outpoint_range,
455                        amount,
456                        fee,
457                    })
458                },
459                TransactionBuilder::new().with_inputs(client_input_bundle),
460            )
461            .await
462            .expect("Input amount is sufficient to finalize transaction");
463
464        let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
465
466        self.client_ctx
467            .log_event(
468                &mut dbtx,
469                ReceivePaymentEvent {
470                    operation_id,
471                    amount,
472                    fee,
473                },
474            )
475            .await;
476
477        dbtx.commit_tx().await;
478
479        (operation_id, range.txid())
480    }
481
482    fn spawn_deposit_scanner(&self, task_group: &TaskGroup) {
483        let module = self.clone();
484
485        task_group.spawn_cancellable("deposit-scanner", async move {
486            let mut dbtx = module.db.begin_transaction().await;
487
488            if dbtx
489                .find_by_prefix(&ValidAddressIndexPrefix)
490                .await
491                .next()
492                .await
493                .is_none()
494            {
495                dbtx.insert_new_entry(&ValidAddressIndexKey(module.next_valid_index(0)), &())
496                    .await;
497            }
498
499            dbtx.commit_tx().await;
500
501            loop {
502                match module.check_deposits().await {
503                    Ok(skip_wait) => {
504                        if skip_wait {
505                            continue;
506                        }
507                    }
508                    Err(e) => {
509                        warn!(target: LOG_CLIENT_MODULE_WALLETV2, "Failed to fetch deposits: {e}");
510                    }
511                }
512
513                sleep(fedimint_walletv2_common::sleep_duration()).await;
514            }
515        });
516    }
517
518    async fn check_deposits(&self) -> anyhow::Result<bool> {
519        let mut dbtx = self.db.begin_transaction_nc().await;
520
521        let next_deposit_index = dbtx.get_value(&NextDepositIndexKey).await.unwrap_or(0);
522
523        let mut valid_indices: Vec<u64> = dbtx
524            .find_by_prefix(&ValidAddressIndexPrefix)
525            .await
526            .map(|entry| entry.0.0)
527            .collect()
528            .await;
529
530        let mut address_map: BTreeMap<ScriptBuf, u64> = valid_indices
531            .iter()
532            .map(|&i| (self.derive_address(i).script_pubkey(), i))
533            .collect();
534
535        let deposit_range = self
536            .module_api
537            .deposit_range(next_deposit_index, next_deposit_index + DEPOSIT_RANGE_SIZE)
538            .await?;
539
540        info!(
541            target: LOG_CLIENT_MODULE_WALLETV2,
542            "Scanning for deposits..."
543        );
544
545        for (deposit_index, tx_out) in (next_deposit_index..).zip(deposit_range.deposits.clone()) {
546            if let Some(&address_index) = address_map.get(&tx_out.script_pubkey) {
547                let receive_fee = self
548                    .module_api
549                    .receive_fee()
550                    .await?
551                    .ok_or(anyhow!("No consensus feerate is available"))?;
552
553                let next_address_index = valid_indices
554                    .last()
555                    .copied()
556                    .expect("we have at least one address index");
557
558                // If we used the highest valid index, add the next valid one
559                if address_index == next_address_index {
560                    let index = self.next_valid_index(next_address_index + 1);
561
562                    let mut dbtx = self.db.begin_transaction().await;
563
564                    dbtx.insert_entry(&ValidAddressIndexKey(index), &()).await;
565
566                    dbtx.commit_tx_result().await?;
567
568                    valid_indices.push(index);
569
570                    address_map.insert(self.derive_address(index).script_pubkey(), index);
571                }
572
573                if tx_out.value > receive_fee && !deposit_range.spent.contains(&deposit_index) {
574                    // In order to not overpay on fees we choose to wait, the congestion will clear
575                    // up within a few blocks.
576                    if self.module_api.pending_tx_chain().await?.len() >= 3 {
577                        return Ok(false);
578                    }
579
580                    let (operation_id, txid) = self
581                        .receive_deposit(deposit_index, tx_out.value, address_index, receive_fee)
582                        .await;
583
584                    self.client_ctx
585                        .transaction_updates(operation_id)
586                        .await
587                        .await_tx_accepted(txid)
588                        .await
589                        .map_err(|e| anyhow!("Claim transaction for deposit was rejected: {e}"))?;
590                }
591            }
592
593            let mut dbtx = self.db.begin_transaction().await;
594
595            dbtx.insert_entry(&NextDepositIndexKey, &(deposit_index + 1))
596                .await;
597
598            dbtx.commit_tx_result().await?;
599        }
600
601        Ok(deposit_range.deposits.len() as u64 == DEPOSIT_RANGE_SIZE)
602    }
603}
604
605#[derive(Error, Debug, Clone, Eq, PartialEq)]
606pub enum SendError {
607    #[error("Address is from a different network than the federation.")]
608    WrongNetwork,
609    #[error("The amount is too small to be sent on-chain")]
610    DustAmount,
611    #[error("Federation returned an error: {0}")]
612    FederationError(String),
613    #[error("No consensus feerate is available at this time")]
614    NoConsensusFeerateAvailable,
615    #[error("The client does not have sufficient funds to send the payment")]
616    InsufficientFunds,
617    #[error("Unsupported address type")]
618    UnsupportedAddress,
619}
620
621#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
622pub enum WalletClientStateMachines {
623    Send(send_sm::SendStateMachine),
624    Receive(receive_sm::ReceiveStateMachine),
625}
626
627impl State for WalletClientStateMachines {
628    type ModuleContext = WalletClientContext;
629
630    fn transitions(
631        &self,
632        context: &Self::ModuleContext,
633        global_context: &DynGlobalClientContext,
634    ) -> Vec<StateTransition<Self>> {
635        match self {
636            WalletClientStateMachines::Send(sm) => sm_enum_variant_translation!(
637                sm.transitions(context, global_context),
638                WalletClientStateMachines::Send
639            ),
640            WalletClientStateMachines::Receive(sm) => sm_enum_variant_translation!(
641                sm.transitions(context, global_context),
642                WalletClientStateMachines::Receive
643            ),
644        }
645    }
646
647    fn operation_id(&self) -> OperationId {
648        match self {
649            WalletClientStateMachines::Send(sm) => sm.operation_id(),
650            WalletClientStateMachines::Receive(sm) => sm.operation_id(),
651        }
652    }
653}
654
655impl IntoDynInstance for WalletClientStateMachines {
656    type DynType = DynState;
657
658    fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
659        DynState::from_typed(instance_id, self)
660    }
661}