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