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