1#![deny(clippy::pedantic)]
2#![allow(clippy::cast_possible_truncation)]
3#![allow(clippy::missing_errors_doc)]
4#![allow(clippy::missing_panics_doc)]
5#![allow(clippy::module_name_repetitions)]
6#![allow(clippy::must_use_candidate)]
7
8#[cfg(feature = "uniffi")]
9::uniffi::setup_scaffolding!();
10
11pub mod api;
12#[cfg(feature = "cli")]
13mod cli;
14
15mod backup;
16
17pub mod client_db;
18mod deposit;
21pub mod events;
22use events::SendPaymentEvent;
23#[cfg(feature = "uniffi")]
24pub mod ffi;
25mod pegin_monitor;
27mod withdraw;
28
29use std::collections::{BTreeMap, BTreeSet};
30use std::future;
31use std::sync::Arc;
32use std::time::{Duration, SystemTime};
33
34use anyhow::{Context as AnyhowContext, anyhow, bail, ensure};
35use async_stream::{stream, try_stream};
36use backup::WalletModuleBackup;
37use bitcoin::address::NetworkUnchecked;
38use bitcoin::secp256k1::{All, SECP256K1, Secp256k1};
39use bitcoin::{Address, Network, ScriptBuf};
40use client_db::{DbKeyPrefix, PegInTweakIndexKey, SupportsSafeDepositKey, TweakIdx};
41use fedimint_api_client::api::{DynModuleApi, FederationResult};
42use fedimint_bitcoind::{BitcoindTracked, DynBitcoindRpc, IBitcoindRpc, create_esplora_rpc};
43use fedimint_client_module::module::init::{
44 ClientModuleInit, ClientModuleInitArgs, ClientModuleRecoverArgs, RecoveryMode,
45};
46use fedimint_client_module::module::recovery::RecoveryProgress;
47use fedimint_client_module::module::{ClientContext, ClientModule, IClientModule, OutPointRange};
48use fedimint_client_module::oplog::UpdateStreamOrOutcome;
49use fedimint_client_module::sm::{Context, DynState, ModuleNotifier, State, StateTransition};
50use fedimint_client_module::transaction::{
51 ClientOutput, ClientOutputBundle, ClientOutputSM, FeeQuote, FeeQuoteRequest,
52 TransactionBuilder, max_affordable_send_amount,
53};
54use fedimint_client_module::{DynGlobalClientContext, sm_enum_variant_translation};
55use fedimint_core::core::{Decoder, IntoDynInstance, ModuleInstanceId, ModuleKind, OperationId};
56use fedimint_core::db::{
57 AutocommitError, Committable, Database, DatabaseError, DatabaseTransaction,
58 IDatabaseTransactionOpsCoreTyped,
59};
60use fedimint_core::encoding::{Decodable, Encodable};
61use fedimint_core::envs::{BitcoinRpcConfig, is_running_in_test_env};
62use fedimint_core::module::{
63 Amounts, ApiAuth, ApiVersion, CommonModuleInit, ModuleCommon, ModuleConsensusVersion,
64 ModuleInit, MultiApiVersion,
65};
66use fedimint_core::task::{MaybeSend, MaybeSync, TaskGroup, sleep};
67use fedimint_core::util::backoff_util::background_backoff;
68use fedimint_core::util::{BoxStream, FmtCompact as _, backoff_util, retry};
69use fedimint_core::{
70 BitcoinHash, OutPoint, TransactionId, apply, async_trait_maybe_send, push_db_pair_items,
71 runtime, secp256k1,
72};
73use fedimint_derive_secret::{ChildId, DerivableSecret};
74use fedimint_logging::LOG_CLIENT_MODULE_WALLET;
75pub use fedimint_wallet_common as common;
76use fedimint_wallet_common::config::{FeeConsensus, WalletClientConfig};
77use fedimint_wallet_common::tweakable::Tweakable;
78pub use fedimint_wallet_common::*;
79use futures::{Stream, StreamExt};
80use rand::{Rng, thread_rng};
81use secp256k1::Keypair;
82use serde::{Deserialize, Serialize};
83use strum::IntoEnumIterator;
84use tokio::sync::watch;
85use tracing::{debug, instrument, warn};
86
87use crate::api::WalletFederationApi;
88use crate::backup::{FEDERATION_RECOVER_MAX_GAP, RecoveryStateV2, WalletRecovery};
89use crate::client_db::{
90 ClaimedPegInData, ClaimedPegInKey, ClaimedPegInPrefix, NextPegInTweakIndexKey,
91 PegInPoolCursorKey, PegInTweakIndexData, PegInTweakIndexPrefix, RecoveryFinalizedKey,
92 RecoveryStateKey, SupportsSafeDepositPrefix,
93};
94use crate::deposit::DepositStateMachine;
95use crate::withdraw::{CreatedWithdrawState, WithdrawStateMachine, WithdrawStates};
96
97const WALLET_TWEAK_CHILD_ID: ChildId = ChildId(0);
98
99#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
100pub struct BitcoinTransactionData {
101 pub btc_transaction: bitcoin::Transaction,
104 pub out_idx: u32,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
109pub enum DepositStateV1 {
110 WaitingForTransaction,
111 WaitingForConfirmation(BitcoinTransactionData),
112 Confirmed(BitcoinTransactionData),
113 Claimed(BitcoinTransactionData),
114 Failed(String),
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
118#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
119pub enum DepositStateV2 {
120 WaitingForTransaction,
121 WaitingForConfirmation {
122 #[serde(with = "bitcoin::amount::serde::as_sat")]
123 btc_deposited: bitcoin::Amount,
124 btc_out_point: bitcoin::OutPoint,
125 },
126 Confirmed {
127 #[serde(with = "bitcoin::amount::serde::as_sat")]
128 btc_deposited: bitcoin::Amount,
129 btc_out_point: bitcoin::OutPoint,
130 },
131 Claimed {
132 #[serde(with = "bitcoin::amount::serde::as_sat")]
133 btc_deposited: bitcoin::Amount,
134 btc_out_point: bitcoin::OutPoint,
135 },
136 Failed(String),
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct DepositAddressInfo {
142 pub operation_id: OperationId,
143 pub address: Address,
144 pub tweak_idx: TweakIdx,
145}
146
147#[allow(clippy::enum_variant_names)]
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum MaybeNewAddress {
155 NewAddress(DepositAddressInfo),
157 TooManyUnusedAddresses(Vec<DepositAddressInfo>),
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum AllocateDepositOutcome {
170 Fresh,
172 Reused { original_tweak_idx: TweakIdx },
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)]
179#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
180pub enum WithdrawState {
181 Created,
182 Succeeded(bitcoin::Txid),
183 Failed(String),
184 }
188
189async fn next_withdraw_state<S>(stream: &mut S) -> Option<WithdrawStates>
190where
191 S: Stream<Item = WalletClientStates> + Unpin,
192{
193 loop {
194 if let WalletClientStates::Withdraw(ds) = stream.next().await? {
195 return Some(ds.state);
196 }
197 tokio::task::yield_now().await;
198 }
199}
200
201#[derive(Debug, Clone, Default)]
202pub struct WalletClientInit(pub Option<DynBitcoindRpc>);
204
205const SLICE_SIZE: u64 = 1000;
206
207impl WalletClientInit {
208 pub fn new(rpc: DynBitcoindRpc) -> Self {
209 Self(Some(rpc))
210 }
211
212 async fn recover_from_slices(
213 &self,
214 args: &ClientModuleRecoverArgs<Self>,
215 ) -> anyhow::Result<Option<fedimint_core::Amount>> {
216 let data = WalletClientModuleData {
217 cfg: args.cfg().clone(),
218 module_root_secret: args.module_root_secret().clone(),
219 };
220
221 let total_items = args.module_api().fetch_recovery_count().await?;
222
223 let mut state = RecoveryStateV2::new();
224
225 state.refill_pending_pool_up_to(&data, TweakIdx(FEDERATION_RECOVER_MAX_GAP));
226
227 for start in (0..total_items).step_by(SLICE_SIZE as usize) {
228 let end = std::cmp::min(start + SLICE_SIZE, total_items);
229
230 let items = args.module_api().fetch_recovery_slice(start, end).await?;
231
232 for item in &items {
233 match item {
234 RecoveryItem::Input { outpoint, script } => {
235 state.handle_item(*outpoint, script, &data);
236 }
237 }
238 }
239
240 args.update_recovery_progress(RecoveryProgress {
241 complete: end.try_into().unwrap_or(u32::MAX),
242 total: total_items.try_into().unwrap_or(u32::MAX),
243 });
244 }
245
246 let mut dbtx = args.db().begin_transaction().await;
247
248 for tweak_idx in 0..state.new_start_idx().0 {
249 let operation_id = data.derive_peg_in_script(TweakIdx(tweak_idx)).3;
250
251 let claimed = state
252 .claimed_outpoints
253 .get(&TweakIdx(tweak_idx))
254 .cloned()
255 .unwrap_or_default();
256
257 dbtx.insert_new_entry(
258 &PegInTweakIndexKey(TweakIdx(tweak_idx)),
259 &PegInTweakIndexData {
260 operation_id,
261 creation_time: fedimint_core::time::now(),
262 last_check_time: None,
263 next_check_time: Some(fedimint_core::time::now()),
264 claimed,
265 },
266 )
267 .await;
268 }
269
270 dbtx.insert_new_entry(&NextPegInTweakIndexKey, &state.new_start_idx())
271 .await;
272
273 dbtx.commit_tx().await;
274
275 Ok(None)
279 }
280}
281
282impl ModuleInit for WalletClientInit {
283 type Common = WalletCommonInit;
284
285 async fn dump_database(
286 &self,
287 dbtx: &mut DatabaseTransaction<'_>,
288 prefix_names: Vec<String>,
289 ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
290 let mut wallet_client_items: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> =
291 BTreeMap::new();
292 let filtered_prefixes = DbKeyPrefix::iter().filter(|f| {
293 prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase())
294 });
295
296 for table in filtered_prefixes {
297 match table {
298 DbKeyPrefix::NextPegInTweakIndex => {
299 if let Some(index) = dbtx.get_value(&NextPegInTweakIndexKey).await {
300 wallet_client_items
301 .insert("NextPegInTweakIndex".to_string(), Box::new(index));
302 }
303 }
304 DbKeyPrefix::PegInTweakIndex => {
305 push_db_pair_items!(
306 dbtx,
307 PegInTweakIndexPrefix,
308 PegInTweakIndexKey,
309 PegInTweakIndexData,
310 wallet_client_items,
311 "Peg-In Tweak Index"
312 );
313 }
314 DbKeyPrefix::ClaimedPegIn => {
315 push_db_pair_items!(
316 dbtx,
317 ClaimedPegInPrefix,
318 ClaimedPegInKey,
319 ClaimedPegInData,
320 wallet_client_items,
321 "Claimed Peg-In"
322 );
323 }
324 DbKeyPrefix::RecoveryFinalized => {
325 if let Some(val) = dbtx.get_value(&RecoveryFinalizedKey).await {
326 wallet_client_items.insert("RecoveryFinalized".to_string(), Box::new(val));
327 }
328 }
329 DbKeyPrefix::SupportsSafeDeposit => {
330 push_db_pair_items!(
331 dbtx,
332 SupportsSafeDepositPrefix,
333 SupportsSafeDepositKey,
334 (),
335 wallet_client_items,
336 "Supports Safe Deposit"
337 );
338 }
339 DbKeyPrefix::PegInPoolCursor => {
340 if let Some(cursor) = dbtx.get_value(&PegInPoolCursorKey).await {
341 wallet_client_items.insert("PegInPoolCursor".to_string(), Box::new(cursor));
342 }
343 }
344 DbKeyPrefix::RecoveryState
345 | DbKeyPrefix::ExternalReservedStart
346 | DbKeyPrefix::CoreInternalReservedStart
347 | DbKeyPrefix::CoreInternalReservedEnd => {}
348 }
349 }
350
351 Box::new(wallet_client_items.into_iter())
352 }
353}
354
355#[apply(async_trait_maybe_send!)]
356impl ClientModuleInit for WalletClientInit {
357 type Module = WalletClientModule;
358
359 fn supported_api_versions(&self) -> MultiApiVersion {
360 MultiApiVersion::try_from_iter([ApiVersion { major: 0, minor: 0 }])
361 .expect("no version conflicts")
362 }
363
364 async fn init(&self, args: &ClientModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
365 let data = WalletClientModuleData {
366 cfg: args.cfg().clone(),
367 module_root_secret: args.module_root_secret().clone(),
368 };
369
370 let db = args.db().clone();
371
372 let rpc_config = WalletClientModule::get_rpc_config(args.cfg());
373
374 let btc_rpc = if let Some(user_rpc) = args.user_bitcoind_rpc() {
381 user_rpc.clone()
382 } else if let Some(factory) = args.user_bitcoind_rpc_no_chain_id() {
383 if let Some(rpc) = factory(rpc_config.url.clone()).await {
384 rpc
385 } else {
386 self.0
387 .clone()
388 .unwrap_or(create_esplora_rpc(&rpc_config.url)?)
389 }
390 } else {
391 self.0
392 .clone()
393 .unwrap_or(create_esplora_rpc(&rpc_config.url)?)
394 };
395 let btc_rpc = BitcoindTracked::new(btc_rpc, "wallet-client").into_dyn();
396
397 let module_api = args.module_api().clone();
398
399 let (pegin_claimed_sender, pegin_claimed_receiver) = watch::channel(());
400 let (pegin_monitor_wakeup_sender, pegin_monitor_wakeup_receiver) = watch::channel(());
401
402 Ok(WalletClientModule {
403 db,
404 data,
405 module_api,
406 notifier: args.notifier().clone(),
407 rpc: btc_rpc,
408 client_ctx: args.context(),
409 pegin_monitor_wakeup_sender,
410 pegin_monitor_wakeup_receiver,
411 pegin_claimed_receiver,
412 pegin_claimed_sender,
413 task_group: args.task_group().clone(),
414 client_span: args.client_span().clone(),
415 admin_auth: args.admin_auth().cloned(),
416 })
417 }
418
419 fn recovery_mode(&self) -> RecoveryMode {
420 RecoveryMode::Unusable
421 }
422
423 async fn recover(
428 &self,
429 args: &ClientModuleRecoverArgs<Self>,
430 snapshot: Option<&<Self::Module as ClientModule>::Backup>,
431 ) -> anyhow::Result<Option<fedimint_core::Amount>> {
432 if args
435 .db()
436 .begin_transaction_nc()
437 .await
438 .get_value(&RecoveryStateKey)
439 .await
440 .is_some()
441 {
442 return args
443 .recover_from_history::<WalletRecovery>(self, snapshot)
444 .await;
445 }
446
447 if args.module_api().fetch_recovery_count().await.is_ok() {
449 self.recover_from_slices(args).await
450 } else {
451 args.recover_from_history::<WalletRecovery>(self, snapshot)
452 .await
453 }
454 }
455
456 fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
457 Some(
458 DbKeyPrefix::iter()
459 .map(|p| p as u8)
460 .chain(
461 DbKeyPrefix::ExternalReservedStart as u8
462 ..=DbKeyPrefix::CoreInternalReservedEnd as u8,
463 )
464 .collect(),
465 )
466 }
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
470pub struct WalletOperationMeta {
471 pub variant: WalletOperationMetaVariant,
472 pub extra_meta: serde_json::Value,
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize)]
476#[serde(rename_all = "snake_case")]
477pub enum WalletOperationMetaVariant {
478 Deposit {
479 address: Address<NetworkUnchecked>,
480 #[serde(default)]
485 tweak_idx: Option<TweakIdx>,
486 #[serde(default, skip_serializing_if = "Option::is_none")]
487 expires_at: Option<SystemTime>,
488 },
489 Withdraw {
490 address: Address<NetworkUnchecked>,
491 #[serde(with = "bitcoin::amount::serde::as_sat")]
492 amount: bitcoin::Amount,
493 fee: PegOutFees,
494 change: Vec<OutPoint>,
495 },
496
497 RbfWithdraw {
498 rbf: Rbf,
499 change: Vec<OutPoint>,
500 },
501}
502
503#[derive(Debug, Clone)]
505pub struct WalletClientModuleData {
506 cfg: WalletClientConfig,
507 module_root_secret: DerivableSecret,
508}
509
510impl WalletClientModuleData {
511 fn derive_deposit_address(
512 &self,
513 idx: TweakIdx,
514 ) -> (Keypair, secp256k1::PublicKey, Address, OperationId) {
515 let idx = ChildId(idx.0);
516
517 let secret_tweak_key = self
518 .module_root_secret
519 .child_key(WALLET_TWEAK_CHILD_ID)
520 .child_key(idx)
521 .to_secp_key(fedimint_core::secp256k1::SECP256K1);
522
523 let public_tweak_key = secret_tweak_key.public_key();
524
525 let address = self
526 .cfg
527 .peg_in_descriptor
528 .tweak(&public_tweak_key, bitcoin::secp256k1::SECP256K1)
529 .address(self.cfg.network.0)
530 .unwrap();
531
532 let operation_id = OperationId(public_tweak_key.x_only_public_key().0.serialize());
534
535 (secret_tweak_key, public_tweak_key, address, operation_id)
536 }
537
538 fn derive_peg_in_script(
539 &self,
540 idx: TweakIdx,
541 ) -> (ScriptBuf, bitcoin::Address, Keypair, OperationId) {
542 let (secret_tweak_key, _, address, operation_id) = self.derive_deposit_address(idx);
543
544 (
545 self.cfg
546 .peg_in_descriptor
547 .tweak(&secret_tweak_key.public_key(), SECP256K1)
548 .script_pubkey(),
549 address,
550 secret_tweak_key,
551 operation_id,
552 )
553 }
554}
555
556#[derive(Debug)]
557#[cfg_attr(feature = "uniffi", derive(uniffi::Object))]
558pub struct WalletClientModule {
559 data: WalletClientModuleData,
560 db: Database,
561 module_api: DynModuleApi,
562 notifier: ModuleNotifier<WalletClientStates>,
563 rpc: DynBitcoindRpc,
564 client_ctx: ClientContext<Self>,
565 pegin_monitor_wakeup_sender: watch::Sender<()>,
567 pegin_monitor_wakeup_receiver: watch::Receiver<()>,
568 pegin_claimed_sender: watch::Sender<()>,
570 pegin_claimed_receiver: watch::Receiver<()>,
571 task_group: TaskGroup,
572 client_span: tracing::Span,
573 admin_auth: Option<ApiAuth>,
574}
575
576#[apply(async_trait_maybe_send!)]
577impl ClientModule for WalletClientModule {
578 type Init = WalletClientInit;
579 type Common = WalletModuleTypes;
580 type Backup = WalletModuleBackup;
581 type ModuleStateMachineContext = WalletClientContext;
582 type States = WalletClientStates;
583
584 fn context(&self) -> Self::ModuleStateMachineContext {
585 WalletClientContext {
586 rpc: self.rpc.clone(),
587 wallet_descriptor: self.cfg().peg_in_descriptor.clone(),
588 wallet_decoder: self.decoder(),
589 secp: Secp256k1::default(),
590 client_ctx: self.client_ctx.clone(),
591 }
592 }
593
594 async fn start(&self) {
595 self.task_group
596 .spawn_cancellable_with_span(self.client_span.clone(), "peg-in monitor", {
597 let client_ctx = self.client_ctx.clone();
598 let db = self.db.clone();
599 let btc_rpc = self.rpc.clone();
600 let module_api = self.module_api.clone();
601 let data = self.data.clone();
602 let pegin_claimed_sender = self.pegin_claimed_sender.clone();
603 let pegin_monitor_wakeup_receiver = self.pegin_monitor_wakeup_receiver.clone();
604 pegin_monitor::run_peg_in_monitor(
605 client_ctx,
606 db,
607 btc_rpc,
608 module_api,
609 data,
610 pegin_claimed_sender,
611 pegin_monitor_wakeup_receiver,
612 )
613 });
614
615 self.task_group.spawn_cancellable_with_span(
616 self.client_span.clone(),
617 "supports-safe-deposit-version",
618 {
619 let db = self.db.clone();
620 let module_api = self.module_api.clone();
621
622 poll_supports_safe_deposit_version(db, module_api)
623 },
624 );
625 }
626
627 fn supports_backup(&self) -> bool {
628 true
629 }
630
631 async fn backup(&self) -> anyhow::Result<backup::WalletModuleBackup> {
632 let session_count = self.client_ctx.global_api().session_count().await?;
634
635 let mut dbtx = self.db.begin_transaction_nc().await;
636 let next_pegin_tweak_idx = dbtx
637 .get_value(&NextPegInTweakIndexKey)
638 .await
639 .unwrap_or_default();
640 let claimed = dbtx
641 .find_by_prefix(&PegInTweakIndexPrefix)
642 .await
643 .filter_map(|(k, v)| async move {
644 if v.claimed.is_empty() {
645 None
646 } else {
647 Some(k.0)
648 }
649 })
650 .collect()
651 .await;
652 Ok(backup::WalletModuleBackup::new_v1(
653 session_count,
654 next_pegin_tweak_idx,
655 claimed,
656 ))
657 }
658
659 fn input_fee(
660 &self,
661 _amount: &Amounts,
662 _input: &<Self::Common as ModuleCommon>::Input,
663 ) -> Option<Amounts> {
664 Some(Amounts::new_bitcoin(self.cfg().fee_consensus.peg_in_abs))
665 }
666
667 fn output_fee(
668 &self,
669 _amount: &Amounts,
670 _output: &<Self::Common as ModuleCommon>::Output,
671 ) -> Option<Amounts> {
672 Some(Amounts::new_bitcoin(self.cfg().fee_consensus.peg_out_abs))
673 }
674
675 async fn handle_rpc(
676 &self,
677 method: String,
678 request: serde_json::Value,
679 ) -> BoxStream<'_, anyhow::Result<serde_json::Value>> {
680 Box::pin(try_stream! {
681 match method.as_str() {
682 "get_wallet_summary" => {
683 let _req: WalletSummaryRequest = serde_json::from_value(request)?;
684 let wallet_summary = self.get_wallet_summary()
685 .await
686 .expect("Failed to fetch wallet summary");
687 let result = serde_json::to_value(&wallet_summary)
688 .expect("Serialization error");
689 yield result;
690 }
691 "get_block_count_local" => {
692 let block_count = self.get_block_count_local().await
693 .expect("Failed to fetch block count");
694 yield serde_json::to_value(block_count)?;
695 }
696 "peg_in" => {
697 let req: PegInRequest = serde_json::from_value(request)?;
698 let response = self.peg_in(req)
699 .await
700 .map_err(|e| anyhow::anyhow!("peg_in failed: {e}"))?;
701 let result = serde_json::to_value(&response)?;
702 yield result;
703 },
704 "peg_out" => {
705 let req: PegOutRequest = serde_json::from_value(request)?;
706 let response = self.peg_out(req)
707 .await
708 .map_err(|e| anyhow::anyhow!("peg_out failed: {e}"))?;
709 let result = serde_json::to_value(&response)?;
710 yield result;
711 },
712 "subscribe_deposit" => {
713 let req: SubscribeDepositRequest = serde_json::from_value(request)?;
714 for await state in self.subscribe_deposit(req.operation_id).await?.into_stream() {
715 yield serde_json::to_value(state)?;
716 }
717 },
718 "subscribe_withdraw" => {
719 let req: SubscribeWithdrawRequest = serde_json::from_value(request)?;
720 for await state in self.subscribe_withdraw_updates(req.operation_id).await?.into_stream(){
721 yield serde_json::to_value(state)?;
722 }
723 }
724 _ => {
725 Err(anyhow::format_err!("Unknown method: {method}"))?;
726 }
727 }
728 })
729 }
730
731 #[cfg(feature = "cli")]
732 async fn handle_cli_command(
733 &self,
734 args: &[std::ffi::OsString],
735 ) -> anyhow::Result<serde_json::Value> {
736 cli::handle_cli_command(self, args).await
737 }
738}
739
740#[derive(Deserialize)]
741struct WalletSummaryRequest {}
742
743#[derive(Debug, Clone)]
744pub struct WalletClientContext {
745 rpc: DynBitcoindRpc,
746 wallet_descriptor: PegInDescriptor,
747 wallet_decoder: Decoder,
748 secp: Secp256k1<All>,
749 pub client_ctx: ClientContext<WalletClientModule>,
750}
751
752#[derive(Debug, Clone, Serialize, Deserialize)]
753pub struct PegInRequest {
754 pub extra_meta: serde_json::Value,
755}
756
757#[cfg(feature = "uniffi")]
758uniffi::custom_type!(PegInRequest, String, {
759 lower: |v| serde_json::to_string(&v).expect("PegInRequest serialization cannot fail"),
760 try_lift: |s| serde_json::from_str::<PegInRequest>(&s).map_err(|e| anyhow!("Failed to parse PegInRequest: {e}")),
761});
762
763#[derive(Deserialize)]
764struct SubscribeDepositRequest {
765 operation_id: OperationId,
766}
767
768#[derive(Deserialize)]
769struct SubscribeWithdrawRequest {
770 operation_id: OperationId,
771}
772
773#[derive(Debug, Clone, Serialize, Deserialize)]
774#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
775pub struct PegInResponse {
776 pub deposit_address: Address<NetworkUnchecked>,
777 pub operation_id: OperationId,
778}
779
780#[derive(Debug, Clone, Serialize, Deserialize)]
781#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
782pub struct PegOutRequest {
783 pub amount_sat: u64,
784 pub destination_address: Address<NetworkUnchecked>,
785 pub extra_meta: serde_json::Value,
786}
787
788#[derive(Debug, Clone, Serialize, Deserialize)]
789#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
790pub struct PegOutResponse {
791 pub operation_id: OperationId,
792}
793
794impl Context for WalletClientContext {
795 const KIND: Option<ModuleKind> = Some(KIND);
796}
797
798impl WalletClientModule {
799 fn cfg(&self) -> &WalletClientConfig {
800 &self.data.cfg
801 }
802
803 fn get_rpc_config(cfg: &WalletClientConfig) -> BitcoinRpcConfig {
804 match BitcoinRpcConfig::get_defaults_from_env_vars() {
805 Ok(rpc_config) => {
806 if rpc_config.kind == "bitcoind" {
809 cfg.default_bitcoin_rpc.clone()
810 } else {
811 rpc_config
812 }
813 }
814 _ => cfg.default_bitcoin_rpc.clone(),
815 }
816 }
817
818 pub fn get_network(&self) -> Network {
819 self.cfg().network.0
820 }
821
822 pub fn get_finality_delay(&self) -> u32 {
823 self.cfg().finality_delay
824 }
825
826 pub fn get_fee_consensus(&self) -> FeeConsensus {
827 self.cfg().fee_consensus
828 }
829
830 async fn allocate_deposit_address_inner(
831 &self,
832 dbtx: &mut DatabaseTransaction<'_>,
833 ) -> DepositAddressInfo {
834 dbtx.ensure_isolated().expect("Must be isolated db");
835
836 let tweak_idx = get_next_peg_in_tweak_child_id(dbtx).await;
837 let (_secret_tweak_key, _, address, operation_id) =
838 self.data.derive_deposit_address(tweak_idx);
839
840 let now = fedimint_core::time::now();
841
842 dbtx.insert_new_entry(
843 &PegInTweakIndexKey(tweak_idx),
844 &PegInTweakIndexData {
845 creation_time: now,
846 next_check_time: Some(now),
847 last_check_time: None,
848 operation_id,
849 claimed: vec![],
850 },
851 )
852 .await;
853
854 DepositAddressInfo {
855 operation_id,
856 address,
857 tweak_idx,
858 }
859 }
860
861 pub async fn get_withdraw_fees(
868 &self,
869 address: &bitcoin::Address,
870 amount: bitcoin::Amount,
871 ) -> anyhow::Result<PegOutFees> {
872 self.module_api
873 .fetch_peg_out_fees(address, amount)
874 .await?
875 .context("Federation didn't return peg-out fees")
876 }
877
878 pub async fn send_fee_quote(&self, amount: bitcoin::Amount) -> anyhow::Result<FeeQuote> {
893 let amount = fedimint_core::Amount::from_sats(amount.to_sat());
894 self.client_ctx
895 .fee_quote(
896 OperationId::new_random(),
897 FeeQuoteRequest {
898 input_amount: Amounts::ZERO,
899 output_amount: Amounts::new_bitcoin(amount),
900 input_fee: Amounts::ZERO,
901 output_fee: Amounts::new_bitcoin(self.cfg().fee_consensus.peg_out_abs),
902 },
903 )
904 .await
905 }
906
907 pub async fn max_withdrawable_amount(
941 &self,
942 address: &bitcoin::Address,
943 balance: fedimint_core::Amount,
944 ) -> anyhow::Result<(bitcoin::Amount, PegOutFees)> {
945 let max_fees = self
948 .get_withdraw_fees(
949 address,
950 bitcoin::Amount::from_sat(balance.sats_round_down()),
951 )
952 .await?;
953 let max_fee_msats = fedimint_core::Amount::from_sats(max_fees.amount().to_sat());
954
955 let max = max_affordable_send_amount(
956 balance,
957 fedimint_core::Amount::from_sats(address.script_pubkey().minimal_non_dust().to_sat()),
958 balance,
959 |amount: fedimint_core::Amount| {
963 fedimint_core::Amount::from_sats(amount.msats.div_ceil(1000)) + max_fee_msats
964 },
965 |funded: fedimint_core::Amount| {
966 self.send_fee_quote(bitcoin::Amount::from_sat(funded.msats / 1000))
967 },
968 )
969 .await
970 .ok_or_else(|| anyhow!("Balance is too low to withdraw any amount after fees"))?;
971
972 let amount = bitcoin::Amount::from_sat(max.msats.div_ceil(1000));
975
976 let fees = self.get_withdraw_fees(address, amount).await?;
981
982 Ok((amount, fees))
983 }
984
985 pub async fn get_wallet_summary(&self) -> anyhow::Result<WalletSummary> {
987 Ok(self.module_api.fetch_wallet_summary().await?)
988 }
989
990 pub async fn get_block_count_local(&self) -> anyhow::Result<u32> {
991 Ok(self.module_api.fetch_block_count_local().await?)
992 }
993
994 pub fn create_withdraw_output(
995 &self,
996 operation_id: OperationId,
997 address: bitcoin::Address,
998 amount: bitcoin::Amount,
999 fees: PegOutFees,
1000 ) -> anyhow::Result<ClientOutputBundle<WalletOutput, WalletClientStates>> {
1001 let output = WalletOutput::new_v0_peg_out(address, amount, fees);
1002
1003 let amount = output.maybe_v0_ref().expect("v0 output").amount().into();
1004
1005 let sm_gen = move |out_point_range: OutPointRange| {
1006 assert_eq!(out_point_range.count(), 1);
1007 let out_idx = out_point_range.start_idx();
1008 vec![WalletClientStates::Withdraw(WithdrawStateMachine {
1009 operation_id,
1010 state: WithdrawStates::Created(CreatedWithdrawState {
1011 fm_outpoint: OutPoint {
1012 txid: out_point_range.txid(),
1013 out_idx,
1014 },
1015 }),
1016 })]
1017 };
1018
1019 Ok(ClientOutputBundle::new(
1020 vec![ClientOutput::<WalletOutput> {
1021 output,
1022 amounts: Amounts::new_bitcoin(amount),
1023 }],
1024 vec![ClientOutputSM::<WalletClientStates> {
1025 state_machines: Arc::new(sm_gen),
1026 }],
1027 ))
1028 }
1029
1030 pub async fn peg_in(&self, req: PegInRequest) -> anyhow::Result<PegInResponse> {
1031 let deposit_address = self.safe_allocate_deposit_address(req.extra_meta).await?;
1032
1033 Ok(PegInResponse {
1034 deposit_address: Address::from_script(
1035 &deposit_address.address.script_pubkey(),
1036 self.get_network(),
1037 )?
1038 .as_unchecked()
1039 .clone(),
1040 operation_id: deposit_address.operation_id,
1041 })
1042 }
1043
1044 pub async fn peg_out(&self, req: PegOutRequest) -> anyhow::Result<PegOutResponse> {
1045 let amount = bitcoin::Amount::from_sat(req.amount_sat);
1046 let destination = req
1047 .destination_address
1048 .require_network(self.get_network())?;
1049
1050 let fees = self.get_withdraw_fees(&destination, amount).await?;
1051 let operation_id = self
1052 .withdraw(&destination, amount, fees, req.extra_meta)
1053 .await
1054 .context("Failed to initiate withdraw")?;
1055
1056 Ok(PegOutResponse { operation_id })
1057 }
1058
1059 pub fn create_rbf_withdraw_output(
1060 &self,
1061 operation_id: OperationId,
1062 rbf: &Rbf,
1063 ) -> anyhow::Result<ClientOutputBundle<WalletOutput, WalletClientStates>> {
1064 let output = WalletOutput::new_v0_rbf(rbf.fees, rbf.txid);
1065
1066 let amount = output.maybe_v0_ref().expect("v0 output").amount().into();
1067
1068 let sm_gen = move |out_point_range: OutPointRange| {
1069 assert_eq!(out_point_range.count(), 1);
1070 let out_idx = out_point_range.start_idx();
1071 vec![WalletClientStates::Withdraw(WithdrawStateMachine {
1072 operation_id,
1073 state: WithdrawStates::Created(CreatedWithdrawState {
1074 fm_outpoint: OutPoint {
1075 txid: out_point_range.txid(),
1076 out_idx,
1077 },
1078 }),
1079 })]
1080 };
1081
1082 Ok(ClientOutputBundle::new(
1083 vec![ClientOutput::<WalletOutput> {
1084 output,
1085 amounts: Amounts::new_bitcoin(amount),
1086 }],
1087 vec![ClientOutputSM::<WalletClientStates> {
1088 state_machines: Arc::new(sm_gen),
1089 }],
1090 ))
1091 }
1092
1093 pub async fn btc_tx_has_no_size_limit(&self) -> FederationResult<bool> {
1094 Ok(self.module_api.module_consensus_version().await? >= ModuleConsensusVersion::new(2, 2))
1095 }
1096
1097 pub async fn supports_safe_deposit(&self) -> bool {
1106 if supports_safe_deposit_verified(&self.db).await {
1107 return true;
1108 }
1109
1110 verify_supports_safe_deposit(&self.db, &self.module_api)
1111 .await
1112 .unwrap_or(false)
1113 }
1114
1115 pub async fn safe_allocate_deposit_address<M>(
1123 &self,
1124 extra_meta: M,
1125 ) -> anyhow::Result<DepositAddressInfo>
1126 where
1127 M: Serialize + MaybeSend + MaybeSync,
1128 {
1129 ensure!(
1130 self.supports_safe_deposit().await,
1131 "Could not verify that the wallet module consensus version supports safe deposits",
1132 );
1133
1134 self.allocate_deposit_address_expert_only(extra_meta).await
1135 }
1136
1137 pub async fn allocate_deposit_address_expert_only<M>(
1155 &self,
1156 extra_meta: M,
1157 ) -> anyhow::Result<DepositAddressInfo>
1158 where
1159 M: Serialize + MaybeSend + MaybeSync,
1160 {
1161 let extra_meta_value =
1162 serde_json::to_value(extra_meta).expect("Failed to serialize extra meta");
1163 let deposit_address = self
1164 .db
1165 .autocommit(
1166 move |dbtx, _| {
1167 let extra_meta_value_inner = extra_meta_value.clone();
1168 Box::pin(async move {
1169 let deposit_address = self.allocate_deposit_address_inner(dbtx).await;
1170
1171 self.client_ctx
1172 .manual_operation_start_dbtx(
1173 dbtx,
1174 deposit_address.operation_id,
1175 WalletCommonInit::KIND.as_str(),
1176 WalletOperationMeta {
1177 variant: WalletOperationMetaVariant::Deposit {
1178 address: deposit_address.address.clone().into_unchecked(),
1179 tweak_idx: Some(deposit_address.tweak_idx),
1180 expires_at: None,
1181 },
1182 extra_meta: extra_meta_value_inner,
1183 },
1184 vec![],
1185 )
1186 .await?;
1187
1188 debug!(
1189 target: LOG_CLIENT_MODULE_WALLET,
1190 tweak_idx = %deposit_address.tweak_idx,
1191 address = %deposit_address.address,
1192 "Derived a new deposit address"
1193 );
1194
1195 self.rpc
1197 .watch_script_history(&deposit_address.address.script_pubkey())
1198 .await?;
1199
1200 let sender = self.pegin_monitor_wakeup_sender.clone();
1201 dbtx.on_commit(move || {
1202 sender.send_replace(());
1203 });
1204
1205 Ok(deposit_address)
1206 })
1207 },
1208 Some(100),
1209 )
1210 .await
1211 .map_err(|e| match e {
1212 AutocommitError::CommitFailed {
1213 last_error,
1214 attempts,
1215 } => anyhow!("Failed to commit after {attempts} attempts: {last_error}"),
1216 AutocommitError::ClosureError { error, .. } => error,
1217 })?;
1218
1219 Ok(deposit_address)
1220 }
1221
1222 pub async fn allocate_deposit_address_pooled_stateless(
1260 &self,
1261 max_gap_size: usize,
1262 ) -> anyhow::Result<MaybeNewAddress> {
1263 let max_gap_size_u64 = u64::try_from(max_gap_size).unwrap_or(u64::MAX);
1264 let extra_meta_value = serde_json::Value::Null;
1265 let result = self
1266 .db
1267 .autocommit(
1268 move |dbtx, _| {
1269 let extra_meta_value_inner = extra_meta_value.clone();
1270 Box::pin(async move {
1271 let unused = self.unused_pooled_deposit_addresses(dbtx).await;
1272
1273 if max_gap_size_u64 <= unused.len() as u64 && !unused.is_empty() {
1274 let addresses = unused
1275 .into_iter()
1276 .map(|(tweak_idx, data)| {
1277 let (_script, address, _key, operation_id) =
1278 self.data.derive_peg_in_script(tweak_idx);
1279
1280 debug_assert_eq!(operation_id, data.operation_id);
1281
1282 DepositAddressInfo {
1283 operation_id,
1284 address,
1285 tweak_idx,
1286 }
1287 })
1288 .collect();
1289
1290 return Ok::<_, anyhow::Error>(
1291 MaybeNewAddress::TooManyUnusedAddresses(addresses),
1292 );
1293 }
1294
1295 let deposit_address = self.allocate_deposit_address_inner(dbtx).await;
1296
1297 self.client_ctx
1298 .manual_operation_start_dbtx(
1299 dbtx,
1300 deposit_address.operation_id,
1301 WalletCommonInit::KIND.as_str(),
1302 WalletOperationMeta {
1303 variant: WalletOperationMetaVariant::Deposit {
1304 address: deposit_address.address.clone().into_unchecked(),
1305 tweak_idx: Some(deposit_address.tweak_idx),
1306 expires_at: None,
1307 },
1308 extra_meta: extra_meta_value_inner,
1309 },
1310 vec![],
1311 )
1312 .await?;
1313
1314 debug!(
1315 target: LOG_CLIENT_MODULE_WALLET,
1316 tweak_idx = %deposit_address.tweak_idx,
1317 address = %deposit_address.address,
1318 "Derived a new pooled deposit address"
1319 );
1320
1321 self.rpc
1322 .watch_script_history(&deposit_address.address.script_pubkey())
1323 .await?;
1324
1325 let sender = self.pegin_monitor_wakeup_sender.clone();
1326 dbtx.on_commit(move || {
1327 sender.send_replace(());
1328 });
1329
1330 Ok(MaybeNewAddress::NewAddress(deposit_address))
1331 })
1332 },
1333 Some(100),
1334 )
1335 .await
1336 .map_err(|e| match e {
1337 AutocommitError::CommitFailed {
1338 last_error,
1339 attempts,
1340 } => anyhow!("Failed to commit after {attempts} attempts: {last_error}"),
1341 AutocommitError::ClosureError { error, .. } => error,
1342 })?;
1343
1344 Ok(result)
1345 }
1346
1347 async fn unused_pooled_deposit_addresses(
1348 &self,
1349 dbtx: &mut DatabaseTransaction<'_>,
1350 ) -> Vec<(TweakIdx, PegInTweakIndexData)> {
1351 let mut unused: Vec<(TweakIdx, PegInTweakIndexData)> = dbtx
1356 .find_by_prefix_sorted_descending(&PegInTweakIndexPrefix)
1357 .await
1358 .take_while(|(_, d)| std::future::ready(d.claimed.is_empty()))
1359 .map(|(k, v)| (k.0, v))
1360 .collect()
1361 .await;
1362
1363 unused.sort_by_key(|(t, d)| (d.creation_time, *t));
1366 unused
1367 }
1368
1369 #[allow(clippy::too_many_lines)]
1392 pub async fn allocate_deposit_address_pooled(
1393 &self,
1394 max_gap_size: usize,
1395 ) -> anyhow::Result<(DepositAddressInfo, AllocateDepositOutcome)> {
1396 let stateless = self
1397 .allocate_deposit_address_pooled_stateless(max_gap_size)
1398 .await?;
1399
1400 let reused_addresses = match stateless {
1401 MaybeNewAddress::NewAddress(deposit_address) => {
1402 return Ok((deposit_address, AllocateDepositOutcome::Fresh));
1403 }
1404 MaybeNewAddress::TooManyUnusedAddresses(addresses) => addresses,
1405 };
1406
1407 let result = self
1408 .db
1409 .autocommit(
1410 move |dbtx, _| {
1411 let reused_addresses = reused_addresses.clone();
1412 Box::pin(async move {
1413 let cursor = dbtx
1414 .get_value(&PegInPoolCursorKey)
1415 .await
1416 .unwrap_or(TweakIdx(0));
1417
1418 let pick_pos = reused_addresses
1419 .iter()
1420 .position(|a| cursor <= a.tweak_idx)
1421 .unwrap_or(0);
1422 let reused_address = reused_addresses[pick_pos].clone();
1423
1424 let existing_tweak_idx = reused_address.tweak_idx;
1425 let existing = dbtx
1426 .get_value(&PegInTweakIndexKey(reused_address.tweak_idx))
1427 .await
1428 .with_context(|| {
1429 format!(
1430 "Pooled address disappeared while reusing {}",
1431 reused_address.tweak_idx
1432 )
1433 })?;
1434
1435 ensure!(
1436 existing.claimed.is_empty(),
1437 "Pooled address was used while reusing {}",
1438 reused_address.tweak_idx
1439 );
1440
1441 dbtx.insert_entry(&PegInPoolCursorKey, &reused_address.tweak_idx.next())
1442 .await;
1443
1444 let now = fedimint_core::time::now();
1452 dbtx.insert_entry(
1453 &PegInTweakIndexKey(reused_address.tweak_idx),
1454 &PegInTweakIndexData {
1455 creation_time: now,
1456 last_check_time: None,
1457 next_check_time: Some(now),
1458 operation_id: existing.operation_id,
1459 claimed: existing.claimed,
1460 },
1461 )
1462 .await;
1463
1464 let sender = self.pegin_monitor_wakeup_sender.clone();
1465 dbtx.on_commit(move || {
1466 sender.send_replace(());
1467 });
1468
1469 Ok::<_, anyhow::Error>((
1470 reused_address,
1471 AllocateDepositOutcome::Reused {
1472 original_tweak_idx: existing_tweak_idx,
1473 },
1474 ))
1475 })
1476 },
1477 Some(100),
1478 )
1479 .await
1480 .map_err(|e| match e {
1481 AutocommitError::CommitFailed {
1482 last_error,
1483 attempts,
1484 } => anyhow!("Failed to commit after {attempts} attempts: {last_error}"),
1485 AutocommitError::ClosureError { error, .. } => error,
1486 })?;
1487
1488 Ok(result)
1489 }
1490
1491 pub async fn subscribe_deposit(
1497 &self,
1498 operation_id: OperationId,
1499 ) -> anyhow::Result<UpdateStreamOrOutcome<DepositStateV2>> {
1500 let operation = self
1501 .client_ctx
1502 .get_operation(operation_id)
1503 .await
1504 .with_context(|| anyhow!("Operation not found: {}", operation_id.fmt_short()))?;
1505
1506 if operation.operation_module_kind() != WalletCommonInit::KIND.as_str() {
1507 bail!("Operation is not a wallet operation");
1508 }
1509
1510 let operation_meta = operation.meta::<WalletOperationMeta>();
1511
1512 let WalletOperationMetaVariant::Deposit {
1513 address, tweak_idx, ..
1514 } = operation_meta.variant
1515 else {
1516 bail!("Operation is not a deposit operation");
1517 };
1518
1519 let address = address.require_network(self.cfg().network.0)?;
1520
1521 let Some(tweak_idx) = tweak_idx else {
1523 let outcome_v1 = operation
1527 .outcome::<DepositStateV1>()
1528 .context("Old pending deposit, can't subscribe to updates")?;
1529
1530 let outcome_v2 = match outcome_v1 {
1531 DepositStateV1::Claimed(tx_info) => DepositStateV2::Claimed {
1532 btc_deposited: tx_info.btc_transaction.output[tx_info.out_idx as usize].value,
1533 btc_out_point: bitcoin::OutPoint {
1534 txid: tx_info.btc_transaction.compute_txid(),
1535 vout: tx_info.out_idx,
1536 },
1537 },
1538 DepositStateV1::Failed(error) => DepositStateV2::Failed(error),
1539 _ => bail!("Non-final outcome in operation log"),
1540 };
1541
1542 return Ok(UpdateStreamOrOutcome::Outcome(outcome_v2));
1543 };
1544
1545 Ok(self.client_ctx.outcome_or_updates(
1546 &operation,
1547 operation_id,
1548 |state| match state {
1549 DepositStateV2::WaitingForTransaction
1550 | DepositStateV2::WaitingForConfirmation { .. }
1551 | DepositStateV2::Confirmed { .. } => false,
1552 DepositStateV2::Claimed { .. } | DepositStateV2::Failed(_) => true,
1553 },
1554 {
1555 let stream_rpc = self.rpc.clone();
1556 let stream_client_ctx = self.client_ctx.clone();
1557 let stream_script_pub_key = address.script_pubkey();
1558 move || {
1559
1560 stream! {
1561 yield DepositStateV2::WaitingForTransaction;
1562
1563 retry(
1564 "subscribe script history",
1565 background_backoff(),
1566 || stream_rpc.watch_script_history(&stream_script_pub_key)
1567 ).await.expect("Will never give up");
1568 let (btc_out_point, btc_deposited) = retry(
1569 "fetch history",
1570 background_backoff(),
1571 || async {
1572 let history = stream_rpc.get_script_history(&stream_script_pub_key).await?;
1573 history.first().and_then(|tx| {
1574 let (out_idx, amount) = tx.output
1575 .iter()
1576 .enumerate()
1577 .find_map(|(idx, output)| (output.script_pubkey == stream_script_pub_key).then_some((idx, output.value)))?;
1578 let txid = tx.compute_txid();
1579
1580 Some((
1581 bitcoin::OutPoint {
1582 txid,
1583 vout: out_idx as u32,
1584 },
1585 amount
1586 ))
1587 }).context("No deposit transaction found")
1588 }
1589 ).await.expect("Will never give up");
1590
1591 yield DepositStateV2::WaitingForConfirmation {
1592 btc_deposited,
1593 btc_out_point
1594 };
1595
1596 let claim_data = stream_client_ctx.module_db().wait_key_exists(&ClaimedPegInKey {
1597 peg_in_index: tweak_idx,
1598 btc_out_point,
1599 }).await;
1600
1601 yield DepositStateV2::Confirmed {
1602 btc_deposited,
1603 btc_out_point
1604 };
1605
1606 match stream_client_ctx.await_primary_module_outputs(operation_id, claim_data.change).await {
1607 Ok(()) => yield DepositStateV2::Claimed {
1608 btc_deposited,
1609 btc_out_point
1610 },
1611 Err(e) => yield DepositStateV2::Failed(e.to_string())
1612 }
1613 }
1614 }}))
1615 }
1616
1617 pub async fn list_peg_in_tweak_idxes(&self) -> BTreeMap<TweakIdx, PegInTweakIndexData> {
1618 self.client_ctx
1619 .module_db()
1620 .clone()
1621 .begin_transaction_nc()
1622 .await
1623 .find_by_prefix(&PegInTweakIndexPrefix)
1624 .await
1625 .map(|(key, data)| (key.0, data))
1626 .collect()
1627 .await
1628 }
1629
1630 pub async fn find_tweak_idx_by_address(
1631 &self,
1632 address: bitcoin::Address<NetworkUnchecked>,
1633 ) -> anyhow::Result<TweakIdx> {
1634 let data = self.data.clone();
1635 let Some((tweak_idx, _)) = self
1636 .db
1637 .begin_transaction_nc()
1638 .await
1639 .find_by_prefix(&PegInTweakIndexPrefix)
1640 .await
1641 .filter(|(k, _)| {
1642 let (_, derived_address, _tweak_key, _) = data.derive_peg_in_script(k.0);
1643 future::ready(derived_address.into_unchecked() == address)
1644 })
1645 .next()
1646 .await
1647 else {
1648 bail!("Address not found in the list of derived keys");
1649 };
1650
1651 Ok(tweak_idx.0)
1652 }
1653 pub async fn find_tweak_idx_by_operation_id(
1654 &self,
1655 operation_id: OperationId,
1656 ) -> anyhow::Result<TweakIdx> {
1657 Ok(self
1658 .client_ctx
1659 .module_db()
1660 .clone()
1661 .begin_transaction_nc()
1662 .await
1663 .find_by_prefix(&PegInTweakIndexPrefix)
1664 .await
1665 .filter(|(_k, v)| future::ready(v.operation_id == operation_id))
1666 .next()
1667 .await
1668 .ok_or_else(|| anyhow::format_err!("OperationId not found"))?
1669 .0
1670 .0)
1671 }
1672
1673 pub async fn get_pegin_tweak_idx(
1674 &self,
1675 tweak_idx: TweakIdx,
1676 ) -> anyhow::Result<PegInTweakIndexData> {
1677 self.client_ctx
1678 .module_db()
1679 .clone()
1680 .begin_transaction_nc()
1681 .await
1682 .get_value(&PegInTweakIndexKey(tweak_idx))
1683 .await
1684 .ok_or_else(|| anyhow::format_err!("TweakIdx not found"))
1685 }
1686
1687 pub async fn get_claimed_pegins(
1688 &self,
1689 dbtx: &mut DatabaseTransaction<'_>,
1690 tweak_idx: TweakIdx,
1691 ) -> Vec<(
1692 bitcoin::OutPoint,
1693 TransactionId,
1694 Vec<fedimint_core::OutPoint>,
1695 )> {
1696 let outpoints = dbtx
1697 .get_value(&PegInTweakIndexKey(tweak_idx))
1698 .await
1699 .map(|v| v.claimed)
1700 .unwrap_or_default();
1701
1702 let mut res = vec![];
1703
1704 for outpoint in outpoints {
1705 let claimed_peg_in_data = dbtx
1706 .get_value(&ClaimedPegInKey {
1707 peg_in_index: tweak_idx,
1708 btc_out_point: outpoint,
1709 })
1710 .await
1711 .expect("Must have a corresponding claim record");
1712 res.push((
1713 outpoint,
1714 claimed_peg_in_data.claim_txid,
1715 claimed_peg_in_data.change,
1716 ));
1717 }
1718
1719 res
1720 }
1721
1722 pub async fn recheck_pegin_address_by_op_id(
1724 &self,
1725 operation_id: OperationId,
1726 ) -> anyhow::Result<()> {
1727 let tweak_idx = self.find_tweak_idx_by_operation_id(operation_id).await?;
1728
1729 self.recheck_pegin_address(tweak_idx).await
1730 }
1731
1732 pub async fn recheck_pegin_address_by_address(
1734 &self,
1735 address: bitcoin::Address<NetworkUnchecked>,
1736 ) -> anyhow::Result<()> {
1737 self.recheck_pegin_address(self.find_tweak_idx_by_address(address).await?)
1738 .await
1739 }
1740
1741 pub async fn recheck_pegin_address(&self, tweak_idx: TweakIdx) -> anyhow::Result<()> {
1743 self.db
1744 .autocommit(
1745 |dbtx, _| {
1746 Box::pin(async {
1747 let db_key = PegInTweakIndexKey(tweak_idx);
1748 let db_val = dbtx
1749 .get_value(&db_key)
1750 .await
1751 .ok_or_else(|| anyhow::format_err!("DBKey not found"))?;
1752
1753 dbtx.insert_entry(
1754 &db_key,
1755 &PegInTweakIndexData {
1756 next_check_time: Some(fedimint_core::time::now()),
1757 ..db_val
1758 },
1759 )
1760 .await;
1761
1762 let sender = self.pegin_monitor_wakeup_sender.clone();
1763 dbtx.on_commit(move || {
1764 sender.send_replace(());
1765 });
1766
1767 Ok::<_, anyhow::Error>(())
1768 })
1769 },
1770 Some(100),
1771 )
1772 .await?;
1773
1774 Ok(())
1775 }
1776
1777 pub async fn await_num_deposits_by_operation_id(
1779 &self,
1780 operation_id: OperationId,
1781 num_deposits: usize,
1782 ) -> anyhow::Result<()> {
1783 let tweak_idx = self.find_tweak_idx_by_operation_id(operation_id).await?;
1784 self.await_num_deposits(tweak_idx, num_deposits).await
1785 }
1786
1787 pub async fn await_num_deposits_by_address(
1788 &self,
1789 address: bitcoin::Address<NetworkUnchecked>,
1790 num_deposits: usize,
1791 ) -> anyhow::Result<()> {
1792 self.await_num_deposits(self.find_tweak_idx_by_address(address).await?, num_deposits)
1793 .await
1794 }
1795
1796 #[instrument(target = LOG_CLIENT_MODULE_WALLET, skip_all, fields(tweak_idx=?tweak_idx, num_deposists=num_deposits))]
1797 pub async fn await_num_deposits(
1798 &self,
1799 tweak_idx: TweakIdx,
1800 num_deposits: usize,
1801 ) -> anyhow::Result<()> {
1802 let operation_id = self.get_pegin_tweak_idx(tweak_idx).await?.operation_id;
1803
1804 let mut receiver = self.pegin_claimed_receiver.clone();
1805 let mut backoff = backoff_util::aggressive_backoff();
1806
1807 loop {
1808 let pegins = self
1809 .get_claimed_pegins(
1810 &mut self.client_ctx.module_db().begin_transaction_nc().await,
1811 tweak_idx,
1812 )
1813 .await;
1814
1815 if pegins.len() < num_deposits {
1816 debug!(target: LOG_CLIENT_MODULE_WALLET, has=pegins.len(), "Not enough deposits");
1817 self.recheck_pegin_address(tweak_idx).await?;
1818 runtime::sleep(backoff.next().unwrap_or_default()).await;
1819 receiver.changed().await?;
1820 continue;
1821 }
1822
1823 debug!(target: LOG_CLIENT_MODULE_WALLET, has=pegins.len(), "Enough deposits detected");
1824
1825 for (_outpoint, transaction_id, change) in pegins {
1826 if transaction_id == TransactionId::from_byte_array([0; 32]) && change.is_empty() {
1827 debug!(target: LOG_CLIENT_MODULE_WALLET, "Deposited amount was too low, skipping");
1828 continue;
1829 }
1830
1831 debug!(target: LOG_CLIENT_MODULE_WALLET, out_points=?change, "Ensuring deposists claimed");
1832 let tx_subscriber = self.client_ctx.transaction_updates(operation_id).await;
1833
1834 if let Err(e) = tx_subscriber.await_tx_accepted(transaction_id).await {
1835 bail!("{e}");
1836 }
1837
1838 debug!(target: LOG_CLIENT_MODULE_WALLET, out_points=?change, "Ensuring outputs claimed");
1839 self.client_ctx
1840 .await_primary_module_outputs(operation_id, change)
1841 .await
1842 .expect("Cannot fail if tx was accepted and federation is honest");
1843 }
1844
1845 return Ok(());
1846 }
1847 }
1848
1849 pub async fn withdraw<M: Serialize + MaybeSend + MaybeSync>(
1854 &self,
1855 address: &bitcoin::Address,
1856 amount: bitcoin::Amount,
1857 fee: PegOutFees,
1858 extra_meta: M,
1859 ) -> anyhow::Result<OperationId> {
1860 {
1861 let operation_id = OperationId(thread_rng().r#gen());
1862
1863 let withdraw_output =
1864 self.create_withdraw_output(operation_id, address.clone(), amount, fee)?;
1865 let tx_builder = TransactionBuilder::new()
1866 .with_outputs(self.client_ctx.make_client_outputs(withdraw_output));
1867
1868 let extra_meta =
1869 serde_json::to_value(extra_meta).expect("Failed to serialize extra meta");
1870 self.client_ctx
1871 .finalize_and_submit_transaction(
1872 operation_id,
1873 WalletCommonInit::KIND.as_str(),
1874 {
1875 let address = address.clone();
1876 move |change_range: OutPointRange| WalletOperationMeta {
1877 variant: WalletOperationMetaVariant::Withdraw {
1878 address: address.clone().into_unchecked(),
1879 amount,
1880 fee,
1881 change: change_range.into_iter().collect(),
1882 },
1883 extra_meta: extra_meta.clone(),
1884 }
1885 },
1886 tx_builder,
1887 )
1888 .await?;
1889
1890 let mut dbtx = self.client_ctx.module_db().begin_transaction().await;
1891
1892 self.client_ctx
1893 .log_event(
1894 &mut dbtx,
1895 SendPaymentEvent {
1896 operation_id,
1897 amount: amount + fee.amount(),
1898 fee: fee.amount(),
1899 },
1900 )
1901 .await;
1902
1903 dbtx.commit_tx().await;
1904
1905 Ok(operation_id)
1906 }
1907 }
1908
1909 #[deprecated(
1914 since = "0.4.0",
1915 note = "RBF withdrawals are rejected by the federation"
1916 )]
1917 pub async fn rbf_withdraw<M: Serialize + MaybeSync + MaybeSend>(
1918 &self,
1919 rbf: Rbf,
1920 extra_meta: M,
1921 ) -> anyhow::Result<OperationId> {
1922 let operation_id = OperationId(thread_rng().r#gen());
1923
1924 let withdraw_output = self.create_rbf_withdraw_output(operation_id, &rbf)?;
1925 let tx_builder = TransactionBuilder::new()
1926 .with_outputs(self.client_ctx.make_client_outputs(withdraw_output));
1927
1928 let extra_meta = serde_json::to_value(extra_meta).expect("Failed to serialize extra meta");
1929 self.client_ctx
1930 .finalize_and_submit_transaction(
1931 operation_id,
1932 WalletCommonInit::KIND.as_str(),
1933 move |change_range: OutPointRange| WalletOperationMeta {
1934 variant: WalletOperationMetaVariant::RbfWithdraw {
1935 rbf: rbf.clone(),
1936 change: change_range.into_iter().collect(),
1937 },
1938 extra_meta: extra_meta.clone(),
1939 },
1940 tx_builder,
1941 )
1942 .await?;
1943
1944 Ok(operation_id)
1945 }
1946
1947 pub async fn subscribe_withdraw_updates(
1948 &self,
1949 operation_id: OperationId,
1950 ) -> anyhow::Result<UpdateStreamOrOutcome<WithdrawState>> {
1951 let operation = self
1952 .client_ctx
1953 .get_operation(operation_id)
1954 .await
1955 .with_context(|| anyhow!("Operation not found: {}", operation_id.fmt_short()))?;
1956
1957 if operation.operation_module_kind() != WalletCommonInit::KIND.as_str() {
1958 bail!("Operation is not a wallet operation");
1959 }
1960
1961 let operation_meta = operation.meta::<WalletOperationMeta>();
1962
1963 let (WalletOperationMetaVariant::Withdraw { change, .. }
1964 | WalletOperationMetaVariant::RbfWithdraw { change, .. }) = operation_meta.variant
1965 else {
1966 bail!("Operation is not a withdraw operation");
1967 };
1968
1969 let mut operation_stream = self.notifier.subscribe(operation_id).await;
1970 let client_ctx = self.client_ctx.clone();
1971
1972 Ok(self.client_ctx.outcome_or_updates(
1973 &operation,
1974 operation_id,
1975 |state| match state {
1976 WithdrawState::Created => false,
1977 WithdrawState::Succeeded(_) | WithdrawState::Failed(_) => true,
1978 },
1979 move || {
1980 stream! {
1981 match next_withdraw_state(&mut operation_stream).await {
1982 Some(WithdrawStates::Created(_)) => {
1983 yield WithdrawState::Created;
1984 },
1985 Some(s) => {
1986 panic!("Unexpected state {s:?}")
1987 },
1988 None => return,
1989 }
1990
1991 let _ = client_ctx
1996 .await_primary_module_outputs(operation_id, change)
1997 .await;
1998
1999
2000 match next_withdraw_state(&mut operation_stream).await {
2001 Some(WithdrawStates::Aborted(inner)) => {
2002 yield WithdrawState::Failed(inner.error);
2003 },
2004 Some(WithdrawStates::Success(inner)) => {
2005 yield WithdrawState::Succeeded(inner.txid);
2006 },
2007 Some(s) => {
2008 panic!("Unexpected state {s:?}")
2009 },
2010 None => {},
2011 }
2012 }
2013 },
2014 ))
2015 }
2016
2017 fn admin_auth(&self) -> anyhow::Result<ApiAuth> {
2018 self.admin_auth
2019 .clone()
2020 .ok_or_else(|| anyhow::format_err!("Admin auth not set"))
2021 }
2022
2023 pub async fn activate_consensus_version_voting(&self) -> anyhow::Result<()> {
2024 self.module_api
2025 .activate_consensus_version_voting(self.admin_auth()?)
2026 .await?;
2027
2028 Ok(())
2029 }
2030}
2031
2032async fn poll_supports_safe_deposit_version(db: Database, module_api: DynModuleApi) {
2035 loop {
2036 if supports_safe_deposit_verified(&db).await {
2037 break;
2038 }
2039
2040 module_api.wait_for_initialized_connections().await;
2041
2042 if verify_supports_safe_deposit(&db, &module_api).await == Some(true) {
2043 break;
2044 }
2045
2046 if is_running_in_test_env() {
2047 sleep(Duration::from_secs(10)).await;
2049 } else {
2050 sleep(Duration::from_hours(1)).await;
2051 }
2052 }
2053}
2054
2055async fn supports_safe_deposit_verified(db: &Database) -> bool {
2058 db.begin_transaction_nc()
2059 .await
2060 .get_value(&SupportsSafeDepositKey)
2061 .await
2062 .is_some()
2063}
2064
2065async fn verify_supports_safe_deposit(db: &Database, module_api: &DynModuleApi) -> Option<bool> {
2070 let module_consensus_version = match module_api.module_consensus_version().await {
2071 Ok(module_consensus_version) => module_consensus_version,
2072 Err(err) => {
2073 debug!(
2074 target: LOG_CLIENT_MODULE_WALLET,
2075 err = %err.fmt_compact(),
2076 "Could not fetch the wallet module consensus version"
2077 );
2078 return None;
2079 }
2080 };
2081
2082 let supported_version = SAFE_DEPOSIT_MODULE_CONSENSUS_VERSION <= module_consensus_version;
2083
2084 if supported_version {
2085 store_supports_safe_deposit(db.begin_transaction().await).await;
2086 }
2087
2088 Some(supported_version)
2089}
2090
2091async fn store_supports_safe_deposit(mut dbtx: DatabaseTransaction<'_, Committable>) {
2106 if dbtx.get_value(&SupportsSafeDepositKey).await.is_some() {
2107 return;
2109 }
2110
2111 dbtx.insert_entry(&SupportsSafeDepositKey, &()).await;
2112
2113 match dbtx.commit_tx_result().await {
2114 Ok(()) | Err(DatabaseError::WriteConflict) => {}
2115 Err(err) => {
2116 warn!(
2117 target: LOG_CLIENT_MODULE_WALLET,
2118 err = %err.fmt_compact(),
2119 "Failed to store the safe-deposit marker"
2120 );
2121 }
2122 }
2123}
2124
2125async fn get_next_peg_in_tweak_child_id(dbtx: &mut DatabaseTransaction<'_>) -> TweakIdx {
2127 let index = dbtx
2128 .get_value(&NextPegInTweakIndexKey)
2129 .await
2130 .unwrap_or_default();
2131 dbtx.insert_entry(&NextPegInTweakIndexKey, &(index.next()))
2132 .await;
2133 index
2134}
2135
2136#[derive(Debug, Clone, Eq, PartialEq, Hash, Decodable, Encodable)]
2137pub enum WalletClientStates {
2138 Deposit(DepositStateMachine),
2139 Withdraw(WithdrawStateMachine),
2140}
2141
2142impl IntoDynInstance for WalletClientStates {
2143 type DynType = DynState;
2144
2145 fn into_dyn(self, instance_id: ModuleInstanceId) -> Self::DynType {
2146 DynState::from_typed(instance_id, self)
2147 }
2148}
2149
2150impl State for WalletClientStates {
2151 type ModuleContext = WalletClientContext;
2152
2153 fn transitions(
2154 &self,
2155 context: &Self::ModuleContext,
2156 global_context: &DynGlobalClientContext,
2157 ) -> Vec<StateTransition<Self>> {
2158 match self {
2159 WalletClientStates::Deposit(sm) => {
2160 sm_enum_variant_translation!(
2161 sm.transitions(context, global_context),
2162 WalletClientStates::Deposit
2163 )
2164 }
2165 WalletClientStates::Withdraw(sm) => {
2166 sm_enum_variant_translation!(
2167 sm.transitions(context, global_context),
2168 WalletClientStates::Withdraw
2169 )
2170 }
2171 }
2172 }
2173
2174 fn operation_id(&self) -> OperationId {
2175 match self {
2176 WalletClientStates::Deposit(sm) => sm.operation_id(),
2177 WalletClientStates::Withdraw(sm) => sm.operation_id(),
2178 }
2179 }
2180}
2181
2182#[cfg(all(test, not(target_family = "wasm")))]
2183mod tests {
2184 use std::collections::BTreeSet;
2185 use std::sync::atomic::{AtomicBool, Ordering};
2186
2187 use fedimint_core::db::mem_impl::MemDatabase;
2188 use fedimint_core::module::registry::ModuleDecoderRegistry;
2189
2190 use super::*;
2191 use crate::backup::{
2192 RECOVER_NUM_IDX_ADD_TO_LAST_USED, RecoverScanOutcome, recover_scan_idxes_for_activity,
2193 };
2194
2195 #[allow(clippy::too_many_lines)] #[tokio::test(flavor = "multi_thread")]
2197 async fn sanity_test_recover_inner() {
2198 {
2199 let last_checked = AtomicBool::new(false);
2200 let last_checked = &last_checked;
2201 assert_eq!(
2202 recover_scan_idxes_for_activity(
2203 TweakIdx(0),
2204 &BTreeSet::new(),
2205 |cur_idx| async move {
2206 Ok(match cur_idx {
2207 TweakIdx(9) => {
2208 last_checked.store(true, Ordering::SeqCst);
2209 vec![]
2210 }
2211 TweakIdx(10) => panic!("Shouldn't happen"),
2212 TweakIdx(11) => {
2213 vec![0usize] }
2215 _ => vec![],
2216 })
2217 }
2218 )
2219 .await
2220 .unwrap(),
2221 RecoverScanOutcome {
2222 last_used_idx: None,
2223 new_start_idx: TweakIdx(RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2224 tweak_idxes_with_pegins: BTreeSet::from([])
2225 }
2226 );
2227 assert!(last_checked.load(Ordering::SeqCst));
2228 }
2229
2230 {
2231 let last_checked = AtomicBool::new(false);
2232 let last_checked = &last_checked;
2233 assert_eq!(
2234 recover_scan_idxes_for_activity(
2235 TweakIdx(0),
2236 &BTreeSet::from([TweakIdx(1), TweakIdx(2)]),
2237 |cur_idx| async move {
2238 Ok(match cur_idx {
2239 TweakIdx(1) => panic!("Shouldn't happen: already used (1)"),
2240 TweakIdx(2) => panic!("Shouldn't happen: already used (2)"),
2241 TweakIdx(11) => {
2242 last_checked.store(true, Ordering::SeqCst);
2243 vec![]
2244 }
2245 TweakIdx(12) => panic!("Shouldn't happen"),
2246 TweakIdx(13) => {
2247 vec![0usize] }
2249 _ => vec![],
2250 })
2251 }
2252 )
2253 .await
2254 .unwrap(),
2255 RecoverScanOutcome {
2256 last_used_idx: Some(TweakIdx(2)),
2257 new_start_idx: TweakIdx(2 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2258 tweak_idxes_with_pegins: BTreeSet::from([])
2259 }
2260 );
2261 assert!(last_checked.load(Ordering::SeqCst));
2262 }
2263
2264 {
2265 let last_checked = AtomicBool::new(false);
2266 let last_checked = &last_checked;
2267 assert_eq!(
2268 recover_scan_idxes_for_activity(
2269 TweakIdx(10),
2270 &BTreeSet::new(),
2271 |cur_idx| async move {
2272 Ok(match cur_idx {
2273 TweakIdx(10) => vec![()],
2274 TweakIdx(19) => {
2275 last_checked.store(true, Ordering::SeqCst);
2276 vec![]
2277 }
2278 TweakIdx(20) => panic!("Shouldn't happen"),
2279 _ => vec![],
2280 })
2281 }
2282 )
2283 .await
2284 .unwrap(),
2285 RecoverScanOutcome {
2286 last_used_idx: Some(TweakIdx(10)),
2287 new_start_idx: TweakIdx(10 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2288 tweak_idxes_with_pegins: BTreeSet::from([TweakIdx(10)])
2289 }
2290 );
2291 assert!(last_checked.load(Ordering::SeqCst));
2292 }
2293
2294 assert_eq!(
2295 recover_scan_idxes_for_activity(TweakIdx(0), &BTreeSet::new(), |cur_idx| async move {
2296 Ok(match cur_idx {
2297 TweakIdx(6 | 15) => vec![()],
2298 _ => vec![],
2299 })
2300 })
2301 .await
2302 .unwrap(),
2303 RecoverScanOutcome {
2304 last_used_idx: Some(TweakIdx(15)),
2305 new_start_idx: TweakIdx(15 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2306 tweak_idxes_with_pegins: BTreeSet::from([TweakIdx(6), TweakIdx(15)])
2307 }
2308 );
2309 assert_eq!(
2310 recover_scan_idxes_for_activity(TweakIdx(10), &BTreeSet::new(), |cur_idx| async move {
2311 Ok(match cur_idx {
2312 TweakIdx(8) => {
2313 vec![()] }
2315 TweakIdx(9) => {
2316 panic!("Shouldn't happen")
2317 }
2318 _ => vec![],
2319 })
2320 })
2321 .await
2322 .unwrap(),
2323 RecoverScanOutcome {
2324 last_used_idx: None,
2325 new_start_idx: TweakIdx(9 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2326 tweak_idxes_with_pegins: BTreeSet::from([])
2327 }
2328 );
2329 assert_eq!(
2330 recover_scan_idxes_for_activity(TweakIdx(10), &BTreeSet::new(), |cur_idx| async move {
2331 Ok(match cur_idx {
2332 TweakIdx(9) => panic!("Shouldn't happen"),
2333 TweakIdx(15) => vec![()],
2334 _ => vec![],
2335 })
2336 })
2337 .await
2338 .unwrap(),
2339 RecoverScanOutcome {
2340 last_used_idx: Some(TweakIdx(15)),
2341 new_start_idx: TweakIdx(15 + RECOVER_NUM_IDX_ADD_TO_LAST_USED),
2342 tweak_idxes_with_pegins: BTreeSet::from([TweakIdx(15)])
2343 }
2344 );
2345 }
2346
2347 #[tokio::test]
2348 async fn store_supports_safe_deposit_tolerates_a_lost_race() {
2349 let db = Database::new(MemDatabase::new(), ModuleDecoderRegistry::default());
2350
2351 let racing_dbtx = db.begin_transaction().await;
2354 let mut sibling_dbtx = db.begin_transaction().await;
2355
2356 let mut competing_dbtx = db.begin_transaction().await;
2357 competing_dbtx
2358 .insert_entry(&SupportsSafeDepositKey, &())
2359 .await;
2360 competing_dbtx.commit_tx().await;
2361
2362 sibling_dbtx
2364 .insert_entry(&SupportsSafeDepositKey, &())
2365 .await;
2366 assert!(matches!(
2367 sibling_dbtx.commit_tx_result().await,
2368 Err(DatabaseError::WriteConflict)
2369 ));
2370
2371 store_supports_safe_deposit(racing_dbtx).await;
2372
2373 assert!(supports_safe_deposit_verified(&db).await);
2374 }
2375}