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