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;
15use fedimint_core::core::{Decoder, ModuleInstanceId, ModuleKind};
16use fedimint_core::encoding::{Decodable, Encodable};
17use fedimint_core::module::{CommonModuleInit, ModuleCommon, ModuleConsensusVersion};
18use fedimint_core::{
19    NumPeersExt, PeerId, extensible_associated_module_type, plugin_types_trait_impl_common,
20};
21use miniscript::descriptor::Wsh;
22use secp256k1::ecdsa::Signature;
23use secp256k1::{PublicKey, Scalar, XOnlyPublicKey};
24use serde::{Deserialize, Serialize};
25use thiserror::Error;
26
27pub mod config;
28pub mod endpoint_constants;
29
30pub const KIND: ModuleKind = ModuleKind::from_static_str("walletv2");
31
32pub const MODULE_CONSENSUS_VERSION: ModuleConsensusVersion = ModuleConsensusVersion::new(1, 0);
33
34/// Returns a sleep duration of 1 second in test environments or 60 seconds in
35/// production. Used for polling intervals where faster feedback is needed
36/// during testing.
37pub fn sleep_duration() -> Duration {
38    if fedimint_core::envs::is_running_in_test_env() {
39        Duration::from_secs(1)
40    } else {
41        Duration::from_mins(1)
42    }
43}
44
45pub fn descriptor(pks: &BTreeMap<PeerId, PublicKey>, tweak: &sha256::Hash) -> Wsh<PublicKey> {
46    Wsh::new_sortedmulti(
47        pks.to_num_peers().threshold(),
48        pks.values()
49            .map(|pk| tweak_public_key(pk, tweak))
50            .collect::<Vec<PublicKey>>(),
51    )
52    .expect("Failed to construct Descriptor")
53}
54
55pub fn tweak_public_key(pk: &PublicKey, tweak: &sha256::Hash) -> PublicKey {
56    pk.add_exp_tweak(
57        secp256k1::SECP256K1,
58        &Scalar::from_be_bytes(tweak.to_byte_array()).expect("Hash is within field order"),
59    )
60    .expect("Failed to tweak bitcoin public key")
61}
62
63/// Returns true if the script pubkey potentially belongs to the federation.
64/// This uses a probabilistic filter - only ~1/65536 of P2WSH scripts pass.
65pub fn is_potential_receive(script_pubkey: &ScriptBuf, pks_hash: &sha256::Hash) -> bool {
66    (script_pubkey, pks_hash)
67        .consensus_hash::<sha256::Hash>()
68        .to_byte_array()
69        .iter()
70        .take(2)
71        .all(|b| *b == 0)
72}
73
74#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Encodable, Decodable)]
75pub struct FederationWallet {
76    pub value: bitcoin::Amount,
77    pub outpoint: bitcoin::OutPoint,
78    pub tweak: sha256::Hash,
79}
80
81#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Encodable, Decodable)]
82pub struct TxInfo {
83    pub index: u64,
84    pub txid: bitcoin::Txid,
85    pub input: bitcoin::Amount,
86    pub output: bitcoin::Amount,
87    pub fee: bitcoin::Amount,
88    pub vbytes: u64,
89    pub created: u64,
90}
91
92impl TxInfo {
93    pub fn feerate(&self) -> u64 {
94        self.fee.to_sat() / self.vbytes
95    }
96}
97
98#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
99pub struct DepositRange {
100    pub deposits: Vec<bitcoin::TxOut>,
101    pub spent: Vec<u64>,
102}
103
104#[derive(Debug)]
105pub struct WalletCommonInit;
106
107impl CommonModuleInit for WalletCommonInit {
108    const CONSENSUS_VERSION: ModuleConsensusVersion = MODULE_CONSENSUS_VERSION;
109    const KIND: ModuleKind = KIND;
110
111    type ClientConfig = WalletClientConfig;
112
113    fn decoder() -> Decoder {
114        WalletModuleTypes::decoder()
115    }
116}
117
118pub struct WalletModuleTypes;
119
120plugin_types_trait_impl_common!(
121    KIND,
122    WalletModuleTypes,
123    WalletClientConfig,
124    WalletInput,
125    WalletOutput,
126    WalletOutputOutcome,
127    WalletConsensusItem,
128    WalletInputError,
129    WalletOutputError
130);
131
132#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Encodable, Decodable)]
133pub enum WalletConsensusItem {
134    BlockCount(u64),
135    Feerate(Option<u64>),
136    Signatures(Txid, Vec<Signature>),
137    #[encodable_default]
138    Default {
139        variant: u64,
140        bytes: Vec<u8>,
141    },
142}
143
144impl std::fmt::Display for WalletConsensusItem {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        match self {
147            WalletConsensusItem::BlockCount(count) => {
148                write!(f, "Wallet Block Count {count}")
149            }
150            WalletConsensusItem::Feerate(feerate) => {
151                write!(f, "Wallet Feerate Vote {feerate:?}")
152            }
153            WalletConsensusItem::Signatures(..) => {
154                write!(f, "Wallet Signatures")
155            }
156            WalletConsensusItem::Default { variant, .. } => {
157                write!(f, "Unknown Wallet CI variant={variant}")
158            }
159        }
160    }
161}
162
163extensible_associated_module_type!(WalletInput, WalletInputV0, UnknownWalletInputVariantError);
164
165#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
166pub struct WalletInputV0 {
167    pub deposit_index: u64,
168    pub tweak: PublicKey,
169    pub fee: bitcoin::Amount,
170}
171
172impl std::fmt::Display for WalletInputV0 {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        write!(f, "Wallet PegIn for deposit index {}", self.deposit_index)
175    }
176}
177
178extensible_associated_module_type!(
179    WalletOutput,
180    WalletOutputV0,
181    UnknownWalletOutputVariantError
182);
183
184#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
185pub struct WalletOutputV0 {
186    pub destination: StandardScript,
187    pub value: bitcoin::Amount,
188    pub fee: bitcoin::Amount,
189}
190
191impl std::fmt::Display for WalletOutputV0 {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        write!(f, "Wallet PegOut {}", self.value)
194    }
195}
196
197#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
198pub struct WalletOutputOutcome;
199
200impl std::fmt::Display for WalletOutputOutcome {
201    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202        write!(f, "Wallet Output Outcome")
203    }
204}
205
206#[derive(Debug, Error, Encodable, Decodable, Hash, Clone, Eq, PartialEq)]
207pub enum WalletInputError {
208    #[error("The wallet input version is not supported by this federation")]
209    UnknownInputVariant(#[from] UnknownWalletInputVariantError),
210    #[error("The deposit has already been claimed")]
211    DepositAlreadySpent,
212    #[error("Unknown deposit index")]
213    UnknownDepositIndex,
214    #[error("The tweak does not match the deposit script")]
215    WrongTweak,
216    #[error("No up to date feerate is available at the moment. Please try again later.")]
217    NoConsensusFeerateAvailable,
218    #[error("The total transaction fee is too low. Please construct a new transaction.")]
219    InsufficientTotalFee,
220    #[error("Constructing the pegin transaction caused an arithmetic overflow")]
221    ArithmeticOverflow,
222}
223
224#[derive(Debug, Error, Encodable, Decodable, Hash, Clone, Eq, PartialEq)]
225pub enum WalletOutputError {
226    #[error("The wallet output version is not supported by this federation")]
227    UnknownOutputVariant(#[from] UnknownWalletOutputVariantError),
228    #[error("The output value is below the dust limit.")]
229    UnderDustLimit,
230    #[error("The federation does not have any funds yet")]
231    NoFederationUTXO,
232    #[error("No up to date feerate is available at the moment. Please try again later.")]
233    NoConsensusFeerateAvailable,
234    #[error("The total transaction fee is too low. Please construct a new transaction.")]
235    InsufficientTotalFee,
236    #[error("The change value is below the dust limit.")]
237    ChangeUnderDustLimit,
238    #[error("Constructing the pegout transaction caused an arithmetic overflow")]
239    ArithmeticOverflow,
240    #[error("Unknown script variant")]
241    UnknownScriptVariant,
242}
243
244#[derive(Debug, Clone, Eq, PartialEq, Hash, Encodable, Decodable, Serialize, Deserialize)]
245pub enum StandardScript {
246    P2PKH(hash160::Hash),
247    P2SH(hash160::Hash),
248    P2WPKH(hash160::Hash),
249    P2WSH(sha256::Hash),
250    P2TR(XOnlyPublicKey),
251    #[encodable_default]
252    Default {
253        variant: u64,
254        bytes: Vec<u8>,
255    },
256}
257
258impl StandardScript {
259    pub fn from_address(address: &Address) -> Option<Self> {
260        if let Some(hash) = address.pubkey_hash() {
261            return Some(StandardScript::P2PKH(hash.to_raw_hash()));
262        }
263
264        if let Some(hash) = address.script_hash() {
265            return Some(StandardScript::P2SH(hash.to_raw_hash()));
266        }
267
268        let program = address.witness_program()?;
269
270        if program.is_p2wpkh() {
271            return Some(StandardScript::P2WPKH(
272                hash160::Hash::from_slice(program.program().as_bytes())
273                    .expect("Witness program is 20 bytes"),
274            ));
275        }
276
277        if program.is_p2wsh() {
278            return Some(StandardScript::P2WSH(
279                sha256::Hash::from_slice(program.program().as_bytes())
280                    .expect("Witness program is 32 bytes"),
281            ));
282        }
283
284        if program.is_p2tr() {
285            return Some(StandardScript::P2TR(
286                XOnlyPublicKey::from_slice(program.program().as_bytes())
287                    .expect("Witness program is 32 bytes"),
288            ));
289        }
290
291        None
292    }
293
294    pub fn script_pubkey(&self) -> Option<ScriptBuf> {
295        match self {
296            Self::P2PKH(hash) => Some(ScriptBuf::new_p2pkh(&PubkeyHash::from_raw_hash(*hash))),
297            Self::P2SH(hash) => Some(ScriptBuf::new_p2sh(&ScriptHash::from_raw_hash(*hash))),
298            Self::P2WPKH(hash) => Some(ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(*hash))),
299            Self::P2WSH(hash) => Some(ScriptBuf::new_p2wsh(&WScriptHash::from_raw_hash(*hash))),
300            Self::P2TR(pk) => Some(ScriptBuf::new_p2tr_tweaked(pk.dangerous_assume_tweaked())),
301            Self::Default { .. } => None,
302        }
303    }
304}
305
306#[cfg(test)]
307fn assert_standard_script_roundtrip(addr: &str, variant: fn(&StandardScript) -> bool) {
308    let address = addr
309        .parse::<bitcoin::Address<bitcoin::address::NetworkUnchecked>>()
310        .expect("Failed to parse address")
311        .require_network(bitcoin::Network::Bitcoin)
312        .expect("Wrong network");
313
314    let script = StandardScript::from_address(&address)
315        .expect("Failed to convert address to StandardScript");
316
317    assert!(variant(&script), "Unexpected StandardScript variant");
318
319    assert_eq!(Some(address.script_pubkey()), script.script_pubkey());
320}
321
322#[test]
323fn test_standard_script_p2pkh() {
324    assert_standard_script_roundtrip("1QJVDzdqb1VpbDK7uDeyVXy9mR27CJiyhY", |s| {
325        matches!(s, StandardScript::P2PKH(..))
326    });
327}
328
329#[test]
330fn test_standard_script_p2sh() {
331    assert_standard_script_roundtrip("33iFwdLuRpW1uK1RTRqsoi8rR4NpDzk66k", |s| {
332        matches!(s, StandardScript::P2SH(..))
333    });
334}
335
336#[test]
337fn test_standard_script_p2wpkh() {
338    assert_standard_script_roundtrip("bc1qvzvkjn4q3nszqxrv3nraga2r822xjty3ykvkuw", |s| {
339        matches!(s, StandardScript::P2WPKH(..))
340    });
341}
342
343#[test]
344fn test_standard_script_p2wsh() {
345    assert_standard_script_roundtrip(
346        "bc1qwqdg6squsna38e46795at95yu9atm8azzmyvckulcc7kytlcckxswvvzej",
347        |s| matches!(s, StandardScript::P2WSH(..)),
348    );
349}
350
351#[test]
352fn test_standard_script_p2tr() {
353    assert_standard_script_roundtrip(
354        "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr",
355        |s| matches!(s, StandardScript::P2TR(..)),
356    );
357}
358
359#[test]
360fn test_standard_script_unknown_witness_version() {
361    let address = "bc1pw508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7kt5nd6y"
362        .parse::<bitcoin::Address<bitcoin::address::NetworkUnchecked>>()
363        .expect("Failed to parse address")
364        .require_network(bitcoin::Network::Bitcoin)
365        .expect("Wrong network");
366
367    assert!(StandardScript::from_address(&address).is_none());
368}