Skip to main content

fedimint_wallet_server/
lib.rs

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, HashMap};
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_rbf_withdrawal_enabled,
59    is_running_in_test_env,
60};
61use fedimint_core::module::audit::Audit;
62use fedimint_core::module::{
63    Amounts, ApiEndpoint, ApiError, ApiRequestErased, ApiVersion, CORE_CONSENSUS_VERSION,
64    CoreConsensusVersion, InputMeta, ModuleConsensusVersion, ModuleInit,
65    SupportedModuleApiVersions, TransactionItemAmounts, api_endpoint,
66};
67use fedimint_core::net::auth::check_auth;
68use fedimint_core::task::TaskGroup;
69#[cfg(not(target_family = "wasm"))]
70use fedimint_core::task::sleep;
71use fedimint_core::util::{FmtCompact, FmtCompactAnyhow as _, backoff_util, retry};
72use fedimint_core::{
73    Feerate, InPoint, NumPeersExt, OutPoint, PeerId, apply, async_trait_maybe_send,
74    get_network_for_address, push_db_key_items, push_db_pair_items,
75};
76use fedimint_logging::LOG_MODULE_WALLET;
77use fedimint_server_core::bitcoin_rpc::ServerBitcoinRpcMonitor;
78use fedimint_server_core::config::{PeerHandleOps, PeerHandleOpsExt};
79use fedimint_server_core::migration::ServerModuleDbMigrationFn;
80use fedimint_server_core::{
81    ConfigGenModuleArgs, EnvVarDoc, ServerModule, ServerModuleInit, ServerModuleInitArgs,
82};
83pub use fedimint_wallet_common as common;
84use fedimint_wallet_common::config::{FeeConsensus, WalletClientConfig, WalletConfig};
85use fedimint_wallet_common::endpoint_constants::{
86    ACTIVATE_CONSENSUS_VERSION_VOTING_ENDPOINT, BITCOIN_KIND_ENDPOINT, BITCOIN_RPC_CONFIG_ENDPOINT,
87    BLOCK_COUNT_ENDPOINT, BLOCK_COUNT_LOCAL_ENDPOINT, MODULE_CONSENSUS_VERSION_ENDPOINT,
88    PEG_OUT_FEES_ENDPOINT, RECOVERY_COUNT_ENDPOINT, RECOVERY_SLICE_ENDPOINT,
89    SUPPORTED_MODULE_CONSENSUS_VERSION_ENDPOINT, UTXO_CONFIRMED_ENDPOINT, WALLET_SUMMARY_ENDPOINT,
90};
91use fedimint_wallet_common::envs::FM_PORT_ESPLORA_ENV;
92use fedimint_wallet_common::keys::CompressedPublicKey;
93use fedimint_wallet_common::tweakable::Tweakable;
94use fedimint_wallet_common::{
95    MODULE_CONSENSUS_VERSION, Rbf, RecoveryItem, UnknownWalletInputVariantError, WalletInputError,
96    WalletOutputError, WalletOutputV0,
97};
98use futures::future::join_all;
99use futures::{FutureExt, StreamExt};
100use itertools::Itertools;
101use metrics::{
102    WALLET_INOUT_FEES_SATS, WALLET_INOUT_SATS, WALLET_PEGIN_FEES_SATS, WALLET_PEGIN_SATS,
103    WALLET_PEGOUT_FEES_SATS, WALLET_PEGOUT_SATS,
104};
105use miniscript::psbt::PsbtExt;
106use miniscript::{Descriptor, TranslatePk, translate_hash_fail};
107use rand::rngs::OsRng;
108use serde::Serialize;
109use strum::IntoEnumIterator;
110use tokio::sync::{Notify, watch};
111use tracing::{debug, info, instrument, trace, warn};
112
113use crate::db::{
114    BlockCountVoteKey, BlockCountVotePrefix, BlockHashKey, BlockHashKeyPrefix,
115    ClaimedPegInOutpointKey, ClaimedPegInOutpointPrefixKey, ConsensusVersionVoteKey,
116    ConsensusVersionVotePrefix, ConsensusVersionVotingActivationKey,
117    ConsensusVersionVotingActivationPrefix, DbKeyPrefix, FeeRateVoteKey, FeeRateVotePrefix,
118    PegOutBitcoinTransaction, PegOutBitcoinTransactionPrefix, PegOutNonceKey, PegOutTxSignatureCI,
119    PegOutTxSignatureCIPrefix, PendingTransactionKey, PendingTransactionPrefixKey, UTXOKey,
120    UTXOPrefixKey, UnsignedTransactionKey, UnsignedTransactionPrefixKey, UnspentTxOutKey,
121    UnspentTxOutPrefix, migrate_to_v1, migrate_to_v2,
122};
123use crate::metrics::WALLET_BLOCK_COUNT;
124
125mod metrics;
126
127#[derive(Debug, Clone)]
128pub struct WalletInit;
129
130impl ModuleInit for WalletInit {
131    type Common = WalletCommonInit;
132
133    async fn dump_database(
134        &self,
135        dbtx: &mut DatabaseTransaction<'_>,
136        prefix_names: Vec<String>,
137    ) -> Box<dyn Iterator<Item = (String, Box<dyn erased_serde::Serialize + Send>)> + '_> {
138        let mut wallet: BTreeMap<String, Box<dyn erased_serde::Serialize + Send>> = BTreeMap::new();
139        let filtered_prefixes = DbKeyPrefix::iter().filter(|f| {
140            prefix_names.is_empty() || prefix_names.contains(&f.to_string().to_lowercase())
141        });
142        for table in filtered_prefixes {
143            match table {
144                DbKeyPrefix::BlockHash => {
145                    push_db_key_items!(dbtx, BlockHashKeyPrefix, BlockHashKey, wallet, "Blocks");
146                }
147                DbKeyPrefix::BlockHashByHeight => {
148                    push_db_key_items!(
149                        dbtx,
150                        BlockHashByHeightKeyPrefix,
151                        BlockHashByHeightKey,
152                        wallet,
153                        "Blocks by height"
154                    );
155                }
156                DbKeyPrefix::PegOutBitcoinOutPoint => {
157                    push_db_pair_items!(
158                        dbtx,
159                        PegOutBitcoinTransactionPrefix,
160                        PegOutBitcoinTransaction,
161                        WalletOutputOutcome,
162                        wallet,
163                        "Peg Out Bitcoin Transaction"
164                    );
165                }
166                DbKeyPrefix::PegOutTxSigCi => {
167                    push_db_pair_items!(
168                        dbtx,
169                        PegOutTxSignatureCIPrefix,
170                        PegOutTxSignatureCI,
171                        Vec<secp256k1::ecdsa::Signature>,
172                        wallet,
173                        "Peg Out Transaction Signatures"
174                    );
175                }
176                DbKeyPrefix::PendingTransaction => {
177                    push_db_pair_items!(
178                        dbtx,
179                        PendingTransactionPrefixKey,
180                        PendingTransactionKey,
181                        PendingTransaction,
182                        wallet,
183                        "Pending Transactions"
184                    );
185                }
186                DbKeyPrefix::PegOutNonce => {
187                    if let Some(nonce) = dbtx.get_value(&PegOutNonceKey).await {
188                        wallet.insert("Peg Out Nonce".to_string(), Box::new(nonce));
189                    }
190                }
191                DbKeyPrefix::UnsignedTransaction => {
192                    push_db_pair_items!(
193                        dbtx,
194                        UnsignedTransactionPrefixKey,
195                        UnsignedTransactionKey,
196                        UnsignedTransaction,
197                        wallet,
198                        "Unsigned Transactions"
199                    );
200                }
201                DbKeyPrefix::Utxo => {
202                    push_db_pair_items!(
203                        dbtx,
204                        UTXOPrefixKey,
205                        UTXOKey,
206                        SpendableUTXO,
207                        wallet,
208                        "UTXOs"
209                    );
210                }
211                DbKeyPrefix::BlockCountVote => {
212                    push_db_pair_items!(
213                        dbtx,
214                        BlockCountVotePrefix,
215                        BlockCountVoteKey,
216                        u32,
217                        wallet,
218                        "Block Count Votes"
219                    );
220                }
221                DbKeyPrefix::FeeRateVote => {
222                    push_db_pair_items!(
223                        dbtx,
224                        FeeRateVotePrefix,
225                        FeeRateVoteKey,
226                        Feerate,
227                        wallet,
228                        "Fee Rate Votes"
229                    );
230                }
231                DbKeyPrefix::ClaimedPegInOutpoint => {
232                    push_db_pair_items!(
233                        dbtx,
234                        ClaimedPegInOutpointPrefixKey,
235                        PeggedInOutpointKey,
236                        (),
237                        wallet,
238                        "Claimed Peg-in Outpoint"
239                    );
240                }
241                DbKeyPrefix::ConsensusVersionVote => {
242                    push_db_pair_items!(
243                        dbtx,
244                        ConsensusVersionVotePrefix,
245                        ConsensusVersionVoteKey,
246                        ModuleConsensusVersion,
247                        wallet,
248                        "Consensus Version Votes"
249                    );
250                }
251                DbKeyPrefix::UnspentTxOut => {
252                    push_db_pair_items!(
253                        dbtx,
254                        UnspentTxOutPrefix,
255                        UnspentTxOutKey,
256                        TxOut,
257                        wallet,
258                        "Consensus Version Votes"
259                    );
260                }
261                DbKeyPrefix::ConsensusVersionVotingActivation => {
262                    push_db_pair_items!(
263                        dbtx,
264                        ConsensusVersionVotingActivationPrefix,
265                        ConsensusVersionVotingActivationKey,
266                        (),
267                        wallet,
268                        "Consensus Version Voting Activation Key"
269                    );
270                }
271                DbKeyPrefix::RecoveryItem => {
272                    push_db_pair_items!(
273                        dbtx,
274                        RecoveryItemKeyPrefix,
275                        RecoveryItemKey,
276                        RecoveryItem,
277                        wallet,
278                        "Recovery Items"
279                    );
280                }
281            }
282        }
283
284        Box::new(wallet.into_iter())
285    }
286}
287
288/// Default finality delay based on network
289fn default_finality_delay(network: Network) -> u32 {
290    match network {
291        Network::Bitcoin | Network::Regtest => 10,
292        Network::Testnet | Network::Signet | Network::Testnet4 => 2,
293    }
294}
295
296/// Default Bitcoin RPC config for clients
297fn default_client_bitcoin_rpc(network: Network) -> BitcoinRpcConfig {
298    let url = match network {
299        Network::Bitcoin => "https://mempool.space/api/".to_string(),
300        Network::Testnet => "https://mempool.space/testnet/api/".to_string(),
301        Network::Testnet4 => "https://mempool.space/testnet4/api/".to_string(),
302        Network::Signet => "https://mutinynet.com/api/".to_string(),
303        Network::Regtest => format!(
304            "http://127.0.0.1:{}/",
305            std::env::var(FM_PORT_ESPLORA_ENV).unwrap_or_else(|_| String::from("50002"))
306        ),
307    };
308
309    BitcoinRpcConfig {
310        kind: "esplora".to_string(),
311        url: fedimint_core::util::SafeUrl::parse(&url).expect("hardcoded URL is valid"),
312    }
313}
314
315#[apply(async_trait_maybe_send!)]
316impl ServerModuleInit for WalletInit {
317    type Module = Wallet;
318
319    fn versions(&self, _core: CoreConsensusVersion) -> &[ModuleConsensusVersion] {
320        &[MODULE_CONSENSUS_VERSION]
321    }
322
323    fn supported_api_versions(&self) -> SupportedModuleApiVersions {
324        SupportedModuleApiVersions::from_raw(
325            (CORE_CONSENSUS_VERSION.major, CORE_CONSENSUS_VERSION.minor),
326            (
327                MODULE_CONSENSUS_VERSION.major,
328                MODULE_CONSENSUS_VERSION.minor,
329            ),
330            &[(0, 2)],
331        )
332    }
333
334    fn is_enabled_by_default(&self) -> bool {
335        is_env_var_set_opt(FM_ENABLE_MODULE_WALLET_ENV).unwrap_or(true)
336    }
337
338    fn get_documented_env_vars(&self) -> Vec<EnvVarDoc> {
339        vec![
340            EnvVarDoc {
341                name: FM_ENABLE_MODULE_WALLET_ENV,
342                description: "Set to 0/false to disable the wallet (on-chain Bitcoin) module. Enabled by default.",
343            },
344            EnvVarDoc {
345                name: FM_WALLET_DISABLE_AUTOMATIC_CONSENSUS_VERSION_VOTING_ENV,
346                description: "Set to 1/true to disable automatic consensus version voting. Useful for testing and development.",
347            },
348            EnvVarDoc {
349                name: envs::FM_WALLET_FEERATE_MULTIPLIER_ENV,
350                description: "Multiplier applied to fee rate estimates (float, clamped 1.0–32.0). Defaults to 1.0.",
351            },
352            EnvVarDoc {
353                name: FM_WALLET_FEERATE_SOURCES_ENV,
354                description: "Semicolon-separated list of JSON API URLs (with optional `#<jq-filter>`) used as fee rate sources.",
355            },
356        ]
357    }
358
359    async fn init(&self, args: &ServerModuleInitArgs<Self>) -> anyhow::Result<Self::Module> {
360        for direction in ["incoming", "outgoing"] {
361            WALLET_INOUT_FEES_SATS
362                .with_label_values(&[direction])
363                .get_sample_count();
364            WALLET_INOUT_SATS
365                .with_label_values(&[direction])
366                .get_sample_count();
367        }
368        // Eagerly initialize metrics that trigger infrequently
369        WALLET_PEGIN_FEES_SATS.get_sample_count();
370        WALLET_PEGIN_SATS.get_sample_count();
371        WALLET_PEGOUT_SATS.get_sample_count();
372        WALLET_PEGOUT_FEES_SATS.get_sample_count();
373
374        Ok(Wallet::new(
375            args.cfg().to_typed()?,
376            args.db(),
377            args.task_group(),
378            args.our_peer_id(),
379            args.module_api().clone(),
380            args.server_bitcoin_rpc_monitor(),
381        )
382        .await?)
383    }
384
385    fn trusted_dealer_gen(
386        &self,
387        peers: &[PeerId],
388        args: &ConfigGenModuleArgs,
389    ) -> BTreeMap<PeerId, ServerModuleConfig> {
390        let secp = bitcoin::secp256k1::Secp256k1::new();
391        let finality_delay = default_finality_delay(args.network);
392        let client_default_bitcoin_rpc = default_client_bitcoin_rpc(args.network);
393
394        let btc_pegin_keys = peers
395            .iter()
396            .map(|&id| (id, secp.generate_keypair(&mut OsRng)))
397            .collect::<Vec<_>>();
398
399        let wallet_cfg: BTreeMap<PeerId, WalletConfig> = btc_pegin_keys
400            .iter()
401            .map(|(id, (sk, _))| {
402                let cfg = WalletConfig::new(
403                    btc_pegin_keys
404                        .iter()
405                        .map(|(peer_id, (_, pk))| (*peer_id, CompressedPublicKey { key: *pk }))
406                        .collect(),
407                    *sk,
408                    peers.to_num_peers().threshold(),
409                    args.network,
410                    finality_delay,
411                    client_default_bitcoin_rpc.clone(),
412                    FeeConsensus::default(),
413                );
414                (*id, cfg)
415            })
416            .collect();
417
418        wallet_cfg
419            .into_iter()
420            .map(|(k, v)| (k, v.to_erased()))
421            .collect()
422    }
423
424    async fn distributed_gen(
425        &self,
426        peers: &(dyn PeerHandleOps + Send + Sync),
427        args: &ConfigGenModuleArgs,
428    ) -> anyhow::Result<ServerModuleConfig> {
429        let secp = secp256k1::Secp256k1::new();
430        let (sk, pk) = secp.generate_keypair(&mut OsRng);
431        let our_key = CompressedPublicKey { key: pk };
432        let peer_peg_in_keys: BTreeMap<PeerId, CompressedPublicKey> = peers
433            .exchange_encodable(our_key.key)
434            .await?
435            .into_iter()
436            .map(|(k, key)| (k, CompressedPublicKey { key }))
437            .collect();
438
439        let finality_delay = default_finality_delay(args.network);
440        let client_default_bitcoin_rpc = default_client_bitcoin_rpc(args.network);
441
442        let wallet_cfg = WalletConfig::new(
443            peer_peg_in_keys,
444            sk,
445            peers.num_peers().threshold(),
446            args.network,
447            finality_delay,
448            client_default_bitcoin_rpc,
449            FeeConsensus::default(),
450        );
451
452        Ok(wallet_cfg.to_erased())
453    }
454
455    fn validate_config(&self, identity: &PeerId, config: ServerModuleConfig) -> anyhow::Result<()> {
456        let config = config.to_typed::<WalletConfig>()?;
457        let pubkey = secp256k1::PublicKey::from_secret_key_global(&config.private.peg_in_key);
458
459        if config
460            .consensus
461            .peer_peg_in_keys
462            .get(identity)
463            .ok_or_else(|| format_err!("Secret key doesn't match any public key"))?
464            != &CompressedPublicKey::new(pubkey)
465        {
466            bail!(" Bitcoin wallet private key doesn't match multisig pubkey");
467        }
468
469        Ok(())
470    }
471
472    fn get_client_config(
473        &self,
474        config: &ServerModuleConsensusConfig,
475    ) -> anyhow::Result<WalletClientConfig> {
476        let config = WalletConfigConsensus::from_erased(config)?;
477        Ok(WalletClientConfig {
478            peg_in_descriptor: config.peg_in_descriptor,
479            network: config.network,
480            fee_consensus: config.fee_consensus,
481            finality_delay: config.finality_delay,
482            default_bitcoin_rpc: config.client_default_bitcoin_rpc,
483        })
484    }
485
486    /// DB migrations to move from old to newer versions
487    fn get_database_migrations(
488        &self,
489    ) -> BTreeMap<DatabaseVersion, ServerModuleDbMigrationFn<Wallet>> {
490        let mut migrations: BTreeMap<DatabaseVersion, ServerModuleDbMigrationFn<Wallet>> =
491            BTreeMap::new();
492        migrations.insert(
493            DatabaseVersion(0),
494            Box::new(|ctx| migrate_to_v1(ctx).boxed()),
495        );
496        migrations.insert(
497            DatabaseVersion(1),
498            Box::new(|ctx| migrate_to_v2(ctx).boxed()),
499        );
500        migrations
501    }
502
503    fn used_db_prefixes(&self) -> Option<BTreeSet<u8>> {
504        Some(DbKeyPrefix::iter().map(|p| p as u8).collect())
505    }
506}
507
508#[apply(async_trait_maybe_send!)]
509impl ServerModule for Wallet {
510    type Common = WalletModuleTypes;
511    type Init = WalletInit;
512
513    async fn consensus_proposal<'a>(
514        &'a self,
515        dbtx: &mut DatabaseTransaction<'_>,
516    ) -> Vec<WalletConsensusItem> {
517        let mut items = dbtx
518            .find_by_prefix(&PegOutTxSignatureCIPrefix)
519            .await
520            .map(|(key, val)| {
521                WalletConsensusItem::PegOutSignature(PegOutSignatureItem {
522                    txid: key.0,
523                    signature: val,
524                })
525            })
526            .collect::<Vec<WalletConsensusItem>>()
527            .await;
528
529        // If we are unable to get a block count from the node we skip adding a block
530        // count vote to consensus items.
531        //
532        // The potential impact of not including the latest block count from our peer's
533        // node is delayed processing of change outputs for the federation, which is an
534        // acceptable risk since subsequent rounds of consensus will reattempt to fetch
535        // the latest block count.
536        match self.get_block_count() {
537            Ok(block_count) => {
538                let mut block_count_vote =
539                    block_count.saturating_sub(self.cfg.consensus.finality_delay);
540
541                let current_consensus_block_count = self.consensus_block_count(dbtx).await;
542
543                // This will prevent that more then five blocks are synced in a single database
544                // transaction if the federation was offline for a prolonged period of time.
545                if current_consensus_block_count != 0 {
546                    block_count_vote = min(
547                        block_count_vote,
548                        current_consensus_block_count
549                            + if is_running_in_test_env() {
550                                // We have some tests that mine *a lot* of blocks (empty)
551                                // and need them processed fast, so we raise the max.
552                                100
553                            } else {
554                                5
555                            },
556                    );
557                }
558
559                let current_vote = dbtx
560                    .get_value(&BlockCountVoteKey(self.our_peer_id))
561                    .await
562                    .unwrap_or(0);
563
564                trace!(
565                    target: LOG_MODULE_WALLET,
566                    ?current_vote,
567                    ?block_count_vote,
568                    ?block_count,
569                    ?current_consensus_block_count,
570                    "Proposing block count"
571                );
572
573                WALLET_BLOCK_COUNT.set(i64::from(block_count_vote));
574                items.push(WalletConsensusItem::BlockCount(block_count_vote));
575            }
576            Err(err) => {
577                warn!(target: LOG_MODULE_WALLET, err = %err.fmt_compact_anyhow(), "Can't update block count");
578            }
579        }
580
581        let fee_rate_proposal = self.get_fee_rate_opt();
582
583        items.push(WalletConsensusItem::Feerate(fee_rate_proposal));
584
585        // Consensus upgrade activation voting
586        let manual_vote = dbtx
587            .get_value(&ConsensusVersionVotingActivationKey)
588            .await
589            .map(|()| {
590                // TODO: allow voting on any version between the currently active and max
591                // supported one in case we support a too high one already
592                MODULE_CONSENSUS_VERSION
593            });
594
595        let active_consensus_version = self.consensus_module_consensus_version(dbtx).await;
596        let automatic_vote = if is_automatic_consensus_version_voting_disabled() {
597            None
598        } else {
599            self.peer_supported_consensus_version
600                .borrow()
601                .and_then(|supported_consensus_version| {
602                    // Only automatically vote if the commonly supported version is higher than the
603                    // currently active one
604                    (active_consensus_version < supported_consensus_version)
605                        .then_some(supported_consensus_version)
606                })
607        };
608
609        // Prioritizing automatic vote for now since the manual vote never resets. Once
610        // that is fixed this should be switched around.
611        if let Some(vote_version) = automatic_vote.or(manual_vote) {
612            items.push(WalletConsensusItem::ModuleConsensusVersion(vote_version));
613        }
614
615        items
616    }
617
618    async fn process_consensus_item<'a, 'b>(
619        &'a self,
620        dbtx: &mut DatabaseTransaction<'b>,
621        consensus_item: WalletConsensusItem,
622        peer: PeerId,
623    ) -> anyhow::Result<()> {
624        trace!(target: LOG_MODULE_WALLET, ?consensus_item, "Processing consensus item proposal");
625
626        match consensus_item {
627            WalletConsensusItem::BlockCount(block_count_vote) => {
628                let current_vote = dbtx.get_value(&BlockCountVoteKey(peer)).await.unwrap_or(0);
629
630                if block_count_vote < current_vote {
631                    warn!(target: LOG_MODULE_WALLET, ?peer, ?block_count_vote, "Block count vote is outdated");
632                }
633
634                ensure!(
635                    block_count_vote > current_vote,
636                    "Block count vote is redundant"
637                );
638
639                let old_consensus_block_count = self.consensus_block_count(dbtx).await;
640
641                dbtx.insert_entry(&BlockCountVoteKey(peer), &block_count_vote)
642                    .await;
643
644                let new_consensus_block_count = self.consensus_block_count(dbtx).await;
645
646                debug!(
647                    target: LOG_MODULE_WALLET,
648                    ?peer,
649                    ?current_vote,
650                    ?block_count_vote,
651                    ?old_consensus_block_count,
652                    ?new_consensus_block_count,
653                    "Received block count vote"
654                );
655
656                assert!(old_consensus_block_count <= new_consensus_block_count);
657
658                if new_consensus_block_count != old_consensus_block_count {
659                    // We do not sync blocks that predate the federation itself
660                    if old_consensus_block_count != 0 {
661                        self.sync_up_to_consensus_count(
662                            dbtx,
663                            old_consensus_block_count,
664                            new_consensus_block_count,
665                        )
666                        .await;
667                    } else {
668                        info!(
669                            target: LOG_MODULE_WALLET,
670                            ?old_consensus_block_count,
671                            ?new_consensus_block_count,
672                            "Not syncing up to consensus block count because we are at block 0"
673                        );
674                    }
675                }
676            }
677            WalletConsensusItem::Feerate(feerate) => {
678                if Some(feerate) == dbtx.insert_entry(&FeeRateVoteKey(peer), &feerate).await {
679                    bail!("Fee rate vote is redundant");
680                }
681            }
682            WalletConsensusItem::PegOutSignature(peg_out_signature) => {
683                let txid = peg_out_signature.txid;
684
685                if dbtx.get_value(&PendingTransactionKey(txid)).await.is_some() {
686                    bail!("Already received a threshold of valid signatures");
687                }
688
689                let mut unsigned = dbtx
690                    .get_value(&UnsignedTransactionKey(txid))
691                    .await
692                    .context("Unsigned transaction does not exist")?;
693
694                self.sign_peg_out_psbt(&mut unsigned.psbt, peer, &peg_out_signature)
695                    .context("Peg out signature is invalid")?;
696
697                dbtx.insert_entry(&UnsignedTransactionKey(txid), &unsigned)
698                    .await;
699
700                if let Ok(pending_tx) = self.finalize_peg_out_psbt(unsigned) {
701                    // We were able to finalize the transaction, so we will delete the
702                    // PSBT and instead keep the extracted tx for periodic transmission
703                    // as well as to accept the change into our wallet eventually once
704                    // it confirms.
705                    dbtx.insert_new_entry(&PendingTransactionKey(txid), &pending_tx)
706                        .await;
707
708                    dbtx.remove_entry(&PegOutTxSignatureCI(txid)).await;
709                    dbtx.remove_entry(&UnsignedTransactionKey(txid)).await;
710                    let broadcast_pending = self.broadcast_pending.clone();
711                    dbtx.on_commit(move || {
712                        broadcast_pending.notify_one();
713                    });
714                }
715            }
716            WalletConsensusItem::ModuleConsensusVersion(module_consensus_version) => {
717                let current_vote = dbtx
718                    .get_value(&ConsensusVersionVoteKey(peer))
719                    .await
720                    .unwrap_or(ModuleConsensusVersion::new(2, 0));
721
722                ensure!(
723                    module_consensus_version > current_vote,
724                    "Module consensus version vote is redundant"
725                );
726
727                dbtx.insert_entry(&ConsensusVersionVoteKey(peer), &module_consensus_version)
728                    .await;
729
730                assert!(
731                    self.consensus_module_consensus_version(dbtx).await <= MODULE_CONSENSUS_VERSION,
732                    "Wallet module does not support new consensus version, please upgrade the module"
733                );
734            }
735            WalletConsensusItem::Default { variant, .. } => {
736                panic!("Received wallet consensus item with unknown variant {variant}");
737            }
738        }
739
740        Ok(())
741    }
742
743    async fn process_input<'a, 'b, 'c>(
744        &'a self,
745        dbtx: &mut DatabaseTransaction<'c>,
746        input: &'b WalletInput,
747        _in_point: InPoint,
748    ) -> Result<InputMeta, WalletInputError> {
749        let (outpoint, tx_out, pub_key) = match input {
750            WalletInput::V0(input) => {
751                if !self.block_is_known(dbtx, input.proof_block()).await {
752                    return Err(WalletInputError::UnknownPegInProofBlock(
753                        input.proof_block(),
754                    ));
755                }
756
757                input.verify(&self.secp, &self.cfg.consensus.peg_in_descriptor)?;
758
759                debug!(target: LOG_MODULE_WALLET, outpoint = %input.outpoint(), "Claiming peg-in");
760
761                (input.0.outpoint(), input.tx_output(), input.tweak_key())
762            }
763            WalletInput::V1(input) => {
764                let input_tx_out = dbtx
765                    .get_value(&UnspentTxOutKey(input.outpoint))
766                    .await
767                    .ok_or(WalletInputError::UnknownUTXO)?;
768
769                if input_tx_out.script_pubkey
770                    != self
771                        .cfg
772                        .consensus
773                        .peg_in_descriptor
774                        .tweak(&input.tweak_key, secp256k1::SECP256K1)
775                        .script_pubkey()
776                {
777                    return Err(WalletInputError::WrongOutputScript);
778                }
779
780                // Verifying this is not strictly necessary for the server as the tx_out is only
781                // used in backup and recovery.
782                if input.tx_out != input_tx_out {
783                    return Err(WalletInputError::WrongTxOut);
784                }
785
786                (input.outpoint, input_tx_out, input.tweak_key)
787            }
788            WalletInput::Default { variant, .. } => {
789                return Err(WalletInputError::UnknownInputVariant(
790                    UnknownWalletInputVariantError { variant: *variant },
791                ));
792            }
793        };
794
795        if dbtx
796            .insert_entry(&ClaimedPegInOutpointKey(outpoint), &())
797            .await
798            .is_some()
799        {
800            return Err(WalletInputError::PegInAlreadyClaimed);
801        }
802
803        dbtx.insert_new_entry(
804            &UTXOKey(outpoint),
805            &SpendableUTXO {
806                tweak: pub_key.serialize(),
807                amount: tx_out.value,
808            },
809        )
810        .await;
811
812        let next_index = get_recovery_count(dbtx).await;
813        dbtx.insert_new_entry(
814            &RecoveryItemKey(next_index),
815            &RecoveryItem::Input {
816                outpoint,
817                script: tx_out.script_pubkey,
818            },
819        )
820        .await;
821
822        let amount = tx_out.value.into();
823
824        let fee = self.cfg.consensus.fee_consensus.peg_in_abs;
825
826        calculate_pegin_metrics(dbtx, amount, fee);
827
828        Ok(InputMeta {
829            amount: TransactionItemAmounts {
830                amounts: Amounts::new_bitcoin(amount),
831                fees: Amounts::new_bitcoin(fee),
832            },
833            pub_key,
834        })
835    }
836
837    async fn process_output<'a, 'b>(
838        &'a self,
839        dbtx: &mut DatabaseTransaction<'b>,
840        output: &'a WalletOutput,
841        out_point: OutPoint,
842    ) -> Result<TransactionItemAmounts, WalletOutputError> {
843        let output = output.ensure_v0_ref()?;
844
845        // In 0.4.0 we began preventing RBF withdrawals. Once we reach EoL support
846        // for 0.4.0, we can safely remove RBF withdrawal logic.
847        // see: https://github.com/fedimint/fedimint/issues/5453
848        if let WalletOutputV0::Rbf(_) = output {
849            // This exists as an escape hatch for any federations that successfully
850            // processed an RBF withdrawal due to having a single UTXO owned by the
851            // federation. If a peer needs to resync the federation's history, they can
852            // enable this variable until they've successfully synced, then restart with
853            // this disabled.
854            if is_rbf_withdrawal_enabled() {
855                warn!(target: LOG_MODULE_WALLET, "processing rbf withdrawal");
856            } else {
857                return Err(DEPRECATED_RBF_ERROR);
858            }
859        }
860
861        let change_tweak = self.consensus_nonce(dbtx).await;
862
863        let mut tx = self.create_peg_out_tx(dbtx, output, &change_tweak).await?;
864
865        let fee_rate = self.consensus_fee_rate(dbtx).await;
866
867        StatelessWallet::validate_tx(&tx, output, fee_rate, self.cfg.consensus.network.0)?;
868
869        self.offline_wallet().sign_psbt(&mut tx.psbt);
870
871        let txid = tx.psbt.unsigned_tx.compute_txid();
872
873        info!(
874            target: LOG_MODULE_WALLET,
875            %txid,
876            "Signing peg out",
877        );
878
879        let sigs = tx
880            .psbt
881            .inputs
882            .iter_mut()
883            .map(|input| {
884                assert_eq!(
885                    input.partial_sigs.len(),
886                    1,
887                    "There was already more than one (our) or no signatures in input"
888                );
889
890                // TODO: don't put sig into PSBT in the first place
891                // We actually take out our own signature so everyone finalizes the tx in the
892                // same epoch.
893                let sig = std::mem::take(&mut input.partial_sigs)
894                    .into_values()
895                    .next()
896                    .expect("asserted previously");
897
898                // We drop SIGHASH_ALL, because we always use that and it is only present in the
899                // PSBT for compatibility with other tools.
900                secp256k1::ecdsa::Signature::from_der(&sig.to_vec()[..sig.to_vec().len() - 1])
901                    .expect("we serialized it ourselves that way")
902            })
903            .collect::<Vec<_>>();
904
905        // Delete used UTXOs
906        for input in &tx.psbt.unsigned_tx.input {
907            dbtx.remove_entry(&UTXOKey(input.previous_output)).await;
908        }
909
910        dbtx.insert_new_entry(&UnsignedTransactionKey(txid), &tx)
911            .await;
912
913        dbtx.insert_new_entry(&PegOutTxSignatureCI(txid), &sigs)
914            .await;
915
916        dbtx.insert_new_entry(
917            &PegOutBitcoinTransaction(out_point),
918            &WalletOutputOutcome::new_v0(txid),
919        )
920        .await;
921        let amount: fedimint_core::Amount = output.amount().into();
922        let fee = self.cfg.consensus.fee_consensus.peg_out_abs;
923        calculate_pegout_metrics(dbtx, amount, fee);
924        Ok(TransactionItemAmounts {
925            amounts: Amounts::new_bitcoin(amount),
926            fees: Amounts::new_bitcoin(fee),
927        })
928    }
929
930    async fn output_status(
931        &self,
932        dbtx: &mut DatabaseTransaction<'_>,
933        out_point: OutPoint,
934    ) -> Option<WalletOutputOutcome> {
935        dbtx.get_value(&PegOutBitcoinTransaction(out_point)).await
936    }
937
938    async fn audit(
939        &self,
940        dbtx: &mut DatabaseTransaction<'_>,
941        audit: &mut Audit,
942        module_instance_id: ModuleInstanceId,
943    ) {
944        audit
945            .add_items(dbtx, module_instance_id, &UTXOPrefixKey, |_, v| {
946                v.amount.to_sat() as i64 * 1000
947            })
948            .await;
949        audit
950            .add_items(
951                dbtx,
952                module_instance_id,
953                &UnsignedTransactionPrefixKey,
954                |_, v| match v.rbf {
955                    None => v.change.to_sat() as i64 * 1000,
956                    Some(rbf) => rbf.fees.amount().to_sat() as i64 * -1000,
957                },
958            )
959            .await;
960        audit
961            .add_items(
962                dbtx,
963                module_instance_id,
964                &PendingTransactionPrefixKey,
965                |_, v| match v.rbf {
966                    None => v.change.to_sat() as i64 * 1000,
967                    Some(rbf) => rbf.fees.amount().to_sat() as i64 * -1000,
968                },
969            )
970            .await;
971    }
972
973    fn api_endpoints(&self) -> Vec<ApiEndpoint<Self>> {
974        vec![
975            api_endpoint! {
976                BLOCK_COUNT_ENDPOINT,
977                ApiVersion::new(0, 0),
978                async |module: &Wallet, context, _params: ()| -> u32 {
979                    let db = context.db();
980                    let mut dbtx = db.begin_transaction_nc().await;
981                    Ok(module.consensus_block_count(&mut dbtx).await)
982                }
983            },
984            api_endpoint! {
985                BLOCK_COUNT_LOCAL_ENDPOINT,
986                ApiVersion::new(0, 0),
987                async |module: &Wallet, _context, _params: ()| -> Option<u32> {
988                    Ok(module.get_block_count().ok())
989                }
990            },
991            api_endpoint! {
992                PEG_OUT_FEES_ENDPOINT,
993                ApiVersion::new(0, 0),
994                async |module: &Wallet, context, params: (Address<NetworkUnchecked>, u64)| -> Option<PegOutFees> {
995                    let (address, sats) = params;
996                    let db = context.db();
997                    let mut dbtx = db.begin_transaction_nc().await;
998                    let feerate = module.consensus_fee_rate(&mut dbtx).await;
999
1000                    // Since we are only calculating the tx size we can use an arbitrary dummy nonce.
1001                    let dummy_tweak = [0; 33];
1002
1003                    let tx = module.offline_wallet().create_tx(
1004                        bitcoin::Amount::from_sat(sats),
1005                        // Note: While calling `assume_checked()` is generally unwise, it's fine
1006                        // here since we're only returning a fee estimate, and we would still
1007                        // reject a transaction with the wrong network upon attempted peg-out.
1008                        address.assume_checked().script_pubkey(),
1009                        vec![],
1010                        module.available_utxos(&mut dbtx).await,
1011                        feerate,
1012                        &dummy_tweak,
1013                        None
1014                    );
1015
1016                    match tx {
1017                        Err(error) => {
1018                            // Usually from not enough spendable UTXOs
1019                            warn!(target: LOG_MODULE_WALLET, "Error returning peg-out fees {error}");
1020                            Ok(None)
1021                        }
1022                        Ok(tx) => Ok(Some(tx.fees))
1023                    }
1024                }
1025            },
1026            api_endpoint! {
1027                BITCOIN_KIND_ENDPOINT,
1028                ApiVersion::new(0, 1),
1029                async |module: &Wallet, _context, _params: ()| -> String {
1030                    Ok(module.btc_rpc.get_bitcoin_rpc_config().kind)
1031                }
1032            },
1033            api_endpoint! {
1034                BITCOIN_RPC_CONFIG_ENDPOINT,
1035                ApiVersion::new(0, 1),
1036                async |module: &Wallet, context, _params: ()| -> BitcoinRpcConfig {
1037                    check_auth(context)?;
1038                    let config = module.btc_rpc.get_bitcoin_rpc_config();
1039
1040                    // we need to remove auth, otherwise we'll send over the wire
1041                    let without_auth = config.url.clone().without_auth().map_err(|()| {
1042                        ApiError::server_error("Unable to remove auth from bitcoin config URL".to_string())
1043                    })?;
1044
1045                    Ok(BitcoinRpcConfig {
1046                        url: without_auth,
1047                        ..config
1048                    })
1049                }
1050            },
1051            api_endpoint! {
1052                WALLET_SUMMARY_ENDPOINT,
1053                ApiVersion::new(0, 1),
1054                async |module: &Wallet, context, _params: ()| -> WalletSummary {
1055                    let db = context.db();
1056                    let mut dbtx = db.begin_transaction_nc().await;
1057                    Ok(module.get_wallet_summary(&mut dbtx).await)
1058                }
1059            },
1060            api_endpoint! {
1061                MODULE_CONSENSUS_VERSION_ENDPOINT,
1062                ApiVersion::new(0, 2),
1063                async |module: &Wallet, context, _params: ()| -> ModuleConsensusVersion {
1064                    let db = context.db();
1065                    let mut dbtx = db.begin_transaction_nc().await;
1066                    Ok(module.consensus_module_consensus_version(&mut dbtx).await)
1067                }
1068            },
1069            api_endpoint! {
1070                SUPPORTED_MODULE_CONSENSUS_VERSION_ENDPOINT,
1071                ApiVersion::new(0, 2),
1072                async |_module: &Wallet, _context, _params: ()| -> ModuleConsensusVersion {
1073                    Ok(MODULE_CONSENSUS_VERSION)
1074                }
1075            },
1076            api_endpoint! {
1077                ACTIVATE_CONSENSUS_VERSION_VOTING_ENDPOINT,
1078                ApiVersion::new(0, 2),
1079                async |_module: &Wallet, context, _params: ()| -> () {
1080                    check_auth(context)?;
1081
1082                    let db = context.db();
1083                    let mut dbtx = db.begin_transaction().await;
1084                    dbtx.to_ref().insert_entry(&ConsensusVersionVotingActivationKey, &()).await;
1085                    dbtx.commit_tx_result().await?;
1086                    Ok(())
1087                }
1088            },
1089            api_endpoint! {
1090                UTXO_CONFIRMED_ENDPOINT,
1091                ApiVersion::new(0, 2),
1092                async |module: &Wallet, context, outpoint: bitcoin::OutPoint| -> bool {
1093                    let db = context.db();
1094                    let mut dbtx = db.begin_transaction_nc().await;
1095                    Ok(module.is_utxo_confirmed(&mut dbtx, outpoint).await)
1096                }
1097            },
1098            api_endpoint! {
1099                RECOVERY_COUNT_ENDPOINT,
1100                ApiVersion::new(0, 1),
1101                async |_module: &Wallet, context, _params: ()| -> u64 {
1102                    let db = context.db();
1103                    let mut dbtx = db.begin_transaction_nc().await;
1104                    Ok(get_recovery_count(&mut dbtx).await)
1105                }
1106            },
1107            api_endpoint! {
1108                RECOVERY_SLICE_ENDPOINT,
1109                ApiVersion::new(0, 1),
1110                async |_module: &Wallet, context, range: (u64, u64)| -> Vec<RecoveryItem> {
1111                    let db = context.db();
1112                    let mut dbtx = db.begin_transaction_nc().await;
1113                    Ok(get_recovery_slice(&mut dbtx, range).await)
1114                }
1115            },
1116        ]
1117    }
1118}
1119
1120async fn get_recovery_count(dbtx: &mut DatabaseTransaction<'_>) -> u64 {
1121    dbtx.find_by_prefix_sorted_descending(&RecoveryItemKeyPrefix)
1122        .await
1123        .next()
1124        .await
1125        .map_or(0, |entry| entry.0.0 + 1)
1126}
1127
1128async fn get_recovery_slice(
1129    dbtx: &mut DatabaseTransaction<'_>,
1130    range: (u64, u64),
1131) -> Vec<RecoveryItem> {
1132    dbtx.find_by_range(RecoveryItemKey(range.0)..RecoveryItemKey(range.1))
1133        .await
1134        .map(|entry| entry.1)
1135        .collect()
1136        .await
1137}
1138
1139fn calculate_pegin_metrics(
1140    dbtx: &mut DatabaseTransaction<'_>,
1141    amount: fedimint_core::Amount,
1142    fee: fedimint_core::Amount,
1143) {
1144    dbtx.on_commit(move || {
1145        WALLET_INOUT_SATS
1146            .with_label_values(&["incoming"])
1147            .observe(amount.sats_f64());
1148        WALLET_INOUT_FEES_SATS
1149            .with_label_values(&["incoming"])
1150            .observe(fee.sats_f64());
1151        WALLET_PEGIN_SATS.observe(amount.sats_f64());
1152        WALLET_PEGIN_FEES_SATS.observe(fee.sats_f64());
1153    });
1154}
1155
1156fn calculate_pegout_metrics(
1157    dbtx: &mut DatabaseTransaction<'_>,
1158    amount: fedimint_core::Amount,
1159    fee: fedimint_core::Amount,
1160) {
1161    dbtx.on_commit(move || {
1162        WALLET_INOUT_SATS
1163            .with_label_values(&["outgoing"])
1164            .observe(amount.sats_f64());
1165        WALLET_INOUT_FEES_SATS
1166            .with_label_values(&["outgoing"])
1167            .observe(fee.sats_f64());
1168        WALLET_PEGOUT_SATS.observe(amount.sats_f64());
1169        WALLET_PEGOUT_FEES_SATS.observe(fee.sats_f64());
1170    });
1171}
1172
1173#[derive(Debug)]
1174pub struct Wallet {
1175    cfg: WalletConfig,
1176    db: Database,
1177    secp: Secp256k1<All>,
1178    btc_rpc: ServerBitcoinRpcMonitor,
1179    our_peer_id: PeerId,
1180    /// Broadcasting pending txes can be triggered immediately with this
1181    broadcast_pending: Arc<Notify>,
1182    task_group: TaskGroup,
1183    /// Maximum consensus version supported by *all* our peers. Used to
1184    /// automatically activate new consensus versions as soon as everyone
1185    /// upgrades.
1186    peer_supported_consensus_version: watch::Receiver<Option<ModuleConsensusVersion>>,
1187}
1188
1189impl Wallet {
1190    pub async fn new(
1191        cfg: WalletConfig,
1192        db: &Database,
1193        task_group: &TaskGroup,
1194        our_peer_id: PeerId,
1195        module_api: DynModuleApi,
1196        server_bitcoin_rpc_monitor: ServerBitcoinRpcMonitor,
1197    ) -> anyhow::Result<Wallet> {
1198        let broadcast_pending = Arc::new(Notify::new());
1199        Self::spawn_broadcast_pending_task(
1200            task_group,
1201            &server_bitcoin_rpc_monitor,
1202            db,
1203            broadcast_pending.clone(),
1204        );
1205
1206        let peer_supported_consensus_version =
1207            Self::spawn_peer_supported_consensus_version_task(module_api, task_group, our_peer_id);
1208
1209        let status = retry("verify network", backoff_util::aggressive_backoff(), || {
1210            std::future::ready(
1211                server_bitcoin_rpc_monitor
1212                    .status()
1213                    .context("No connection to bitcoin rpc"),
1214            )
1215        })
1216        .await?;
1217
1218        ensure!(status.network == cfg.consensus.network.0, "Wrong Network");
1219
1220        let wallet = Wallet {
1221            cfg,
1222            db: db.clone(),
1223            secp: Default::default(),
1224            btc_rpc: server_bitcoin_rpc_monitor,
1225            our_peer_id,
1226            task_group: task_group.clone(),
1227            peer_supported_consensus_version,
1228            broadcast_pending,
1229        };
1230
1231        Ok(wallet)
1232    }
1233
1234    /// Try to attach signatures to a pending peg-out tx.
1235    fn sign_peg_out_psbt(
1236        &self,
1237        psbt: &mut Psbt,
1238        peer: PeerId,
1239        signature: &PegOutSignatureItem,
1240    ) -> Result<(), ProcessPegOutSigError> {
1241        let peer_key = self
1242            .cfg
1243            .consensus
1244            .peer_peg_in_keys
1245            .get(&peer)
1246            .expect("always called with valid peer id");
1247
1248        if psbt.inputs.len() != signature.signature.len() {
1249            return Err(ProcessPegOutSigError::WrongSignatureCount(
1250                psbt.inputs.len(),
1251                signature.signature.len(),
1252            ));
1253        }
1254
1255        let mut tx_hasher = SighashCache::new(&psbt.unsigned_tx);
1256        for (idx, (input, signature)) in psbt
1257            .inputs
1258            .iter_mut()
1259            .zip(signature.signature.iter())
1260            .enumerate()
1261        {
1262            let tx_hash = tx_hasher
1263                .p2wsh_signature_hash(
1264                    idx,
1265                    input
1266                        .witness_script
1267                        .as_ref()
1268                        .expect("Missing witness script"),
1269                    input.witness_utxo.as_ref().expect("Missing UTXO").value,
1270                    EcdsaSighashType::All,
1271                )
1272                .map_err(|_| ProcessPegOutSigError::SighashError)?;
1273
1274            let tweak = input
1275                .proprietary
1276                .get(&proprietary_tweak_key())
1277                .expect("we saved it with a tweak");
1278
1279            let tweaked_peer_key = peer_key.tweak(tweak, &self.secp);
1280            self.secp
1281                .verify_ecdsa(
1282                    &Message::from_digest_slice(&tx_hash[..]).unwrap(),
1283                    signature,
1284                    &tweaked_peer_key.key,
1285                )
1286                .map_err(|_| ProcessPegOutSigError::InvalidSignature)?;
1287
1288            if input
1289                .partial_sigs
1290                .insert(tweaked_peer_key.into(), EcdsaSig::sighash_all(*signature))
1291                .is_some()
1292            {
1293                // Should never happen since peers only sign a PSBT once
1294                return Err(ProcessPegOutSigError::DuplicateSignature);
1295            }
1296        }
1297        Ok(())
1298    }
1299
1300    fn finalize_peg_out_psbt(
1301        &self,
1302        mut unsigned: UnsignedTransaction,
1303    ) -> Result<PendingTransaction, ProcessPegOutSigError> {
1304        // We need to save the change output's tweak key to be able to access the funds
1305        // later on. The tweak is extracted here because the psbt is moved next
1306        // and not available anymore when the tweak is actually needed in the
1307        // end to be put into the batch on success.
1308        let change_tweak: [u8; 33] = unsigned
1309            .psbt
1310            .outputs
1311            .iter()
1312            .find_map(|output| output.proprietary.get(&proprietary_tweak_key()).cloned())
1313            .ok_or(ProcessPegOutSigError::MissingOrMalformedChangeTweak)?
1314            .try_into()
1315            .map_err(|_| ProcessPegOutSigError::MissingOrMalformedChangeTweak)?;
1316
1317        if let Err(error) = unsigned.psbt.finalize_mut(&self.secp) {
1318            return Err(ProcessPegOutSigError::ErrorFinalizingPsbt(error));
1319        }
1320
1321        let tx = unsigned.psbt.clone().extract_tx_unchecked_fee_rate();
1322
1323        Ok(PendingTransaction {
1324            tx,
1325            tweak: change_tweak,
1326            change: unsigned.change,
1327            destination: unsigned.destination,
1328            fees: unsigned.fees,
1329            selected_utxos: unsigned.selected_utxos,
1330            peg_out_amount: unsigned.peg_out_amount,
1331            rbf: unsigned.rbf,
1332        })
1333    }
1334
1335    fn get_block_count(&self) -> anyhow::Result<u32> {
1336        self.btc_rpc
1337            .status()
1338            .context("No bitcoin rpc connection")
1339            .and_then(|status| {
1340                status
1341                    .block_count
1342                    .try_into()
1343                    .map_err(|_| format_err!("Block count exceeds u32 limits"))
1344            })
1345    }
1346
1347    pub fn get_fee_rate_opt(&self) -> Feerate {
1348        // `get_feerate_multiplier` is clamped and can't be negative
1349        // feerate sources as clamped and can't be negative or too large
1350        #[allow(clippy::cast_precision_loss)]
1351        #[allow(clippy::cast_sign_loss)]
1352        Feerate {
1353            sats_per_kvb: ((self
1354                .btc_rpc
1355                .status()
1356                .map_or(self.cfg.consensus.default_fee, |status| status.fee_rate)
1357                .sats_per_kvb as f64
1358                * get_feerate_multiplier())
1359            .round()) as u64,
1360        }
1361    }
1362
1363    pub async fn consensus_block_count(&self, dbtx: &mut DatabaseTransaction<'_>) -> u32 {
1364        let peer_count = self.cfg.consensus.peer_peg_in_keys.to_num_peers().total();
1365
1366        let mut counts = dbtx
1367            .find_by_prefix(&BlockCountVotePrefix)
1368            .await
1369            .map(|entry| entry.1)
1370            .collect::<Vec<u32>>()
1371            .await;
1372
1373        assert!(counts.len() <= peer_count);
1374
1375        while counts.len() < peer_count {
1376            counts.push(0);
1377        }
1378
1379        counts.sort_unstable();
1380
1381        counts[peer_count / 2]
1382    }
1383
1384    pub async fn consensus_fee_rate(&self, dbtx: &mut DatabaseTransaction<'_>) -> Feerate {
1385        let peer_count = self.cfg.consensus.peer_peg_in_keys.to_num_peers().total();
1386
1387        let mut rates = dbtx
1388            .find_by_prefix(&FeeRateVotePrefix)
1389            .await
1390            .map(|(.., rate)| rate)
1391            .collect::<Vec<_>>()
1392            .await;
1393
1394        assert!(rates.len() <= peer_count);
1395
1396        while rates.len() < peer_count {
1397            rates.push(self.cfg.consensus.default_fee);
1398        }
1399
1400        rates.sort_unstable();
1401
1402        rates[peer_count / 2]
1403    }
1404
1405    async fn consensus_module_consensus_version(
1406        &self,
1407        dbtx: &mut DatabaseTransaction<'_>,
1408    ) -> ModuleConsensusVersion {
1409        let num_peers = self.cfg.consensus.peer_peg_in_keys.to_num_peers();
1410
1411        let mut versions = dbtx
1412            .find_by_prefix(&ConsensusVersionVotePrefix)
1413            .await
1414            .map(|entry| entry.1)
1415            .collect::<Vec<ModuleConsensusVersion>>()
1416            .await;
1417
1418        while versions.len() < num_peers.total() {
1419            versions.push(ModuleConsensusVersion::new(2, 0));
1420        }
1421
1422        assert_eq!(versions.len(), num_peers.total());
1423
1424        versions.sort_unstable();
1425
1426        assert!(versions.first() <= versions.last());
1427
1428        versions[num_peers.max_evil()]
1429    }
1430
1431    pub async fn consensus_nonce(&self, dbtx: &mut DatabaseTransaction<'_>) -> [u8; 33] {
1432        let nonce_idx = dbtx.get_value(&PegOutNonceKey).await.unwrap_or(0);
1433        dbtx.insert_entry(&PegOutNonceKey, &(nonce_idx + 1)).await;
1434
1435        nonce_from_idx(nonce_idx)
1436    }
1437
1438    async fn sync_up_to_consensus_count(
1439        &self,
1440        dbtx: &mut DatabaseTransaction<'_>,
1441        old_count: u32,
1442        new_count: u32,
1443    ) {
1444        let sync_start = fedimint_core::time::now();
1445        info!(
1446            target: LOG_MODULE_WALLET,
1447            old_count,
1448            new_count,
1449            blocks_to_go = new_count
1450                .checked_sub(old_count)
1451                .expect("new_count must be >= old_count"),
1452            "New block count consensus, initiating sync",
1453        );
1454
1455        // Before we can safely call our bitcoin backend to process the new consensus
1456        // count, we need to ensure we observed enough confirmations
1457        self.wait_for_finality_confs_or_shutdown(new_count).await;
1458
1459        for height in old_count..new_count {
1460            let block_start = fedimint_core::time::now();
1461            info!(
1462                target: LOG_MODULE_WALLET,
1463                height,
1464                "Processing block of height {height}",
1465            );
1466
1467            // TODO: use batching for mainnet syncing
1468            trace!(block = height, "Fetching block hash");
1469            let block_hash = retry("get_block_hash", backoff_util::background_backoff(), || {
1470                self.btc_rpc.get_block_hash(u64::from(height)) // TODO: use u64 for height everywhere
1471            })
1472            .await
1473            .expect("bitcoind rpc to get block hash");
1474
1475            let block = retry("get_block", backoff_util::background_backoff(), || {
1476                self.btc_rpc.get_block(&block_hash)
1477            })
1478            .await
1479            .expect("bitcoind rpc to get block");
1480
1481            if let Some(prev_block_height) = height.checked_sub(1) {
1482                if let Some(hash) = dbtx
1483                    .get_value(&BlockHashByHeightKey(prev_block_height))
1484                    .await
1485                {
1486                    assert_eq!(block.header.prev_blockhash, hash.0);
1487                } else {
1488                    warn!(
1489                        target: LOG_MODULE_WALLET,
1490                        %height,
1491                        %block_hash,
1492                        %prev_block_height,
1493                        prev_blockhash = %block.header.prev_blockhash,
1494                        "Missing previous block hash. This should only happen on the first processed block height."
1495                    );
1496                }
1497            }
1498
1499            if self.consensus_module_consensus_version(dbtx).await
1500                >= ModuleConsensusVersion::new(2, 2)
1501            {
1502                for transaction in &block.txdata {
1503                    // We maintain the subset of unspent P2WSH transaction outputs created
1504                    // since the module was running on the new consensus version, which might be
1505                    // the same time as the genesis session.
1506
1507                    for tx_in in &transaction.input {
1508                        dbtx.remove_entry(&UnspentTxOutKey(tx_in.previous_output))
1509                            .await;
1510                    }
1511
1512                    for (vout, tx_out) in transaction.output.iter().enumerate() {
1513                        let should_track_utxo = if self.cfg.consensus.peer_peg_in_keys.len() > 1 {
1514                            tx_out.script_pubkey.is_p2wsh()
1515                        } else {
1516                            tx_out.script_pubkey.is_p2wpkh()
1517                        };
1518
1519                        if should_track_utxo {
1520                            let outpoint = bitcoin::OutPoint {
1521                                txid: transaction.compute_txid(),
1522                                vout: vout as u32,
1523                            };
1524
1525                            dbtx.insert_new_entry(&UnspentTxOutKey(outpoint), tx_out)
1526                                .await;
1527                        }
1528                    }
1529                }
1530            }
1531
1532            let pending_transactions = dbtx
1533                .find_by_prefix(&PendingTransactionPrefixKey)
1534                .await
1535                .map(|(key, transaction)| (key.0, transaction))
1536                .collect::<HashMap<Txid, PendingTransaction>>()
1537                .await;
1538            let pending_transactions_len = pending_transactions.len();
1539
1540            debug!(
1541                target: LOG_MODULE_WALLET,
1542                ?height,
1543                ?pending_transactions_len,
1544                "Recognizing change UTXOs"
1545            );
1546            for (txid, tx) in &pending_transactions {
1547                let is_tx_in_block = block.txdata.iter().any(|tx| tx.compute_txid() == *txid);
1548
1549                if is_tx_in_block {
1550                    debug!(
1551                        target: LOG_MODULE_WALLET,
1552                        ?txid, ?height, ?block_hash, "Recognizing change UTXO"
1553                    );
1554                    self.recognize_change_utxo(dbtx, tx).await;
1555                } else {
1556                    debug!(
1557                        target: LOG_MODULE_WALLET,
1558                        ?txid,
1559                        ?height,
1560                        ?block_hash,
1561                        "Pending transaction not yet confirmed in this block"
1562                    );
1563                }
1564            }
1565
1566            dbtx.insert_new_entry(&BlockHashKey(block_hash), &()).await;
1567            dbtx.insert_new_entry(
1568                &BlockHashByHeightKey(height),
1569                &BlockHashByHeightValue(block_hash),
1570            )
1571            .await;
1572
1573            info!(
1574                target: LOG_MODULE_WALLET,
1575                height,
1576                ?block_hash,
1577                duration = ?block_start.elapsed().unwrap_or_default(),
1578                "Successfully processed block of height {height}",
1579            );
1580        }
1581
1582        info!(
1583            target: LOG_MODULE_WALLET,
1584            old_count,
1585            new_count,
1586            blocks_processed = new_count
1587                .checked_sub(old_count)
1588                .expect("new_count must be >= old_count"),
1589            duration = ?sync_start.elapsed().unwrap_or_default(),
1590            "Block count consensus sync complete",
1591        );
1592    }
1593
1594    /// Add a change UTXO to our spendable UTXO database after it was included
1595    /// in a block that we got consensus on.
1596    async fn recognize_change_utxo(
1597        &self,
1598        dbtx: &mut DatabaseTransaction<'_>,
1599        pending_tx: &PendingTransaction,
1600    ) {
1601        self.remove_rbf_transactions(dbtx, pending_tx).await;
1602
1603        let script_pk = self
1604            .cfg
1605            .consensus
1606            .peg_in_descriptor
1607            .tweak(&pending_tx.tweak, &self.secp)
1608            .script_pubkey();
1609        for (idx, output) in pending_tx.tx.output.iter().enumerate() {
1610            if output.script_pubkey == script_pk {
1611                dbtx.insert_entry(
1612                    &UTXOKey(bitcoin::OutPoint {
1613                        txid: pending_tx.tx.compute_txid(),
1614                        vout: idx as u32,
1615                    }),
1616                    &SpendableUTXO {
1617                        tweak: pending_tx.tweak,
1618                        amount: output.value,
1619                    },
1620                )
1621                .await;
1622            }
1623        }
1624    }
1625
1626    /// Removes the `PendingTransaction` and any transactions tied to it via RBF
1627    async fn remove_rbf_transactions(
1628        &self,
1629        dbtx: &mut DatabaseTransaction<'_>,
1630        pending_tx: &PendingTransaction,
1631    ) {
1632        let mut all_transactions: BTreeMap<Txid, PendingTransaction> = dbtx
1633            .find_by_prefix(&PendingTransactionPrefixKey)
1634            .await
1635            .map(|(key, val)| (key.0, val))
1636            .collect::<BTreeMap<Txid, PendingTransaction>>()
1637            .await;
1638
1639        // We need to search and remove all `PendingTransactions` invalidated by RBF
1640        let mut pending_to_remove = vec![pending_tx.clone()];
1641        while let Some(removed) = pending_to_remove.pop() {
1642            all_transactions.remove(&removed.tx.compute_txid());
1643            dbtx.remove_entry(&PendingTransactionKey(removed.tx.compute_txid()))
1644                .await;
1645
1646            // Search for tx that this `removed` has as RBF
1647            if let Some(rbf) = &removed.rbf
1648                && let Some(tx) = all_transactions.get(&rbf.txid)
1649            {
1650                pending_to_remove.push(tx.clone());
1651            }
1652
1653            // Search for tx that wanted to RBF the `removed` one
1654            for tx in all_transactions.values() {
1655                if let Some(rbf) = &tx.rbf
1656                    && rbf.txid == removed.tx.compute_txid()
1657                {
1658                    pending_to_remove.push(tx.clone());
1659                }
1660            }
1661        }
1662    }
1663
1664    async fn block_is_known(
1665        &self,
1666        dbtx: &mut DatabaseTransaction<'_>,
1667        block_hash: BlockHash,
1668    ) -> bool {
1669        dbtx.get_value(&BlockHashKey(block_hash)).await.is_some()
1670    }
1671
1672    async fn create_peg_out_tx(
1673        &self,
1674        dbtx: &mut DatabaseTransaction<'_>,
1675        output: &WalletOutputV0,
1676        change_tweak: &[u8; 33],
1677    ) -> Result<UnsignedTransaction, WalletOutputError> {
1678        match output {
1679            WalletOutputV0::PegOut(peg_out) => self.offline_wallet().create_tx(
1680                peg_out.amount,
1681                // Note: While calling `assume_checked()` is generally unwise, checking the
1682                // network here could be a consensus-breaking change. Ignoring the network
1683                // is fine here since we validate it in `process_output()`.
1684                peg_out.recipient.clone().assume_checked().script_pubkey(),
1685                vec![],
1686                self.available_utxos(dbtx).await,
1687                peg_out.fees.fee_rate,
1688                change_tweak,
1689                None,
1690            ),
1691            WalletOutputV0::Rbf(rbf) => {
1692                let tx = dbtx
1693                    .get_value(&PendingTransactionKey(rbf.txid))
1694                    .await
1695                    .ok_or(WalletOutputError::RbfTransactionIdNotFound)?;
1696
1697                self.offline_wallet().create_tx(
1698                    tx.peg_out_amount,
1699                    tx.destination,
1700                    tx.selected_utxos,
1701                    self.available_utxos(dbtx).await,
1702                    tx.fees.fee_rate,
1703                    change_tweak,
1704                    Some(rbf.clone()),
1705                )
1706            }
1707        }
1708    }
1709
1710    async fn available_utxos(
1711        &self,
1712        dbtx: &mut DatabaseTransaction<'_>,
1713    ) -> Vec<(UTXOKey, SpendableUTXO)> {
1714        dbtx.find_by_prefix(&UTXOPrefixKey)
1715            .await
1716            .collect::<Vec<(UTXOKey, SpendableUTXO)>>()
1717            .await
1718    }
1719
1720    pub async fn get_wallet_value(&self, dbtx: &mut DatabaseTransaction<'_>) -> bitcoin::Amount {
1721        let sat_sum = self
1722            .available_utxos(dbtx)
1723            .await
1724            .into_iter()
1725            .map(|(_, utxo)| utxo.amount.to_sat())
1726            .sum();
1727        bitcoin::Amount::from_sat(sat_sum)
1728    }
1729
1730    async fn get_wallet_summary(&self, dbtx: &mut DatabaseTransaction<'_>) -> WalletSummary {
1731        fn partition_peg_out_and_change(
1732            transactions: Vec<Transaction>,
1733        ) -> (Vec<TxOutputSummary>, Vec<TxOutputSummary>) {
1734            let mut peg_out_txos: Vec<TxOutputSummary> = Vec::new();
1735            let mut change_utxos: Vec<TxOutputSummary> = Vec::new();
1736
1737            for tx in transactions {
1738                let txid = tx.compute_txid();
1739
1740                // to identify outputs for the peg_out (idx = 0) and change (idx = 1), we lean
1741                // on how the wallet constructs the transaction
1742                let peg_out_output = tx
1743                    .output
1744                    .first()
1745                    .expect("tx must contain withdrawal output");
1746
1747                let change_output = tx.output.last().expect("tx must contain change output");
1748
1749                peg_out_txos.push(TxOutputSummary {
1750                    outpoint: bitcoin::OutPoint { txid, vout: 0 },
1751                    amount: peg_out_output.value,
1752                });
1753
1754                change_utxos.push(TxOutputSummary {
1755                    outpoint: bitcoin::OutPoint { txid, vout: 1 },
1756                    amount: change_output.value,
1757                });
1758            }
1759
1760            (peg_out_txos, change_utxos)
1761        }
1762
1763        let spendable_utxos = self
1764            .available_utxos(dbtx)
1765            .await
1766            .iter()
1767            .map(|(utxo_key, spendable_utxo)| TxOutputSummary {
1768                outpoint: utxo_key.0,
1769                amount: spendable_utxo.amount,
1770            })
1771            .collect::<Vec<_>>();
1772
1773        // constructed peg-outs without threshold signatures
1774        let unsigned_transactions = dbtx
1775            .find_by_prefix(&UnsignedTransactionPrefixKey)
1776            .await
1777            .map(|(_tx_key, tx)| tx.psbt.unsigned_tx)
1778            .collect::<Vec<_>>()
1779            .await;
1780
1781        // peg-outs with threshold signatures, awaiting finality delay confirmations
1782        let unconfirmed_transactions = dbtx
1783            .find_by_prefix(&PendingTransactionPrefixKey)
1784            .await
1785            .map(|(_tx_key, tx)| tx.tx)
1786            .collect::<Vec<_>>()
1787            .await;
1788
1789        let (unsigned_peg_out_txos, unsigned_change_utxos) =
1790            partition_peg_out_and_change(unsigned_transactions);
1791
1792        let (unconfirmed_peg_out_txos, unconfirmed_change_utxos) =
1793            partition_peg_out_and_change(unconfirmed_transactions);
1794
1795        WalletSummary {
1796            spendable_utxos,
1797            unsigned_peg_out_txos,
1798            unsigned_change_utxos,
1799            unconfirmed_peg_out_txos,
1800            unconfirmed_change_utxos,
1801        }
1802    }
1803
1804    async fn is_utxo_confirmed(
1805        &self,
1806        dbtx: &mut DatabaseTransaction<'_>,
1807        outpoint: bitcoin::OutPoint,
1808    ) -> bool {
1809        dbtx.get_value(&UnspentTxOutKey(outpoint)).await.is_some()
1810    }
1811
1812    fn offline_wallet(&'_ self) -> StatelessWallet<'_> {
1813        StatelessWallet {
1814            descriptor: &self.cfg.consensus.peg_in_descriptor,
1815            secret_key: &self.cfg.private.peg_in_key,
1816            secp: &self.secp,
1817        }
1818    }
1819
1820    fn spawn_broadcast_pending_task(
1821        task_group: &TaskGroup,
1822        server_bitcoin_rpc_monitor: &ServerBitcoinRpcMonitor,
1823        db: &Database,
1824        broadcast_pending_notify: Arc<Notify>,
1825    ) {
1826        task_group.spawn_cancellable("broadcast pending", {
1827            let btc_rpc = server_bitcoin_rpc_monitor.clone();
1828            let db = db.clone();
1829            run_broadcast_pending_tx(db, btc_rpc, broadcast_pending_notify)
1830        });
1831    }
1832
1833    /// Get the bitcoin network for UI display
1834    pub fn network_ui(&self) -> Network {
1835        self.cfg.consensus.network.0
1836    }
1837
1838    /// Get the current consensus block count for UI display
1839    pub async fn consensus_block_count_ui(&self) -> u32 {
1840        self.consensus_block_count(&mut self.db.begin_transaction_nc().await)
1841            .await
1842    }
1843
1844    /// Get the current consensus fee rate for UI display
1845    pub async fn consensus_feerate_ui(&self) -> Feerate {
1846        self.consensus_fee_rate(&mut self.db.begin_transaction_nc().await)
1847            .await
1848    }
1849
1850    /// Get the current wallet summary for UI display
1851    pub async fn get_wallet_summary_ui(&self) -> WalletSummary {
1852        self.get_wallet_summary(&mut self.db.begin_transaction_nc().await)
1853            .await
1854    }
1855
1856    /// Shutdown the task group shared throughout fedimintd, giving 60 seconds
1857    /// for other services to gracefully shutdown.
1858    async fn graceful_shutdown(&self) {
1859        if let Err(e) = self
1860            .task_group
1861            .clone()
1862            .shutdown_join_all(Some(Duration::from_mins(1)))
1863            .await
1864        {
1865            panic!("Error while shutting down fedimintd task group: {e}");
1866        }
1867    }
1868
1869    /// Returns once our bitcoin backend observes finality delay confirmations
1870    /// of the consensus block count. If we don't observe enough confirmations
1871    /// after one hour, we gracefully shutdown fedimintd. This is necessary
1872    /// since we can no longer participate in consensus if our bitcoin backend
1873    /// is unable to observe the same chain tip as our peers.
1874    async fn wait_for_finality_confs_or_shutdown(&self, consensus_block_count: u32) {
1875        let backoff = if is_running_in_test_env() {
1876            // every 100ms for 60s
1877            backoff_util::custom_backoff(
1878                Duration::from_millis(100),
1879                Duration::from_millis(100),
1880                Some(10 * 60),
1881            )
1882        } else {
1883            // every max 10s for 1 hour
1884            backoff_util::fibonacci_max_one_hour()
1885        };
1886
1887        let wait_for_finality_confs = || async {
1888            let our_chain_tip_block_count = self.get_block_count()?;
1889            let consensus_chain_tip_block_count =
1890                consensus_block_count + self.cfg.consensus.finality_delay;
1891
1892            if consensus_chain_tip_block_count <= our_chain_tip_block_count {
1893                Ok(())
1894            } else {
1895                Err(anyhow::anyhow!("not enough confirmations"))
1896            }
1897        };
1898
1899        if retry("wait_for_finality_confs", backoff, wait_for_finality_confs)
1900            .await
1901            .is_err()
1902        {
1903            self.graceful_shutdown().await;
1904        }
1905    }
1906
1907    fn spawn_peer_supported_consensus_version_task(
1908        api_client: DynModuleApi,
1909        task_group: &TaskGroup,
1910        our_peer_id: PeerId,
1911    ) -> watch::Receiver<Option<ModuleConsensusVersion>> {
1912        let (sender, receiver) = watch::channel(None);
1913        task_group.spawn_cancellable("fetch-peer-consensus-versions", async move {
1914            loop {
1915                let request_futures = api_client.all_peers().iter().filter_map(|&peer| {
1916                    if peer == our_peer_id {
1917                        return None;
1918                    }
1919
1920                    let api_client_inner = api_client.clone();
1921                    Some(async move {
1922                        api_client_inner
1923                            .request_single_peer::<ModuleConsensusVersion>(
1924                                SUPPORTED_MODULE_CONSENSUS_VERSION_ENDPOINT.to_owned(),
1925                                ApiRequestErased::default(),
1926                                peer,
1927                            )
1928                            .await
1929                            .inspect(|res| debug!(
1930                                target: LOG_MODULE_WALLET,
1931                                %peer,
1932                                %our_peer_id,
1933                                ?res,
1934                                "Fetched supported module consensus version from peer"
1935                            ))
1936                            .inspect_err(|err| warn!(
1937                                target: LOG_MODULE_WALLET,
1938                                 %peer,
1939                                 err=%err.fmt_compact(),
1940                                "Failed to fetch consensus version from peer"
1941                            ))
1942                            .ok()
1943                    })
1944                });
1945
1946                let peer_consensus_versions = join_all(request_futures)
1947                    .await
1948                    .into_iter()
1949                    .flatten()
1950                    .collect::<Vec<_>>();
1951
1952                let sorted_consensus_versions = peer_consensus_versions
1953                    .into_iter()
1954                    .chain(std::iter::once(MODULE_CONSENSUS_VERSION))
1955                    .sorted()
1956                    .collect::<Vec<_>>();
1957                let all_peers_supported_version =
1958                    if sorted_consensus_versions.len() == api_client.all_peers().len() {
1959                        let min_supported_version = *sorted_consensus_versions
1960                            .first()
1961                            .expect("at least one element");
1962
1963                        debug!(
1964                            target: LOG_MODULE_WALLET,
1965                            ?sorted_consensus_versions,
1966                            "Fetched supported consensus versions from peers"
1967                        );
1968
1969                        Some(min_supported_version)
1970                    } else {
1971                        assert!(
1972                            sorted_consensus_versions.len() <= api_client.all_peers().len(),
1973                            "Too many peer responses",
1974                        );
1975                        trace!(
1976                            target: LOG_MODULE_WALLET,
1977                            ?sorted_consensus_versions,
1978                            "Not all peers have reported their consensus version yet"
1979                        );
1980                        None
1981                    };
1982
1983                #[allow(clippy::disallowed_methods)]
1984                if sender.send(all_peers_supported_version).is_err() {
1985                    warn!(target: LOG_MODULE_WALLET, "Failed to send consensus version to watch channel, stopping task");
1986                    break;
1987                }
1988
1989                if is_running_in_test_env() {
1990                    // Even in tests we don't want to spam the federation with requests about it
1991                    sleep(Duration::from_secs(5)).await;
1992                } else {
1993                    sleep(Duration::from_mins(10)).await;
1994                }
1995            }
1996        });
1997        receiver
1998    }
1999}
2000
2001#[instrument(target = LOG_MODULE_WALLET, level = "debug", skip_all)]
2002pub async fn run_broadcast_pending_tx(
2003    db: Database,
2004    rpc: ServerBitcoinRpcMonitor,
2005    broadcast: Arc<Notify>,
2006) {
2007    loop {
2008        // Unless something new happened, we broadcast once a minute
2009        let _ = tokio::time::timeout(Duration::from_mins(1), broadcast.notified()).await;
2010        broadcast_pending_tx(db.begin_transaction_nc().await, &rpc).await;
2011    }
2012}
2013
2014pub async fn broadcast_pending_tx(
2015    mut dbtx: DatabaseTransaction<'_>,
2016    rpc: &ServerBitcoinRpcMonitor,
2017) {
2018    let pending_tx: Vec<PendingTransaction> = dbtx
2019        .find_by_prefix(&PendingTransactionPrefixKey)
2020        .await
2021        .map(|(_, val)| val)
2022        .collect::<Vec<_>>()
2023        .await;
2024    let rbf_txids: BTreeSet<Txid> = pending_tx
2025        .iter()
2026        .filter_map(|tx| tx.rbf.clone().map(|rbf| rbf.txid))
2027        .collect();
2028    if !pending_tx.is_empty() {
2029        debug!(
2030            target: LOG_MODULE_WALLET,
2031            "Broadcasting pending transactions (total={}, rbf={})",
2032            pending_tx.len(),
2033            rbf_txids.len()
2034        );
2035    }
2036
2037    for PendingTransaction { tx, .. } in pending_tx {
2038        if !rbf_txids.contains(&tx.compute_txid()) {
2039            debug!(
2040                target: LOG_MODULE_WALLET,
2041                tx = %tx.compute_txid(),
2042                weight = tx.weight().to_wu(),
2043                output = ?tx.output,
2044                "Broadcasting peg-out",
2045            );
2046            trace!(transaction = ?tx);
2047            if let Err(err) = rpc.submit_transaction(tx).await {
2048                debug!(
2049                    target: LOG_MODULE_WALLET,
2050                    err = %err.fmt_compact_anyhow(),
2051                    "Error broadcasting peg-out transaction"
2052                );
2053            }
2054        }
2055    }
2056}
2057
2058struct StatelessWallet<'a> {
2059    descriptor: &'a Descriptor<CompressedPublicKey>,
2060    secret_key: &'a secp256k1::SecretKey,
2061    secp: &'a secp256k1::Secp256k1<secp256k1::All>,
2062}
2063
2064impl StatelessWallet<'_> {
2065    /// Given a tx created from an `WalletOutput`, validate there will be no
2066    /// issues submitting the transaction to the Bitcoin network
2067    fn validate_tx(
2068        tx: &UnsignedTransaction,
2069        output: &WalletOutputV0,
2070        consensus_fee_rate: Feerate,
2071        network: Network,
2072    ) -> Result<(), WalletOutputError> {
2073        if let WalletOutputV0::PegOut(peg_out) = output
2074            && !peg_out.recipient.is_valid_for_network(network)
2075        {
2076            return Err(WalletOutputError::WrongNetwork(
2077                NetworkLegacyEncodingWrapper(network),
2078                NetworkLegacyEncodingWrapper(get_network_for_address(&peg_out.recipient)),
2079            ));
2080        }
2081
2082        // Validate the tx amount is over the dust limit
2083        if tx.peg_out_amount < tx.destination.minimal_non_dust() {
2084            return Err(WalletOutputError::PegOutUnderDustLimit);
2085        }
2086
2087        // Validate tx fee rate is above the consensus fee rate
2088        if tx.fees.fee_rate < consensus_fee_rate {
2089            return Err(WalletOutputError::PegOutFeeBelowConsensus(
2090                tx.fees.fee_rate,
2091                consensus_fee_rate,
2092            ));
2093        }
2094
2095        // Validate added fees are above the min relay tx fee
2096        // BIP-0125 requires 1 sat/vb for RBF by default (same as normal txs)
2097        let fees = match output {
2098            WalletOutputV0::PegOut(pegout) => pegout.fees,
2099            WalletOutputV0::Rbf(rbf) => rbf.fees,
2100        };
2101        if fees.fee_rate.sats_per_kvb < u64::from(DEFAULT_MIN_RELAY_TX_FEE) {
2102            return Err(WalletOutputError::BelowMinRelayFee);
2103        }
2104
2105        // Validate fees weight matches the actual weight
2106        if fees.total_weight != tx.fees.total_weight {
2107            return Err(WalletOutputError::TxWeightIncorrect(
2108                fees.total_weight,
2109                tx.fees.total_weight,
2110            ));
2111        }
2112
2113        Ok(())
2114    }
2115
2116    /// Attempts to create a tx ready to be signed from available UTXOs.
2117    //
2118    // * `peg_out_amount`: How much the peg-out should be
2119    // * `destination`: The address the user is pegging-out to
2120    // * `included_utxos`: UXTOs that must be included (for RBF)
2121    // * `remaining_utxos`: All other spendable UXTOs
2122    // * `fee_rate`: How much needs to be spent on fees
2123    // * `change_tweak`: How the federation can recognize it's change UTXO
2124    // * `rbf`: If this is an RBF transaction
2125    #[allow(clippy::too_many_arguments)]
2126    fn create_tx(
2127        &self,
2128        peg_out_amount: bitcoin::Amount,
2129        destination: ScriptBuf,
2130        mut included_utxos: Vec<(UTXOKey, SpendableUTXO)>,
2131        mut remaining_utxos: Vec<(UTXOKey, SpendableUTXO)>,
2132        mut fee_rate: Feerate,
2133        change_tweak: &[u8; 33],
2134        rbf: Option<Rbf>,
2135    ) -> Result<UnsignedTransaction, WalletOutputError> {
2136        // Add the rbf fees to the existing tx fees
2137        if let Some(rbf) = &rbf {
2138            fee_rate.sats_per_kvb += rbf.fees.fee_rate.sats_per_kvb;
2139        }
2140
2141        // When building a transaction we need to take care of two things:
2142        //  * We need enough input amount to fund all outputs
2143        //  * We need to keep an eye on the tx weight so we can factor the fees into out
2144        //    calculation
2145        // We then go on to calculate the base size of the transaction `total_weight`
2146        // and the maximum weight per added input which we will add every time
2147        // we select an input.
2148        let change_script = self.derive_script(change_tweak);
2149        let out_weight = (destination.len() * 4 + 1 + 32
2150            // Add change script weight, it's very likely to be needed if not we just overpay in fees
2151            + 1 // script len varint, 1 byte for all addresses we accept
2152            + change_script.len() * 4 // script len
2153            + 32) as u64; // value
2154        let mut total_weight = 16 + // version
2155            12 + // up to 2**16-1 inputs
2156            12 + // up to 2**16-1 outputs
2157            out_weight + // weight of all outputs
2158            16; // lock time
2159        // https://github.com/fedimint/fedimint/issues/4590
2160        #[allow(deprecated)]
2161        let max_input_weight = (self
2162            .descriptor
2163            .max_satisfaction_weight()
2164            .expect("is satisfyable") +
2165            128 + // TxOutHash
2166            16 + // TxOutIndex
2167            16) as u64; // sequence
2168
2169        // Ensure deterministic ordering of UTXOs for all peers
2170        included_utxos.sort_by_key(|(_, utxo)| utxo.amount);
2171        remaining_utxos.sort_by_key(|(_, utxo)| utxo.amount);
2172        included_utxos.extend(remaining_utxos);
2173
2174        // Finally we initialize our accumulator for selected input amounts
2175        let mut total_selected_value = bitcoin::Amount::from_sat(0);
2176        let mut selected_utxos: Vec<(UTXOKey, SpendableUTXO)> = vec![];
2177        let mut fees = fee_rate.calculate_fee(total_weight);
2178
2179        while total_selected_value < peg_out_amount + change_script.minimal_non_dust() + fees {
2180            match included_utxos.pop() {
2181                Some((utxo_key, utxo)) => {
2182                    total_selected_value += utxo.amount;
2183                    total_weight += max_input_weight;
2184                    fees = fee_rate.calculate_fee(total_weight);
2185                    selected_utxos.push((utxo_key, utxo));
2186                }
2187                _ => return Err(WalletOutputError::NotEnoughSpendableUTXO), // Not enough UTXOs
2188            }
2189        }
2190
2191        // We always pay ourselves change back to ensure that we don't lose anything due
2192        // to dust
2193        let change = total_selected_value - fees - peg_out_amount;
2194        let output: Vec<TxOut> = vec![
2195            TxOut {
2196                value: peg_out_amount,
2197                script_pubkey: destination.clone(),
2198            },
2199            TxOut {
2200                value: change,
2201                script_pubkey: change_script,
2202            },
2203        ];
2204        let mut change_out = bitcoin::psbt::Output::default();
2205        change_out
2206            .proprietary
2207            .insert(proprietary_tweak_key(), change_tweak.to_vec());
2208
2209        info!(
2210            target: LOG_MODULE_WALLET,
2211            inputs = selected_utxos.len(),
2212            input_sats = total_selected_value.to_sat(),
2213            peg_out_sats = peg_out_amount.to_sat(),
2214            ?total_weight,
2215            fees_sats = fees.to_sat(),
2216            fee_rate = fee_rate.sats_per_kvb,
2217            change_sats = change.to_sat(),
2218            "Creating peg-out tx",
2219        );
2220
2221        let transaction = Transaction {
2222            version: bitcoin::transaction::Version(2),
2223            lock_time: LockTime::ZERO,
2224            input: selected_utxos
2225                .iter()
2226                .map(|(utxo_key, _utxo)| TxIn {
2227                    previous_output: utxo_key.0,
2228                    script_sig: Default::default(),
2229                    sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
2230                    witness: bitcoin::Witness::new(),
2231                })
2232                .collect(),
2233            output,
2234        };
2235        info!(
2236            target: LOG_MODULE_WALLET,
2237            txid = %transaction.compute_txid(), "Creating peg-out tx"
2238        );
2239
2240        // FIXME: use custom data structure that guarantees more invariants and only
2241        // convert to PSBT for finalization
2242        let psbt = Psbt {
2243            unsigned_tx: transaction,
2244            version: 0,
2245            xpub: Default::default(),
2246            proprietary: Default::default(),
2247            unknown: Default::default(),
2248            inputs: selected_utxos
2249                .iter()
2250                .map(|(_utxo_key, utxo)| {
2251                    let script_pubkey = self
2252                        .descriptor
2253                        .tweak(&utxo.tweak, self.secp)
2254                        .script_pubkey();
2255                    Input {
2256                        non_witness_utxo: None,
2257                        witness_utxo: Some(TxOut {
2258                            value: utxo.amount,
2259                            script_pubkey,
2260                        }),
2261                        partial_sigs: Default::default(),
2262                        sighash_type: None,
2263                        redeem_script: None,
2264                        witness_script: Some(
2265                            self.descriptor
2266                                .tweak(&utxo.tweak, self.secp)
2267                                .script_code()
2268                                .expect("Failed to tweak descriptor"),
2269                        ),
2270                        bip32_derivation: Default::default(),
2271                        final_script_sig: None,
2272                        final_script_witness: None,
2273                        ripemd160_preimages: Default::default(),
2274                        sha256_preimages: Default::default(),
2275                        hash160_preimages: Default::default(),
2276                        hash256_preimages: Default::default(),
2277                        proprietary: vec![(proprietary_tweak_key(), utxo.tweak.to_vec())]
2278                            .into_iter()
2279                            .collect(),
2280                        tap_key_sig: Default::default(),
2281                        tap_script_sigs: Default::default(),
2282                        tap_scripts: Default::default(),
2283                        tap_key_origins: Default::default(),
2284                        tap_internal_key: Default::default(),
2285                        tap_merkle_root: Default::default(),
2286                        unknown: Default::default(),
2287                    }
2288                })
2289                .collect(),
2290            outputs: vec![Default::default(), change_out],
2291        };
2292
2293        Ok(UnsignedTransaction {
2294            psbt,
2295            signatures: vec![],
2296            change,
2297            fees: PegOutFees {
2298                fee_rate,
2299                total_weight,
2300            },
2301            destination,
2302            selected_utxos,
2303            peg_out_amount,
2304            rbf,
2305        })
2306    }
2307
2308    fn sign_psbt(&self, psbt: &mut Psbt) {
2309        let mut tx_hasher = SighashCache::new(&psbt.unsigned_tx);
2310
2311        for (idx, (psbt_input, _tx_input)) in psbt
2312            .inputs
2313            .iter_mut()
2314            .zip(psbt.unsigned_tx.input.iter())
2315            .enumerate()
2316        {
2317            let tweaked_secret = {
2318                let tweak = psbt_input
2319                    .proprietary
2320                    .get(&proprietary_tweak_key())
2321                    .expect("Malformed PSBT: expected tweak");
2322
2323                self.secret_key.tweak(tweak, self.secp)
2324            };
2325
2326            let tx_hash = tx_hasher
2327                .p2wsh_signature_hash(
2328                    idx,
2329                    psbt_input
2330                        .witness_script
2331                        .as_ref()
2332                        .expect("Missing witness script"),
2333                    psbt_input
2334                        .witness_utxo
2335                        .as_ref()
2336                        .expect("Missing UTXO")
2337                        .value,
2338                    EcdsaSighashType::All,
2339                )
2340                .expect("Failed to create segwit sighash");
2341
2342            let signature = self.secp.sign_ecdsa(
2343                &Message::from_digest_slice(&tx_hash[..]).unwrap(),
2344                &tweaked_secret,
2345            );
2346
2347            psbt_input.partial_sigs.insert(
2348                bitcoin::PublicKey {
2349                    compressed: true,
2350                    inner: secp256k1::PublicKey::from_secret_key(self.secp, &tweaked_secret),
2351                },
2352                EcdsaSig::sighash_all(signature),
2353            );
2354        }
2355    }
2356
2357    fn derive_script(&self, tweak: &[u8]) -> ScriptBuf {
2358        struct CompressedPublicKeyTranslator<'t, 's, Ctx: Verification> {
2359            tweak: &'t [u8],
2360            secp: &'s Secp256k1<Ctx>,
2361        }
2362
2363        impl<Ctx: Verification>
2364            miniscript::Translator<CompressedPublicKey, CompressedPublicKey, Infallible>
2365            for CompressedPublicKeyTranslator<'_, '_, Ctx>
2366        {
2367            fn pk(&mut self, pk: &CompressedPublicKey) -> Result<CompressedPublicKey, Infallible> {
2368                let hashed_tweak = {
2369                    let mut hasher = HmacEngine::<sha256::Hash>::new(&pk.key.serialize()[..]);
2370                    hasher.input(self.tweak);
2371                    Hmac::from_engine(hasher).to_byte_array()
2372                };
2373
2374                Ok(CompressedPublicKey {
2375                    key: pk
2376                        .key
2377                        .add_exp_tweak(
2378                            self.secp,
2379                            &Scalar::from_be_bytes(hashed_tweak).expect("can't fail"),
2380                        )
2381                        .expect("tweaking failed"),
2382                })
2383            }
2384            translate_hash_fail!(CompressedPublicKey, CompressedPublicKey, Infallible);
2385        }
2386
2387        let descriptor = self
2388            .descriptor
2389            .translate_pk(&mut CompressedPublicKeyTranslator {
2390                tweak,
2391                secp: self.secp,
2392            })
2393            .expect("can't fail");
2394
2395        descriptor.script_pubkey()
2396    }
2397}
2398
2399pub fn nonce_from_idx(nonce_idx: u64) -> [u8; 33] {
2400    let mut nonce: [u8; 33] = [0; 33];
2401    // Make it look like a compressed pubkey, has to be either 0x02 or 0x03
2402    nonce[0] = 0x02;
2403    nonce[1..].copy_from_slice(&nonce_idx.consensus_hash::<bitcoin::hashes::sha256::Hash>()[..]);
2404
2405    nonce
2406}
2407
2408/// A peg-out tx that is ready to be broadcast with a tweak for the change UTXO
2409#[derive(Clone, Debug, Encodable, Decodable)]
2410pub struct PendingTransaction {
2411    pub tx: bitcoin::Transaction,
2412    pub tweak: [u8; 33],
2413    pub change: bitcoin::Amount,
2414    pub destination: ScriptBuf,
2415    pub fees: PegOutFees,
2416    pub selected_utxos: Vec<(UTXOKey, SpendableUTXO)>,
2417    pub peg_out_amount: bitcoin::Amount,
2418    pub rbf: Option<Rbf>,
2419}
2420
2421impl Serialize for PendingTransaction {
2422    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2423    where
2424        S: serde::Serializer,
2425    {
2426        if serializer.is_human_readable() {
2427            serializer.serialize_str(&self.consensus_encode_to_hex())
2428        } else {
2429            serializer.serialize_bytes(&self.consensus_encode_to_vec())
2430        }
2431    }
2432}
2433
2434/// A PSBT that is awaiting enough signatures from the federation to becoming a
2435/// `PendingTransaction`
2436#[derive(Clone, Debug, Eq, PartialEq, Encodable, Decodable)]
2437pub struct UnsignedTransaction {
2438    pub psbt: Psbt,
2439    pub signatures: Vec<(PeerId, PegOutSignatureItem)>,
2440    pub change: bitcoin::Amount,
2441    pub fees: PegOutFees,
2442    pub destination: ScriptBuf,
2443    pub selected_utxos: Vec<(UTXOKey, SpendableUTXO)>,
2444    pub peg_out_amount: bitcoin::Amount,
2445    pub rbf: Option<Rbf>,
2446}
2447
2448impl Serialize for UnsignedTransaction {
2449    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2450    where
2451        S: serde::Serializer,
2452    {
2453        if serializer.is_human_readable() {
2454            serializer.serialize_str(&self.consensus_encode_to_hex())
2455        } else {
2456            serializer.serialize_bytes(&self.consensus_encode_to_vec())
2457        }
2458    }
2459}
2460
2461#[cfg(test)]
2462mod tests {
2463
2464    use std::str::FromStr;
2465
2466    use bitcoin::Network::{Bitcoin, Testnet};
2467    use bitcoin::hashes::Hash;
2468    use bitcoin::{Address, Amount, OutPoint, Txid, secp256k1};
2469    use fedimint_core::Feerate;
2470    use fedimint_core::encoding::btc::NetworkLegacyEncodingWrapper;
2471    use fedimint_core::envs::is_automatic_consensus_version_voting_disabled;
2472    use fedimint_wallet_common::{PegOut, PegOutFees, Rbf, WalletOutputV0};
2473    use miniscript::descriptor::Wsh;
2474
2475    use crate::common::PegInDescriptor;
2476    use crate::{
2477        CompressedPublicKey, OsRng, SpendableUTXO, StatelessWallet, UTXOKey, WalletOutputError,
2478    };
2479
2480    #[test]
2481    fn create_tx_should_validate_amounts() {
2482        let secp = secp256k1::Secp256k1::new();
2483
2484        let descriptor = PegInDescriptor::Wsh(
2485            Wsh::new_sortedmulti(
2486                3,
2487                (0..4)
2488                    .map(|_| secp.generate_keypair(&mut OsRng))
2489                    .map(|(_, key)| CompressedPublicKey { key })
2490                    .collect(),
2491            )
2492            .unwrap(),
2493        );
2494
2495        let (secret_key, _) = secp.generate_keypair(&mut OsRng);
2496
2497        let wallet = StatelessWallet {
2498            descriptor: &descriptor,
2499            secret_key: &secret_key,
2500            secp: &secp,
2501        };
2502
2503        let spendable = SpendableUTXO {
2504            tweak: [0; 33],
2505            amount: bitcoin::Amount::from_sat(3000),
2506        };
2507
2508        let recipient = Address::from_str("32iVBEu4dxkUQk9dJbZUiBiQdmypcEyJRf").unwrap();
2509
2510        let fee = Feerate { sats_per_kvb: 1000 };
2511        let weight = 875;
2512
2513        // not enough SpendableUTXO
2514        // tx fee = ceil(875 / 4) * 1 sat/vb = 219
2515        // change script dust = 330
2516        // spendable sats = 3000 - 219 - 330 = 2451
2517        let tx = wallet.create_tx(
2518            Amount::from_sat(2452),
2519            recipient.clone().assume_checked().script_pubkey(),
2520            vec![],
2521            vec![(UTXOKey(OutPoint::null()), spendable.clone())],
2522            fee,
2523            &[0; 33],
2524            None,
2525        );
2526        assert_eq!(tx, Err(WalletOutputError::NotEnoughSpendableUTXO));
2527
2528        // successful tx creation
2529        let mut tx = wallet
2530            .create_tx(
2531                Amount::from_sat(1000),
2532                recipient.clone().assume_checked().script_pubkey(),
2533                vec![],
2534                vec![(UTXOKey(OutPoint::null()), spendable)],
2535                fee,
2536                &[0; 33],
2537                None,
2538            )
2539            .expect("is ok");
2540
2541        // peg out weight is incorrectly set to 0
2542        let res = StatelessWallet::validate_tx(&tx, &rbf(fee.sats_per_kvb, 0), fee, Bitcoin);
2543        assert_eq!(res, Err(WalletOutputError::TxWeightIncorrect(0, weight)));
2544
2545        // fee rate set below min relay fee to 0
2546        let res = StatelessWallet::validate_tx(&tx, &rbf(0, weight), fee, Bitcoin);
2547        assert_eq!(res, Err(WalletOutputError::BelowMinRelayFee));
2548
2549        // fees are okay
2550        let res = StatelessWallet::validate_tx(&tx, &rbf(fee.sats_per_kvb, weight), fee, Bitcoin);
2551        assert_eq!(res, Ok(()));
2552
2553        // tx has fee below consensus
2554        tx.fees = PegOutFees::new(0, weight);
2555        let res = StatelessWallet::validate_tx(&tx, &rbf(fee.sats_per_kvb, weight), fee, Bitcoin);
2556        assert_eq!(
2557            res,
2558            Err(WalletOutputError::PegOutFeeBelowConsensus(
2559                Feerate { sats_per_kvb: 0 },
2560                fee
2561            ))
2562        );
2563
2564        // tx has peg-out amount under dust limit
2565        tx.peg_out_amount = bitcoin::Amount::ZERO;
2566        let res = StatelessWallet::validate_tx(&tx, &rbf(fee.sats_per_kvb, weight), fee, Bitcoin);
2567        assert_eq!(res, Err(WalletOutputError::PegOutUnderDustLimit));
2568
2569        // tx is invalid for network
2570        let output = WalletOutputV0::PegOut(PegOut {
2571            recipient,
2572            amount: bitcoin::Amount::from_sat(1000),
2573            fees: PegOutFees::new(100, weight),
2574        });
2575        let res = StatelessWallet::validate_tx(&tx, &output, fee, Testnet);
2576        assert_eq!(
2577            res,
2578            Err(WalletOutputError::WrongNetwork(
2579                NetworkLegacyEncodingWrapper(Testnet),
2580                NetworkLegacyEncodingWrapper(Bitcoin)
2581            ))
2582        );
2583    }
2584
2585    fn rbf(sats_per_kvb: u64, total_weight: u64) -> WalletOutputV0 {
2586        WalletOutputV0::Rbf(Rbf {
2587            fees: PegOutFees::new(sats_per_kvb, total_weight),
2588            txid: Txid::all_zeros(),
2589        })
2590    }
2591
2592    #[test]
2593    fn automatic_vote_suppressed_when_env_set() {
2594        unsafe {
2595            std::env::set_var("FM_WALLET_DISABLE_AUTOMATIC_CONSENSUS_VERSION_VOTING", "1");
2596        }
2597        assert!(is_automatic_consensus_version_voting_disabled());
2598        unsafe {
2599            std::env::remove_var("FM_WALLET_DISABLE_AUTOMATIC_CONSENSUS_VERSION_VOTING");
2600        }
2601    }
2602
2603    #[test]
2604    fn automatic_vote_active_when_env_unset() {
2605        unsafe {
2606            std::env::remove_var("FM_WALLET_DISABLE_AUTOMATIC_CONSENSUS_VERSION_VOTING");
2607        }
2608        assert!(!is_automatic_consensus_version_voting_disabled());
2609    }
2610}