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 .and_then(|status| status.fee_rate)
1419 .unwrap_or(self.cfg.consensus.default_fee)
1420 .sats_per_kvb as f64
1421 * get_feerate_multiplier())
1422 .round()) as u64,
1423 }
1424 }
1425
1426 pub async fn consensus_block_count(&self, dbtx: &mut DatabaseTransaction<'_>) -> u32 {
1427 let peer_count = self.cfg.consensus.peer_peg_in_keys.to_num_peers().total();
1428
1429 let mut counts = dbtx
1430 .find_by_prefix(&BlockCountVotePrefix)
1431 .await
1432 .map(|entry| entry.1)
1433 .collect::<Vec<u32>>()
1434 .await;
1435
1436 assert!(counts.len() <= peer_count);
1437
1438 while counts.len() < peer_count {
1439 counts.push(0);
1440 }
1441
1442 counts.sort_unstable();
1443
1444 counts[peer_count / 2]
1445 }
1446
1447 pub async fn consensus_fee_rate(&self, dbtx: &mut DatabaseTransaction<'_>) -> Feerate {
1448 let peer_count = self.cfg.consensus.peer_peg_in_keys.to_num_peers().total();
1449
1450 let mut rates = dbtx
1451 .find_by_prefix(&FeeRateVotePrefix)
1452 .await
1453 .map(|(.., rate)| rate)
1454 .collect::<Vec<_>>()
1455 .await;
1456
1457 assert!(rates.len() <= peer_count);
1458
1459 while rates.len() < peer_count {
1460 rates.push(self.cfg.consensus.default_fee);
1461 }
1462
1463 rates.sort_unstable();
1464
1465 rates[peer_count / 2]
1466 }
1467
1468 async fn consensus_module_consensus_version(
1469 &self,
1470 dbtx: &mut DatabaseTransaction<'_>,
1471 ) -> ModuleConsensusVersion {
1472 let num_peers = self.cfg.consensus.peer_peg_in_keys.to_num_peers();
1473
1474 let mut versions = dbtx
1475 .find_by_prefix(&ConsensusVersionVotePrefix)
1476 .await
1477 .map(|entry| entry.1)
1478 .collect::<Vec<ModuleConsensusVersion>>()
1479 .await;
1480
1481 while versions.len() < num_peers.total() {
1482 versions.push(ModuleConsensusVersion::new(2, 0));
1483 }
1484
1485 assert_eq!(versions.len(), num_peers.total());
1486
1487 versions.sort_unstable();
1488
1489 assert!(versions.first() <= versions.last());
1490
1491 versions[num_peers.max_evil()]
1492 }
1493
1494 pub async fn consensus_nonce(&self, dbtx: &mut DatabaseTransaction<'_>) -> [u8; 33] {
1495 let nonce_idx = dbtx.get_value(&PegOutNonceKey).await.unwrap_or(0);
1496 dbtx.insert_entry(&PegOutNonceKey, &(nonce_idx + 1)).await;
1497
1498 nonce_from_idx(nonce_idx)
1499 }
1500
1501 async fn sync_up_to_consensus_count(
1502 &self,
1503 dbtx: &mut DatabaseTransaction<'_>,
1504 old_count: u32,
1505 new_count: u32,
1506 ) {
1507 let sync_start = fedimint_core::time::now();
1508 info!(
1509 target: LOG_MODULE_WALLET,
1510 old_count,
1511 new_count,
1512 blocks_to_go = new_count
1513 .checked_sub(old_count)
1514 .expect("new_count must be >= old_count"),
1515 "New block count consensus, initiating sync",
1516 );
1517
1518 self.wait_for_finality_confs_or_shutdown(new_count).await;
1521
1522 for height in old_count..new_count {
1523 let block_start = fedimint_core::time::now();
1524 info!(
1525 target: LOG_MODULE_WALLET,
1526 height,
1527 "Processing block of height {height}",
1528 );
1529
1530 trace!(block = height, "Fetching block hash");
1532 let block_hash = retry("get_block_hash", backoff_util::background_backoff(), || {
1533 self.btc_rpc.get_block_hash(u64::from(height)) })
1535 .await
1536 .expect("bitcoind rpc to get block hash");
1537
1538 let block = retry("get_block", backoff_util::background_backoff(), || {
1539 self.btc_rpc.get_block(&block_hash)
1540 })
1541 .await
1542 .expect("bitcoind rpc to get block");
1543
1544 if let Some(prev_block_height) = height.checked_sub(1) {
1545 if let Some(hash) = dbtx
1546 .get_value(&BlockHashByHeightKey(prev_block_height))
1547 .await
1548 {
1549 assert_eq!(block.header.prev_blockhash, hash.0);
1550 } else {
1551 warn!(
1552 target: LOG_MODULE_WALLET,
1553 %height,
1554 %block_hash,
1555 %prev_block_height,
1556 prev_blockhash = %block.header.prev_blockhash,
1557 "Missing previous block hash. This should only happen on the first processed block height."
1558 );
1559 }
1560 }
1561
1562 if self.consensus_module_consensus_version(dbtx).await
1563 >= ModuleConsensusVersion::new(2, 2)
1564 {
1565 for transaction in &block.txdata {
1566 for tx_in in &transaction.input {
1571 dbtx.remove_entry(&UnspentTxOutKey(tx_in.previous_output))
1572 .await;
1573 }
1574
1575 for (vout, tx_out) in transaction.output.iter().enumerate() {
1576 let should_track_utxo = if self.cfg.consensus.peer_peg_in_keys.len() > 1 {
1577 tx_out.script_pubkey.is_p2wsh()
1578 } else {
1579 tx_out.script_pubkey.is_p2wpkh()
1580 };
1581
1582 if should_track_utxo {
1583 let outpoint = bitcoin::OutPoint {
1584 txid: transaction.compute_txid(),
1585 vout: vout as u32,
1586 };
1587
1588 dbtx.insert_new_entry(&UnspentTxOutKey(outpoint), tx_out)
1589 .await;
1590 }
1591 }
1592 }
1593 }
1594
1595 let pending_transactions = dbtx
1596 .find_by_prefix(&PendingTransactionPrefixKey)
1597 .await
1598 .map(|(key, transaction)| (key.0, transaction))
1599 .collect::<BTreeMap<Txid, PendingTransaction>>()
1600 .await;
1601 let pending_transactions_len = pending_transactions.len();
1602
1603 debug!(
1604 target: LOG_MODULE_WALLET,
1605 ?height,
1606 ?pending_transactions_len,
1607 "Recognizing change UTXOs"
1608 );
1609 for (txid, tx) in &pending_transactions {
1610 let is_tx_in_block = block.txdata.iter().any(|tx| tx.compute_txid() == *txid);
1611
1612 if is_tx_in_block {
1613 debug!(
1614 target: LOG_MODULE_WALLET,
1615 ?txid, ?height, ?block_hash, "Recognizing change UTXO"
1616 );
1617 self.recognize_change_utxo(dbtx, tx).await;
1618 } else {
1619 debug!(
1620 target: LOG_MODULE_WALLET,
1621 ?txid,
1622 ?height,
1623 ?block_hash,
1624 "Pending transaction not yet confirmed in this block"
1625 );
1626 }
1627 }
1628
1629 dbtx.insert_new_entry(&BlockHashKey(block_hash), &()).await;
1630 dbtx.insert_new_entry(
1631 &BlockHashByHeightKey(height),
1632 &BlockHashByHeightValue(block_hash),
1633 )
1634 .await;
1635
1636 info!(
1637 target: LOG_MODULE_WALLET,
1638 height,
1639 ?block_hash,
1640 duration = ?block_start.elapsed().unwrap_or_default(),
1641 "Successfully processed block of height {height}",
1642 );
1643 }
1644
1645 info!(
1646 target: LOG_MODULE_WALLET,
1647 old_count,
1648 new_count,
1649 blocks_processed = new_count
1650 .checked_sub(old_count)
1651 .expect("new_count must be >= old_count"),
1652 duration = ?sync_start.elapsed().unwrap_or_default(),
1653 "Block count consensus sync complete",
1654 );
1655 }
1656
1657 async fn recognize_change_utxo(
1660 &self,
1661 dbtx: &mut DatabaseTransaction<'_>,
1662 pending_tx: &PendingTransaction,
1663 ) {
1664 self.remove_rbf_transactions(dbtx, pending_tx).await;
1665
1666 let script_pk = self
1667 .cfg
1668 .consensus
1669 .peg_in_descriptor
1670 .tweak(&pending_tx.tweak, &self.secp)
1671 .script_pubkey();
1672 for (idx, output) in pending_tx.tx.output.iter().enumerate() {
1673 if output.script_pubkey == script_pk {
1674 dbtx.insert_entry(
1675 &UTXOKey(bitcoin::OutPoint {
1676 txid: pending_tx.tx.compute_txid(),
1677 vout: idx as u32,
1678 }),
1679 &SpendableUTXO {
1680 tweak: pending_tx.tweak,
1681 amount: output.value,
1682 },
1683 )
1684 .await;
1685 }
1686 }
1687 }
1688
1689 async fn remove_rbf_transactions(
1691 &self,
1692 dbtx: &mut DatabaseTransaction<'_>,
1693 pending_tx: &PendingTransaction,
1694 ) {
1695 let mut all_transactions: BTreeMap<Txid, PendingTransaction> = dbtx
1696 .find_by_prefix(&PendingTransactionPrefixKey)
1697 .await
1698 .map(|(key, val)| (key.0, val))
1699 .collect::<BTreeMap<Txid, PendingTransaction>>()
1700 .await;
1701
1702 let mut pending_to_remove = vec![pending_tx.clone()];
1704 while let Some(removed) = pending_to_remove.pop() {
1705 all_transactions.remove(&removed.tx.compute_txid());
1706 dbtx.remove_entry(&PendingTransactionKey(removed.tx.compute_txid()))
1707 .await;
1708
1709 if let Some(rbf) = &removed.rbf
1711 && let Some(tx) = all_transactions.get(&rbf.txid)
1712 {
1713 pending_to_remove.push(tx.clone());
1714 }
1715
1716 for tx in all_transactions.values() {
1718 if let Some(rbf) = &tx.rbf
1719 && rbf.txid == removed.tx.compute_txid()
1720 {
1721 pending_to_remove.push(tx.clone());
1722 }
1723 }
1724 }
1725 }
1726
1727 async fn block_is_known(
1728 &self,
1729 dbtx: &mut DatabaseTransaction<'_>,
1730 block_hash: BlockHash,
1731 ) -> bool {
1732 dbtx.get_value(&BlockHashKey(block_hash)).await.is_some()
1733 }
1734
1735 async fn create_peg_out_tx(
1736 &self,
1737 dbtx: &mut DatabaseTransaction<'_>,
1738 output: &WalletOutputV0,
1739 change_tweak: &[u8; 33],
1740 ) -> Result<UnsignedTransaction, WalletOutputError> {
1741 let fee_arithmetic = if CHECKED_PEG_OUT_FEE_MODULE_CONSENSUS_VERSION
1742 <= self.consensus_module_consensus_version(dbtx).await
1743 {
1744 FeeArithmetic::Checked
1745 } else {
1746 FeeArithmetic::Wrapping
1747 };
1748
1749 match output {
1750 WalletOutputV0::PegOut(peg_out) => self.offline_wallet().create_tx(
1751 peg_out.amount,
1752 peg_out.recipient.clone().assume_checked().script_pubkey(),
1756 vec![],
1757 self.available_utxos(dbtx).await,
1758 peg_out.fees.fee_rate,
1759 change_tweak,
1760 None,
1761 fee_arithmetic,
1762 ),
1763 WalletOutputV0::Rbf(rbf) => {
1764 let tx = dbtx
1765 .get_value(&PendingTransactionKey(rbf.txid))
1766 .await
1767 .ok_or(WalletOutputError::RbfTransactionIdNotFound)?;
1768
1769 self.offline_wallet().create_tx(
1770 tx.peg_out_amount,
1771 tx.destination,
1772 tx.selected_utxos,
1773 self.available_utxos(dbtx).await,
1774 tx.fees.fee_rate,
1775 change_tweak,
1776 Some(rbf.clone()),
1777 fee_arithmetic,
1778 )
1779 }
1780 }
1781 }
1782
1783 async fn available_utxos(
1784 &self,
1785 dbtx: &mut DatabaseTransaction<'_>,
1786 ) -> Vec<(UTXOKey, SpendableUTXO)> {
1787 dbtx.find_by_prefix(&UTXOPrefixKey)
1788 .await
1789 .collect::<Vec<(UTXOKey, SpendableUTXO)>>()
1790 .await
1791 }
1792
1793 pub async fn get_wallet_value(&self, dbtx: &mut DatabaseTransaction<'_>) -> bitcoin::Amount {
1794 let sat_sum = self
1795 .available_utxos(dbtx)
1796 .await
1797 .into_iter()
1798 .map(|(_, utxo)| utxo.amount.to_sat())
1799 .sum();
1800 bitcoin::Amount::from_sat(sat_sum)
1801 }
1802
1803 async fn get_wallet_summary(&self, dbtx: &mut DatabaseTransaction<'_>) -> WalletSummary {
1804 fn partition_peg_out_and_change(
1805 transactions: Vec<Transaction>,
1806 ) -> (Vec<TxOutputSummary>, Vec<TxOutputSummary>) {
1807 let mut peg_out_txos: Vec<TxOutputSummary> = Vec::new();
1808 let mut change_utxos: Vec<TxOutputSummary> = Vec::new();
1809
1810 for tx in transactions {
1811 let txid = tx.compute_txid();
1812
1813 let peg_out_output = tx
1816 .output
1817 .first()
1818 .expect("tx must contain withdrawal output");
1819
1820 let change_output = tx.output.last().expect("tx must contain change output");
1821
1822 peg_out_txos.push(TxOutputSummary {
1823 outpoint: bitcoin::OutPoint { txid, vout: 0 },
1824 amount: peg_out_output.value,
1825 });
1826
1827 change_utxos.push(TxOutputSummary {
1828 outpoint: bitcoin::OutPoint { txid, vout: 1 },
1829 amount: change_output.value,
1830 });
1831 }
1832
1833 (peg_out_txos, change_utxos)
1834 }
1835
1836 let spendable_utxos = self
1837 .available_utxos(dbtx)
1838 .await
1839 .iter()
1840 .map(|(utxo_key, spendable_utxo)| TxOutputSummary {
1841 outpoint: utxo_key.0,
1842 amount: spendable_utxo.amount,
1843 })
1844 .collect::<Vec<_>>();
1845
1846 let unsigned_transactions = dbtx
1848 .find_by_prefix(&UnsignedTransactionPrefixKey)
1849 .await
1850 .map(|(_tx_key, tx)| tx.psbt.unsigned_tx)
1851 .collect::<Vec<_>>()
1852 .await;
1853
1854 let unconfirmed_transactions = dbtx
1856 .find_by_prefix(&PendingTransactionPrefixKey)
1857 .await
1858 .map(|(_tx_key, tx)| tx.tx)
1859 .collect::<Vec<_>>()
1860 .await;
1861
1862 let (unsigned_peg_out_txos, unsigned_change_utxos) =
1863 partition_peg_out_and_change(unsigned_transactions);
1864
1865 let (unconfirmed_peg_out_txos, unconfirmed_change_utxos) =
1866 partition_peg_out_and_change(unconfirmed_transactions);
1867
1868 WalletSummary {
1869 spendable_utxos,
1870 unsigned_peg_out_txos,
1871 unsigned_change_utxos,
1872 unconfirmed_peg_out_txos,
1873 unconfirmed_change_utxos,
1874 }
1875 }
1876
1877 async fn is_utxo_confirmed(
1878 &self,
1879 dbtx: &mut DatabaseTransaction<'_>,
1880 outpoint: bitcoin::OutPoint,
1881 ) -> bool {
1882 dbtx.get_value(&UnspentTxOutKey(outpoint)).await.is_some()
1883 }
1884
1885 fn offline_wallet(&'_ self) -> StatelessWallet<'_> {
1886 StatelessWallet {
1887 descriptor: &self.cfg.consensus.peg_in_descriptor,
1888 secret_key: &self.cfg.private.peg_in_key,
1889 secp: &self.secp,
1890 }
1891 }
1892
1893 fn spawn_broadcast_pending_task(
1894 task_group: &TaskGroup,
1895 server_bitcoin_rpc_monitor: &ServerBitcoinRpcMonitor,
1896 db: &Database,
1897 broadcast_pending_notify: Arc<Notify>,
1898 ) {
1899 task_group.spawn_cancellable("broadcast pending", {
1900 let btc_rpc = server_bitcoin_rpc_monitor.clone();
1901 let db = db.clone();
1902 run_broadcast_pending_tx(db, btc_rpc, broadcast_pending_notify)
1903 });
1904 }
1905
1906 pub fn network_ui(&self) -> Network {
1908 self.cfg.consensus.network.0
1909 }
1910
1911 pub async fn consensus_block_count_ui(&self) -> u32 {
1913 self.consensus_block_count(&mut self.db.begin_transaction_nc().await)
1914 .await
1915 }
1916
1917 pub async fn consensus_feerate_ui(&self) -> Feerate {
1919 self.consensus_fee_rate(&mut self.db.begin_transaction_nc().await)
1920 .await
1921 }
1922
1923 pub async fn get_wallet_summary_ui(&self) -> WalletSummary {
1925 self.get_wallet_summary(&mut self.db.begin_transaction_nc().await)
1926 .await
1927 }
1928
1929 async fn graceful_shutdown(&self) {
1932 if let Err(e) = self
1933 .task_group
1934 .clone()
1935 .shutdown_join_all(Some(Duration::from_mins(1)))
1936 .await
1937 {
1938 panic!("Error while shutting down fedimintd task group: {e}");
1939 }
1940 }
1941
1942 async fn wait_for_finality_confs_or_shutdown(&self, consensus_block_count: u32) {
1948 let backoff = if is_running_in_test_env() {
1949 backoff_util::custom_backoff(
1951 Duration::from_millis(100),
1952 Duration::from_millis(100),
1953 Some(10 * 60),
1954 )
1955 } else {
1956 backoff_util::fibonacci_max_one_hour()
1958 };
1959
1960 let wait_for_finality_confs = || async {
1961 let our_chain_tip_block_count = self.get_block_count()?;
1962 let consensus_chain_tip_block_count =
1963 consensus_block_count + self.cfg.consensus.finality_delay;
1964
1965 if consensus_chain_tip_block_count <= our_chain_tip_block_count {
1966 Ok(())
1967 } else {
1968 Err(anyhow::anyhow!("not enough confirmations"))
1969 }
1970 };
1971
1972 if retry("wait_for_finality_confs", backoff, wait_for_finality_confs)
1973 .await
1974 .is_err()
1975 {
1976 self.graceful_shutdown().await;
1977 }
1978 }
1979
1980 fn spawn_peer_supported_consensus_version_task(
1981 api_client: DynModuleApi,
1982 task_group: &TaskGroup,
1983 our_peer_id: PeerId,
1984 ) -> watch::Receiver<Option<ModuleConsensusVersion>> {
1985 let (sender, receiver) = watch::channel(None);
1986 task_group.spawn_cancellable("fetch-peer-consensus-versions", async move {
1987 loop {
1988 let request_futures = api_client.all_peers().iter().filter_map(|&peer| {
1989 if peer == our_peer_id {
1990 return None;
1991 }
1992
1993 let api_client_inner = api_client.clone();
1994 Some(async move {
1995 api_client_inner
1996 .request_single_peer::<ModuleConsensusVersion>(
1997 SUPPORTED_MODULE_CONSENSUS_VERSION_ENDPOINT.to_owned(),
1998 ApiRequestErased::default(),
1999 peer,
2000 )
2001 .await
2002 .inspect(|res| debug!(
2003 target: LOG_MODULE_WALLET,
2004 %peer,
2005 %our_peer_id,
2006 ?res,
2007 "Fetched supported module consensus version from peer"
2008 ))
2009 .inspect_err(|err| warn!(
2010 target: LOG_MODULE_WALLET,
2011 %peer,
2012 err=%err.fmt_compact(),
2013 "Failed to fetch consensus version from peer"
2014 ))
2015 .ok()
2016 })
2017 });
2018
2019 let peer_consensus_versions = join_all(request_futures)
2020 .await
2021 .into_iter()
2022 .flatten()
2023 .collect::<Vec<_>>();
2024
2025 let sorted_consensus_versions = peer_consensus_versions
2026 .into_iter()
2027 .chain(std::iter::once(MODULE_CONSENSUS_VERSION))
2028 .sorted()
2029 .collect::<Vec<_>>();
2030 let all_peers_supported_version =
2031 if sorted_consensus_versions.len() == api_client.all_peers().len() {
2032 let min_supported_version = *sorted_consensus_versions
2033 .first()
2034 .expect("at least one element");
2035
2036 debug!(
2037 target: LOG_MODULE_WALLET,
2038 ?sorted_consensus_versions,
2039 "Fetched supported consensus versions from peers"
2040 );
2041
2042 Some(min_supported_version)
2043 } else {
2044 assert!(
2045 sorted_consensus_versions.len() <= api_client.all_peers().len(),
2046 "Too many peer responses",
2047 );
2048 trace!(
2049 target: LOG_MODULE_WALLET,
2050 ?sorted_consensus_versions,
2051 "Not all peers have reported their consensus version yet"
2052 );
2053 None
2054 };
2055
2056 #[allow(clippy::disallowed_methods)]
2057 if sender.send(all_peers_supported_version).is_err() {
2058 warn!(target: LOG_MODULE_WALLET, "Failed to send consensus version to watch channel, stopping task");
2059 break;
2060 }
2061
2062 sleep(next_poll_delay(all_peers_supported_version.is_some())).await;
2063 }
2064 });
2065 receiver
2066 }
2067}
2068
2069#[instrument(target = LOG_MODULE_WALLET, level = "debug", skip_all)]
2070pub async fn run_broadcast_pending_tx(
2071 db: Database,
2072 rpc: ServerBitcoinRpcMonitor,
2073 broadcast: Arc<Notify>,
2074) {
2075 loop {
2076 let _ = tokio::time::timeout(Duration::from_mins(1), broadcast.notified()).await;
2078 broadcast_pending_tx(db.begin_transaction_nc().await, &rpc).await;
2079 }
2080}
2081
2082pub async fn broadcast_pending_tx(
2083 mut dbtx: DatabaseTransaction<'_>,
2084 rpc: &ServerBitcoinRpcMonitor,
2085) {
2086 let pending_tx: Vec<PendingTransaction> = dbtx
2087 .find_by_prefix(&PendingTransactionPrefixKey)
2088 .await
2089 .map(|(_, val)| val)
2090 .collect::<Vec<_>>()
2091 .await;
2092 let rbf_txids: BTreeSet<Txid> = pending_tx
2093 .iter()
2094 .filter_map(|tx| tx.rbf.clone().map(|rbf| rbf.txid))
2095 .collect();
2096 if !pending_tx.is_empty() {
2097 debug!(
2098 target: LOG_MODULE_WALLET,
2099 "Broadcasting pending transactions (total={}, rbf={})",
2100 pending_tx.len(),
2101 rbf_txids.len()
2102 );
2103 }
2104
2105 for PendingTransaction { tx, .. } in pending_tx {
2106 if !rbf_txids.contains(&tx.compute_txid()) {
2107 debug!(
2108 target: LOG_MODULE_WALLET,
2109 tx = %tx.compute_txid(),
2110 weight = tx.weight().to_wu(),
2111 output = ?tx.output,
2112 "Broadcasting peg-out",
2113 );
2114 trace!(transaction = ?tx);
2115 if let Err(err) = rpc.submit_transaction(tx).await {
2116 debug!(
2117 target: LOG_MODULE_WALLET,
2118 err = %err.fmt_compact_anyhow(),
2119 "Error broadcasting peg-out transaction"
2120 );
2121 }
2122 }
2123 }
2124}
2125
2126#[derive(Debug, Clone, Copy, Eq, PartialEq)]
2133pub enum FeeArithmetic {
2134 Wrapping,
2136 Checked,
2138}
2139
2140impl FeeArithmetic {
2141 fn calculate_fee(
2142 self,
2143 fee_rate: Feerate,
2144 weight: u64,
2145 ) -> Result<bitcoin::Amount, WalletOutputError> {
2146 match self {
2147 FeeArithmetic::Wrapping => Ok(fee_rate.wrapping_calculate_fee(weight)),
2148 FeeArithmetic::Checked => fee_rate
2149 .checked_calculate_fee(weight)
2150 .ok_or(WalletOutputError::NotEnoughSpendableUTXO),
2151 }
2152 }
2153}
2154
2155struct StatelessWallet<'a> {
2156 descriptor: &'a Descriptor<CompressedPublicKey>,
2157 secret_key: &'a secp256k1::SecretKey,
2158 secp: &'a secp256k1::Secp256k1<secp256k1::All>,
2159}
2160
2161impl StatelessWallet<'_> {
2162 fn validate_tx(
2165 tx: &UnsignedTransaction,
2166 output: &WalletOutputV0,
2167 consensus_fee_rate: Feerate,
2168 network: Network,
2169 ) -> Result<(), WalletOutputError> {
2170 if let WalletOutputV0::PegOut(peg_out) = output
2171 && !peg_out.recipient.is_valid_for_network(network)
2172 {
2173 return Err(WalletOutputError::WrongNetwork(
2174 NetworkLegacyEncodingWrapper(network),
2175 NetworkLegacyEncodingWrapper(get_network_for_address(&peg_out.recipient)),
2176 ));
2177 }
2178
2179 if tx.peg_out_amount < tx.destination.minimal_non_dust() {
2181 return Err(WalletOutputError::PegOutUnderDustLimit);
2182 }
2183
2184 if tx.fees.fee_rate < consensus_fee_rate {
2186 return Err(WalletOutputError::PegOutFeeBelowConsensus(
2187 tx.fees.fee_rate,
2188 consensus_fee_rate,
2189 ));
2190 }
2191
2192 let fees = match output {
2195 WalletOutputV0::PegOut(pegout) => pegout.fees,
2196 WalletOutputV0::Rbf(rbf) => rbf.fees,
2197 };
2198 if fees.fee_rate.sats_per_kvb < u64::from(DEFAULT_MIN_RELAY_TX_FEE) {
2199 return Err(WalletOutputError::BelowMinRelayFee);
2200 }
2201
2202 if fees.total_weight != tx.fees.total_weight {
2204 return Err(WalletOutputError::TxWeightIncorrect(
2205 fees.total_weight,
2206 tx.fees.total_weight,
2207 ));
2208 }
2209
2210 Ok(())
2211 }
2212
2213 #[allow(clippy::too_many_arguments)]
2223 fn create_tx(
2224 &self,
2225 peg_out_amount: bitcoin::Amount,
2226 destination: ScriptBuf,
2227 mut included_utxos: Vec<(UTXOKey, SpendableUTXO)>,
2228 mut remaining_utxos: Vec<(UTXOKey, SpendableUTXO)>,
2229 mut fee_rate: Feerate,
2230 change_tweak: &[u8; 33],
2231 rbf: Option<Rbf>,
2232 fee_arithmetic: FeeArithmetic,
2233 ) -> Result<UnsignedTransaction, WalletOutputError> {
2234 if peg_out_amount > bitcoin::Amount::MAX_MONEY {
2239 return Err(WalletOutputError::NotEnoughSpendableUTXO);
2240 }
2241
2242 if let Some(rbf) = &rbf {
2244 fee_rate.sats_per_kvb = fee_rate
2245 .sats_per_kvb
2246 .saturating_add(rbf.fees.fee_rate.sats_per_kvb);
2247 }
2248
2249 let change_script = self.derive_script(change_tweak);
2257 let out_weight = (destination.len() * 4 + 1 + 32
2258 + 1 + change_script.len() * 4 + 32) as u64; let mut total_weight = 16 + 12 + 12 + out_weight + 16; #[allow(deprecated)]
2269 let max_input_weight = (self
2270 .descriptor
2271 .max_satisfaction_weight()
2272 .expect("is satisfyable") +
2273 128 + 16 + 16) as u64; included_utxos.sort_by_key(|(_, utxo)| utxo.amount);
2279 remaining_utxos.sort_by_key(|(_, utxo)| utxo.amount);
2280 included_utxos.extend(remaining_utxos);
2281
2282 let mut total_selected_value = bitcoin::Amount::from_sat(0);
2284 let mut selected_utxos: Vec<(UTXOKey, SpendableUTXO)> = vec![];
2285 let mut fees = fee_arithmetic.calculate_fee(fee_rate, total_weight)?;
2286
2287 loop {
2288 let target = peg_out_amount
2293 .checked_add(change_script.minimal_non_dust())
2294 .and_then(|target| target.checked_add(fees))
2295 .ok_or(WalletOutputError::NotEnoughSpendableUTXO)?;
2296
2297 if total_selected_value >= target {
2298 break;
2299 }
2300
2301 let Some((utxo_key, utxo)) = included_utxos.pop() else {
2302 return Err(WalletOutputError::NotEnoughSpendableUTXO); };
2304
2305 total_selected_value += utxo.amount;
2306 total_weight += max_input_weight;
2307 fees = fee_arithmetic.calculate_fee(fee_rate, total_weight)?;
2308 selected_utxos.push((utxo_key, utxo));
2309 }
2310
2311 let change = total_selected_value - fees - peg_out_amount;
2314 let output: Vec<TxOut> = vec![
2315 TxOut {
2316 value: peg_out_amount,
2317 script_pubkey: destination.clone(),
2318 },
2319 TxOut {
2320 value: change,
2321 script_pubkey: change_script,
2322 },
2323 ];
2324 let mut change_out = bitcoin::psbt::Output::default();
2325 change_out
2326 .proprietary
2327 .insert(proprietary_tweak_key(), change_tweak.to_vec());
2328
2329 info!(
2330 target: LOG_MODULE_WALLET,
2331 inputs = selected_utxos.len(),
2332 input_sats = total_selected_value.to_sat(),
2333 peg_out_sats = peg_out_amount.to_sat(),
2334 ?total_weight,
2335 fees_sats = fees.to_sat(),
2336 fee_rate = fee_rate.sats_per_kvb,
2337 change_sats = change.to_sat(),
2338 "Creating peg-out tx",
2339 );
2340
2341 let transaction = Transaction {
2342 version: bitcoin::transaction::Version(2),
2343 lock_time: LockTime::ZERO,
2344 input: selected_utxos
2345 .iter()
2346 .map(|(utxo_key, _utxo)| TxIn {
2347 previous_output: utxo_key.0,
2348 script_sig: Default::default(),
2349 sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
2350 witness: bitcoin::Witness::new(),
2351 })
2352 .collect(),
2353 output,
2354 };
2355 info!(
2356 target: LOG_MODULE_WALLET,
2357 txid = %transaction.compute_txid(), "Creating peg-out tx"
2358 );
2359
2360 let psbt = Psbt {
2363 unsigned_tx: transaction,
2364 version: 0,
2365 xpub: Default::default(),
2366 proprietary: Default::default(),
2367 unknown: Default::default(),
2368 inputs: selected_utxos
2369 .iter()
2370 .map(|(_utxo_key, utxo)| {
2371 let script_pubkey = self
2372 .descriptor
2373 .tweak(&utxo.tweak, self.secp)
2374 .script_pubkey();
2375 Input {
2376 non_witness_utxo: None,
2377 witness_utxo: Some(TxOut {
2378 value: utxo.amount,
2379 script_pubkey,
2380 }),
2381 partial_sigs: Default::default(),
2382 sighash_type: None,
2383 redeem_script: None,
2384 witness_script: Some(
2385 self.descriptor
2386 .tweak(&utxo.tweak, self.secp)
2387 .script_code()
2388 .expect("Failed to tweak descriptor"),
2389 ),
2390 bip32_derivation: Default::default(),
2391 final_script_sig: None,
2392 final_script_witness: None,
2393 ripemd160_preimages: Default::default(),
2394 sha256_preimages: Default::default(),
2395 hash160_preimages: Default::default(),
2396 hash256_preimages: Default::default(),
2397 proprietary: vec![(proprietary_tweak_key(), utxo.tweak.to_vec())]
2398 .into_iter()
2399 .collect(),
2400 tap_key_sig: Default::default(),
2401 tap_script_sigs: Default::default(),
2402 tap_scripts: Default::default(),
2403 tap_key_origins: Default::default(),
2404 tap_internal_key: Default::default(),
2405 tap_merkle_root: Default::default(),
2406 unknown: Default::default(),
2407 }
2408 })
2409 .collect(),
2410 outputs: vec![Default::default(), change_out],
2411 };
2412
2413 Ok(UnsignedTransaction {
2414 psbt,
2415 signatures: vec![],
2416 change,
2417 fees: PegOutFees {
2418 fee_rate,
2419 total_weight,
2420 },
2421 destination,
2422 selected_utxos,
2423 peg_out_amount,
2424 rbf,
2425 })
2426 }
2427
2428 fn sign_psbt(&self, psbt: &mut Psbt) {
2429 let mut tx_hasher = SighashCache::new(&psbt.unsigned_tx);
2430
2431 for (idx, (psbt_input, _tx_input)) in psbt
2432 .inputs
2433 .iter_mut()
2434 .zip(psbt.unsigned_tx.input.iter())
2435 .enumerate()
2436 {
2437 let tweaked_secret = {
2438 let tweak = psbt_input
2439 .proprietary
2440 .get(&proprietary_tweak_key())
2441 .expect("Malformed PSBT: expected tweak");
2442
2443 self.secret_key.tweak(tweak, self.secp)
2444 };
2445
2446 let tx_hash = tx_hasher
2447 .p2wsh_signature_hash(
2448 idx,
2449 psbt_input
2450 .witness_script
2451 .as_ref()
2452 .expect("Missing witness script"),
2453 psbt_input
2454 .witness_utxo
2455 .as_ref()
2456 .expect("Missing UTXO")
2457 .value,
2458 EcdsaSighashType::All,
2459 )
2460 .expect("Failed to create segwit sighash");
2461
2462 let signature = self.secp.sign_ecdsa(
2463 &Message::from_digest_slice(&tx_hash[..]).unwrap(),
2464 &tweaked_secret,
2465 );
2466
2467 psbt_input.partial_sigs.insert(
2468 bitcoin::PublicKey {
2469 compressed: true,
2470 inner: secp256k1::PublicKey::from_secret_key(self.secp, &tweaked_secret),
2471 },
2472 EcdsaSig::sighash_all(signature),
2473 );
2474 }
2475 }
2476
2477 fn derive_script(&self, tweak: &[u8]) -> ScriptBuf {
2478 struct CompressedPublicKeyTranslator<'t, 's, Ctx: Verification> {
2479 tweak: &'t [u8],
2480 secp: &'s Secp256k1<Ctx>,
2481 }
2482
2483 impl<Ctx: Verification>
2484 miniscript::Translator<CompressedPublicKey, CompressedPublicKey, Infallible>
2485 for CompressedPublicKeyTranslator<'_, '_, Ctx>
2486 {
2487 fn pk(&mut self, pk: &CompressedPublicKey) -> Result<CompressedPublicKey, Infallible> {
2488 let hashed_tweak = {
2489 let mut hasher = HmacEngine::<sha256::Hash>::new(&pk.key.serialize()[..]);
2490 hasher.input(self.tweak);
2491 Hmac::from_engine(hasher).to_byte_array()
2492 };
2493
2494 Ok(CompressedPublicKey {
2495 key: pk
2496 .key
2497 .add_exp_tweak(
2498 self.secp,
2499 &Scalar::from_be_bytes(hashed_tweak).expect("can't fail"),
2500 )
2501 .expect("tweaking failed"),
2502 })
2503 }
2504 translate_hash_fail!(CompressedPublicKey, CompressedPublicKey, Infallible);
2505 }
2506
2507 let descriptor = self
2508 .descriptor
2509 .translate_pk(&mut CompressedPublicKeyTranslator {
2510 tweak,
2511 secp: self.secp,
2512 })
2513 .expect("can't fail");
2514
2515 descriptor.script_pubkey()
2516 }
2517}
2518
2519pub fn nonce_from_idx(nonce_idx: u64) -> [u8; 33] {
2520 let mut nonce: [u8; 33] = [0; 33];
2521 nonce[0] = 0x02;
2523 nonce[1..].copy_from_slice(&nonce_idx.consensus_hash::<bitcoin::hashes::sha256::Hash>()[..]);
2524
2525 nonce
2526}
2527
2528#[derive(Clone, Debug, Encodable, Decodable)]
2530pub struct PendingTransaction {
2531 pub tx: bitcoin::Transaction,
2532 pub tweak: [u8; 33],
2533 pub change: bitcoin::Amount,
2534 pub destination: ScriptBuf,
2535 pub fees: PegOutFees,
2536 pub selected_utxos: Vec<(UTXOKey, SpendableUTXO)>,
2537 pub peg_out_amount: bitcoin::Amount,
2538 pub rbf: Option<Rbf>,
2539}
2540
2541impl Serialize for PendingTransaction {
2542 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2543 where
2544 S: serde::Serializer,
2545 {
2546 if serializer.is_human_readable() {
2547 serializer.serialize_str(&self.consensus_encode_to_hex())
2548 } else {
2549 serializer.serialize_bytes(&self.consensus_encode_to_vec())
2550 }
2551 }
2552}
2553
2554#[derive(Clone, Debug, Eq, PartialEq, Encodable, Decodable)]
2557pub struct UnsignedTransaction {
2558 pub psbt: Psbt,
2559 pub signatures: Vec<(PeerId, PegOutSignatureItem)>,
2560 pub change: bitcoin::Amount,
2561 pub fees: PegOutFees,
2562 pub destination: ScriptBuf,
2563 pub selected_utxos: Vec<(UTXOKey, SpendableUTXO)>,
2564 pub peg_out_amount: bitcoin::Amount,
2565 pub rbf: Option<Rbf>,
2566}
2567
2568impl Serialize for UnsignedTransaction {
2569 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2570 where
2571 S: serde::Serializer,
2572 {
2573 if serializer.is_human_readable() {
2574 serializer.serialize_str(&self.consensus_encode_to_hex())
2575 } else {
2576 serializer.serialize_bytes(&self.consensus_encode_to_vec())
2577 }
2578 }
2579}
2580
2581#[cfg(test)]
2582mod tests {
2583
2584 use std::str::FromStr;
2585
2586 use bitcoin::Network::{Bitcoin, Testnet};
2587 use bitcoin::hashes::Hash;
2588 use bitcoin::{Address, Amount, OutPoint, Txid, secp256k1};
2589 use fedimint_core::Feerate;
2590 use fedimint_core::encoding::btc::NetworkLegacyEncodingWrapper;
2591 use fedimint_core::envs::is_automatic_consensus_version_voting_disabled;
2592 use fedimint_wallet_common::{PegOut, PegOutFees, Rbf, WalletOutputV0};
2593 use miniscript::descriptor::Wsh;
2594
2595 use crate::common::PegInDescriptor;
2596 use crate::{
2597 CompressedPublicKey, FeeArithmetic, OsRng, SpendableUTXO, StatelessWallet, UTXOKey,
2598 WalletOutputError,
2599 };
2600
2601 #[test]
2610 fn peg_out_destination_can_collide_with_the_change_script() {
2611 let secp = secp256k1::Secp256k1::new();
2612
2613 let descriptor = PegInDescriptor::Wsh(
2614 Wsh::new_sortedmulti(
2615 3,
2616 (0..4)
2617 .map(|_| secp.generate_keypair(&mut OsRng))
2618 .map(|(_, key)| CompressedPublicKey { key })
2619 .collect(),
2620 )
2621 .unwrap(),
2622 );
2623
2624 let (secret_key, _) = secp.generate_keypair(&mut OsRng);
2625
2626 let wallet = StatelessWallet {
2627 descriptor: &descriptor,
2628 secret_key: &secret_key,
2629 secp: &secp,
2630 };
2631
2632 let change_tweak = crate::nonce_from_idx(0);
2633 let change_script = wallet.derive_script(&change_tweak);
2634
2635 let tx = wallet
2636 .create_tx(
2637 Amount::from_sat(1000),
2638 change_script.clone(),
2639 vec![],
2640 vec![(
2641 UTXOKey(OutPoint::null()),
2642 SpendableUTXO {
2643 tweak: [0; 33],
2644 amount: bitcoin::Amount::from_sat(100_000),
2645 },
2646 )],
2647 Feerate { sats_per_kvb: 1000 },
2648 &change_tweak,
2649 None,
2650 FeeArithmetic::Checked,
2651 )
2652 .expect("tx creation succeeds");
2653
2654 let matching = tx
2655 .psbt
2656 .unsigned_tx
2657 .output
2658 .iter()
2659 .filter(|o| o.script_pubkey == change_script)
2660 .count();
2661
2662 assert_eq!(
2663 matching, 2,
2664 "both the destination and the change output carry the change script"
2665 );
2666 }
2667
2668 #[test]
2673 fn fee_arithmetic_rejects_an_unpayable_rate_only_once_active() {
2674 let secp = secp256k1::Secp256k1::new();
2675
2676 let descriptor = PegInDescriptor::Wsh(
2677 Wsh::new_sortedmulti(
2678 3,
2679 (0..4)
2680 .map(|_| secp.generate_keypair(&mut OsRng))
2681 .map(|(_, key)| CompressedPublicKey { key })
2682 .collect(),
2683 )
2684 .unwrap(),
2685 );
2686
2687 let absurd = Feerate {
2688 sats_per_kvb: u64::MAX,
2689 };
2690 let ordinary = Feerate { sats_per_kvb: 1000 };
2691
2692 assert_eq!(
2693 FeeArithmetic::Checked.calculate_fee(absurd, 958),
2694 Err(WalletOutputError::NotEnoughSpendableUTXO),
2695 "an unpayable rate is rejected once 2.3 is active"
2696 );
2697 assert_eq!(
2698 FeeArithmetic::Wrapping.calculate_fee(absurd, 958),
2699 Ok(absurd.wrapping_calculate_fee(958)),
2700 "pre-2.3 behaviour is reproduced exactly, wrap and all"
2701 );
2702
2703 for arithmetic in [FeeArithmetic::Checked, FeeArithmetic::Wrapping] {
2704 assert_eq!(
2705 arithmetic.calculate_fee(ordinary, 958),
2706 Ok(ordinary.calculate_fee(958)),
2707 "an ordinary rate is unaffected in either regime"
2708 );
2709 }
2710
2711 let _ = descriptor;
2712 }
2713
2714 #[test]
2719 fn create_tx_rejects_amounts_that_cannot_exist_on_chain() {
2720 let secp = secp256k1::Secp256k1::new();
2721
2722 let descriptor = PegInDescriptor::Wsh(
2723 Wsh::new_sortedmulti(
2724 3,
2725 (0..4)
2726 .map(|_| secp.generate_keypair(&mut OsRng))
2727 .map(|(_, key)| CompressedPublicKey { key })
2728 .collect(),
2729 )
2730 .unwrap(),
2731 );
2732
2733 let (secret_key, _) = secp.generate_keypair(&mut OsRng);
2734
2735 let wallet = StatelessWallet {
2736 descriptor: &descriptor,
2737 secret_key: &secret_key,
2738 secp: &secp,
2739 };
2740
2741 let recipient = Address::from_str("32iVBEu4dxkUQk9dJbZUiBiQdmypcEyJRf").unwrap();
2742 let utxos = vec![(
2743 UTXOKey(OutPoint::null()),
2744 SpendableUTXO {
2745 tweak: [0; 33],
2746 amount: bitcoin::Amount::from_sat(100_000),
2747 },
2748 )];
2749
2750 for amount in [
2751 Amount::from_sat(u64::MAX),
2752 Amount::MAX_MONEY + Amount::from_sat(1),
2753 ] {
2754 let tx = wallet.create_tx(
2755 amount,
2756 recipient.clone().assume_checked().script_pubkey(),
2757 vec![],
2758 utxos.clone(),
2759 Feerate { sats_per_kvb: 1000 },
2760 &[0; 33],
2761 None,
2762 FeeArithmetic::Checked,
2763 );
2764
2765 assert_eq!(tx, Err(WalletOutputError::NotEnoughSpendableUTXO));
2766 }
2767 }
2768
2769 #[test]
2770 fn create_tx_should_validate_amounts() {
2771 let secp = secp256k1::Secp256k1::new();
2772
2773 let descriptor = PegInDescriptor::Wsh(
2774 Wsh::new_sortedmulti(
2775 3,
2776 (0..4)
2777 .map(|_| secp.generate_keypair(&mut OsRng))
2778 .map(|(_, key)| CompressedPublicKey { key })
2779 .collect(),
2780 )
2781 .unwrap(),
2782 );
2783
2784 let (secret_key, _) = secp.generate_keypair(&mut OsRng);
2785
2786 let wallet = StatelessWallet {
2787 descriptor: &descriptor,
2788 secret_key: &secret_key,
2789 secp: &secp,
2790 };
2791
2792 let spendable = SpendableUTXO {
2793 tweak: [0; 33],
2794 amount: bitcoin::Amount::from_sat(3000),
2795 };
2796
2797 let recipient = Address::from_str("32iVBEu4dxkUQk9dJbZUiBiQdmypcEyJRf").unwrap();
2798
2799 let fee = Feerate { sats_per_kvb: 1000 };
2800 let weight = 875;
2801
2802 let tx = wallet.create_tx(
2807 Amount::from_sat(2452),
2808 recipient.clone().assume_checked().script_pubkey(),
2809 vec![],
2810 vec![(UTXOKey(OutPoint::null()), spendable.clone())],
2811 fee,
2812 &[0; 33],
2813 None,
2814 FeeArithmetic::Checked,
2815 );
2816 assert_eq!(tx, Err(WalletOutputError::NotEnoughSpendableUTXO));
2817
2818 let mut tx = wallet
2820 .create_tx(
2821 Amount::from_sat(1000),
2822 recipient.clone().assume_checked().script_pubkey(),
2823 vec![],
2824 vec![(UTXOKey(OutPoint::null()), spendable)],
2825 fee,
2826 &[0; 33],
2827 None,
2828 FeeArithmetic::Checked,
2829 )
2830 .expect("is ok");
2831
2832 let res = StatelessWallet::validate_tx(&tx, &rbf(fee.sats_per_kvb, 0), fee, Bitcoin);
2834 assert_eq!(res, Err(WalletOutputError::TxWeightIncorrect(0, weight)));
2835
2836 let res = StatelessWallet::validate_tx(&tx, &rbf(0, weight), fee, Bitcoin);
2838 assert_eq!(res, Err(WalletOutputError::BelowMinRelayFee));
2839
2840 let res = StatelessWallet::validate_tx(&tx, &rbf(fee.sats_per_kvb, weight), fee, Bitcoin);
2842 assert_eq!(res, Ok(()));
2843
2844 tx.fees = PegOutFees::new(0, weight);
2846 let res = StatelessWallet::validate_tx(&tx, &rbf(fee.sats_per_kvb, weight), fee, Bitcoin);
2847 assert_eq!(
2848 res,
2849 Err(WalletOutputError::PegOutFeeBelowConsensus(
2850 Feerate { sats_per_kvb: 0 },
2851 fee
2852 ))
2853 );
2854
2855 tx.peg_out_amount = bitcoin::Amount::ZERO;
2857 let res = StatelessWallet::validate_tx(&tx, &rbf(fee.sats_per_kvb, weight), fee, Bitcoin);
2858 assert_eq!(res, Err(WalletOutputError::PegOutUnderDustLimit));
2859
2860 let output = WalletOutputV0::PegOut(PegOut {
2862 recipient,
2863 amount: bitcoin::Amount::from_sat(1000),
2864 fees: PegOutFees::new(100, weight),
2865 });
2866 let res = StatelessWallet::validate_tx(&tx, &output, fee, Testnet);
2867 assert_eq!(
2868 res,
2869 Err(WalletOutputError::WrongNetwork(
2870 NetworkLegacyEncodingWrapper(Testnet),
2871 NetworkLegacyEncodingWrapper(Bitcoin)
2872 ))
2873 );
2874 }
2875
2876 fn rbf(sats_per_kvb: u64, total_weight: u64) -> WalletOutputV0 {
2877 WalletOutputV0::Rbf(Rbf {
2878 fees: PegOutFees::new(sats_per_kvb, total_weight),
2879 txid: Txid::all_zeros(),
2880 })
2881 }
2882
2883 #[test]
2884 fn automatic_vote_suppressed_when_env_set() {
2885 unsafe {
2886 std::env::set_var("FM_WALLET_DISABLE_AUTOMATIC_CONSENSUS_VERSION_VOTING", "1");
2887 }
2888 assert!(is_automatic_consensus_version_voting_disabled());
2889 unsafe {
2890 std::env::remove_var("FM_WALLET_DISABLE_AUTOMATIC_CONSENSUS_VERSION_VOTING");
2891 }
2892 }
2893
2894 #[test]
2895 fn automatic_vote_active_when_env_unset() {
2896 unsafe {
2897 std::env::remove_var("FM_WALLET_DISABLE_AUTOMATIC_CONSENSUS_VERSION_VOTING");
2898 }
2899 assert!(!is_automatic_consensus_version_voting_disabled());
2900 }
2901}