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 OutputInfo {
100    pub index: u64,
101    pub script: ScriptBuf,
102    pub value: bitcoin::Amount,
103    pub spent: bool,
104}
105
106#[derive(Debug)]
107pub struct WalletCommonInit;
108
109impl CommonModuleInit for WalletCommonInit {
110    const CONSENSUS_VERSION: ModuleConsensusVersion = MODULE_CONSENSUS_VERSION;
111    const KIND: ModuleKind = KIND;
112
113    type ClientConfig = WalletClientConfig;
114
115    fn decoder() -> Decoder {
116        WalletModuleTypes::decoder()
117    }
118}
119
120pub struct WalletModuleTypes;
121
122plugin_types_trait_impl_common!(
123    KIND,
124    WalletModuleTypes,
125    WalletClientConfig,
126    WalletInput,
127    WalletOutput,
128    WalletOutputOutcome,
129    WalletConsensusItem,
130    WalletInputError,
131    WalletOutputError
132);
133
134#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Encodable, Decodable)]
135pub enum WalletConsensusItem {
136    BlockCount(u64),
137    Feerate(Option<u64>),
138    Signatures(Txid, Vec<Signature>),
139    #[encodable_default]
140    Default {
141        variant: u64,
142        bytes: Vec<u8>,
143    },
144}
145
146impl std::fmt::Display for WalletConsensusItem {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        match self {
149            WalletConsensusItem::BlockCount(count) => {
150                write!(f, "Wallet Block Count {count}")
151            }
152            WalletConsensusItem::Feerate(feerate) => {
153                write!(f, "Wallet Feerate Vote {feerate:?}")
154            }
155            WalletConsensusItem::Signatures(..) => {
156                write!(f, "Wallet Signatures")
157            }
158            WalletConsensusItem::Default { variant, .. } => {
159                write!(f, "Unknown Wallet CI variant={variant}")
160            }
161        }
162    }
163}
164
165extensible_associated_module_type!(WalletInput, WalletInputV0, UnknownWalletInputVariantError);
166
167#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
168pub struct WalletInputV0 {
169    pub output_index: u64,
170    pub tweak: PublicKey,
171    pub fee: bitcoin::Amount,
172}
173
174impl std::fmt::Display for WalletInputV0 {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        write!(f, "Wallet PegIn for output index {}", self.output_index)
177    }
178}
179
180extensible_associated_module_type!(
181    WalletOutput,
182    WalletOutputV0,
183    UnknownWalletOutputVariantError
184);
185
186#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
187pub struct WalletOutputV0 {
188    pub destination: StandardScript,
189    pub value: bitcoin::Amount,
190    pub fee: bitcoin::Amount,
191}
192
193impl std::fmt::Display for WalletOutputV0 {
194    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195        write!(f, "Wallet PegOut {}", self.value)
196    }
197}
198
199#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
200pub struct WalletOutputOutcome;
201
202impl std::fmt::Display for WalletOutputOutcome {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        write!(f, "Wallet Output Outcome")
205    }
206}
207
208#[derive(Debug, Error, Encodable, Decodable, Hash, Clone, Eq, PartialEq)]
209pub enum WalletInputError {
210    #[error("The wallet input version is not supported by this federation")]
211    UnknownInputVariant(#[from] UnknownWalletInputVariantError),
212    #[error("The output has already been claimed")]
213    OutputAlreadySpent,
214    #[error("Unknown output index")]
215    UnknownOutputIndex,
216    #[error("The tweak does not match the output script")]
217    WrongTweak,
218    #[error("No up to date feerate is available at the moment. Please try again later.")]
219    NoConsensusFeerateAvailable,
220    #[error("The total transaction fee is too low. Please construct a new transaction.")]
221    InsufficientTotalFee,
222    #[error("Constructing the pegin transaction caused an arithmetic overflow")]
223    ArithmeticOverflow,
224}
225
226#[derive(Debug, Error, Encodable, Decodable, Hash, Clone, Eq, PartialEq)]
227pub enum WalletOutputError {
228    #[error("The wallet output version is not supported by this federation")]
229    UnknownOutputVariant(#[from] UnknownWalletOutputVariantError),
230    #[error("The output value is below the dust limit.")]
231    UnderDustLimit,
232    #[error("The federation does not have any funds yet")]
233    NoFederationUTXO,
234    #[error("No up to date feerate is available at the moment. Please try again later.")]
235    NoConsensusFeerateAvailable,
236    #[error("The total transaction fee is too low. Please construct a new transaction.")]
237    InsufficientTotalFee,
238    #[error("The change value is below the dust limit.")]
239    ChangeUnderDustLimit,
240    #[error("Constructing the pegout transaction caused an arithmetic overflow")]
241    ArithmeticOverflow,
242    #[error("Unknown script variant")]
243    UnknownScriptVariant,
244}
245
246#[derive(Debug, Clone, Eq, PartialEq, Hash, Encodable, Decodable, Serialize, Deserialize)]
247pub enum StandardScript {
248    P2PKH(hash160::Hash),
249    P2SH(hash160::Hash),
250    P2WPKH(hash160::Hash),
251    P2WSH(sha256::Hash),
252    P2TR(XOnlyPublicKey),
253    #[encodable_default]
254    Default {
255        variant: u64,
256        bytes: Vec<u8>,
257    },
258}
259
260impl StandardScript {
261    pub fn from_address(address: &Address) -> Option<Self> {
262        if let Some(hash) = address.pubkey_hash() {
263            return Some(StandardScript::P2PKH(hash.to_raw_hash()));
264        }
265
266        if let Some(hash) = address.script_hash() {
267            return Some(StandardScript::P2SH(hash.to_raw_hash()));
268        }
269
270        let program = address.witness_program()?;
271
272        if program.is_p2wpkh() {
273            return Some(StandardScript::P2WPKH(
274                hash160::Hash::from_slice(program.program().as_bytes())
275                    .expect("Witness program is 20 bytes"),
276            ));
277        }
278
279        if program.is_p2wsh() {
280            return Some(StandardScript::P2WSH(
281                sha256::Hash::from_slice(program.program().as_bytes())
282                    .expect("Witness program is 32 bytes"),
283            ));
284        }
285
286        if program.is_p2tr() {
287            return Some(StandardScript::P2TR(
288                XOnlyPublicKey::from_slice(program.program().as_bytes())
289                    .expect("Witness program is 32 bytes"),
290            ));
291        }
292
293        None
294    }
295
296    pub fn script_pubkey(&self) -> Option<ScriptBuf> {
297        match self {
298            Self::P2PKH(hash) => Some(ScriptBuf::new_p2pkh(&PubkeyHash::from_raw_hash(*hash))),
299            Self::P2SH(hash) => Some(ScriptBuf::new_p2sh(&ScriptHash::from_raw_hash(*hash))),
300            Self::P2WPKH(hash) => Some(ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(*hash))),
301            Self::P2WSH(hash) => Some(ScriptBuf::new_p2wsh(&WScriptHash::from_raw_hash(*hash))),
302            Self::P2TR(pk) => Some(ScriptBuf::new_p2tr_tweaked(pk.dangerous_assume_tweaked())),
303            Self::Default { .. } => None,
304        }
305    }
306}
307
308#[cfg(test)]
309fn assert_standard_script_roundtrip(addr: &str, variant: fn(&StandardScript) -> bool) {
310    let address = addr
311        .parse::<bitcoin::Address<bitcoin::address::NetworkUnchecked>>()
312        .expect("Failed to parse address")
313        .require_network(bitcoin::Network::Bitcoin)
314        .expect("Wrong network");
315
316    let script = StandardScript::from_address(&address)
317        .expect("Failed to convert address to StandardScript");
318
319    assert!(variant(&script), "Unexpected StandardScript variant");
320
321    assert_eq!(Some(address.script_pubkey()), script.script_pubkey());
322}
323
324#[test]
325fn test_standard_script_p2pkh() {
326    assert_standard_script_roundtrip("1QJVDzdqb1VpbDK7uDeyVXy9mR27CJiyhY", |s| {
327        matches!(s, StandardScript::P2PKH(..))
328    });
329}
330
331#[test]
332fn test_standard_script_p2sh() {
333    assert_standard_script_roundtrip("33iFwdLuRpW1uK1RTRqsoi8rR4NpDzk66k", |s| {
334        matches!(s, StandardScript::P2SH(..))
335    });
336}
337
338#[test]
339fn test_standard_script_p2wpkh() {
340    assert_standard_script_roundtrip("bc1qvzvkjn4q3nszqxrv3nraga2r822xjty3ykvkuw", |s| {
341        matches!(s, StandardScript::P2WPKH(..))
342    });
343}
344
345#[test]
346fn test_standard_script_p2wsh() {
347    assert_standard_script_roundtrip(
348        "bc1qwqdg6squsna38e46795at95yu9atm8azzmyvckulcc7kytlcckxswvvzej",
349        |s| matches!(s, StandardScript::P2WSH(..)),
350    );
351}
352
353#[test]
354fn test_standard_script_p2tr() {
355    assert_standard_script_roundtrip(
356        "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr",
357        |s| matches!(s, StandardScript::P2TR(..)),
358    );
359}
360
361#[test]
362fn test_standard_script_unknown_witness_version() {
363    let address = "bc1pw508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7kt5nd6y"
364        .parse::<bitcoin::Address<bitcoin::address::NetworkUnchecked>>()
365        .expect("Failed to parse address")
366        .require_network(bitcoin::Network::Bitcoin)
367        .expect("Wrong network");
368
369    assert!(StandardScript::from_address(&address).is_none());
370}