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::return_self_not_must_use)]
7
8use std::hash::Hasher;
9
10use bitcoin::address::NetworkUnchecked;
11use bitcoin::psbt::raw::ProprietaryKey;
12use bitcoin::{secp256k1, Address, Amount, BlockHash, Network, Txid};
13use config::WalletClientConfig;
14use fedimint_core::core::{Decoder, ModuleInstanceId, ModuleKind};
15use fedimint_core::encoding::{Decodable, Encodable};
16use fedimint_core::module::{CommonModuleInit, ModuleCommon, ModuleConsensusVersion};
17use fedimint_core::{extensible_associated_module_type, plugin_types_trait_impl_common, Feerate};
18use impl_tools::autoimpl;
19use miniscript::Descriptor;
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22use tracing::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, 1);
36
37/// Used for estimating a feerate that will confirm within a target number of
38/// blocks.
39///
40/// Since the wallet's UTXOs are a shared resource, we need to reduce the risk
41/// of a peg-out transaction getting stuck in the mempool, hence we use a low
42/// confirmation target. Other fee bumping techniques, such as RBF and CPFP, can
43/// help mitigate this problem but are out-of-scope for this version of the
44/// wallet.
45pub const CONFIRMATION_TARGET: u16 = 1;
46
47/// To further mitigate the risk of a peg-out transaction getting stuck in the
48/// mempool, we multiply the feerate estimate returned from the backend by this
49/// value.
50pub const FEERATE_MULTIPLIER: u64 = 4;
51
52pub type PartialSig = Vec<u8>;
53
54pub type PegInDescriptor = Descriptor<CompressedPublicKey>;
55
56#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Encodable, Decodable)]
57pub enum WalletConsensusItem {
58    BlockCount(u32), /* FIXME: use block hash instead, but needs more complicated
59                      * * verification logic */
60    Feerate(Feerate),
61    PegOutSignature(PegOutSignatureItem),
62    #[encodable_default]
63    Default {
64        variant: u64,
65        bytes: Vec<u8>,
66    },
67}
68
69impl std::fmt::Display for WalletConsensusItem {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            WalletConsensusItem::BlockCount(count) => {
73                write!(f, "Wallet Block Count {count}")
74            }
75            WalletConsensusItem::Feerate(feerate) => {
76                write!(
77                    f,
78                    "Wallet Feerate with sats per kvb {}",
79                    feerate.sats_per_kvb
80                )
81            }
82            WalletConsensusItem::PegOutSignature(sig) => {
83                write!(f, "Wallet PegOut signature for Bitcoin TxId {}", sig.txid)
84            }
85            WalletConsensusItem::Default { variant, .. } => {
86                write!(f, "Unknown Wallet CI variant={variant}")
87            }
88        }
89    }
90}
91
92#[derive(Clone, Debug, Serialize, Deserialize, Encodable, Decodable)]
93pub struct PegOutSignatureItem {
94    pub txid: Txid,
95    pub signature: Vec<secp256k1::ecdsa::Signature>,
96}
97
98#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Encodable, Decodable)]
99pub struct SpendableUTXO {
100    #[serde(with = "::fedimint_core::encoding::as_hex")]
101    pub tweak: [u8; 33],
102    #[serde(with = "bitcoin::amount::serde::as_sat")]
103    pub amount: bitcoin::Amount,
104}
105
106/// A transaction output, either unspent or consumed
107#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
108pub struct TxOutputSummary {
109    pub outpoint: bitcoin::OutPoint,
110    #[serde(with = "bitcoin::amount::serde::as_sat")]
111    pub amount: bitcoin::Amount,
112}
113
114/// Summary of the coins within the wallet.
115///
116/// Coins within the wallet go from spendable, to consumed in a transaction that
117/// does not have threshold signatures (unsigned), to threshold signed and
118/// unconfirmed on-chain (unconfirmed).
119///
120/// This summary provides the most granular view possible of coins in the
121/// wallet.
122#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
123pub struct WalletSummary {
124    /// All UTXOs available as inputs for transactions
125    pub spendable_utxos: Vec<TxOutputSummary>,
126    /// Transaction outputs consumed in peg-out transactions that have not
127    /// reached threshold signatures
128    pub unsigned_peg_out_txos: Vec<TxOutputSummary>,
129    /// Change UTXOs created from peg-out transactions that have not reached
130    /// threshold signatures
131    pub unsigned_change_utxos: Vec<TxOutputSummary>,
132    /// Transaction outputs consumed in peg-out transactions that have reached
133    /// threshold signatures waiting for finality delay confirmations
134    pub unconfirmed_peg_out_txos: Vec<TxOutputSummary>,
135    /// Change UTXOs created from peg-out transactions that have reached
136    /// threshold signatures waiting for finality delay confirmations
137    pub unconfirmed_change_utxos: Vec<TxOutputSummary>,
138}
139
140impl WalletSummary {
141    fn sum<'a>(txos: impl Iterator<Item = &'a TxOutputSummary>) -> Amount {
142        txos.fold(Amount::ZERO, |acc, txo| txo.amount + acc)
143    }
144
145    /// Total amount of all spendable UTXOs
146    pub fn total_spendable_balance(&self) -> Amount {
147        WalletSummary::sum(self.spendable_utxos.iter())
148    }
149
150    /// Total amount of all transaction outputs from peg-out transactions that
151    /// have not reached threshold signatures
152    pub fn total_unsigned_peg_out_balance(&self) -> Amount {
153        WalletSummary::sum(self.unsigned_peg_out_txos.iter())
154    }
155
156    /// Total amount of all change UTXOs from peg-out transactions that have not
157    /// reached threshold signatures
158    pub fn total_unsigned_change_balance(&self) -> Amount {
159        WalletSummary::sum(self.unsigned_change_utxos.iter())
160    }
161
162    /// Total amount of all transaction outputs from peg-out transactions that
163    /// have reached threshold signatures waiting for finality delay
164    /// confirmations
165    pub fn total_unconfirmed_peg_out_balance(&self) -> Amount {
166        WalletSummary::sum(self.unconfirmed_peg_out_txos.iter())
167    }
168
169    /// Total amount of all change UTXOs from peg-out transactions that have
170    /// reached threshold signatures waiting for finality delay confirmations
171    pub fn total_unconfirmed_change_balance(&self) -> Amount {
172        WalletSummary::sum(self.unconfirmed_change_utxos.iter())
173    }
174
175    /// Total amount of all transaction outputs from peg-out transactions that
176    /// are either waiting for threshold signatures or confirmations. This is
177    /// the total in-flight amount leaving the wallet.
178    pub fn total_pending_peg_out_balance(&self) -> Amount {
179        self.total_unsigned_peg_out_balance() + self.total_unconfirmed_peg_out_balance()
180    }
181
182    /// Total amount of all change UTXOs from peg-out transactions that are
183    /// either waiting for threshold signatures or confirmations. This is the
184    /// total in-flight amount that will become spendable by the wallet.
185    pub fn total_pending_change_balance(&self) -> Amount {
186        self.total_unsigned_change_balance() + self.total_unconfirmed_change_balance()
187    }
188
189    /// Total amount of immediately spendable UTXOs and pending change UTXOs.
190    /// This is the spendable balance once all transactions confirm.
191    pub fn total_owned_balance(&self) -> Amount {
192        self.total_spendable_balance() + self.total_pending_change_balance()
193    }
194
195    /// All transaction outputs from peg-out transactions that are either
196    /// waiting for threshold signatures or confirmations. These are all the
197    /// in-flight coins leaving the wallet.
198    pub fn pending_peg_out_txos(&self) -> Vec<TxOutputSummary> {
199        self.unsigned_peg_out_txos
200            .clone()
201            .into_iter()
202            .chain(self.unconfirmed_peg_out_txos.clone())
203            .collect()
204    }
205
206    /// All change UTXOs from peg-out transactions that are either waiting for
207    /// threshold signatures or confirmations. These are all the in-flight coins
208    /// that will become spendable by the wallet.
209    pub fn pending_change_utxos(&self) -> Vec<TxOutputSummary> {
210        self.unsigned_change_utxos
211            .clone()
212            .into_iter()
213            .chain(self.unconfirmed_change_utxos.clone())
214            .collect()
215    }
216}
217
218#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
219pub struct PegOutFees {
220    pub fee_rate: Feerate,
221    pub total_weight: u64,
222}
223
224impl PegOutFees {
225    pub fn new(sats_per_kvb: u64, total_weight: u64) -> Self {
226        PegOutFees {
227            fee_rate: Feerate { sats_per_kvb },
228            total_weight,
229        }
230    }
231
232    pub fn amount(&self) -> Amount {
233        self.fee_rate.calculate_fee(self.total_weight)
234    }
235}
236
237#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
238pub struct PegOut {
239    pub recipient: Address<NetworkUnchecked>,
240    #[serde(with = "bitcoin::amount::serde::as_sat")]
241    pub amount: bitcoin::Amount,
242    pub fees: PegOutFees,
243}
244
245extensible_associated_module_type!(
246    WalletOutputOutcome,
247    WalletOutputOutcomeV0,
248    UnknownWalletOutputOutcomeVariantError
249);
250
251impl WalletOutputOutcome {
252    pub fn new_v0(txid: bitcoin::Txid) -> WalletOutputOutcome {
253        WalletOutputOutcome::V0(WalletOutputOutcomeV0(txid))
254    }
255}
256
257/// Contains the Bitcoin transaction id of the transaction created by the
258/// withdraw request
259#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
260pub struct WalletOutputOutcomeV0(pub bitcoin::Txid);
261
262impl std::fmt::Display for WalletOutputOutcomeV0 {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        write!(f, "Wallet PegOut Bitcoin TxId {}", self.0)
265    }
266}
267
268#[derive(Debug)]
269pub struct WalletCommonInit;
270
271impl CommonModuleInit for WalletCommonInit {
272    const CONSENSUS_VERSION: ModuleConsensusVersion = MODULE_CONSENSUS_VERSION;
273    const KIND: ModuleKind = KIND;
274
275    type ClientConfig = WalletClientConfig;
276
277    fn decoder() -> Decoder {
278        WalletModuleTypes::decoder()
279    }
280}
281
282extensible_associated_module_type!(WalletInput, WalletInputV0, UnknownWalletInputVariantError);
283
284impl WalletInput {
285    pub fn new_v0(peg_in_proof: PegInProof) -> WalletInput {
286        WalletInput::V0(WalletInputV0(Box::new(peg_in_proof)))
287    }
288}
289
290#[autoimpl(Deref, DerefMut using self.0)]
291#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
292pub struct WalletInputV0(pub Box<PegInProof>);
293
294impl std::fmt::Display for WalletInputV0 {
295    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296        write!(
297            f,
298            "Wallet PegIn with Bitcoin TxId {}",
299            self.0.outpoint().txid
300        )
301    }
302}
303
304extensible_associated_module_type!(
305    WalletOutput,
306    WalletOutputV0,
307    UnknownWalletOutputVariantError
308);
309
310impl WalletOutput {
311    pub fn new_v0_peg_out(
312        recipient: Address<NetworkUnchecked>,
313        amount: bitcoin::Amount,
314        fees: PegOutFees,
315    ) -> WalletOutput {
316        WalletOutput::V0(WalletOutputV0::PegOut(PegOut {
317            recipient,
318            amount,
319            fees,
320        }))
321    }
322    pub fn new_v0_rbf(fees: PegOutFees, txid: Txid) -> WalletOutput {
323        WalletOutput::V0(WalletOutputV0::Rbf(Rbf { fees, txid }))
324    }
325}
326
327#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
328pub enum WalletOutputV0 {
329    PegOut(PegOut),
330    Rbf(Rbf),
331}
332
333/// Allows a user to bump the fees of a `PendingTransaction`
334#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
335pub struct Rbf {
336    /// Fees expressed as an increase over existing peg-out fees
337    pub fees: PegOutFees,
338    /// Bitcoin tx id to bump the fees for
339    pub txid: Txid,
340}
341
342impl WalletOutputV0 {
343    pub fn amount(&self) -> Amount {
344        match self {
345            WalletOutputV0::PegOut(pegout) => pegout.amount + pegout.fees.amount(),
346            WalletOutputV0::Rbf(rbf) => rbf.fees.amount(),
347        }
348    }
349}
350
351impl std::fmt::Display for WalletOutputV0 {
352    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353        match self {
354            WalletOutputV0::PegOut(pegout) => {
355                write!(
356                    f,
357                    "Wallet PegOut {} to {}",
358                    pegout.amount,
359                    pegout.recipient.clone().assume_checked()
360                )
361            }
362            WalletOutputV0::Rbf(rbf) => write!(f, "Wallet RBF {:?} to {}", rbf.fees, rbf.txid),
363        }
364    }
365}
366
367pub struct WalletModuleTypes;
368
369pub fn proprietary_tweak_key() -> ProprietaryKey {
370    ProprietaryKey {
371        prefix: b"fedimint".to_vec(),
372        subtype: 0x00,
373        key: vec![],
374    }
375}
376
377impl std::hash::Hash for PegOutSignatureItem {
378    fn hash<H: Hasher>(&self, state: &mut H) {
379        self.txid.hash(state);
380        for sig in &self.signature {
381            sig.serialize_der().hash(state);
382        }
383    }
384}
385
386impl PartialEq for PegOutSignatureItem {
387    fn eq(&self, other: &PegOutSignatureItem) -> bool {
388        self.txid == other.txid && self.signature == other.signature
389    }
390}
391
392impl Eq for PegOutSignatureItem {}
393
394plugin_types_trait_impl_common!(
395    KIND,
396    WalletModuleTypes,
397    WalletClientConfig,
398    WalletInput,
399    WalletOutput,
400    WalletOutputOutcome,
401    WalletConsensusItem,
402    WalletInputError,
403    WalletOutputError
404);
405
406#[derive(Debug, Error, Encodable, Decodable, Hash, Clone, Eq, PartialEq)]
407pub enum WalletCreationError {
408    #[error("Connected bitcoind is on wrong network, expected {0}, got {1}")]
409    WrongNetwork(Network, Network),
410    #[error("Error querying bitcoind: {0}")]
411    RpcError(String),
412}
413
414#[derive(Debug, Error, Encodable, Decodable, Hash, Clone, Eq, PartialEq)]
415pub enum WalletInputError {
416    #[error("Unknown block hash in peg-in proof: {0}")]
417    UnknownPegInProofBlock(BlockHash),
418    #[error("Invalid peg-in proof: {0}")]
419    PegInProofError(#[from] PegInProofError),
420    #[error("The peg-in was already claimed")]
421    PegInAlreadyClaimed,
422    #[error("The wallet input version is not supported by this federation")]
423    UnknownInputVariant(#[from] UnknownWalletInputVariantError),
424}
425
426#[derive(Debug, Error, Encodable, Decodable, Hash, Clone, Eq, PartialEq)]
427pub enum WalletOutputError {
428    #[error("Connected bitcoind is on wrong network, expected {0}, got {1}")]
429    WrongNetwork(Network, Network),
430    #[error("Peg-out fee rate {0:?} is set below consensus {1:?}")]
431    PegOutFeeBelowConsensus(Feerate, Feerate),
432    #[error("Not enough SpendableUTXO")]
433    NotEnoughSpendableUTXO,
434    #[error("Peg out amount was under the dust limit")]
435    PegOutUnderDustLimit,
436    #[error("RBF transaction id not found")]
437    RbfTransactionIdNotFound,
438    #[error("Peg-out fee weight {0} doesn't match actual weight {1}")]
439    TxWeightIncorrect(u64, u64),
440    #[error("Peg-out fee rate is below min relay fee")]
441    BelowMinRelayFee,
442    #[error("The wallet output version is not supported by this federation")]
443    UnknownOutputVariant(#[from] UnknownWalletOutputVariantError),
444}
445
446// For backwards-compatibility with old clients, we use an UnknownOutputVariant
447// error when a client attempts a deprecated RBF withdrawal.
448// see: https://github.com/fedimint/fedimint/issues/5453
449pub const DEPRECATED_RBF_ERROR: WalletOutputError =
450    WalletOutputError::UnknownOutputVariant(UnknownWalletOutputVariantError { variant: 1 });
451
452#[derive(Debug, Error)]
453pub enum ProcessPegOutSigError {
454    #[error("No unsigned transaction with id {0} exists")]
455    UnknownTransaction(Txid),
456    #[error("Expected {0} signatures, got {1}")]
457    WrongSignatureCount(usize, usize),
458    #[error("Bad Sighash")]
459    SighashError,
460    #[error("Malformed signature: {0}")]
461    MalformedSignature(secp256k1::Error),
462    #[error("Invalid signature")]
463    InvalidSignature,
464    #[error("Duplicate signature")]
465    DuplicateSignature,
466    #[error("Missing change tweak")]
467    MissingOrMalformedChangeTweak,
468    #[error("Error finalizing PSBT {0:?}")]
469    ErrorFinalizingPsbt(Vec<miniscript::psbt::Error>),
470}