Skip to main content

fedimint_walletv2_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::collections::BTreeMap;
9use std::time::Duration;
10
11use bitcoin::hashes::{Hash, hash160, sha256};
12use bitcoin::key::TapTweak;
13use bitcoin::{Address, PubkeyHash, ScriptBuf, ScriptHash, Txid, WPubkeyHash, WScriptHash};
14use config::WalletClientConfig;
15pub use fedimint_core::config::ExcessiveRelativeFeeError;
16use fedimint_core::core::{Decoder, ModuleInstanceId, ModuleKind};
17use fedimint_core::encoding::{Decodable, Encodable};
18use fedimint_core::module::{CommonModuleInit, ModuleCommon, ModuleConsensusVersion};
19use fedimint_core::{
20    NumPeersExt, PeerId, extensible_associated_module_type, plugin_types_trait_impl_common,
21};
22use miniscript::descriptor::Wsh;
23use secp256k1::ecdsa::Signature;
24use secp256k1::{PublicKey, Scalar, SecretKey, XOnlyPublicKey};
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27
28pub mod config;
29pub mod endpoint_constants;
30
31#[cfg(test)]
32mod tests;
33
34pub const KIND: ModuleKind = ModuleKind::from_static_str("walletv2");
35
36pub const MODULE_CONSENSUS_VERSION: ModuleConsensusVersion = ModuleConsensusVersion::new(1, 0);
37
38/// Number of confirmations required for a transaction to be considered as
39/// final by the federation. The block that mines the transaction does
40/// not count towards the number of confirmations.
41pub const CONFIRMATION_FINALITY_DELAY: u64 = 6;
42
43/// How many blocks a peer's block count vote trails its bitcoin backend: the
44/// finality delay, plus one block of slack. Processing a new consensus block
45/// count waits for the local backend to report the consensus count plus the
46/// finality delay, and without the slack that is exactly the block the voter
47/// saw, so a backend a little behind, or a backend status that is stale (it is
48/// polled once a minute), stalled item processing on every new block.
49///
50/// Lives here rather than in the server so that the client can tell how deep a
51/// peg-in has to be before the federation claims it.
52pub const BLOCK_COUNT_VOTE_DELAY: u64 = CONFIRMATION_FINALITY_DELAY + 1;
53
54/// Returns a sleep duration of 1 second in test environments or 60 seconds in
55/// production. Used for polling intervals where faster feedback is needed
56/// during testing.
57pub fn sleep_duration() -> Duration {
58    if fedimint_core::envs::is_running_in_test_env() {
59        Duration::from_secs(1)
60    } else {
61        Duration::from_mins(1)
62    }
63}
64
65pub fn descriptor(pks: &BTreeMap<PeerId, PublicKey>, tweak: &sha256::Hash) -> Wsh<PublicKey> {
66    let scalar = Scalar::from_be_bytes(tweak.to_byte_array()).expect("Hash is within field order");
67    // Every guardian key is tweaked by the same point t*G. Compute it once
68    // instead of repeating the scalar multiplication for each guardian on every
69    // candidate in the client's probabilistic address search.
70    // Zero is a valid additive tweak, but cannot be represented as a public key.
71    let tweak_pk = (scalar != Scalar::ZERO).then(|| {
72        PublicKey::from_secret_key(
73            secp256k1::SECP256K1,
74            &SecretKey::from_slice(&tweak.to_byte_array()).expect("Nonzero scalar is a valid key"),
75        )
76    });
77
78    Wsh::new_sortedmulti(
79        pks.to_num_peers().threshold(),
80        pks.values()
81            .map(|pk| {
82                tweak_pk.map_or(*pk, |tweak_pk| {
83                    pk.combine(&tweak_pk)
84                        .expect("Failed to tweak bitcoin public key")
85                })
86            })
87            .collect::<Vec<PublicKey>>(),
88    )
89    .expect("Failed to construct Descriptor")
90}
91
92pub fn tweak_public_key(pk: &PublicKey, tweak: &sha256::Hash) -> PublicKey {
93    pk.add_exp_tweak(
94        secp256k1::SECP256K1,
95        &Scalar::from_be_bytes(tweak.to_byte_array()).expect("Hash is within field order"),
96    )
97    .expect("Failed to tweak bitcoin public key")
98}
99
100/// Returns true if the script pubkey potentially belongs to the federation.
101/// This uses a probabilistic filter - only ~1/65536 of P2WSH scripts pass.
102pub fn is_potential_receive(script_pubkey: &ScriptBuf, pks_hash: &sha256::Hash) -> bool {
103    (script_pubkey, pks_hash)
104        .consensus_hash::<sha256::Hash>()
105        .to_byte_array()
106        .iter()
107        .take(2)
108        .all(|b| *b == 0)
109}
110
111#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Encodable, Decodable)]
112pub struct FederationWallet {
113    pub value: bitcoin::Amount,
114    pub outpoint: bitcoin::OutPoint,
115    pub tweak: sha256::Hash,
116}
117
118#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Encodable, Decodable)]
119pub struct TxInfo {
120    pub index: u64,
121    pub txid: bitcoin::Txid,
122    pub input: bitcoin::Amount,
123    pub output: bitcoin::Amount,
124    pub fee: bitcoin::Amount,
125    pub vbytes: u64,
126    pub created: u64,
127}
128
129impl TxInfo {
130    pub fn feerate(&self) -> u64 {
131        self.fee.to_sat() / self.vbytes
132    }
133}
134
135#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
136pub struct OutputInfo {
137    pub index: u64,
138    pub script: ScriptBuf,
139    pub value: bitcoin::Amount,
140    pub spent: bool,
141    pub outpoint: Option<bitcoin::OutPoint>,
142}
143
144/// An output passing the probabilistic receive filter that a guardian can see,
145/// but which is not yet deep enough to have entered the federation's consensus
146/// output log.
147///
148/// This is advisory, non-consensus data: it is derived from a single guardian's
149/// local view of the chain and its mempool, and may be revoked by a reorg, an
150/// eviction or a replacement. It exists purely so a client can show a peg-in's
151/// progress instead of nothing at all while it waits out the module's
152/// confirmation finality delay.
153#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
154pub struct PendingOutput {
155    pub script: ScriptBuf,
156    pub outpoint: bitcoin::OutPoint,
157    pub value: bitcoin::Amount,
158    /// Height of the block that mined this output, or `None` while it is only
159    /// in the guardian's mempool.
160    ///
161    /// A mempool output is the weakest signal the federation offers: it is not
162    /// mined, not final, and can vanish entirely. Only guardians whose bitcoin
163    /// backend can enumerate a mempool report these at all.
164    pub height: Option<u64>,
165}
166
167/// A guardian's local, advisory view of the receive outputs it can see but the
168/// federation has not recorded yet, together with the block count that view was
169/// derived from.
170///
171/// Two kinds of output land here: candidates sitting in the guardian's mempool,
172/// and the receive outputs of the handful of blocks below its local chain tip.
173/// That block window is measured against the guardian's own tip rather than the
174/// consensus block count, so an output keeps being reported for a while *after*
175/// it becomes final - deliberately, so that a progress display does not blank
176/// out in the gap between finality and the claim landing. Presence here
177/// therefore does not mean a deposit is still unclaimed.
178///
179/// The block count is returned alongside the outputs so that a client computes
180/// confirmations against a single consistent view rather than mixing a height
181/// from one guardian with a chain tip from another.
182#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
183pub struct PendingOutputs {
184    /// The responding guardian's local block count, or `0` if its bitcoin
185    /// backend is unavailable.
186    ///
187    /// A mined output's confirmations are `block_count - height`, which counts
188    /// the block that mined it as the first confirmation. Note this is offset
189    /// by one from [`BLOCK_COUNT_VOTE_DELAY`], which does not count the mining
190    /// block, so a peg-in becomes claimable at `BLOCK_COUNT_VOTE_DELAY + 1`
191    /// confirmations.
192    pub block_count: u64,
193    pub outputs: Vec<PendingOutput>,
194    /// How much of its mempool this guardian inspected for these outputs.
195    ///
196    /// Distinguishes "no unmined peg-ins" from "this guardian could not look",
197    /// which an esplora-backed guardian never can. Without it a client could
198    /// not tell an empty mempool from a blind guardian.
199    pub mempool_visibility: MempoolVisibility,
200}
201
202/// How much of a mempool was inspected for unmined peg-ins.
203///
204/// Ordered from least to most visibility, so the best of several views is
205/// their maximum.
206#[derive(
207    Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
208)]
209pub enum MempoolVisibility {
210    /// Nothing unmined is reported, either because the backend cannot
211    /// enumerate a mempool (as esplora never can), because the mempool scan is
212    /// turned off, or because listing the mempool failed.
213    #[default]
214    Unavailable,
215    /// The mempool was listed, but not all of its transactions were
216    /// inspected: either fetching some of them failed, or the scan ran out of
217    /// its time budget and is still working through them. The latter is
218    /// normal on a healthy backend while a large mempool is first scanned.
219    ///
220    /// Every unmined output reported is real, but the set may be short of
221    /// transactions the guardian has but has not inspected yet.
222    Partial,
223    /// Every transaction in the guardian's mempool was inspected, so a deposit
224    /// that is not reported had not reached this mempool when it was scanned.
225    ///
226    /// This describes one guardian's local snapshot, not the network: a
227    /// payment that is not reported may still have been broadcast and not
228    /// have propagated to this node yet.
229    Complete,
230}
231
232#[derive(Debug)]
233pub struct WalletCommonInit;
234
235impl CommonModuleInit for WalletCommonInit {
236    const CONSENSUS_VERSION: ModuleConsensusVersion = MODULE_CONSENSUS_VERSION;
237    const KIND: ModuleKind = KIND;
238
239    type ClientConfig = WalletClientConfig;
240
241    fn decoder() -> Decoder {
242        WalletModuleTypes::decoder()
243    }
244}
245
246pub struct WalletModuleTypes;
247
248plugin_types_trait_impl_common!(
249    KIND,
250    WalletModuleTypes,
251    WalletClientConfig,
252    WalletInput,
253    WalletOutput,
254    WalletOutputOutcome,
255    WalletConsensusItem,
256    WalletInputError,
257    WalletOutputError
258);
259
260#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Encodable, Decodable)]
261pub enum WalletConsensusItem {
262    BlockCount(u64),
263    Feerate(Option<u64>),
264    Signatures(Txid, Vec<Signature>),
265    #[encodable_default]
266    Default {
267        variant: u64,
268        bytes: Vec<u8>,
269    },
270}
271
272impl std::fmt::Display for WalletConsensusItem {
273    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274        match self {
275            WalletConsensusItem::BlockCount(count) => {
276                write!(f, "Wallet Block Count {count}")
277            }
278            WalletConsensusItem::Feerate(feerate) => {
279                write!(f, "Wallet Feerate Vote {feerate:?}")
280            }
281            WalletConsensusItem::Signatures(..) => {
282                write!(f, "Wallet Signatures")
283            }
284            WalletConsensusItem::Default { variant, .. } => {
285                write!(f, "Unknown Wallet CI variant={variant}")
286            }
287        }
288    }
289}
290
291extensible_associated_module_type!(WalletInput, WalletInputV0, UnknownWalletInputVariantError);
292
293#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
294pub struct WalletInputV0 {
295    pub output_index: u64,
296    pub tweak: PublicKey,
297    pub fee: bitcoin::Amount,
298}
299
300impl std::fmt::Display for WalletInputV0 {
301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        write!(f, "Wallet PegIn for output index {}", self.output_index)
303    }
304}
305
306extensible_associated_module_type!(
307    WalletOutput,
308    WalletOutputV0,
309    UnknownWalletOutputVariantError
310);
311
312#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
313pub struct WalletOutputV0 {
314    pub destination: StandardScript,
315    pub value: bitcoin::Amount,
316    pub fee: bitcoin::Amount,
317}
318
319impl std::fmt::Display for WalletOutputV0 {
320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321        write!(f, "Wallet PegOut {}", self.value)
322    }
323}
324
325#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
326pub struct WalletOutputOutcome;
327
328impl std::fmt::Display for WalletOutputOutcome {
329    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330        write!(f, "Wallet Output Outcome")
331    }
332}
333
334#[derive(Debug, Error, Encodable, Decodable, Hash, Clone, Eq, PartialEq)]
335pub enum WalletInputError {
336    #[error("The wallet input version is not supported by this federation")]
337    UnknownInputVariant(#[from] UnknownWalletInputVariantError),
338    #[error("The output has already been claimed")]
339    OutputAlreadySpent,
340    #[error("Unknown output index")]
341    UnknownOutputIndex,
342    #[error("The tweak does not match the output script")]
343    WrongTweak,
344    #[error("No up to date feerate is available at the moment. Please try again later.")]
345    NoConsensusFeerateAvailable,
346    #[error("The total transaction fee is too low. Please construct a new transaction.")]
347    InsufficientTotalFee,
348    #[error("Constructing the pegin transaction caused an arithmetic overflow")]
349    ArithmeticOverflow,
350}
351
352#[derive(Debug, Error, Encodable, Decodable, Hash, Clone, Eq, PartialEq)]
353pub enum WalletOutputError {
354    #[error("The wallet output version is not supported by this federation")]
355    UnknownOutputVariant(#[from] UnknownWalletOutputVariantError),
356    #[error("The output value is below the dust limit.")]
357    UnderDustLimit,
358    #[error("The federation does not have any funds yet")]
359    NoFederationUTXO,
360    #[error("No up to date feerate is available at the moment. Please try again later.")]
361    NoConsensusFeerateAvailable,
362    #[error("The total transaction fee is too low. Please construct a new transaction.")]
363    InsufficientTotalFee,
364    #[error("The change value is below the dust limit.")]
365    ChangeUnderDustLimit,
366    #[error("Constructing the pegout transaction caused an arithmetic overflow")]
367    ArithmeticOverflow,
368    #[error("Unknown script variant")]
369    UnknownScriptVariant,
370}
371
372#[derive(Debug, Clone, Eq, PartialEq, Hash, Encodable, Decodable, Serialize, Deserialize)]
373pub enum StandardScript {
374    P2PKH(hash160::Hash),
375    P2SH(hash160::Hash),
376    P2WPKH(hash160::Hash),
377    P2WSH(sha256::Hash),
378    P2TR(XOnlyPublicKey),
379    #[encodable_default]
380    Default {
381        variant: u64,
382        bytes: Vec<u8>,
383    },
384}
385
386impl StandardScript {
387    pub fn from_address(address: &Address) -> Option<Self> {
388        if let Some(hash) = address.pubkey_hash() {
389            return Some(StandardScript::P2PKH(hash.to_raw_hash()));
390        }
391
392        if let Some(hash) = address.script_hash() {
393            return Some(StandardScript::P2SH(hash.to_raw_hash()));
394        }
395
396        let program = address.witness_program()?;
397
398        if program.is_p2wpkh() {
399            return Some(StandardScript::P2WPKH(
400                hash160::Hash::from_slice(program.program().as_bytes())
401                    .expect("Witness program is 20 bytes"),
402            ));
403        }
404
405        if program.is_p2wsh() {
406            return Some(StandardScript::P2WSH(
407                sha256::Hash::from_slice(program.program().as_bytes())
408                    .expect("Witness program is 32 bytes"),
409            ));
410        }
411
412        if program.is_p2tr() {
413            return Some(StandardScript::P2TR(
414                XOnlyPublicKey::from_slice(program.program().as_bytes())
415                    .expect("Witness program is 32 bytes"),
416            ));
417        }
418
419        None
420    }
421
422    pub fn script_pubkey(&self) -> Option<ScriptBuf> {
423        match self {
424            Self::P2PKH(hash) => Some(ScriptBuf::new_p2pkh(&PubkeyHash::from_raw_hash(*hash))),
425            Self::P2SH(hash) => Some(ScriptBuf::new_p2sh(&ScriptHash::from_raw_hash(*hash))),
426            Self::P2WPKH(hash) => Some(ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(*hash))),
427            Self::P2WSH(hash) => Some(ScriptBuf::new_p2wsh(&WScriptHash::from_raw_hash(*hash))),
428            Self::P2TR(pk) => Some(ScriptBuf::new_p2tr_tweaked(pk.dangerous_assume_tweaked())),
429            Self::Default { .. } => None,
430        }
431    }
432}
433
434#[cfg(test)]
435fn assert_standard_script_roundtrip(addr: &str, variant: fn(&StandardScript) -> bool) {
436    let address = addr
437        .parse::<bitcoin::Address<bitcoin::address::NetworkUnchecked>>()
438        .expect("Failed to parse address")
439        .require_network(bitcoin::Network::Bitcoin)
440        .expect("Wrong network");
441
442    let script = StandardScript::from_address(&address)
443        .expect("Failed to convert address to StandardScript");
444
445    assert!(variant(&script), "Unexpected StandardScript variant");
446
447    assert_eq!(Some(address.script_pubkey()), script.script_pubkey());
448}
449
450#[test]
451fn test_standard_script_p2pkh() {
452    assert_standard_script_roundtrip("1QJVDzdqb1VpbDK7uDeyVXy9mR27CJiyhY", |s| {
453        matches!(s, StandardScript::P2PKH(..))
454    });
455}
456
457#[test]
458fn test_standard_script_p2sh() {
459    assert_standard_script_roundtrip("33iFwdLuRpW1uK1RTRqsoi8rR4NpDzk66k", |s| {
460        matches!(s, StandardScript::P2SH(..))
461    });
462}
463
464#[test]
465fn test_standard_script_p2wpkh() {
466    assert_standard_script_roundtrip("bc1qvzvkjn4q3nszqxrv3nraga2r822xjty3ykvkuw", |s| {
467        matches!(s, StandardScript::P2WPKH(..))
468    });
469}
470
471#[test]
472fn test_standard_script_p2wsh() {
473    assert_standard_script_roundtrip(
474        "bc1qwqdg6squsna38e46795at95yu9atm8azzmyvckulcc7kytlcckxswvvzej",
475        |s| matches!(s, StandardScript::P2WSH(..)),
476    );
477}
478
479#[test]
480fn test_standard_script_p2tr() {
481    assert_standard_script_roundtrip(
482        "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr",
483        |s| matches!(s, StandardScript::P2TR(..)),
484    );
485}
486
487#[test]
488fn test_standard_script_unknown_witness_version() {
489    let address = "bc1pw508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7kt5nd6y"
490        .parse::<bitcoin::Address<bitcoin::address::NetworkUnchecked>>()
491        .expect("Failed to parse address")
492        .require_network(bitcoin::Network::Bitcoin)
493        .expect("Wrong network");
494
495    assert!(StandardScript::from_address(&address).is_none());
496}