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
64const 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub enum FinalSendOperationState {
91 Success(bitcoin::Txid),
93 Aborted,
95 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 pub fn get_network(&self) -> bitcoin::Network {
212 self.cfg.network
213 }
214
215 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 pub async fn block_count(&self) -> FederationResult<u64> {
225 self.module_api.consensus_block_count().await
226 }
227
228 pub async fn feerate(&self) -> FederationResult<Option<u64>> {
230 self.module_api.consensus_feerate().await
231 }
232
233 async fn pending_tx_chain(&self) -> FederationResult<Vec<TxInfo>> {
235 self.module_api.pending_tx_chain().await
236 }
237
238 async fn tx_chain(&self, n: usize) -> FederationResult<Vec<TxInfo>> {
240 self.module_api.tx_chain(n).await
241 }
242
243 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 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 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 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 #[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 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 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 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}