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