1#![deny(clippy::pedantic)]
2#![allow(clippy::cast_possible_truncation)]
3#![allow(clippy::cast_possible_wrap)]
4#![allow(clippy::default_trait_access)]
5#![allow(clippy::missing_errors_doc)]
6#![allow(clippy::missing_panics_doc)]
7#![allow(clippy::module_name_repetitions)]
8#![allow(clippy::must_use_candidate)]
9#![allow(clippy::needless_lifetimes)]
10#![allow(clippy::too_many_lines)]
11
12pub mod db;
13pub mod envs;
14
15use std::clone::Clone;
16use std::cmp::min;
17use std::collections::{BTreeMap, BTreeSet};
18use std::convert::Infallible;
19use std::sync::Arc;
20#[cfg(not(target_family = "wasm"))]
21use std::time::Duration;
22
23use anyhow::{Context, bail, ensure, format_err};
24use bitcoin::absolute::LockTime;
25use bitcoin::address::NetworkUnchecked;
26use bitcoin::ecdsa::Signature as EcdsaSig;
27use bitcoin::hashes::{Hash as BitcoinHash, HashEngine, Hmac, HmacEngine, sha256};
28use bitcoin::policy::DEFAULT_MIN_RELAY_TX_FEE;
29use bitcoin::psbt::{Input, Psbt};
30use bitcoin::secp256k1::{self, All, Message, Scalar, Secp256k1, Verification};
31use bitcoin::sighash::{EcdsaSighashType, SighashCache};
32use bitcoin::{Address, BlockHash, Network, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid};
33use common::config::WalletConfigConsensus;
34use common::{
35 DEPRECATED_RBF_ERROR, PegOutFees, PegOutSignatureItem, ProcessPegOutSigError, SpendableUTXO,
36 TxOutputSummary, WalletCommonInit, WalletConsensusItem, WalletInput, WalletModuleTypes,
37 WalletOutput, WalletOutputOutcome, WalletSummary, proprietary_tweak_key,
38};
39use db::{
40 BlockHashByHeightKey, BlockHashByHeightKeyPrefix, BlockHashByHeightValue, RecoveryItemKey,
41 RecoveryItemKeyPrefix,
42};
43use envs::get_feerate_multiplier;
44use fedimint_api_client::api::{DynModuleApi, FederationApiExt};
45use fedimint_core::config::{
46 ServerModuleConfig, ServerModuleConsensusConfig, TypedServerModuleConfig,
47 TypedServerModuleConsensusConfig,
48};
49use fedimint_core::core::ModuleInstanceId;
50use fedimint_core::db::{
51 Database, DatabaseTransaction, DatabaseVersion, IDatabaseTransactionOpsCoreTyped,
52};
53use fedimint_core::encoding::btc::NetworkLegacyEncodingWrapper;
54use fedimint_core::encoding::{Decodable, Encodable};
55use fedimint_core::envs::{
56 BitcoinRpcConfig, FM_ENABLE_MODULE_WALLET_ENV,
57 FM_WALLET_DISABLE_AUTOMATIC_CONSENSUS_VERSION_VOTING_ENV, FM_WALLET_FEERATE_SOURCES_ENV,
58 is_automatic_consensus_version_voting_disabled, is_env_var_set_opt, is_running_in_test_env,
59 next_poll_delay,
60};
61use fedimint_core::module::audit::Audit;
62use fedimint_core::module::{
63 Amounts, ApiEndpoint, ApiError, ApiRequestErased, ApiVersion, CoreConsensusVersion, InputMeta,
64 ModuleConsensusVersion, ModuleInit, TransactionItemAmounts, admin_api_endpoint,
65 public_api_endpoint,
66};
67use fedimint_core::task::TaskGroup;
68#[cfg(not(target_family = "wasm"))]
69use fedimint_core::task::sleep;
70use fedimint_core::util::{FmtCompact, FmtCompactAnyhow as _, backoff_util, retry};
71use fedimint_core::{
72 Feerate, InPoint, NumPeersExt, OutPoint, PeerId, apply, async_trait_maybe_send,
73 get_network_for_address, push_db_key_items, push_db_pair_items, weight_to_vbytes,
74};
75use fedimint_logging::LOG_MODULE_WALLET;
76use fedimint_server_core::bitcoin_rpc::ServerBitcoinRpcMonitor;
77use fedimint_server_core::config::{PeerHandleOps, PeerHandleOpsExt};
78use fedimint_server_core::migration::ServerModuleDbMigrationFn;
79use fedimint_server_core::{
80 ConfigGenModuleArgs, EnvVarDoc, ServerModule, ServerModuleInit, ServerModuleInitArgs,
81};
82pub use fedimint_wallet_common as common;
83use fedimint_wallet_common::config::{FeeConsensus, WalletClientConfig, WalletConfig};
84use fedimint_wallet_common::endpoint_constants::{
85 ACTIVATE_CONSENSUS_VERSION_VOTING_ENDPOINT, BITCOIN_KIND_ENDPOINT, BITCOIN_RPC_CONFIG_ENDPOINT,
86 BLOCK_COUNT_ENDPOINT, BLOCK_COUNT_LOCAL_ENDPOINT, MODULE_CONSENSUS_VERSION_ENDPOINT,
87 PEG_OUT_FEES_ENDPOINT, RECOVERY_COUNT_ENDPOINT, RECOVERY_SLICE_ENDPOINT,
88 SUPPORTED_MODULE_CONSENSUS_VERSION_ENDPOINT, UTXO_CONFIRMED_ENDPOINT, WALLET_SUMMARY_ENDPOINT,
89};
90use fedimint_wallet_common::envs::FM_PORT_ESPLORA_ENV;
91use fedimint_wallet_common::keys::CompressedPublicKey;
92use fedimint_wallet_common::tweakable::Tweakable;
93use fedimint_wallet_common::{
94 CHECKED_PEG_OUT_FEE_MODULE_CONSENSUS_VERSION, MODULE_CONSENSUS_VERSION, Rbf, RecoveryItem,
95 UnknownWalletInputVariantError, WalletInputError, WalletOutputError, WalletOutputV0,
96};
97use futures::future::join_all;
98use futures::{FutureExt, StreamExt};
99use itertools::Itertools;
100use metrics::{
101 WALLET_INOUT_FEES_SATS, WALLET_INOUT_SATS, WALLET_PEGIN_FEES_SATS, WALLET_PEGIN_SATS,
102 WALLET_PEGOUT_FEES_SATS, WALLET_PEGOUT_SATS,
103};
104use miniscript::psbt::PsbtExt;
105use miniscript::{Descriptor, TranslatePk, translate_hash_fail};
106use rand::rngs::OsRng;
107use serde::Serialize;
108use strum::IntoEnumIterator;
109use tokio::sync::{Notify, watch};
110use tracing::{debug, info, instrument, trace, warn};
111
112use crate::db::{
113 BlockCountVoteKey, BlockCountVotePrefix, BlockHashKey, BlockHashKeyPrefix,
114 ClaimedPegInOutpointKey, ClaimedPegInOutpointPrefixKey, ConsensusVersionVoteKey,
115 ConsensusVersionVotePrefix, ConsensusVersionVotingActivationKey,
116 ConsensusVersionVotingActivationPrefix, DbKeyPrefix, FeeRateVoteKey, FeeRateVotePrefix,
117 PegOutBitcoinTransaction, PegOutBitcoinTransactionPrefix, PegOutNonceKey, PegOutTxSignatureCI,
118 PegOutTxSignatureCIPrefix, PendingTransactionKey, PendingTransactionPrefixKey, UTXOKey,
119 UTXOPrefixKey, UnsignedTransactionKey, UnsignedTransactionPrefixKey, UnspentTxOutKey,
120 UnspentTxOutPrefix, migrate_to_v1, migrate_to_v2, migrate_to_v3,
121};
122use crate::metrics::WALLET_BLOCK_COUNT;
123
124mod metrics;
125
126pub const PEG_OUT_CHANGE_VOUT: u32 = 1;
131
132#[derive(Debug, Clone)]
133pub struct WalletInit;
134
135impl ModuleInit for WalletInit {
136 type Common = WalletCommonInit;
137
138 async fn dump_database(
139 &self,
140 dbtx: &mut DatabaseTransaction<'_>,
141 prefix_names: Vec<String>,
142 ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
143 let mut wallet: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> = BTreeMap::new();
144 let filtered_prefixes = DbKeyPrefix::iter().filter(|f| {
145 prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase())
146 });
147 for table in filtered_prefixes {
148 match table {
149 DbKeyPrefix::BlockHash => {
150 push_db_key_items!(dbtx, BlockHashKeyPrefix, BlockHashKey, wallet, "Blocks");
151 }
152 DbKeyPrefix::BlockHashByHeight => {
153 push_db_key_items!(
154 dbtx,
155 BlockHashByHeightKeyPrefix,
156 BlockHashByHeightKey,
157 wallet,
158 "Blocks by height"
159 );
160 }
161 DbKeyPrefix::PegOutBitcoinOutPoint => {
162 push_db_pair_items!(
163 dbtx,
164 PegOutBitcoinTransactionPrefix,
165 PegOutBitcoinTransaction,
166 WalletOutputOutcome,
167 wallet,
168 "Peg Out Bitcoin Transaction"
169 );
170 }
171 DbKeyPrefix::PegOutTxSigCi => {
172 push_db_pair_items!(
173 dbtx,
174 PegOutTxSignatureCIPrefix,
175 PegOutTxSignatureCI,
176 Vec<secp256k1::ecdsa::Signature>,
177 wallet,
178 "Peg Out Transaction Signatures"
179 );
180 }
181 DbKeyPrefix::PendingTransaction => {
182 push_db_pair_items!(
183 dbtx,
184 PendingTransactionPrefixKey,
185 PendingTransactionKey,
186 PendingTransaction,
187 wallet,
188 "Pending Transactions"
189 );
190 }
191 DbKeyPrefix::PegOutNonce => {
192 if let Some(nonce) = dbtx.get_value(&PegOutNonceKey).await {
193 wallet.insert("Peg Out Nonce".to_string(), Box::new(nonce));
194 }
195 }
196 DbKeyPrefix::UnsignedTransaction => {
197 push_db_pair_items!(
198 dbtx,
199 UnsignedTransactionPrefixKey,
200 UnsignedTransactionKey,
201 UnsignedTransaction,
202 wallet,
203 "Unsigned Transactions"
204 );
205 }
206 DbKeyPrefix::Utxo => {
207 push_db_pair_items!(
208 dbtx,
209 UTXOPrefixKey,
210 UTXOKey,
211 SpendableUTXO,
212 wallet,
213 "UTXOs"
214 );
215 }
216 DbKeyPrefix::BlockCountVote => {
217 push_db_pair_items!(
218 dbtx,
219 BlockCountVotePrefix,
220 BlockCountVoteKey,
221 u32,
222 wallet,
223 "Block Count Votes"
224 );
225 }
226 DbKeyPrefix::FeeRateVote => {
227 push_db_pair_items!(
228 dbtx,
229 FeeRateVotePrefix,
230 FeeRateVoteKey,
231 Feerate,
232 wallet,
233 "Fee Rate Votes"
234 );
235 }
236 DbKeyPrefix::ClaimedPegInOutpoint => {
237 push_db_pair_items!(
238 dbtx,
239 ClaimedPegInOutpointPrefixKey,
240 PeggedInOutpointKey,
241 (),
242 wallet,
243 "Claimed Peg-in Outpoint"
244 );
245 }
246 DbKeyPrefix::ConsensusVersionVote => {
247 push_db_pair_items!(
248 dbtx,
249 ConsensusVersionVotePrefix,
250 ConsensusVersionVoteKey,
251 ModuleConsensusVersion,
252 wallet,
253 "Consensus Version Votes"
254 );
255 }
256 DbKeyPrefix::UnspentTxOut => {
257 push_db_pair_items!(
258 dbtx,
259 UnspentTxOutPrefix,
260 UnspentTxOutKey,
261 TxOut,
262 wallet,
263 "Consensus Version Votes"
264 );
265 }
266 DbKeyPrefix::ConsensusVersionVotingActivation => {
267 push_db_pair_items!(
268 dbtx,
269 ConsensusVersionVotingActivationPrefix,
270 ConsensusVersionVotingActivationKey,
271 (),
272 wallet,
273 "Consensus Version Voting Activation Key"
274 );
275 }
276 DbKeyPrefix::RecoveryItem => {
277 push_db_pair_items!(
278 dbtx,
279 RecoveryItemKeyPrefix,
280 RecoveryItemKey,
281 RecoveryItem,
282 wallet,
283 "Recovery Items"
284 );
285 }
286 }
287 }
288
289 Box::new(wallet.into_iter())
290 }
291}
292
293fn default_finality_delay(network: Network) -> u32 {
295 match network {
296 Network::Bitcoin | Network::Regtest => 10,
297 Network::Testnet | Network::Signet | Network::Testnet4 => 2,
298 }
299}
300
301fn default_client_bitcoin_rpc(network: Network) -> BitcoinRpcConfig {
303 let url = match network {
304 Network::Bitcoin => "https://mempool.space/api/".to_string(),
305 Network::Testnet => "https://mempool.space/testnet/api/".to_string(),
306 Network::Testnet4 => "https://mempool.space/testnet4/api/".to_string(),
307 Network::Signet => "https://mutinynet.com/api/".to_string(),
308 Network::Regtest => format!(
309 "http://127.0.0.1:{}/",
310 std::env::var(FM_PORT_ESPLORA_ENV).unwrap_or_else(|_| String::from("50002"))
311 ),
312 };
313
314 BitcoinRpcConfig {
315 kind: "esplora".to_string(),
316 url: fedimint_core::util::SafeUrl::parse(&url).expect("hardcoded URL is valid"),
317 }
318}
319
320#[apply(async_trait_maybe_send!)]
321impl ServerModuleInit for WalletInit {
322 type Module = Wallet;
323
324 fn versions(&self, _core: CoreConsensusVersion) -> &[ModuleConsensusVersion] {
325 &[MODULE_CONSENSUS_VERSION]
326 }
327
328 fn is_enabled_by_default(&self) -> bool {
329 is_env_var_set_opt(FM_ENABLE_MODULE_WALLET_ENV).unwrap_or(false)
330 }
331
332 fn get_documented_env_vars(&self) -> Vec<EnvVarDoc> {
333 vec![
334 EnvVarDoc {
335 name: FM_ENABLE_MODULE_WALLET_ENV,
336 description: "Set to 1/true to enable the wallet (on-chain Bitcoin) module. Disabled by default.",
337 },
338 EnvVarDoc {
339 name: FM_WALLET_DISABLE_AUTOMATIC_CONSENSUS_VERSION_VOTING_ENV,
340 description: "Set to 1/true to disable automatic consensus version voting. Useful for testing and development.",
341 },
342 EnvVarDoc {
343 name: envs::FM_WALLET_FEERATE_MULTIPLIER_ENV,
344 description: "Multiplier applied to fee rate estimates (float, clamped 1.0–32.0). Defaults to 1.0.",
345 },
346 EnvVarDoc {
347 name: FM_WALLET_FEERATE_SOURCES_ENV,
348 description: "Semicolon-separated list of JSON API URLs (with optional `#<jq-filter>`) used as fee rate sources.",
349 },
350 ]
351 }
352
353 async fn init(&self, args: &ServerModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
354 for direction in ["incoming", "outgoing"] {
355 WALLET_INOUT_FEES_SATS
356 .with_label_values(&[direction])
357 .get_sample_count();
358 WALLET_INOUT_SATS
359 .with_label_values(&[direction])
360 .get_sample_count();
361 }
362 WALLET_PEGIN_FEES_SATS.get_sample_count();
364 WALLET_PEGIN_SATS.get_sample_count();
365 WALLET_PEGOUT_SATS.get_sample_count();
366 WALLET_PEGOUT_FEES_SATS.get_sample_count();
367
368 Ok(Wallet::new(
369 args.cfg().to_typed()?,
370 args.db(),
371 args.task_group(),
372 args.our_peer_id(),
373 args.module_api().clone(),
374 args.server_bitcoin_rpc_monitor(),
375 )
376 .await?)
377 }
378
379 fn trusted_dealer_gen(
380 &self,
381 peers: &[PeerId],
382 args: &ConfigGenModuleArgs,
383 ) -> BTreeMap<PeerId, ServerModuleConfig> {
384 let secp = bitcoin::secp256k1::Secp256k1::new();
385 let finality_delay = default_finality_delay(args.network);
386 let client_default_bitcoin_rpc = default_client_bitcoin_rpc(args.network);
387
388 let btc_pegin_keys = peers
389 .iter()
390 .map(|&id| (id, secp.generate_keypair(&mut OsRng)))
391 .collect::<Vec<_>>();
392
393 let wallet_cfg: BTreeMap<PeerId, WalletConfig> = btc_pegin_keys
394 .iter()
395 .map(|(id, (sk, _))| {
396 let cfg = WalletConfig::new(
397 btc_pegin_keys
398 .iter()
399 .map(|(peer_id, (_, pk))| (*peer_id, CompressedPublicKey { key: *pk }))
400 .collect(),
401 *sk,
402 peers.to_num_peers().threshold(),
403 args.network,
404 finality_delay,
405 client_default_bitcoin_rpc.clone(),
406 FeeConsensus::default(),
407 );
408 (*id, cfg)
409 })
410 .collect();
411
412 wallet_cfg
413 .into_iter()
414 .map(|(k, v)| (k, v.to_erased()))
415 .collect()
416 }
417
418 async fn distributed_gen(
419 &self,
420 peers: &(dyn PeerHandleOps + Send + Sync),
421 args: &ConfigGenModuleArgs,
422 ) -> anyhow::Result<ServerModuleConfig> {
423 let secp = secp256k1::Secp256k1::new();
424 let (sk, pk) = secp.generate_keypair(&mut OsRng);
425 let our_key = CompressedPublicKey { key: pk };
426 let peer_peg_in_keys: BTreeMap<PeerId, CompressedPublicKey> = peers
427 .exchange_encodable(our_key.key)
428 .await?
429 .into_iter()
430 .map(|(k, key)| (k, CompressedPublicKey { key }))
431 .collect();
432
433 let finality_delay = default_finality_delay(args.network);
434 let client_default_bitcoin_rpc = default_client_bitcoin_rpc(args.network);
435
436 let wallet_cfg = WalletConfig::new(
437 peer_peg_in_keys,
438 sk,
439 peers.num_peers().threshold(),
440 args.network,
441 finality_delay,
442 client_default_bitcoin_rpc,
443 FeeConsensus::default(),
444 );
445
446 Ok(wallet_cfg.to_erased())
447 }
448
449 fn validate_config(&self, identity: &PeerId, config: ServerModuleConfig) -> anyhow::Result<()> {
450 let config = config.to_typed::<WalletConfig>()?;
451 let pubkey = secp256k1::PublicKey::from_secret_key_global(&config.private.peg_in_key);
452
453 if config
454 .consensus
455 .peer_peg_in_keys
456 .get(identity)
457 .ok_or_else(|| format_err!("Secret key doesn't match any public key"))?
458 != &CompressedPublicKey::new(pubkey)
459 {
460 bail!(" Bitcoin wallet private key doesn't match multisig pubkey");
461 }
462
463 Ok(())
464 }
465
466 fn get_client_config(
467 &self,
468 config: &ServerModuleConsensusConfig,
469 ) -> anyhow::Result<WalletClientConfig> {
470 let config = WalletConfigConsensus::from_erased(config)?;
471 Ok(WalletClientConfig {
472 peg_in_descriptor: config.peg_in_descriptor,
473 network: config.network,
474 fee_consensus: config.fee_consensus,
475 finality_delay: config.finality_delay,
476 default_bitcoin_rpc: config.client_default_bitcoin_rpc,
477 })
478 }
479
480 fn get_database_migrations(
482 &self,
483 ) -> BTreeMap<DatabaseVersion, ServerModuleDbMigrationFn<Wallet>> {
484 let mut migrations: BTreeMap<DatabaseVersion, ServerModuleDbMigrationFn<Wallet>> =
485 BTreeMap::new();
486 migrations.insert(
487 DatabaseVersion(0),
488 Box::new(|ctx| migrate_to_v1(ctx).boxed()),
489 );
490 migrations.insert(
491 DatabaseVersion(1),
492 Box::new(|ctx| migrate_to_v2(ctx).boxed()),
493 );
494 migrations.insert(
495 DatabaseVersion(2),
496 Box::new(|ctx| migrate_to_v3(ctx).boxed()),
497 );
498 migrations
499 }
500
501 fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
502 Some(DbKeyPrefix::iter().map(|p| p as u8).collect())
503 }
504}
505
506#[apply(async_trait_maybe_send!)]
507impl ServerModule for Wallet {
508 type Common = WalletModuleTypes;
509 type Init = WalletInit;
510
511 async fn consensus_proposal<'a>(
512 &'a self,
513 dbtx: &mut DatabaseTransaction<'_>,
514 ) -> Vec<WalletConsensusItem> {
515 let mut items = dbtx
516 .find_by_prefix(&PegOutTxSignatureCIPrefix)
517 .await
518 .map(|(key, val)| {
519 WalletConsensusItem::PegOutSignature(PegOutSignatureItem {
520 txid: key.0,
521 signature: val,
522 })
523 })
524 .collect::<Vec<WalletConsensusItem>>()
525 .await;
526
527 match self.get_block_count() {
535 Ok(block_count) => {
536 let mut block_count_vote =
537 block_count.saturating_sub(self.cfg.consensus.finality_delay);
538
539 let current_consensus_block_count = self.consensus_block_count(dbtx).await;
540
541 if current_consensus_block_count != 0 {
544 block_count_vote = min(
545 block_count_vote,
546 current_consensus_block_count
547 + if is_running_in_test_env() {
548 100
551 } else {
552 5
553 },
554 );
555 }
556
557 let current_vote = dbtx
558 .get_value(&BlockCountVoteKey(self.our_peer_id))
559 .await
560 .unwrap_or(0);
561
562 trace!(
563 target: LOG_MODULE_WALLET,
564 ?current_vote,
565 ?block_count_vote,
566 ?block_count,
567 ?current_consensus_block_count,
568 "Proposing block count"
569 );
570
571 WALLET_BLOCK_COUNT.set(i64::from(block_count_vote));
572 items.push(WalletConsensusItem::BlockCount(block_count_vote));
573 }
574 Err(err) => {
575 warn!(target: LOG_MODULE_WALLET, err = %err.fmt_compact_anyhow(), "Can't update block count");
576 }
577 }
578
579 let fee_rate_proposal = self.get_fee_rate_opt();
580
581 items.push(WalletConsensusItem::Feerate(fee_rate_proposal));
582
583 let manual_vote = dbtx
585 .get_value(&ConsensusVersionVotingActivationKey)
586 .await
587 .map(|()| {
588 MODULE_CONSENSUS_VERSION
591 });
592
593 let active_consensus_version = self.consensus_module_consensus_version(dbtx).await;
594 let automatic_vote = if is_automatic_consensus_version_voting_disabled() {
595 None
596 } else {
597 self.peer_supported_consensus_version
598 .borrow()
599 .and_then(|supported_consensus_version| {
600 (active_consensus_version < supported_consensus_version)
603 .then_some(supported_consensus_version)
604 })
605 };
606
607 if let Some(vote_version) = automatic_vote.or(manual_vote) {
610 items.push(WalletConsensusItem::ModuleConsensusVersion(vote_version));
611 }
612
613 items
614 }
615
616 async fn process_consensus_item<'a, 'b>(
617 &'a self,
618 dbtx: &mut DatabaseTransaction<'b>,
619 consensus_item: WalletConsensusItem,
620 peer: PeerId,
621 ) -> anyhow::Result<()> {
622 trace!(target: LOG_MODULE_WALLET, ?consensus_item, "Processing consensus item proposal");
623
624 match consensus_item {
625 WalletConsensusItem::BlockCount(block_count_vote) => {
626 let current_vote = dbtx.get_value(&BlockCountVoteKey(peer)).await.unwrap_or(0);
627
628 if block_count_vote < current_vote {
629 warn!(target: LOG_MODULE_WALLET, ?peer, ?block_count_vote, "Block count vote is outdated");
630 }
631
632 ensure!(
633 block_count_vote > current_vote,
634 "Block count vote is redundant"
635 );
636
637 let old_consensus_block_count = self.consensus_block_count(dbtx).await;
638
639 dbtx.insert_entry(&BlockCountVoteKey(peer), &block_count_vote)
640 .await;
641
642 let new_consensus_block_count = self.consensus_block_count(dbtx).await;
643
644 debug!(
645 target: LOG_MODULE_WALLET,
646 ?peer,
647 ?current_vote,
648 ?block_count_vote,
649 ?old_consensus_block_count,
650 ?new_consensus_block_count,
651 "Received block count vote"
652 );
653
654 assert!(old_consensus_block_count <= new_consensus_block_count);
655
656 if new_consensus_block_count != old_consensus_block_count {
657 if old_consensus_block_count != 0 {
659 self.sync_up_to_consensus_count(
660 dbtx,
661 old_consensus_block_count,
662 new_consensus_block_count,
663 )
664 .await;
665 } else {
666 info!(
667 target: LOG_MODULE_WALLET,
668 ?old_consensus_block_count,
669 ?new_consensus_block_count,
670 "Not syncing up to consensus block count because we are at block 0"
671 );
672 }
673 }
674 }
675 WalletConsensusItem::Feerate(feerate) => {
676 if Some(feerate) == dbtx.insert_entry(&FeeRateVoteKey(peer), &feerate).await {
677 bail!("Fee rate vote is redundant");
678 }
679 }
680 WalletConsensusItem::PegOutSignature(peg_out_signature) => {
681 let txid = peg_out_signature.txid;
682
683 if dbtx.get_value(&PendingTransactionKey(txid)).await.is_some() {
684 bail!("Already received a threshold of valid signatures");
685 }
686
687 let mut unsigned = dbtx
688 .get_value(&UnsignedTransactionKey(txid))
689 .await
690 .context("Unsigned transaction does not exist")?;
691
692 self.sign_peg_out_psbt(&mut unsigned.psbt, peer, &peg_out_signature)
693 .context("Peg out signature is invalid")?;
694
695 dbtx.insert_entry(&UnsignedTransactionKey(txid), &unsigned)
696 .await;
697
698 if let Ok(pending_tx) = self.finalize_peg_out_psbt(unsigned) {
699 dbtx.insert_new_entry(&PendingTransactionKey(txid), &pending_tx)
704 .await;
705
706 dbtx.remove_entry(&PegOutTxSignatureCI(txid)).await;
707 dbtx.remove_entry(&UnsignedTransactionKey(txid)).await;
708 let broadcast_pending = self.broadcast_pending.clone();
709 dbtx.on_commit(move || {
710 broadcast_pending.notify_one();
711 });
712 }
713 }
714 WalletConsensusItem::ModuleConsensusVersion(module_consensus_version) => {
715 let current_vote = dbtx
716 .get_value(&ConsensusVersionVoteKey(peer))
717 .await
718 .unwrap_or(ModuleConsensusVersion::new(2, 0));
719
720 ensure!(
721 module_consensus_version > current_vote,
722 "Module consensus version vote is redundant"
723 );
724
725 dbtx.insert_entry(&ConsensusVersionVoteKey(peer), &module_consensus_version)
726 .await;
727
728 assert!(
729 self.consensus_module_consensus_version(dbtx).await <= MODULE_CONSENSUS_VERSION,
730 "Wallet module does not support new consensus version, please upgrade the module"
731 );
732 }
733 WalletConsensusItem::Default { variant, .. } => {
734 bail!("Unknown wallet consensus item received, variant={variant}");
735 }
736 }
737
738 Ok(())
739 }
740
741 async fn process_input<'a, 'b, 'c>(
742 &'a self,
743 dbtx: &mut DatabaseTransaction<'c>,
744 input: &'b WalletInput,
745 _in_point: InPoint,
746 ) -> Result<InputMeta, WalletInputError> {
747 let (outpoint, tx_out, pub_key) = match input {
748 WalletInput::V0(input) => {
749 if !self.block_is_known(dbtx, input.proof_block()).await {
750 return Err(WalletInputError::UnknownPegInProofBlock(
751 input.proof_block(),
752 ));
753 }
754
755 input.verify(&self.secp, &self.cfg.consensus.peg_in_descriptor)?;
756
757 debug!(target: LOG_MODULE_WALLET, outpoint = %input.outpoint(), "Claiming peg-in");
758
759 (input.0.outpoint(), input.tx_output(), input.tweak_key())
760 }
761 WalletInput::V1(input) => {
762 let input_tx_out = dbtx
763 .get_value(&UnspentTxOutKey(input.outpoint))
764 .await
765 .ok_or(WalletInputError::UnknownUTXO)?;
766
767 if input_tx_out.script_pubkey
768 != self
769 .cfg
770 .consensus
771 .peg_in_descriptor
772 .tweak(&input.tweak_key, secp256k1::SECP256K1)
773 .script_pubkey()
774 {
775 return Err(WalletInputError::WrongOutputScript);
776 }
777
778 if input.tx_out != input_tx_out {
781 return Err(WalletInputError::WrongTxOut);
782 }
783
784 (input.outpoint, input_tx_out, input.tweak_key)
785 }
786 WalletInput::Default { variant, .. } => {
787 return Err(WalletInputError::UnknownInputVariant(
788 UnknownWalletInputVariantError { variant: *variant },
789 ));
790 }
791 };
792
793 if dbtx.get_value(&UTXOKey(outpoint)).await.is_some() {
800 return Err(WalletInputError::PegInAlreadyClaimed);
801 }
802
803 if dbtx
804 .insert_entry(&ClaimedPegInOutpointKey(outpoint), &())
805 .await
806 .is_some()
807 {
808 return Err(WalletInputError::PegInAlreadyClaimed);
809 }
810
811 dbtx.insert_new_entry(
812 &UTXOKey(outpoint),
813 &SpendableUTXO {
814 tweak: pub_key.serialize(),
815 amount: tx_out.value,
816 },
817 )
818 .await;
819
820 let next_index = get_recovery_count(dbtx).await;
821 dbtx.insert_new_entry(
822 &RecoveryItemKey(next_index),
823 &RecoveryItem::Input {
824 outpoint,
825 script: tx_out.script_pubkey,
826 },
827 )
828 .await;
829
830 let amount = tx_out.value.into();
831
832 let fee = self.cfg.consensus.fee_consensus.peg_in_abs;
833
834 calculate_pegin_metrics(dbtx, amount, fee);
835
836 Ok(InputMeta {
837 amount: TransactionItemAmounts {
838 amounts: Amounts::new_bitcoin(amount),
839 fees: Amounts::new_bitcoin(fee),
840 },
841 pub_key,
842 })
843 }
844
845 async fn process_output<'a, 'b>(
846 &'a self,
847 dbtx: &mut DatabaseTransaction<'b>,
848 output: &'a WalletOutput,
849 out_point: OutPoint,
850 ) -> Result<TransactionItemAmounts, WalletOutputError> {
851 let output = output.ensure_v0_ref()?;
852
853 if let WalletOutputV0::Rbf(_) = output {
861 return Err(DEPRECATED_RBF_ERROR);
862 }
863
864 let change_tweak = self.consensus_nonce(dbtx).await;
865
866 let mut tx = self.create_peg_out_tx(dbtx, output, &change_tweak).await?;
867
868 let fee_rate = self.consensus_fee_rate(dbtx).await;
869
870 StatelessWallet::validate_tx(&tx, output, fee_rate, self.cfg.consensus.network.0)?;
871
872 self.offline_wallet().sign_psbt(&mut tx.psbt);
873
874 let txid = tx.psbt.unsigned_tx.compute_txid();
875
876 info!(
877 target: LOG_MODULE_WALLET,
878 %txid,
879 "Signing peg out",
880 );
881
882 let sigs = tx
883 .psbt
884 .inputs
885 .iter_mut()
886 .map(|input| {
887 assert_eq!(
888 input.partial_sigs.len(),
889 1,
890 "There was already more than one (our) or no signatures in input"
891 );
892
893 let sig = std::mem::take(&mut input.partial_sigs)
897 .into_values()
898 .next()
899 .expect("asserted previously");
900
901 secp256k1::ecdsa::Signature::from_der(&sig.to_vec()[..sig.to_vec().len() - 1])
904 .expect("we serialized it ourselves that way")
905 })
906 .collect::<Vec<_>>();
907
908 for input in &tx.psbt.unsigned_tx.input {
910 dbtx.remove_entry(&UTXOKey(input.previous_output)).await;
911 }
912
913 dbtx.insert_entry(
919 &ClaimedPegInOutpointKey(bitcoin::OutPoint {
920 txid,
921 vout: PEG_OUT_CHANGE_VOUT,
922 }),
923 &(),
924 )
925 .await;
926
927 dbtx.insert_new_entry(&UnsignedTransactionKey(txid), &tx)
928 .await;
929
930 dbtx.insert_new_entry(&PegOutTxSignatureCI(txid), &sigs)
931 .await;
932
933 dbtx.insert_new_entry(
934 &PegOutBitcoinTransaction(out_point),
935 &WalletOutputOutcome::new_v0(txid),
936 )
937 .await;
938 let amount: fedimint_core::Amount = output.amount().into();
939 let fee = self.cfg.consensus.fee_consensus.peg_out_abs;
940 calculate_pegout_metrics(dbtx, amount, fee);
941 Ok(TransactionItemAmounts {
942 amounts: Amounts::new_bitcoin(amount),
943 fees: Amounts::new_bitcoin(fee),
944 })
945 }
946
947 #[doc(hidden)]
967 async fn verify_output_submission<'a, 'b>(
968 &'a self,
969 _dbtx: &mut DatabaseTransaction<'b>,
970 output: &'a WalletOutput,
971 _out_point: OutPoint,
972 ) -> Result<(), WalletOutputError> {
973 let fees = match output.ensure_v0_ref()? {
974 WalletOutputV0::PegOut(peg_out) => peg_out.fees,
975 WalletOutputV0::Rbf(rbf) => rbf.fees,
976 };
977
978 let fee_sats = weight_to_vbytes(fees.total_weight)
979 .checked_mul(fees.fee_rate.sats_per_kvb)
980 .map(|sats| sats / 1000);
981
982 match fee_sats {
986 Some(sats) if sats <= bitcoin::Amount::MAX_MONEY.to_sat() => Ok(()),
987 _ => Err(WalletOutputError::NotEnoughSpendableUTXO),
988 }
989 }
990
991 async fn output_status(
992 &self,
993 dbtx: &mut DatabaseTransaction<'_>,
994 out_point: OutPoint,
995 ) -> Option<WalletOutputOutcome> {
996 dbtx.get_value(&PegOutBitcoinTransaction(out_point)).await
997 }
998
999 async fn audit(
1000 &self,
1001 dbtx: &mut DatabaseTransaction<'_>,
1002 audit: &mut Audit,
1003 module_instance_id: ModuleInstanceId,
1004 ) {
1005 audit
1006 .add_items(dbtx, module_instance_id, &UTXOPrefixKey, |_, v| {
1007 v.amount.to_sat() as i64 * 1000
1008 })
1009 .await;
1010 audit
1011 .add_items(
1012 dbtx,
1013 module_instance_id,
1014 &UnsignedTransactionPrefixKey,
1015 |_, v| match v.rbf {
1016 None => v.change.to_sat() as i64 * 1000,
1017 Some(rbf) => rbf.fees.amount().to_sat() as i64 * -1000,
1018 },
1019 )
1020 .await;
1021 audit
1022 .add_items(
1023 dbtx,
1024 module_instance_id,
1025 &PendingTransactionPrefixKey,
1026 |_, v| match v.rbf {
1027 None => v.change.to_sat() as i64 * 1000,
1028 Some(rbf) => rbf.fees.amount().to_sat() as i64 * -1000,
1029 },
1030 )
1031 .await;
1032 }
1033
1034 fn api_endpoints(&self) -> Vec<ApiEndpoint<Self>> {
1035 vec![
1036 public_api_endpoint! {
1037 BLOCK_COUNT_ENDPOINT,
1038 ApiVersion::new(0, 0),
1039 async |module: &Wallet, context, _params: ()| -> u32 {
1040 let db = context.db();
1041 let mut dbtx = db.begin_transaction_nc().await;
1042 Ok(module.consensus_block_count(&mut dbtx).await)
1043 }
1044 },
1045 public_api_endpoint! {
1046 BLOCK_COUNT_LOCAL_ENDPOINT,
1047 ApiVersion::new(0, 0),
1048 async |module: &Wallet, _context, _params: ()| -> Option<u32> {
1049 Ok(module.get_block_count().ok())
1050 }
1051 },
1052 public_api_endpoint! {
1053 PEG_OUT_FEES_ENDPOINT,
1054 ApiVersion::new(0, 0),
1055 async |module: &Wallet, context, params: (Address<NetworkUnchecked>, u64)| -> Option<PegOutFees> {
1056 let (address, sats) = params;
1057 let db = context.db();
1058 let mut dbtx = db.begin_transaction_nc().await;
1059 let feerate = module.consensus_fee_rate(&mut dbtx).await;
1060
1061 let dummy_tweak = [0; 33];
1063
1064 let tx = module.offline_wallet().create_tx(
1065 bitcoin::Amount::from_sat(sats),
1066 address.assume_checked().script_pubkey(),
1070 vec![],
1071 module.available_utxos(&mut dbtx).await,
1072 feerate,
1073 &dummy_tweak,
1074 None,
1075 FeeArithmetic::Checked
1078 );
1079
1080 match tx {
1081 Err(error) => {
1082 warn!(target: LOG_MODULE_WALLET, "Error returning peg-out fees {error}");
1084 Ok(None)
1085 }
1086 Ok(tx) => Ok(Some(tx.fees))
1087 }
1088 }
1089 },
1090 public_api_endpoint! {
1091 BITCOIN_KIND_ENDPOINT,
1092 ApiVersion::new(0, 1),
1093 async |module: &Wallet, _context, _params: ()| -> String {
1094 Ok(module.btc_rpc.get_bitcoin_rpc_config().kind)
1095 }
1096 },
1097 admin_api_endpoint! {
1098 BITCOIN_RPC_CONFIG_ENDPOINT,
1099 ApiVersion::new(0, 1),
1100 async |module: &Wallet, context, _params: ()| -> BitcoinRpcConfig {
1101 let config = module.btc_rpc.get_bitcoin_rpc_config();
1102
1103 let without_auth = config.url.clone().without_auth().map_err(|()| {
1105 ApiError::server_error("Unable to remove auth from bitcoin config URL".to_string())
1106 })?;
1107
1108 Ok(BitcoinRpcConfig {
1109 url: without_auth,
1110 ..config
1111 })
1112 }
1113 },
1114 public_api_endpoint! {
1115 WALLET_SUMMARY_ENDPOINT,
1116 ApiVersion::new(0, 1),
1117 async |module: &Wallet, context, _params: ()| -> WalletSummary {
1118 let db = context.db();
1119 let mut dbtx = db.begin_transaction_nc().await;
1120 Ok(module.get_wallet_summary(&mut dbtx).await)
1121 }
1122 },
1123 public_api_endpoint! {
1124 MODULE_CONSENSUS_VERSION_ENDPOINT,
1125 ApiVersion::new(0, 2),
1126 async |module: &Wallet, context, _params: ()| -> ModuleConsensusVersion {
1127 let db = context.db();
1128 let mut dbtx = db.begin_transaction_nc().await;
1129 Ok(module.consensus_module_consensus_version(&mut dbtx).await)
1130 }
1131 },
1132 public_api_endpoint! {
1133 SUPPORTED_MODULE_CONSENSUS_VERSION_ENDPOINT,
1134 ApiVersion::new(0, 2),
1135 async |_module: &Wallet, _context, _params: ()| -> ModuleConsensusVersion {
1136 Ok(MODULE_CONSENSUS_VERSION)
1137 }
1138 },
1139 admin_api_endpoint! {
1140 ACTIVATE_CONSENSUS_VERSION_VOTING_ENDPOINT,
1141 ApiVersion::new(0, 2),
1142 async |_module: &Wallet, context, _params: ()| -> () {
1143
1144 let db = context.db();
1145 let mut dbtx = db.begin_transaction().await;
1146 dbtx.to_ref().insert_entry(&ConsensusVersionVotingActivationKey, &()).await;
1147 dbtx.commit_tx_result().await?;
1148 Ok(())
1149 }
1150 },
1151 public_api_endpoint! {
1152 UTXO_CONFIRMED_ENDPOINT,
1153 ApiVersion::new(0, 2),
1154 async |module: &Wallet, context, outpoint: bitcoin::OutPoint| -> bool {
1155 let db = context.db();
1156 let mut dbtx = db.begin_transaction_nc().await;
1157 Ok(module.is_utxo_confirmed(&mut dbtx, outpoint).await)
1158 }
1159 },
1160 public_api_endpoint! {
1161 RECOVERY_COUNT_ENDPOINT,
1162 ApiVersion::new(0, 1),
1163 async |_module: &Wallet, context, _params: ()| -> u64 {
1164 let db = context.db();
1165 let mut dbtx = db.begin_transaction_nc().await;
1166 Ok(get_recovery_count(&mut dbtx).await)
1167 }
1168 },
1169 public_api_endpoint! {
1170 RECOVERY_SLICE_ENDPOINT,
1171 ApiVersion::new(0, 1),
1172 async |_module: &Wallet, context, range: (u64, u64)| -> Vec<RecoveryItem> {
1173 let db = context.db();
1174 let mut dbtx = db.begin_transaction_nc().await;
1175 Ok(get_recovery_slice(&mut dbtx, range).await)
1176 }
1177 },
1178 ]
1179 }
1180}
1181
1182async fn get_recovery_count(dbtx: &mut DatabaseTransaction<'_>) -> u64 {
1183 dbtx.find_by_prefix_sorted_descending(&RecoveryItemKeyPrefix)
1184 .await
1185 .next()
1186 .await
1187 .map_or(0, |entry| entry.0.0 + 1)
1188}
1189
1190async fn get_recovery_slice(
1191 dbtx: &mut DatabaseTransaction<'_>,
1192 range: (u64, u64),
1193) -> Vec<RecoveryItem> {
1194 dbtx.find_by_range(RecoveryItemKey(range.0)..RecoveryItemKey(range.1))
1195 .await
1196 .map(|entry| entry.1)
1197 .collect()
1198 .await
1199}
1200
1201fn calculate_pegin_metrics(
1202 dbtx: &mut DatabaseTransaction<'_>,
1203 amount: fedimint_core::Amount,
1204 fee: fedimint_core::Amount,
1205) {
1206 dbtx.on_commit(move || {
1207 WALLET_INOUT_SATS
1208 .with_label_values(&["incoming"])
1209 .observe(amount.sats_f64());
1210 WALLET_INOUT_FEES_SATS
1211 .with_label_values(&["incoming"])
1212 .observe(fee.sats_f64());
1213 WALLET_PEGIN_SATS.observe(amount.sats_f64());
1214 WALLET_PEGIN_FEES_SATS.observe(fee.sats_f64());
1215 });
1216}
1217
1218fn calculate_pegout_metrics(
1219 dbtx: &mut DatabaseTransaction<'_>,
1220 amount: fedimint_core::Amount,
1221 fee: fedimint_core::Amount,
1222) {
1223 dbtx.on_commit(move || {
1224 WALLET_INOUT_SATS
1225 .with_label_values(&["outgoing"])
1226 .observe(amount.sats_f64());
1227 WALLET_INOUT_FEES_SATS
1228 .with_label_values(&["outgoing"])
1229 .observe(fee.sats_f64());
1230 WALLET_PEGOUT_SATS.observe(amount.sats_f64());
1231 WALLET_PEGOUT_FEES_SATS.observe(fee.sats_f64());
1232 });
1233}
1234
1235#[derive(Debug)]
1236pub struct Wallet {
1237 cfg: WalletConfig,
1238 db: Database,
1239 secp: Secp256k1<All>,
1240 btc_rpc: ServerBitcoinRpcMonitor,
1241 our_peer_id: PeerId,
1242 broadcast_pending: Arc<Notify>,
1244 task_group: TaskGroup,
1245 peer_supported_consensus_version: watch::Receiver<Option<ModuleConsensusVersion>>,
1249}
1250
1251impl Wallet {
1252 pub async fn new(
1253 cfg: WalletConfig,
1254 db: &Database,
1255 task_group: &TaskGroup,
1256 our_peer_id: PeerId,
1257 module_api: DynModuleApi,
1258 server_bitcoin_rpc_monitor: ServerBitcoinRpcMonitor,
1259 ) -> anyhow::Result<Wallet> {
1260 let broadcast_pending = Arc::new(Notify::new());
1261 Self::spawn_broadcast_pending_task(
1262 task_group,
1263 &server_bitcoin_rpc_monitor,
1264 db,
1265 broadcast_pending.clone(),
1266 );
1267
1268 let peer_supported_consensus_version =
1269 Self::spawn_peer_supported_consensus_version_task(module_api, task_group, our_peer_id);
1270
1271 let status = retry("verify network", backoff_util::aggressive_backoff(), || {
1272 std::future::ready(
1273 server_bitcoin_rpc_monitor
1274 .status()
1275 .context("No connection to bitcoin rpc"),
1276 )
1277 })
1278 .await?;
1279
1280 ensure!(status.network == cfg.consensus.network.0, "Wrong Network");
1281
1282 let wallet = Wallet {
1283 cfg,
1284 db: db.clone(),
1285 secp: Default::default(),
1286 btc_rpc: server_bitcoin_rpc_monitor,
1287 our_peer_id,
1288 task_group: task_group.clone(),
1289 peer_supported_consensus_version,
1290 broadcast_pending,
1291 };
1292
1293 Ok(wallet)
1294 }
1295
1296 fn sign_peg_out_psbt(
1298 &self,
1299 psbt: &mut Psbt,
1300 peer: PeerId,
1301 signature: &PegOutSignatureItem,
1302 ) -> Result<(), ProcessPegOutSigError> {
1303 let peer_key = self
1304 .cfg
1305 .consensus
1306 .peer_peg_in_keys
1307 .get(&peer)
1308 .expect("always called with valid peer id");
1309
1310 if psbt.inputs.len() != signature.signature.len() {
1311 return Err(ProcessPegOutSigError::WrongSignatureCount(
1312 psbt.inputs.len(),
1313 signature.signature.len(),
1314 ));
1315 }
1316
1317 let mut tx_hasher = SighashCache::new(&psbt.unsigned_tx);
1318 for (idx, (input, signature)) in psbt
1319 .inputs
1320 .iter_mut()
1321 .zip(signature.signature.iter())
1322 .enumerate()
1323 {
1324 let tx_hash = tx_hasher
1325 .p2wsh_signature_hash(
1326 idx,
1327 input
1328 .witness_script
1329 .as_ref()
1330 .expect("Missing witness script"),
1331 input.witness_utxo.as_ref().expect("Missing UTXO").value,
1332 EcdsaSighashType::All,
1333 )
1334 .map_err(|_| ProcessPegOutSigError::SighashError)?;
1335
1336 let tweak = input
1337 .proprietary
1338 .get(&proprietary_tweak_key())
1339 .expect("we saved it with a tweak");
1340
1341 let tweaked_peer_key = peer_key.tweak(tweak, &self.secp);
1342 self.secp
1343 .verify_ecdsa(
1344 &Message::from_digest_slice(&tx_hash[..]).unwrap(),
1345 signature,
1346 &tweaked_peer_key.key,
1347 )
1348 .map_err(|_| ProcessPegOutSigError::InvalidSignature)?;
1349
1350 if input
1351 .partial_sigs
1352 .insert(tweaked_peer_key.into(), EcdsaSig::sighash_all(*signature))
1353 .is_some()
1354 {
1355 return Err(ProcessPegOutSigError::DuplicateSignature);
1357 }
1358 }
1359 Ok(())
1360 }
1361
1362 fn finalize_peg_out_psbt(
1363 &self,
1364 mut unsigned: UnsignedTransaction,
1365 ) -> Result<PendingTransaction, ProcessPegOutSigError> {
1366 let change_tweak: [u8; 33] = unsigned
1371 .psbt
1372 .outputs
1373 .iter()
1374 .find_map(|output| output.proprietary.get(&proprietary_tweak_key()).cloned())
1375 .ok_or(ProcessPegOutSigError::MissingOrMalformedChangeTweak)?
1376 .try_into()
1377 .map_err(|_| ProcessPegOutSigError::MissingOrMalformedChangeTweak)?;
1378
1379 if let Err(error) = unsigned.psbt.finalize_mut(&self.secp) {
1380 return Err(ProcessPegOutSigError::ErrorFinalizingPsbt(error));
1381 }
1382
1383 let tx = unsigned.psbt.clone().extract_tx_unchecked_fee_rate();
1384
1385 Ok(PendingTransaction {
1386 tx,
1387 tweak: change_tweak,
1388 change: unsigned.change,
1389 destination: unsigned.destination,
1390 fees: unsigned.fees,
1391 selected_utxos: unsigned.selected_utxos,
1392 peg_out_amount: unsigned.peg_out_amount,
1393 rbf: unsigned.rbf,
1394 })
1395 }
1396
1397 fn get_block_count(&self) -> anyhow::Result<u32> {
1398 self.btc_rpc
1399 .status()
1400 .context("No bitcoin rpc connection")
1401 .and_then(|status| {
1402 status
1403 .block_count
1404 .try_into()
1405 .map_err(|_| format_err!("Block count exceeds u32 limits"))
1406 })
1407 }
1408
1409 pub fn get_fee_rate_opt(&self) -> Feerate {
1410 #[allow(clippy::cast_precision_loss)]
1413 #[allow(clippy::cast_sign_loss)]
1414 Feerate {
1415 sats_per_kvb: ((self
1416 .btc_rpc
1417 .status()
1418 .map_or(self.cfg.consensus.default_fee, |status| status.fee_rate)
1419 .sats_per_kvb as f64
1420 * get_feerate_multiplier())
1421 .round()) as u64,
1422 }
1423 }
1424
1425 pub async fn consensus_block_count(&self, dbtx: &mut DatabaseTransaction<'_>) -> u32 {
1426 let peer_count = self.cfg.consensus.peer_peg_in_keys.to_num_peers().total();
1427
1428 let mut counts = dbtx
1429 .find_by_prefix(&BlockCountVotePrefix)
1430 .await
1431 .map(|entry| entry.1)
1432 .collect::<Vec<u32>>()
1433 .await;
1434
1435 assert!(counts.len() <= peer_count);
1436
1437 while counts.len() < peer_count {
1438 counts.push(0);
1439 }
1440
1441 counts.sort_unstable();
1442
1443 counts[peer_count / 2]
1444 }
1445
1446 pub async fn consensus_fee_rate(&self, dbtx: &mut DatabaseTransaction<'_>) -> Feerate {
1447 let peer_count = self.cfg.consensus.peer_peg_in_keys.to_num_peers().total();
1448
1449 let mut rates = dbtx
1450 .find_by_prefix(&FeeRateVotePrefix)
1451 .await
1452 .map(|(.., rate)| rate)
1453 .collect::<Vec<_>>()
1454 .await;
1455
1456 assert!(rates.len() <= peer_count);
1457
1458 while rates.len() < peer_count {
1459 rates.push(self.cfg.consensus.default_fee);
1460 }
1461
1462 rates.sort_unstable();
1463
1464 rates[peer_count / 2]
1465 }
1466
1467 async fn consensus_module_consensus_version(
1468 &self,
1469 dbtx: &mut DatabaseTransaction<'_>,
1470 ) -> ModuleConsensusVersion {
1471 let num_peers = self.cfg.consensus.peer_peg_in_keys.to_num_peers();
1472
1473 let mut versions = dbtx
1474 .find_by_prefix(&ConsensusVersionVotePrefix)
1475 .await
1476 .map(|entry| entry.1)
1477 .collect::<Vec<ModuleConsensusVersion>>()
1478 .await;
1479
1480 while versions.len() < num_peers.total() {
1481 versions.push(ModuleConsensusVersion::new(2, 0));
1482 }
1483
1484 assert_eq!(versions.len(), num_peers.total());
1485
1486 versions.sort_unstable();
1487
1488 assert!(versions.first() <= versions.last());
1489
1490 versions[num_peers.max_evil()]
1491 }
1492
1493 pub async fn consensus_nonce(&self, dbtx: &mut DatabaseTransaction<'_>) -> [u8; 33] {
1494 let nonce_idx = dbtx.get_value(&PegOutNonceKey).await.unwrap_or(0);
1495 dbtx.insert_entry(&PegOutNonceKey, &(nonce_idx + 1)).await;
1496
1497 nonce_from_idx(nonce_idx)
1498 }
1499
1500 async fn sync_up_to_consensus_count(
1501 &self,
1502 dbtx: &mut DatabaseTransaction<'_>,
1503 old_count: u32,
1504 new_count: u32,
1505 ) {
1506 let sync_start = fedimint_core::time::now();
1507 info!(
1508 target: LOG_MODULE_WALLET,
1509 old_count,
1510 new_count,
1511 blocks_to_go = new_count
1512 .checked_sub(old_count)
1513 .expect("new_count must be >= old_count"),
1514 "New block count consensus, initiating sync",
1515 );
1516
1517 self.wait_for_finality_confs_or_shutdown(new_count).await;
1520
1521 for height in old_count..new_count {
1522 let block_start = fedimint_core::time::now();
1523 info!(
1524 target: LOG_MODULE_WALLET,
1525 height,
1526 "Processing block of height {height}",
1527 );
1528
1529 trace!(block = height, "Fetching block hash");
1531 let block_hash = retry("get_block_hash", backoff_util::background_backoff(), || {
1532 self.btc_rpc.get_block_hash(u64::from(height)) })
1534 .await
1535 .expect("bitcoind rpc to get block hash");
1536
1537 let block = retry("get_block", backoff_util::background_backoff(), || {
1538 self.btc_rpc.get_block(&block_hash)
1539 })
1540 .await
1541 .expect("bitcoind rpc to get block");
1542
1543 if let Some(prev_block_height) = height.checked_sub(1) {
1544 if let Some(hash) = dbtx
1545 .get_value(&BlockHashByHeightKey(prev_block_height))
1546 .await
1547 {
1548 assert_eq!(block.header.prev_blockhash, hash.0);
1549 } else {
1550 warn!(
1551 target: LOG_MODULE_WALLET,
1552 %height,
1553 %block_hash,
1554 %prev_block_height,
1555 prev_blockhash = %block.header.prev_blockhash,
1556 "Missing previous block hash. This should only happen on the first processed block height."
1557 );
1558 }
1559 }
1560
1561 if self.consensus_module_consensus_version(dbtx).await
1562 >= ModuleConsensusVersion::new(2, 2)
1563 {
1564 for transaction in &block.txdata {
1565 for tx_in in &transaction.input {
1570 dbtx.remove_entry(&UnspentTxOutKey(tx_in.previous_output))
1571 .await;
1572 }
1573
1574 for (vout, tx_out) in transaction.output.iter().enumerate() {
1575 let should_track_utxo = if self.cfg.consensus.peer_peg_in_keys.len() > 1 {
1576 tx_out.script_pubkey.is_p2wsh()
1577 } else {
1578 tx_out.script_pubkey.is_p2wpkh()
1579 };
1580
1581 if should_track_utxo {
1582 let outpoint = bitcoin::OutPoint {
1583 txid: transaction.compute_txid(),
1584 vout: vout as u32,
1585 };
1586
1587 dbtx.insert_new_entry(&UnspentTxOutKey(outpoint), tx_out)
1588 .await;
1589 }
1590 }
1591 }
1592 }
1593
1594 let pending_transactions = dbtx
1595 .find_by_prefix(&PendingTransactionPrefixKey)
1596 .await
1597 .map(|(key, transaction)| (key.0, transaction))
1598 .collect::<BTreeMap<Txid, PendingTransaction>>()
1599 .await;
1600 let pending_transactions_len = pending_transactions.len();
1601
1602 debug!(
1603 target: LOG_MODULE_WALLET,
1604 ?height,
1605 ?pending_transactions_len,
1606 "Recognizing change UTXOs"
1607 );
1608 for (txid, tx) in &pending_transactions {
1609 let is_tx_in_block = block.txdata.iter().any(|tx| tx.compute_txid() == *txid);
1610
1611 if is_tx_in_block {
1612 debug!(
1613 target: LOG_MODULE_WALLET,
1614 ?txid, ?height, ?block_hash, "Recognizing change UTXO"
1615 );
1616 self.recognize_change_utxo(dbtx, tx).await;
1617 } else {
1618 debug!(
1619 target: LOG_MODULE_WALLET,
1620 ?txid,
1621 ?height,
1622 ?block_hash,
1623 "Pending transaction not yet confirmed in this block"
1624 );
1625 }
1626 }
1627
1628 dbtx.insert_new_entry(&BlockHashKey(block_hash), &()).await;
1629 dbtx.insert_new_entry(
1630 &BlockHashByHeightKey(height),
1631 &BlockHashByHeightValue(block_hash),
1632 )
1633 .await;
1634
1635 info!(
1636 target: LOG_MODULE_WALLET,
1637 height,
1638 ?block_hash,
1639 duration = ?block_start.elapsed().unwrap_or_default(),
1640 "Successfully processed block of height {height}",
1641 );
1642 }
1643
1644 info!(
1645 target: LOG_MODULE_WALLET,
1646 old_count,
1647 new_count,
1648 blocks_processed = new_count
1649 .checked_sub(old_count)
1650 .expect("new_count must be >= old_count"),
1651 duration = ?sync_start.elapsed().unwrap_or_default(),
1652 "Block count consensus sync complete",
1653 );
1654 }
1655
1656 async fn recognize_change_utxo(
1659 &self,
1660 dbtx: &mut DatabaseTransaction<'_>,
1661 pending_tx: &PendingTransaction,
1662 ) {
1663 self.remove_rbf_transactions(dbtx, pending_tx).await;
1664
1665 let script_pk = self
1666 .cfg
1667 .consensus
1668 .peg_in_descriptor
1669 .tweak(&pending_tx.tweak, &self.secp)
1670 .script_pubkey();
1671 for (idx, output) in pending_tx.tx.output.iter().enumerate() {
1672 if output.script_pubkey == script_pk {
1673 dbtx.insert_entry(
1674 &UTXOKey(bitcoin::OutPoint {
1675 txid: pending_tx.tx.compute_txid(),
1676 vout: idx as u32,
1677 }),
1678 &SpendableUTXO {
1679 tweak: pending_tx.tweak,
1680 amount: output.value,
1681 },
1682 )
1683 .await;
1684 }
1685 }
1686 }
1687
1688 async fn remove_rbf_transactions(
1690 &self,
1691 dbtx: &mut DatabaseTransaction<'_>,
1692 pending_tx: &PendingTransaction,
1693 ) {
1694 let mut all_transactions: BTreeMap<Txid, PendingTransaction> = dbtx
1695 .find_by_prefix(&PendingTransactionPrefixKey)
1696 .await
1697 .map(|(key, val)| (key.0, val))
1698 .collect::<BTreeMap<Txid, PendingTransaction>>()
1699 .await;
1700
1701 let mut pending_to_remove = vec![pending_tx.clone()];
1703 while let Some(removed) = pending_to_remove.pop() {
1704 all_transactions.remove(&removed.tx.compute_txid());
1705 dbtx.remove_entry(&PendingTransactionKey(removed.tx.compute_txid()))
1706 .await;
1707
1708 if let Some(rbf) = &removed.rbf
1710 && let Some(tx) = all_transactions.get(&rbf.txid)
1711 {
1712 pending_to_remove.push(tx.clone());
1713 }
1714
1715 for tx in all_transactions.values() {
1717 if let Some(rbf) = &tx.rbf
1718 && rbf.txid == removed.tx.compute_txid()
1719 {
1720 pending_to_remove.push(tx.clone());
1721 }
1722 }
1723 }
1724 }
1725
1726 async fn block_is_known(
1727 &self,
1728 dbtx: &mut DatabaseTransaction<'_>,
1729 block_hash: BlockHash,
1730 ) -> bool {
1731 dbtx.get_value(&BlockHashKey(block_hash)).await.is_some()
1732 }
1733
1734 async fn create_peg_out_tx(
1735 &self,
1736 dbtx: &mut DatabaseTransaction<'_>,
1737 output: &WalletOutputV0,
1738 change_tweak: &[u8; 33],
1739 ) -> Result<UnsignedTransaction, WalletOutputError> {
1740 let fee_arithmetic = if CHECKED_PEG_OUT_FEE_MODULE_CONSENSUS_VERSION
1741 <= self.consensus_module_consensus_version(dbtx).await
1742 {
1743 FeeArithmetic::Checked
1744 } else {
1745 FeeArithmetic::Wrapping
1746 };
1747
1748 match output {
1749 WalletOutputV0::PegOut(peg_out) => self.offline_wallet().create_tx(
1750 peg_out.amount,
1751 peg_out.recipient.clone().assume_checked().script_pubkey(),
1755 vec![],
1756 self.available_utxos(dbtx).await,
1757 peg_out.fees.fee_rate,
1758 change_tweak,
1759 None,
1760 fee_arithmetic,
1761 ),
1762 WalletOutputV0::Rbf(rbf) => {
1763 let tx = dbtx
1764 .get_value(&PendingTransactionKey(rbf.txid))
1765 .await
1766 .ok_or(WalletOutputError::RbfTransactionIdNotFound)?;
1767
1768 self.offline_wallet().create_tx(
1769 tx.peg_out_amount,
1770 tx.destination,
1771 tx.selected_utxos,
1772 self.available_utxos(dbtx).await,
1773 tx.fees.fee_rate,
1774 change_tweak,
1775 Some(rbf.clone()),
1776 fee_arithmetic,
1777 )
1778 }
1779 }
1780 }
1781
1782 async fn available_utxos(
1783 &self,
1784 dbtx: &mut DatabaseTransaction<'_>,
1785 ) -> Vec<(UTXOKey, SpendableUTXO)> {
1786 dbtx.find_by_prefix(&UTXOPrefixKey)
1787 .await
1788 .collect::<Vec<(UTXOKey, SpendableUTXO)>>()
1789 .await
1790 }
1791
1792 pub async fn get_wallet_value(&self, dbtx: &mut DatabaseTransaction<'_>) -> bitcoin::Amount {
1793 let sat_sum = self
1794 .available_utxos(dbtx)
1795 .await
1796 .into_iter()
1797 .map(|(_, utxo)| utxo.amount.to_sat())
1798 .sum();
1799 bitcoin::Amount::from_sat(sat_sum)
1800 }
1801
1802 async fn get_wallet_summary(&self, dbtx: &mut DatabaseTransaction<'_>) -> WalletSummary {
1803 fn partition_peg_out_and_change(
1804 transactions: Vec<Transaction>,
1805 ) -> (Vec<TxOutputSummary>, Vec<TxOutputSummary>) {
1806 let mut peg_out_txos: Vec<TxOutputSummary> = Vec::new();
1807 let mut change_utxos: Vec<TxOutputSummary> = Vec::new();
1808
1809 for tx in transactions {
1810 let txid = tx.compute_txid();
1811
1812 let peg_out_output = tx
1815 .output
1816 .first()
1817 .expect("tx must contain withdrawal output");
1818
1819 let change_output = tx.output.last().expect("tx must contain change output");
1820
1821 peg_out_txos.push(TxOutputSummary {
1822 outpoint: bitcoin::OutPoint { txid, vout: 0 },
1823 amount: peg_out_output.value,
1824 });
1825
1826 change_utxos.push(TxOutputSummary {
1827 outpoint: bitcoin::OutPoint { txid, vout: 1 },
1828 amount: change_output.value,
1829 });
1830 }
1831
1832 (peg_out_txos, change_utxos)
1833 }
1834
1835 let spendable_utxos = self
1836 .available_utxos(dbtx)
1837 .await
1838 .iter()
1839 .map(|(utxo_key, spendable_utxo)| TxOutputSummary {
1840 outpoint: utxo_key.0,
1841 amount: spendable_utxo.amount,
1842 })
1843 .collect::<Vec<_>>();
1844
1845 let unsigned_transactions = dbtx
1847 .find_by_prefix(&UnsignedTransactionPrefixKey)
1848 .await
1849 .map(|(_tx_key, tx)| tx.psbt.unsigned_tx)
1850 .collect::<Vec<_>>()
1851 .await;
1852
1853 let unconfirmed_transactions = dbtx
1855 .find_by_prefix(&PendingTransactionPrefixKey)
1856 .await
1857 .map(|(_tx_key, tx)| tx.tx)
1858 .collect::<Vec<_>>()
1859 .await;
1860
1861 let (unsigned_peg_out_txos, unsigned_change_utxos) =
1862 partition_peg_out_and_change(unsigned_transactions);
1863
1864 let (unconfirmed_peg_out_txos, unconfirmed_change_utxos) =
1865 partition_peg_out_and_change(unconfirmed_transactions);
1866
1867 WalletSummary {
1868 spendable_utxos,
1869 unsigned_peg_out_txos,
1870 unsigned_change_utxos,
1871 unconfirmed_peg_out_txos,
1872 unconfirmed_change_utxos,
1873 }
1874 }
1875
1876 async fn is_utxo_confirmed(
1877 &self,
1878 dbtx: &mut DatabaseTransaction<'_>,
1879 outpoint: bitcoin::OutPoint,
1880 ) -> bool {
1881 dbtx.get_value(&UnspentTxOutKey(outpoint)).await.is_some()
1882 }
1883
1884 fn offline_wallet(&'_ self) -> StatelessWallet<'_> {
1885 StatelessWallet {
1886 descriptor: &self.cfg.consensus.peg_in_descriptor,
1887 secret_key: &self.cfg.private.peg_in_key,
1888 secp: &self.secp,
1889 }
1890 }
1891
1892 fn spawn_broadcast_pending_task(
1893 task_group: &TaskGroup,
1894 server_bitcoin_rpc_monitor: &ServerBitcoinRpcMonitor,
1895 db: &Database,
1896 broadcast_pending_notify: Arc<Notify>,
1897 ) {
1898 task_group.spawn_cancellable("broadcast pending", {
1899 let btc_rpc = server_bitcoin_rpc_monitor.clone();
1900 let db = db.clone();
1901 run_broadcast_pending_tx(db, btc_rpc, broadcast_pending_notify)
1902 });
1903 }
1904
1905 pub fn network_ui(&self) -> Network {
1907 self.cfg.consensus.network.0
1908 }
1909
1910 pub async fn consensus_block_count_ui(&self) -> u32 {
1912 self.consensus_block_count(&mut self.db.begin_transaction_nc().await)
1913 .await
1914 }
1915
1916 pub async fn consensus_feerate_ui(&self) -> Feerate {
1918 self.consensus_fee_rate(&mut self.db.begin_transaction_nc().await)
1919 .await
1920 }
1921
1922 pub async fn get_wallet_summary_ui(&self) -> WalletSummary {
1924 self.get_wallet_summary(&mut self.db.begin_transaction_nc().await)
1925 .await
1926 }
1927
1928 async fn graceful_shutdown(&self) {
1931 if let Err(e) = self
1932 .task_group
1933 .clone()
1934 .shutdown_join_all(Some(Duration::from_mins(1)))
1935 .await
1936 {
1937 panic!("Error while shutting down fedimintd task group: {e}");
1938 }
1939 }
1940
1941 async fn wait_for_finality_confs_or_shutdown(&self, consensus_block_count: u32) {
1947 let backoff = if is_running_in_test_env() {
1948 backoff_util::custom_backoff(
1950 Duration::from_millis(100),
1951 Duration::from_millis(100),
1952 Some(10 * 60),
1953 )
1954 } else {
1955 backoff_util::fibonacci_max_one_hour()
1957 };
1958
1959 let wait_for_finality_confs = || async {
1960 let our_chain_tip_block_count = self.get_block_count()?;
1961 let consensus_chain_tip_block_count =
1962 consensus_block_count + self.cfg.consensus.finality_delay;
1963
1964 if consensus_chain_tip_block_count <= our_chain_tip_block_count {
1965 Ok(())
1966 } else {
1967 Err(anyhow::anyhow!("not enough confirmations"))
1968 }
1969 };
1970
1971 if retry("wait_for_finality_confs", backoff, wait_for_finality_confs)
1972 .await
1973 .is_err()
1974 {
1975 self.graceful_shutdown().await;
1976 }
1977 }
1978
1979 fn spawn_peer_supported_consensus_version_task(
1980 api_client: DynModuleApi,
1981 task_group: &TaskGroup,
1982 our_peer_id: PeerId,
1983 ) -> watch::Receiver<Option<ModuleConsensusVersion>> {
1984 let (sender, receiver) = watch::channel(None);
1985 task_group.spawn_cancellable("fetch-peer-consensus-versions", async move {
1986 loop {
1987 let request_futures = api_client.all_peers().iter().filter_map(|&peer| {
1988 if peer == our_peer_id {
1989 return None;
1990 }
1991
1992 let api_client_inner = api_client.clone();
1993 Some(async move {
1994 api_client_inner
1995 .request_single_peer::<ModuleConsensusVersion>(
1996 SUPPORTED_MODULE_CONSENSUS_VERSION_ENDPOINT.to_owned(),
1997 ApiRequestErased::default(),
1998 peer,
1999 )
2000 .await
2001 .inspect(|res| debug!(
2002 target: LOG_MODULE_WALLET,
2003 %peer,
2004 %our_peer_id,
2005 ?res,
2006 "Fetched supported module consensus version from peer"
2007 ))
2008 .inspect_err(|err| warn!(
2009 target: LOG_MODULE_WALLET,
2010 %peer,
2011 err=%err.fmt_compact(),
2012 "Failed to fetch consensus version from peer"
2013 ))
2014 .ok()
2015 })
2016 });
2017
2018 let peer_consensus_versions = join_all(request_futures)
2019 .await
2020 .into_iter()
2021 .flatten()
2022 .collect::<Vec<_>>();
2023
2024 let sorted_consensus_versions = peer_consensus_versions
2025 .into_iter()
2026 .chain(std::iter::once(MODULE_CONSENSUS_VERSION))
2027 .sorted()
2028 .collect::<Vec<_>>();
2029 let all_peers_supported_version =
2030 if sorted_consensus_versions.len() == api_client.all_peers().len() {
2031 let min_supported_version = *sorted_consensus_versions
2032 .first()
2033 .expect("at least one element");
2034
2035 debug!(
2036 target: LOG_MODULE_WALLET,
2037 ?sorted_consensus_versions,
2038 "Fetched supported consensus versions from peers"
2039 );
2040
2041 Some(min_supported_version)
2042 } else {
2043 assert!(
2044 sorted_consensus_versions.len() <= api_client.all_peers().len(),
2045 "Too many peer responses",
2046 );
2047 trace!(
2048 target: LOG_MODULE_WALLET,
2049 ?sorted_consensus_versions,
2050 "Not all peers have reported their consensus version yet"
2051 );
2052 None
2053 };
2054
2055 #[allow(clippy::disallowed_methods)]
2056 if sender.send(all_peers_supported_version).is_err() {
2057 warn!(target: LOG_MODULE_WALLET, "Failed to send consensus version to watch channel, stopping task");
2058 break;
2059 }
2060
2061 sleep(next_poll_delay(all_peers_supported_version.is_some())).await;
2062 }
2063 });
2064 receiver
2065 }
2066}
2067
2068#[instrument(target = LOG_MODULE_WALLET, level = "debug", skip_all)]
2069pub async fn run_broadcast_pending_tx(
2070 db: Database,
2071 rpc: ServerBitcoinRpcMonitor,
2072 broadcast: Arc<Notify>,
2073) {
2074 loop {
2075 let _ = tokio::time::timeout(Duration::from_mins(1), broadcast.notified()).await;
2077 broadcast_pending_tx(db.begin_transaction_nc().await, &rpc).await;
2078 }
2079}
2080
2081pub async fn broadcast_pending_tx(
2082 mut dbtx: DatabaseTransaction<'_>,
2083 rpc: &ServerBitcoinRpcMonitor,
2084) {
2085 let pending_tx: Vec<PendingTransaction> = dbtx
2086 .find_by_prefix(&PendingTransactionPrefixKey)
2087 .await
2088 .map(|(_, val)| val)
2089 .collect::<Vec<_>>()
2090 .await;
2091 let rbf_txids: BTreeSet<Txid> = pending_tx
2092 .iter()
2093 .filter_map(|tx| tx.rbf.clone().map(|rbf| rbf.txid))
2094 .collect();
2095 if !pending_tx.is_empty() {
2096 debug!(
2097 target: LOG_MODULE_WALLET,
2098 "Broadcasting pending transactions (total={}, rbf={})",
2099 pending_tx.len(),
2100 rbf_txids.len()
2101 );
2102 }
2103
2104 for PendingTransaction { tx, .. } in pending_tx {
2105 if !rbf_txids.contains(&tx.compute_txid()) {
2106 debug!(
2107 target: LOG_MODULE_WALLET,
2108 tx = %tx.compute_txid(),
2109 weight = tx.weight().to_wu(),
2110 output = ?tx.output,
2111 "Broadcasting peg-out",
2112 );
2113 trace!(transaction = ?tx);
2114 if let Err(err) = rpc.submit_transaction(tx).await {
2115 debug!(
2116 target: LOG_MODULE_WALLET,
2117 err = %err.fmt_compact_anyhow(),
2118 "Error broadcasting peg-out transaction"
2119 );
2120 }
2121 }
2122 }
2123}
2124
2125#[derive(Debug, Clone, Copy, Eq, PartialEq)]
2132pub enum FeeArithmetic {
2133 Wrapping,
2135 Checked,
2137}
2138
2139impl FeeArithmetic {
2140 fn calculate_fee(
2141 self,
2142 fee_rate: Feerate,
2143 weight: u64,
2144 ) -> Result<bitcoin::Amount, WalletOutputError> {
2145 match self {
2146 FeeArithmetic::Wrapping => Ok(fee_rate.wrapping_calculate_fee(weight)),
2147 FeeArithmetic::Checked => fee_rate
2148 .checked_calculate_fee(weight)
2149 .ok_or(WalletOutputError::NotEnoughSpendableUTXO),
2150 }
2151 }
2152}
2153
2154struct StatelessWallet<'a> {
2155 descriptor: &'a Descriptor<CompressedPublicKey>,
2156 secret_key: &'a secp256k1::SecretKey,
2157 secp: &'a secp256k1::Secp256k1<secp256k1::All>,
2158}
2159
2160impl StatelessWallet<'_> {
2161 fn validate_tx(
2164 tx: &UnsignedTransaction,
2165 output: &WalletOutputV0,
2166 consensus_fee_rate: Feerate,
2167 network: Network,
2168 ) -> Result<(), WalletOutputError> {
2169 if let WalletOutputV0::PegOut(peg_out) = output
2170 && !peg_out.recipient.is_valid_for_network(network)
2171 {
2172 return Err(WalletOutputError::WrongNetwork(
2173 NetworkLegacyEncodingWrapper(network),
2174 NetworkLegacyEncodingWrapper(get_network_for_address(&peg_out.recipient)),
2175 ));
2176 }
2177
2178 if tx.peg_out_amount < tx.destination.minimal_non_dust() {
2180 return Err(WalletOutputError::PegOutUnderDustLimit);
2181 }
2182
2183 if tx.fees.fee_rate < consensus_fee_rate {
2185 return Err(WalletOutputError::PegOutFeeBelowConsensus(
2186 tx.fees.fee_rate,
2187 consensus_fee_rate,
2188 ));
2189 }
2190
2191 let fees = match output {
2194 WalletOutputV0::PegOut(pegout) => pegout.fees,
2195 WalletOutputV0::Rbf(rbf) => rbf.fees,
2196 };
2197 if fees.fee_rate.sats_per_kvb < u64::from(DEFAULT_MIN_RELAY_TX_FEE) {
2198 return Err(WalletOutputError::BelowMinRelayFee);
2199 }
2200
2201 if fees.total_weight != tx.fees.total_weight {
2203 return Err(WalletOutputError::TxWeightIncorrect(
2204 fees.total_weight,
2205 tx.fees.total_weight,
2206 ));
2207 }
2208
2209 Ok(())
2210 }
2211
2212 #[allow(clippy::too_many_arguments)]
2222 fn create_tx(
2223 &self,
2224 peg_out_amount: bitcoin::Amount,
2225 destination: ScriptBuf,
2226 mut included_utxos: Vec<(UTXOKey, SpendableUTXO)>,
2227 mut remaining_utxos: Vec<(UTXOKey, SpendableUTXO)>,
2228 mut fee_rate: Feerate,
2229 change_tweak: &[u8; 33],
2230 rbf: Option<Rbf>,
2231 fee_arithmetic: FeeArithmetic,
2232 ) -> Result<UnsignedTransaction, WalletOutputError> {
2233 if peg_out_amount > bitcoin::Amount::MAX_MONEY {
2238 return Err(WalletOutputError::NotEnoughSpendableUTXO);
2239 }
2240
2241 if let Some(rbf) = &rbf {
2243 fee_rate.sats_per_kvb = fee_rate
2244 .sats_per_kvb
2245 .saturating_add(rbf.fees.fee_rate.sats_per_kvb);
2246 }
2247
2248 let change_script = self.derive_script(change_tweak);
2256 let out_weight = (destination.len() * 4 + 1 + 32
2257 + 1 + change_script.len() * 4 + 32) as u64; let mut total_weight = 16 + 12 + 12 + out_weight + 16; #[allow(deprecated)]
2268 let max_input_weight = (self
2269 .descriptor
2270 .max_satisfaction_weight()
2271 .expect("is satisfyable") +
2272 128 + 16 + 16) as u64; included_utxos.sort_by_key(|(_, utxo)| utxo.amount);
2278 remaining_utxos.sort_by_key(|(_, utxo)| utxo.amount);
2279 included_utxos.extend(remaining_utxos);
2280
2281 let mut total_selected_value = bitcoin::Amount::from_sat(0);
2283 let mut selected_utxos: Vec<(UTXOKey, SpendableUTXO)> = vec![];
2284 let mut fees = fee_arithmetic.calculate_fee(fee_rate, total_weight)?;
2285
2286 loop {
2287 let target = peg_out_amount
2292 .checked_add(change_script.minimal_non_dust())
2293 .and_then(|target| target.checked_add(fees))
2294 .ok_or(WalletOutputError::NotEnoughSpendableUTXO)?;
2295
2296 if total_selected_value >= target {
2297 break;
2298 }
2299
2300 let Some((utxo_key, utxo)) = included_utxos.pop() else {
2301 return Err(WalletOutputError::NotEnoughSpendableUTXO); };
2303
2304 total_selected_value += utxo.amount;
2305 total_weight += max_input_weight;
2306 fees = fee_arithmetic.calculate_fee(fee_rate, total_weight)?;
2307 selected_utxos.push((utxo_key, utxo));
2308 }
2309
2310 let change = total_selected_value - fees - peg_out_amount;
2313 let output: Vec<TxOut> = vec![
2314 TxOut {
2315 value: peg_out_amount,
2316 script_pubkey: destination.clone(),
2317 },
2318 TxOut {
2319 value: change,
2320 script_pubkey: change_script,
2321 },
2322 ];
2323 let mut change_out = bitcoin::psbt::Output::default();
2324 change_out
2325 .proprietary
2326 .insert(proprietary_tweak_key(), change_tweak.to_vec());
2327
2328 info!(
2329 target: LOG_MODULE_WALLET,
2330 inputs = selected_utxos.len(),
2331 input_sats = total_selected_value.to_sat(),
2332 peg_out_sats = peg_out_amount.to_sat(),
2333 ?total_weight,
2334 fees_sats = fees.to_sat(),
2335 fee_rate = fee_rate.sats_per_kvb,
2336 change_sats = change.to_sat(),
2337 "Creating peg-out tx",
2338 );
2339
2340 let transaction = Transaction {
2341 version: bitcoin::transaction::Version(2),
2342 lock_time: LockTime::ZERO,
2343 input: selected_utxos
2344 .iter()
2345 .map(|(utxo_key, _utxo)| TxIn {
2346 previous_output: utxo_key.0,
2347 script_sig: Default::default(),
2348 sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
2349 witness: bitcoin::Witness::new(),
2350 })
2351 .collect(),
2352 output,
2353 };
2354 info!(
2355 target: LOG_MODULE_WALLET,
2356 txid = %transaction.compute_txid(), "Creating peg-out tx"
2357 );
2358
2359 let psbt = Psbt {
2362 unsigned_tx: transaction,
2363 version: 0,
2364 xpub: Default::default(),
2365 proprietary: Default::default(),
2366 unknown: Default::default(),
2367 inputs: selected_utxos
2368 .iter()
2369 .map(|(_utxo_key, utxo)| {
2370 let script_pubkey = self
2371 .descriptor
2372 .tweak(&utxo.tweak, self.secp)
2373 .script_pubkey();
2374 Input {
2375 non_witness_utxo: None,
2376 witness_utxo: Some(TxOut {
2377 value: utxo.amount,
2378 script_pubkey,
2379 }),
2380 partial_sigs: Default::default(),
2381 sighash_type: None,
2382 redeem_script: None,
2383 witness_script: Some(
2384 self.descriptor
2385 .tweak(&utxo.tweak, self.secp)
2386 .script_code()
2387 .expect("Failed to tweak descriptor"),
2388 ),
2389 bip32_derivation: Default::default(),
2390 final_script_sig: None,
2391 final_script_witness: None,
2392 ripemd160_preimages: Default::default(),
2393 sha256_preimages: Default::default(),
2394 hash160_preimages: Default::default(),
2395 hash256_preimages: Default::default(),
2396 proprietary: vec![(proprietary_tweak_key(), utxo.tweak.to_vec())]
2397 .into_iter()
2398 .collect(),
2399 tap_key_sig: Default::default(),
2400 tap_script_sigs: Default::default(),
2401 tap_scripts: Default::default(),
2402 tap_key_origins: Default::default(),
2403 tap_internal_key: Default::default(),
2404 tap_merkle_root: Default::default(),
2405 unknown: Default::default(),
2406 }
2407 })
2408 .collect(),
2409 outputs: vec![Default::default(), change_out],
2410 };
2411
2412 Ok(UnsignedTransaction {
2413 psbt,
2414 signatures: vec![],
2415 change,
2416 fees: PegOutFees {
2417 fee_rate,
2418 total_weight,
2419 },
2420 destination,
2421 selected_utxos,
2422 peg_out_amount,
2423 rbf,
2424 })
2425 }
2426
2427 fn sign_psbt(&self, psbt: &mut Psbt) {
2428 let mut tx_hasher = SighashCache::new(&psbt.unsigned_tx);
2429
2430 for (idx, (psbt_input, _tx_input)) in psbt
2431 .inputs
2432 .iter_mut()
2433 .zip(psbt.unsigned_tx.input.iter())
2434 .enumerate()
2435 {
2436 let tweaked_secret = {
2437 let tweak = psbt_input
2438 .proprietary
2439 .get(&proprietary_tweak_key())
2440 .expect("Malformed PSBT: expected tweak");
2441
2442 self.secret_key.tweak(tweak, self.secp)
2443 };
2444
2445 let tx_hash = tx_hasher
2446 .p2wsh_signature_hash(
2447 idx,
2448 psbt_input
2449 .witness_script
2450 .as_ref()
2451 .expect("Missing witness script"),
2452 psbt_input
2453 .witness_utxo
2454 .as_ref()
2455 .expect("Missing UTXO")
2456 .value,
2457 EcdsaSighashType::All,
2458 )
2459 .expect("Failed to create segwit sighash");
2460
2461 let signature = self.secp.sign_ecdsa(
2462 &Message::from_digest_slice(&tx_hash[..]).unwrap(),
2463 &tweaked_secret,
2464 );
2465
2466 psbt_input.partial_sigs.insert(
2467 bitcoin::PublicKey {
2468 compressed: true,
2469 inner: secp256k1::PublicKey::from_secret_key(self.secp, &tweaked_secret),
2470 },
2471 EcdsaSig::sighash_all(signature),
2472 );
2473 }
2474 }
2475
2476 fn derive_script(&self, tweak: &[u8]) -> ScriptBuf {
2477 struct CompressedPublicKeyTranslator<'t, 's, Ctx: Verification> {
2478 tweak: &'t [u8],
2479 secp: &'s Secp256k1<Ctx>,
2480 }
2481
2482 impl<Ctx: Verification>
2483 miniscript::Translator<CompressedPublicKey, CompressedPublicKey, Infallible>
2484 for CompressedPublicKeyTranslator<'_, '_, Ctx>
2485 {
2486 fn pk(&mut self, pk: &CompressedPublicKey) -> Result<CompressedPublicKey, Infallible> {
2487 let hashed_tweak = {
2488 let mut hasher = HmacEngine::<sha256::Hash>::new(&pk.key.serialize()[..]);
2489 hasher.input(self.tweak);
2490 Hmac::from_engine(hasher).to_byte_array()
2491 };
2492
2493 Ok(CompressedPublicKey {
2494 key: pk
2495 .key
2496 .add_exp_tweak(
2497 self.secp,
2498 &Scalar::from_be_bytes(hashed_tweak).expect("can't fail"),
2499 )
2500 .expect("tweaking failed"),
2501 })
2502 }
2503 translate_hash_fail!(CompressedPublicKey, CompressedPublicKey, Infallible);
2504 }
2505
2506 let descriptor = self
2507 .descriptor
2508 .translate_pk(&mut CompressedPublicKeyTranslator {
2509 tweak,
2510 secp: self.secp,
2511 })
2512 .expect("can't fail");
2513
2514 descriptor.script_pubkey()
2515 }
2516}
2517
2518pub fn nonce_from_idx(nonce_idx: u64) -> [u8; 33] {
2519 let mut nonce: [u8; 33] = [0; 33];
2520 nonce[0] = 0x02;
2522 nonce[1..].copy_from_slice(&nonce_idx.consensus_hash::<bitcoin::hashes::sha256::Hash>()[..]);
2523
2524 nonce
2525}
2526
2527#[derive(Clone, Debug, Encodable, Decodable)]
2529pub struct PendingTransaction {
2530 pub tx: bitcoin::Transaction,
2531 pub tweak: [u8; 33],
2532 pub change: bitcoin::Amount,
2533 pub destination: ScriptBuf,
2534 pub fees: PegOutFees,
2535 pub selected_utxos: Vec<(UTXOKey, SpendableUTXO)>,
2536 pub peg_out_amount: bitcoin::Amount,
2537 pub rbf: Option<Rbf>,
2538}
2539
2540impl Serialize for PendingTransaction {
2541 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2542 where
2543 S: serde::Serializer,
2544 {
2545 if serializer.is_human_readable() {
2546 serializer.serialize_str(&self.consensus_encode_to_hex())
2547 } else {
2548 serializer.serialize_bytes(&self.consensus_encode_to_vec())
2549 }
2550 }
2551}
2552
2553#[derive(Clone, Debug, Eq, PartialEq, Encodable, Decodable)]
2556pub struct UnsignedTransaction {
2557 pub psbt: Psbt,
2558 pub signatures: Vec<(PeerId, PegOutSignatureItem)>,
2559 pub change: bitcoin::Amount,
2560 pub fees: PegOutFees,
2561 pub destination: ScriptBuf,
2562 pub selected_utxos: Vec<(UTXOKey, SpendableUTXO)>,
2563 pub peg_out_amount: bitcoin::Amount,
2564 pub rbf: Option<Rbf>,
2565}
2566
2567impl Serialize for UnsignedTransaction {
2568 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2569 where
2570 S: serde::Serializer,
2571 {
2572 if serializer.is_human_readable() {
2573 serializer.serialize_str(&self.consensus_encode_to_hex())
2574 } else {
2575 serializer.serialize_bytes(&self.consensus_encode_to_vec())
2576 }
2577 }
2578}
2579
2580#[cfg(test)]
2581mod tests {
2582
2583 use std::str::FromStr;
2584
2585 use bitcoin::Network::{Bitcoin, Testnet};
2586 use bitcoin::hashes::Hash;
2587 use bitcoin::{Address, Amount, OutPoint, Txid, secp256k1};
2588 use fedimint_core::Feerate;
2589 use fedimint_core::encoding::btc::NetworkLegacyEncodingWrapper;
2590 use fedimint_core::envs::is_automatic_consensus_version_voting_disabled;
2591 use fedimint_wallet_common::{PegOut, PegOutFees, Rbf, WalletOutputV0};
2592 use miniscript::descriptor::Wsh;
2593
2594 use crate::common::PegInDescriptor;
2595 use crate::{
2596 CompressedPublicKey, FeeArithmetic, OsRng, SpendableUTXO, StatelessWallet, UTXOKey,
2597 WalletOutputError,
2598 };
2599
2600 #[test]
2609 fn peg_out_destination_can_collide_with_the_change_script() {
2610 let secp = secp256k1::Secp256k1::new();
2611
2612 let descriptor = PegInDescriptor::Wsh(
2613 Wsh::new_sortedmulti(
2614 3,
2615 (0..4)
2616 .map(|_| secp.generate_keypair(&mut OsRng))
2617 .map(|(_, key)| CompressedPublicKey { key })
2618 .collect(),
2619 )
2620 .unwrap(),
2621 );
2622
2623 let (secret_key, _) = secp.generate_keypair(&mut OsRng);
2624
2625 let wallet = StatelessWallet {
2626 descriptor: &descriptor,
2627 secret_key: &secret_key,
2628 secp: &secp,
2629 };
2630
2631 let change_tweak = crate::nonce_from_idx(0);
2632 let change_script = wallet.derive_script(&change_tweak);
2633
2634 let tx = wallet
2635 .create_tx(
2636 Amount::from_sat(1000),
2637 change_script.clone(),
2638 vec![],
2639 vec![(
2640 UTXOKey(OutPoint::null()),
2641 SpendableUTXO {
2642 tweak: [0; 33],
2643 amount: bitcoin::Amount::from_sat(100_000),
2644 },
2645 )],
2646 Feerate { sats_per_kvb: 1000 },
2647 &change_tweak,
2648 None,
2649 FeeArithmetic::Checked,
2650 )
2651 .expect("tx creation succeeds");
2652
2653 let matching = tx
2654 .psbt
2655 .unsigned_tx
2656 .output
2657 .iter()
2658 .filter(|o| o.script_pubkey == change_script)
2659 .count();
2660
2661 assert_eq!(
2662 matching, 2,
2663 "both the destination and the change output carry the change script"
2664 );
2665 }
2666
2667 #[test]
2672 fn fee_arithmetic_rejects_an_unpayable_rate_only_once_active() {
2673 let secp = secp256k1::Secp256k1::new();
2674
2675 let descriptor = PegInDescriptor::Wsh(
2676 Wsh::new_sortedmulti(
2677 3,
2678 (0..4)
2679 .map(|_| secp.generate_keypair(&mut OsRng))
2680 .map(|(_, key)| CompressedPublicKey { key })
2681 .collect(),
2682 )
2683 .unwrap(),
2684 );
2685
2686 let absurd = Feerate {
2687 sats_per_kvb: u64::MAX,
2688 };
2689 let ordinary = Feerate { sats_per_kvb: 1000 };
2690
2691 assert_eq!(
2692 FeeArithmetic::Checked.calculate_fee(absurd, 958),
2693 Err(WalletOutputError::NotEnoughSpendableUTXO),
2694 "an unpayable rate is rejected once 2.3 is active"
2695 );
2696 assert_eq!(
2697 FeeArithmetic::Wrapping.calculate_fee(absurd, 958),
2698 Ok(absurd.wrapping_calculate_fee(958)),
2699 "pre-2.3 behaviour is reproduced exactly, wrap and all"
2700 );
2701
2702 for arithmetic in [FeeArithmetic::Checked, FeeArithmetic::Wrapping] {
2703 assert_eq!(
2704 arithmetic.calculate_fee(ordinary, 958),
2705 Ok(ordinary.calculate_fee(958)),
2706 "an ordinary rate is unaffected in either regime"
2707 );
2708 }
2709
2710 let _ = descriptor;
2711 }
2712
2713 #[test]
2718 fn create_tx_rejects_amounts_that_cannot_exist_on_chain() {
2719 let secp = secp256k1::Secp256k1::new();
2720
2721 let descriptor = PegInDescriptor::Wsh(
2722 Wsh::new_sortedmulti(
2723 3,
2724 (0..4)
2725 .map(|_| secp.generate_keypair(&mut OsRng))
2726 .map(|(_, key)| CompressedPublicKey { key })
2727 .collect(),
2728 )
2729 .unwrap(),
2730 );
2731
2732 let (secret_key, _) = secp.generate_keypair(&mut OsRng);
2733
2734 let wallet = StatelessWallet {
2735 descriptor: &descriptor,
2736 secret_key: &secret_key,
2737 secp: &secp,
2738 };
2739
2740 let recipient = Address::from_str("32iVBEu4dxkUQk9dJbZUiBiQdmypcEyJRf").unwrap();
2741 let utxos = vec![(
2742 UTXOKey(OutPoint::null()),
2743 SpendableUTXO {
2744 tweak: [0; 33],
2745 amount: bitcoin::Amount::from_sat(100_000),
2746 },
2747 )];
2748
2749 for amount in [
2750 Amount::from_sat(u64::MAX),
2751 Amount::MAX_MONEY + Amount::from_sat(1),
2752 ] {
2753 let tx = wallet.create_tx(
2754 amount,
2755 recipient.clone().assume_checked().script_pubkey(),
2756 vec![],
2757 utxos.clone(),
2758 Feerate { sats_per_kvb: 1000 },
2759 &[0; 33],
2760 None,
2761 FeeArithmetic::Checked,
2762 );
2763
2764 assert_eq!(tx, Err(WalletOutputError::NotEnoughSpendableUTXO));
2765 }
2766 }
2767
2768 #[test]
2769 fn create_tx_should_validate_amounts() {
2770 let secp = secp256k1::Secp256k1::new();
2771
2772 let descriptor = PegInDescriptor::Wsh(
2773 Wsh::new_sortedmulti(
2774 3,
2775 (0..4)
2776 .map(|_| secp.generate_keypair(&mut OsRng))
2777 .map(|(_, key)| CompressedPublicKey { key })
2778 .collect(),
2779 )
2780 .unwrap(),
2781 );
2782
2783 let (secret_key, _) = secp.generate_keypair(&mut OsRng);
2784
2785 let wallet = StatelessWallet {
2786 descriptor: &descriptor,
2787 secret_key: &secret_key,
2788 secp: &secp,
2789 };
2790
2791 let spendable = SpendableUTXO {
2792 tweak: [0; 33],
2793 amount: bitcoin::Amount::from_sat(3000),
2794 };
2795
2796 let recipient = Address::from_str("32iVBEu4dxkUQk9dJbZUiBiQdmypcEyJRf").unwrap();
2797
2798 let fee = Feerate { sats_per_kvb: 1000 };
2799 let weight = 875;
2800
2801 let tx = wallet.create_tx(
2806 Amount::from_sat(2452),
2807 recipient.clone().assume_checked().script_pubkey(),
2808 vec![],
2809 vec![(UTXOKey(OutPoint::null()), spendable.clone())],
2810 fee,
2811 &[0; 33],
2812 None,
2813 FeeArithmetic::Checked,
2814 );
2815 assert_eq!(tx, Err(WalletOutputError::NotEnoughSpendableUTXO));
2816
2817 let mut tx = wallet
2819 .create_tx(
2820 Amount::from_sat(1000),
2821 recipient.clone().assume_checked().script_pubkey(),
2822 vec![],
2823 vec![(UTXOKey(OutPoint::null()), spendable)],
2824 fee,
2825 &[0; 33],
2826 None,
2827 FeeArithmetic::Checked,
2828 )
2829 .expect("is ok");
2830
2831 let res = StatelessWallet::validate_tx(&tx, &rbf(fee.sats_per_kvb, 0), fee, Bitcoin);
2833 assert_eq!(res, Err(WalletOutputError::TxWeightIncorrect(0, weight)));
2834
2835 let res = StatelessWallet::validate_tx(&tx, &rbf(0, weight), fee, Bitcoin);
2837 assert_eq!(res, Err(WalletOutputError::BelowMinRelayFee));
2838
2839 let res = StatelessWallet::validate_tx(&tx, &rbf(fee.sats_per_kvb, weight), fee, Bitcoin);
2841 assert_eq!(res, Ok(()));
2842
2843 tx.fees = PegOutFees::new(0, weight);
2845 let res = StatelessWallet::validate_tx(&tx, &rbf(fee.sats_per_kvb, weight), fee, Bitcoin);
2846 assert_eq!(
2847 res,
2848 Err(WalletOutputError::PegOutFeeBelowConsensus(
2849 Feerate { sats_per_kvb: 0 },
2850 fee
2851 ))
2852 );
2853
2854 tx.peg_out_amount = bitcoin::Amount::ZERO;
2856 let res = StatelessWallet::validate_tx(&tx, &rbf(fee.sats_per_kvb, weight), fee, Bitcoin);
2857 assert_eq!(res, Err(WalletOutputError::PegOutUnderDustLimit));
2858
2859 let output = WalletOutputV0::PegOut(PegOut {
2861 recipient,
2862 amount: bitcoin::Amount::from_sat(1000),
2863 fees: PegOutFees::new(100, weight),
2864 });
2865 let res = StatelessWallet::validate_tx(&tx, &output, fee, Testnet);
2866 assert_eq!(
2867 res,
2868 Err(WalletOutputError::WrongNetwork(
2869 NetworkLegacyEncodingWrapper(Testnet),
2870 NetworkLegacyEncodingWrapper(Bitcoin)
2871 ))
2872 );
2873 }
2874
2875 fn rbf(sats_per_kvb: u64, total_weight: u64) -> WalletOutputV0 {
2876 WalletOutputV0::Rbf(Rbf {
2877 fees: PegOutFees::new(sats_per_kvb, total_weight),
2878 txid: Txid::all_zeros(),
2879 })
2880 }
2881
2882 #[test]
2883 fn automatic_vote_suppressed_when_env_set() {
2884 unsafe {
2885 std::env::set_var("FM_WALLET_DISABLE_AUTOMATIC_CONSENSUS_VERSION_VOTING", "1");
2886 }
2887 assert!(is_automatic_consensus_version_voting_disabled());
2888 unsafe {
2889 std::env::remove_var("FM_WALLET_DISABLE_AUTOMATIC_CONSENSUS_VERSION_VOTING");
2890 }
2891 }
2892
2893 #[test]
2894 fn automatic_vote_active_when_env_unset() {
2895 unsafe {
2896 std::env::remove_var("FM_WALLET_DISABLE_AUTOMATIC_CONSENSUS_VERSION_VOTING");
2897 }
2898 assert!(!is_automatic_consensus_version_voting_disabled());
2899 }
2900}