Skip to main content

fedimint_wallet_common/
lib.rs

1#![deny(clippy::pedantic)]
2#![allow(clippy::missing_errors_doc)]
3#![allow(clippy::missing_panics_doc)]
4#![allow(clippy::module_name_repetitions)]
5#![allow(clippy::must_use_candidate)]
6#![allow(clippy::needless_lifetimes)]
7#![allow(clippy::return_self_not_must_use)]
8
9use std::hash::Hasher;
10
11use bitcoin::address::NetworkUnchecked;
12use bitcoin::psbt::raw::ProprietaryKey;
13use bitcoin::{Address, Amount, BlockHash, OutPoint, TxOut, Txid, secp256k1};
14use config::WalletClientConfig;
15use fedimint_core::core::{Decoder, ModuleInstanceId, ModuleKind};
16use fedimint_core::encoding::btc::NetworkLegacyEncodingWrapper;
17use fedimint_core::encoding::{Decodable, Encodable};
18use fedimint_core::module::{CommonModuleInit, ModuleCommon, ModuleConsensusVersion};
19use fedimint_core::{Feerate, extensible_associated_module_type, plugin_types_trait_impl_common};
20use miniscript::Descriptor;
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23
24use crate::keys::CompressedPublicKey;
25use crate::txoproof::{PegInProof, PegInProofError};
26
27pub mod config;
28pub mod endpoint_constants;
29pub mod envs;
30pub mod keys;
31pub mod tweakable;
32pub mod txoproof;
33
34pub const KIND: ModuleKind = ModuleKind::from_static_str("wallet");
35pub const MODULE_CONSENSUS_VERSION: ModuleConsensusVersion = ModuleConsensusVersion::new(2, 3);
36
37/// From this module consensus version on, a peg-out whose declared fee rate and
38/// weight do not describe a fee that can exist on chain is rejected.
39///
40/// Before it, `Feerate::calculate_fee` multiplied unchecked and wrapped in
41/// release profiles, so such a peg-out was accepted and broadcast with a fee
42/// too low to confirm, stranding the inputs it had already removed from the
43/// wallet.
44pub const CHECKED_PEG_OUT_FEE_MODULE_CONSENSUS_VERSION: ModuleConsensusVersion =
45    ModuleConsensusVersion::new(2, 3);
46
47/// Module consensus version that introduced support for processing Bitcoin
48/// transactions that exceed the `ALEPH_BFT_UNIT_BYTE_LIMIT`.
49pub const SAFE_DEPOSIT_MODULE_CONSENSUS_VERSION: ModuleConsensusVersion =
50    ModuleConsensusVersion::new(2, 2);
51
52/// To further mitigate the risk of a peg-out transaction getting stuck in the
53/// mempool, we multiply the feerate estimate returned from the backend by this
54/// value.
55pub const FEERATE_MULTIPLIER_DEFAULT: f64 = 2.0;
56
57pub type PartialSig = Vec<u8>;
58
59pub type PegInDescriptor = Descriptor<CompressedPublicKey>;
60
61#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Encodable, Decodable)]
62pub enum WalletConsensusItem {
63    BlockCount(u32), /* FIXME: use block hash instead, but needs more complicated
64                      * * verification logic */
65    Feerate(Feerate),
66    PegOutSignature(PegOutSignatureItem),
67    ModuleConsensusVersion(ModuleConsensusVersion),
68    #[encodable_default]
69    Default {
70        variant: u64,
71        bytes: Vec<u8>,
72    },
73}
74
75impl std::fmt::Display for WalletConsensusItem {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        match self {
78            WalletConsensusItem::BlockCount(count) => {
79                write!(f, "Wallet Block Count {count}")
80            }
81            WalletConsensusItem::Feerate(feerate) => {
82                write!(
83                    f,
84                    "Wallet Feerate with sats per kvb {}",
85                    feerate.sats_per_kvb
86                )
87            }
88            WalletConsensusItem::PegOutSignature(sig) => {
89                write!(f, "Wallet PegOut signature for Bitcoin TxId {}", sig.txid)
90            }
91            WalletConsensusItem::ModuleConsensusVersion(version) => {
92                write!(
93                    f,
94                    "Wallet Consensus Version {}.{}",
95                    version.major, version.minor
96                )
97            }
98            WalletConsensusItem::Default { variant, .. } => {
99                write!(f, "Unknown Wallet CI variant={variant}")
100            }
101        }
102    }
103}
104
105#[derive(Clone, Debug, Serialize, Deserialize, Encodable, Decodable)]
106pub struct PegOutSignatureItem {
107    pub txid: Txid,
108    pub signature: Vec<secp256k1::ecdsa::Signature>,
109}
110
111#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Encodable, Decodable)]
112pub struct SpendableUTXO {
113    #[serde(with = "::fedimint_core::encoding::as_hex")]
114    pub tweak: [u8; 33],
115    #[serde(with = "bitcoin::amount::serde::as_sat")]
116    pub amount: bitcoin::Amount,
117}
118
119/// A transaction output, either unspent or consumed
120#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
121pub struct TxOutputSummary {
122    pub outpoint: OutPoint,
123    #[serde(with = "bitcoin::amount::serde::as_sat")]
124    pub amount: Amount,
125}
126
127/// Summary of the coins within the wallet.
128///
129/// Coins within the wallet go from spendable, to consumed in a transaction that
130/// does not have threshold signatures (unsigned), to threshold signed and
131/// unconfirmed on-chain (unconfirmed).
132///
133/// This summary provides the most granular view possible of coins in the
134/// wallet.
135#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
136pub struct WalletSummary {
137    /// All UTXOs available as inputs for transactions
138    pub spendable_utxos: Vec<TxOutputSummary>,
139    /// Transaction outputs consumed in peg-out transactions that have not
140    /// reached threshold signatures
141    pub unsigned_peg_out_txos: Vec<TxOutputSummary>,
142    /// Change UTXOs created from peg-out transactions that have not reached
143    /// threshold signatures
144    pub unsigned_change_utxos: Vec<TxOutputSummary>,
145    /// Transaction outputs consumed in peg-out transactions that have reached
146    /// threshold signatures waiting for finality delay confirmations
147    pub unconfirmed_peg_out_txos: Vec<TxOutputSummary>,
148    /// Change UTXOs created from peg-out transactions that have reached
149    /// threshold signatures waiting for finality delay confirmations
150    pub unconfirmed_change_utxos: Vec<TxOutputSummary>,
151}
152
153impl WalletSummary {
154    fn sum<'a>(txos: impl Iterator<Item = &'a TxOutputSummary>) -> Amount {
155        txos.fold(Amount::ZERO, |acc, txo| txo.amount + acc)
156    }
157
158    /// Total amount of all spendable UTXOs
159    pub fn total_spendable_balance(&self) -> Amount {
160        WalletSummary::sum(self.spendable_utxos.iter())
161    }
162
163    /// Total amount of all transaction outputs from peg-out transactions that
164    /// have not reached threshold signatures
165    pub fn total_unsigned_peg_out_balance(&self) -> Amount {
166        WalletSummary::sum(self.unsigned_peg_out_txos.iter())
167    }
168
169    /// Total amount of all change UTXOs from peg-out transactions that have not
170    /// reached threshold signatures
171    pub fn total_unsigned_change_balance(&self) -> Amount {
172        WalletSummary::sum(self.unsigned_change_utxos.iter())
173    }
174
175    /// Total amount of all transaction outputs from peg-out transactions that
176    /// have reached threshold signatures waiting for finality delay
177    /// confirmations
178    pub fn total_unconfirmed_peg_out_balance(&self) -> Amount {
179        WalletSummary::sum(self.unconfirmed_peg_out_txos.iter())
180    }
181
182    /// Total amount of all change UTXOs from peg-out transactions that have
183    /// reached threshold signatures waiting for finality delay confirmations
184    pub fn total_unconfirmed_change_balance(&self) -> Amount {
185        WalletSummary::sum(self.unconfirmed_change_utxos.iter())
186    }
187
188    /// Total amount of all transaction outputs from peg-out transactions that
189    /// are either waiting for threshold signatures or confirmations. This is
190    /// the total in-flight amount leaving the wallet.
191    pub fn total_pending_peg_out_balance(&self) -> Amount {
192        self.total_unsigned_peg_out_balance() + self.total_unconfirmed_peg_out_balance()
193    }
194
195    /// Total amount of all change UTXOs from peg-out transactions that are
196    /// either waiting for threshold signatures or confirmations. This is the
197    /// total in-flight amount that will become spendable by the wallet.
198    pub fn total_pending_change_balance(&self) -> Amount {
199        self.total_unsigned_change_balance() + self.total_unconfirmed_change_balance()
200    }
201
202    /// Total amount of immediately spendable UTXOs and pending change UTXOs.
203    /// This is the spendable balance once all transactions confirm.
204    pub fn total_owned_balance(&self) -> Amount {
205        self.total_spendable_balance() + self.total_pending_change_balance()
206    }
207
208    /// All transaction outputs from peg-out transactions that are either
209    /// waiting for threshold signatures or confirmations. These are all the
210    /// in-flight coins leaving the wallet.
211    pub fn pending_peg_out_txos(&self) -> Vec<TxOutputSummary> {
212        self.unsigned_peg_out_txos
213            .clone()
214            .into_iter()
215            .chain(self.unconfirmed_peg_out_txos.clone())
216            .collect()
217    }
218
219    /// All change UTXOs from peg-out transactions that are either waiting for
220    /// threshold signatures or confirmations. These are all the in-flight coins
221    /// that will become spendable by the wallet.
222    pub fn pending_change_utxos(&self) -> Vec<TxOutputSummary> {
223        self.unsigned_change_utxos
224            .clone()
225            .into_iter()
226            .chain(self.unconfirmed_change_utxos.clone())
227            .collect()
228    }
229}
230
231/// Recovery data for slice-based client recovery
232#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
233pub enum RecoveryItem {
234    /// A peg-in input was claimed
235    Input {
236        /// The Bitcoin outpoint that was claimed
237        outpoint: bitcoin::OutPoint,
238        /// The `script_pubkey` of the peg-in address (tweaked descriptor)
239        script: bitcoin::ScriptBuf,
240    },
241}
242
243#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
244pub struct PegOutFees {
245    pub fee_rate: Feerate,
246    pub total_weight: u64,
247}
248
249impl PegOutFees {
250    pub fn new(sats_per_kvb: u64, total_weight: u64) -> Self {
251        PegOutFees {
252            fee_rate: Feerate { sats_per_kvb },
253            total_weight,
254        }
255    }
256
257    /// Creates a `PegOutFees` from a flat fee amount. Uses weight=4000
258    /// so that `amount()` returns the exact flat fee:
259    /// `(4000 / 4) * sats / 1000 = sats`
260    pub fn from_amount(amount: bitcoin::Amount) -> Self {
261        PegOutFees::new(amount.to_sat(), 4000)
262    }
263
264    pub fn amount(&self) -> Amount {
265        self.fee_rate.calculate_fee(self.total_weight)
266    }
267}
268
269#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
270pub struct PegOut {
271    pub recipient: Address<NetworkUnchecked>,
272    #[serde(with = "bitcoin::amount::serde::as_sat")]
273    pub amount: bitcoin::Amount,
274    pub fees: PegOutFees,
275}
276
277extensible_associated_module_type!(
278    WalletOutputOutcome,
279    WalletOutputOutcomeV0,
280    UnknownWalletOutputOutcomeVariantError
281);
282
283impl WalletOutputOutcome {
284    pub fn new_v0(txid: bitcoin::Txid) -> WalletOutputOutcome {
285        WalletOutputOutcome::V0(WalletOutputOutcomeV0(txid))
286    }
287}
288
289/// Contains the Bitcoin transaction id of the transaction created by the
290/// withdraw request
291#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
292pub struct WalletOutputOutcomeV0(pub bitcoin::Txid);
293
294impl std::fmt::Display for WalletOutputOutcomeV0 {
295    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296        write!(f, "Wallet PegOut Bitcoin TxId {}", self.0)
297    }
298}
299
300#[derive(Debug)]
301pub struct WalletCommonInit;
302
303impl CommonModuleInit for WalletCommonInit {
304    const CONSENSUS_VERSION: ModuleConsensusVersion = MODULE_CONSENSUS_VERSION;
305    const KIND: ModuleKind = KIND;
306
307    type ClientConfig = WalletClientConfig;
308
309    fn decoder() -> Decoder {
310        WalletModuleTypes::decoder()
311    }
312}
313
314#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
315pub enum WalletInput {
316    V0(WalletInputV0),
317    V1(WalletInputV1),
318    #[encodable_default]
319    Default {
320        variant: u64,
321        bytes: Vec<u8>,
322    },
323}
324
325impl WalletInput {
326    pub fn maybe_v0_ref(&self) -> Option<&WalletInputV0> {
327        match self {
328            WalletInput::V0(v0) => Some(v0),
329            _ => None,
330        }
331    }
332}
333
334#[derive(
335    Debug,
336    thiserror::Error,
337    Clone,
338    Eq,
339    PartialEq,
340    Hash,
341    serde::Deserialize,
342    serde::Serialize,
343    fedimint_core::encoding::Encodable,
344    fedimint_core::encoding::Decodable,
345)]
346#[error("Unknown {} variant {variant}", stringify!($name))]
347pub struct UnknownWalletInputVariantError {
348    pub variant: u64,
349}
350
351impl std::fmt::Display for WalletInput {
352    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353        match &self {
354            WalletInput::V0(inner) => std::fmt::Display::fmt(&inner, f),
355            WalletInput::V1(inner) => std::fmt::Display::fmt(&inner, f),
356            WalletInput::Default { variant, .. } => {
357                write!(f, "Unknown variant (variant={variant})")
358            }
359        }
360    }
361}
362
363impl WalletInput {
364    pub fn new_v0(peg_in_proof: PegInProof) -> WalletInput {
365        WalletInput::V0(WalletInputV0(Box::new(peg_in_proof)))
366    }
367
368    pub fn new_v1(peg_in_proof: &PegInProof) -> WalletInput {
369        WalletInput::V1(WalletInputV1 {
370            outpoint: peg_in_proof.outpoint(),
371            tweak_key: peg_in_proof.tweak_key(),
372            tx_out: peg_in_proof.tx_output(),
373        })
374    }
375}
376
377#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
378pub struct WalletInputV0(pub Box<PegInProof>);
379
380impl std::ops::Deref for WalletInputV0 {
381    type Target = Box<PegInProof>;
382
383    fn deref(&self) -> &Self::Target {
384        &self.0
385    }
386}
387
388impl std::ops::DerefMut for WalletInputV0 {
389    fn deref_mut(&mut self) -> &mut Self::Target {
390        &mut self.0
391    }
392}
393
394#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
395pub struct WalletInputV1 {
396    pub outpoint: bitcoin::OutPoint,
397    pub tweak_key: secp256k1::PublicKey,
398    pub tx_out: TxOut,
399}
400
401impl std::fmt::Display for WalletInputV0 {
402    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403        write!(
404            f,
405            "Wallet PegIn with Bitcoin TxId {}",
406            self.0.outpoint().txid
407        )
408    }
409}
410
411impl std::fmt::Display for WalletInputV1 {
412    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
413        write!(f, "Wallet PegIn V1 with TxId {}", self.outpoint.txid)
414    }
415}
416
417extensible_associated_module_type!(
418    WalletOutput,
419    WalletOutputV0,
420    UnknownWalletOutputVariantError
421);
422
423impl WalletOutput {
424    pub fn new_v0_peg_out(
425        recipient: Address,
426        amount: bitcoin::Amount,
427        fees: PegOutFees,
428    ) -> WalletOutput {
429        WalletOutput::V0(WalletOutputV0::PegOut(PegOut {
430            recipient: recipient.into_unchecked(),
431            amount,
432            fees,
433        }))
434    }
435    pub fn new_v0_rbf(fees: PegOutFees, txid: Txid) -> WalletOutput {
436        WalletOutput::V0(WalletOutputV0::Rbf(Rbf { fees, txid }))
437    }
438}
439
440#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
441pub enum WalletOutputV0 {
442    PegOut(PegOut),
443    Rbf(Rbf),
444}
445
446/// Allows a user to bump the fees of a `PendingTransaction`
447#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
448pub struct Rbf {
449    /// Fees expressed as an increase over existing peg-out fees
450    pub fees: PegOutFees,
451    /// Bitcoin tx id to bump the fees for
452    pub txid: Txid,
453}
454
455impl WalletOutputV0 {
456    pub fn amount(&self) -> Amount {
457        match self {
458            WalletOutputV0::PegOut(pegout) => pegout.amount + pegout.fees.amount(),
459            WalletOutputV0::Rbf(rbf) => rbf.fees.amount(),
460        }
461    }
462}
463
464impl std::fmt::Display for WalletOutputV0 {
465    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466        match self {
467            WalletOutputV0::PegOut(pegout) => {
468                write!(
469                    f,
470                    "Wallet PegOut {} to {}",
471                    pegout.amount,
472                    pegout.recipient.clone().assume_checked()
473                )
474            }
475            WalletOutputV0::Rbf(rbf) => write!(f, "Wallet RBF {:?} to {}", rbf.fees, rbf.txid),
476        }
477    }
478}
479
480pub struct WalletModuleTypes;
481
482pub fn proprietary_tweak_key() -> ProprietaryKey {
483    ProprietaryKey {
484        prefix: b"fedimint".to_vec(),
485        subtype: 0x00,
486        key: vec![],
487    }
488}
489
490impl std::hash::Hash for PegOutSignatureItem {
491    fn hash<H: Hasher>(&self, state: &mut H) {
492        self.txid.hash(state);
493        for sig in &self.signature {
494            sig.serialize_der().hash(state);
495        }
496    }
497}
498
499impl PartialEq for PegOutSignatureItem {
500    fn eq(&self, other: &PegOutSignatureItem) -> bool {
501        self.txid == other.txid && self.signature == other.signature
502    }
503}
504
505impl Eq for PegOutSignatureItem {}
506
507plugin_types_trait_impl_common!(
508    KIND,
509    WalletModuleTypes,
510    WalletClientConfig,
511    WalletInput,
512    WalletOutput,
513    WalletOutputOutcome,
514    WalletConsensusItem,
515    WalletInputError,
516    WalletOutputError
517);
518
519#[derive(Debug, Error, Encodable, Decodable, Hash, Clone, Eq, PartialEq)]
520pub enum WalletInputError {
521    #[error("Unknown block hash in peg-in proof: {0}")]
522    UnknownPegInProofBlock(BlockHash),
523    #[error("Invalid peg-in proof: {0}")]
524    PegInProofError(#[from] PegInProofError),
525    #[error("The peg-in was already claimed")]
526    PegInAlreadyClaimed,
527    #[error("The wallet input version is not supported by this federation")]
528    UnknownInputVariant(#[from] UnknownWalletInputVariantError),
529    #[error("Unknown UTXO")]
530    UnknownUTXO,
531    #[error("Wrong output script")]
532    WrongOutputScript,
533    #[error("Wrong tx out")]
534    WrongTxOut,
535}
536
537#[derive(Debug, Error, Encodable, Decodable, Hash, Clone, Eq, PartialEq)]
538pub enum WalletOutputError {
539    #[error("Connected bitcoind is on wrong network, expected {0}, got {1}")]
540    WrongNetwork(NetworkLegacyEncodingWrapper, NetworkLegacyEncodingWrapper),
541    #[error("Peg-out fee rate {0:?} is set below consensus {1:?}")]
542    PegOutFeeBelowConsensus(Feerate, Feerate),
543    #[error("Not enough SpendableUTXO")]
544    NotEnoughSpendableUTXO,
545    #[error("Peg out amount was under the dust limit")]
546    PegOutUnderDustLimit,
547    #[error("RBF transaction id not found")]
548    RbfTransactionIdNotFound,
549    #[error("Peg-out fee weight {0} doesn't match actual weight {1}")]
550    TxWeightIncorrect(u64, u64),
551    #[error("Peg-out fee rate is below min relay fee")]
552    BelowMinRelayFee,
553    #[error("The wallet output version is not supported by this federation")]
554    UnknownOutputVariant(#[from] UnknownWalletOutputVariantError),
555}
556
557// For backwards-compatibility with old clients, we use an UnknownOutputVariant
558// error when a client attempts a deprecated RBF withdrawal.
559// see: https://github.com/fedimint/fedimint/issues/5453
560pub const DEPRECATED_RBF_ERROR: WalletOutputError =
561    WalletOutputError::UnknownOutputVariant(UnknownWalletOutputVariantError { variant: 1 });
562
563#[derive(Debug, Error)]
564pub enum ProcessPegOutSigError {
565    #[error("No unsigned transaction with id {0} exists")]
566    UnknownTransaction(Txid),
567    #[error("Expected {0} signatures, got {1}")]
568    WrongSignatureCount(usize, usize),
569    #[error("Bad Sighash")]
570    SighashError,
571    #[error("Malformed signature: {0}")]
572    MalformedSignature(secp256k1::Error),
573    #[error("Invalid signature")]
574    InvalidSignature,
575    #[error("Duplicate signature")]
576    DuplicateSignature,
577    #[error("Missing change tweak")]
578    MissingOrMalformedChangeTweak,
579    #[error("Error finalizing PSBT {0:?}")]
580    ErrorFinalizingPsbt(Vec<miniscript::psbt::Error>),
581}