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, SecretKey, XOnlyPublicKey};
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27
28pub mod config;
29pub mod endpoint_constants;
30
31#[cfg(test)]
32mod tests;
33
34pub const KIND: ModuleKind = ModuleKind::from_static_str("walletv2");
35
36pub const MODULE_CONSENSUS_VERSION: ModuleConsensusVersion = ModuleConsensusVersion::new(1, 0);
37
38pub const CONFIRMATION_FINALITY_DELAY: u64 = 6;
42
43pub const BLOCK_COUNT_VOTE_DELAY: u64 = CONFIRMATION_FINALITY_DELAY + 1;
53
54pub fn sleep_duration() -> Duration {
58 if fedimint_core::envs::is_running_in_test_env() {
59 Duration::from_secs(1)
60 } else {
61 Duration::from_mins(1)
62 }
63}
64
65pub fn descriptor(pks: &BTreeMap<PeerId, PublicKey>, tweak: &sha256::Hash) -> Wsh<PublicKey> {
66 let scalar = Scalar::from_be_bytes(tweak.to_byte_array()).expect("Hash is within field order");
67 let tweak_pk = (scalar != Scalar::ZERO).then(|| {
72 PublicKey::from_secret_key(
73 secp256k1::SECP256K1,
74 &SecretKey::from_slice(&tweak.to_byte_array()).expect("Nonzero scalar is a valid key"),
75 )
76 });
77
78 Wsh::new_sortedmulti(
79 pks.to_num_peers().threshold(),
80 pks.values()
81 .map(|pk| {
82 tweak_pk.map_or(*pk, |tweak_pk| {
83 pk.combine(&tweak_pk)
84 .expect("Failed to tweak bitcoin public key")
85 })
86 })
87 .collect::<Vec<PublicKey>>(),
88 )
89 .expect("Failed to construct Descriptor")
90}
91
92pub fn tweak_public_key(pk: &PublicKey, tweak: &sha256::Hash) -> PublicKey {
93 pk.add_exp_tweak(
94 secp256k1::SECP256K1,
95 &Scalar::from_be_bytes(tweak.to_byte_array()).expect("Hash is within field order"),
96 )
97 .expect("Failed to tweak bitcoin public key")
98}
99
100pub fn is_potential_receive(script_pubkey: &ScriptBuf, pks_hash: &sha256::Hash) -> bool {
103 (script_pubkey, pks_hash)
104 .consensus_hash::<sha256::Hash>()
105 .to_byte_array()
106 .iter()
107 .take(2)
108 .all(|b| *b == 0)
109}
110
111#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Encodable, Decodable)]
112pub struct FederationWallet {
113 pub value: bitcoin::Amount,
114 pub outpoint: bitcoin::OutPoint,
115 pub tweak: sha256::Hash,
116}
117
118#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Encodable, Decodable)]
119pub struct TxInfo {
120 pub index: u64,
121 pub txid: bitcoin::Txid,
122 pub input: bitcoin::Amount,
123 pub output: bitcoin::Amount,
124 pub fee: bitcoin::Amount,
125 pub vbytes: u64,
126 pub created: u64,
127}
128
129impl TxInfo {
130 pub fn feerate(&self) -> u64 {
131 self.fee.to_sat() / self.vbytes
132 }
133}
134
135#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
136pub struct OutputInfo {
137 pub index: u64,
138 pub script: ScriptBuf,
139 pub value: bitcoin::Amount,
140 pub spent: bool,
141 pub outpoint: Option<bitcoin::OutPoint>,
142}
143
144#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
154pub struct PendingOutput {
155 pub script: ScriptBuf,
156 pub outpoint: bitcoin::OutPoint,
157 pub value: bitcoin::Amount,
158 pub height: Option<u64>,
165}
166
167#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
183pub struct PendingOutputs {
184 pub block_count: u64,
193 pub outputs: Vec<PendingOutput>,
194 pub mempool_visibility: MempoolVisibility,
200}
201
202#[derive(
207 Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
208)]
209pub enum MempoolVisibility {
210 #[default]
214 Unavailable,
215 Partial,
223 Complete,
230}
231
232#[derive(Debug)]
233pub struct WalletCommonInit;
234
235impl CommonModuleInit for WalletCommonInit {
236 const CONSENSUS_VERSION: ModuleConsensusVersion = MODULE_CONSENSUS_VERSION;
237 const KIND: ModuleKind = KIND;
238
239 type ClientConfig = WalletClientConfig;
240
241 fn decoder() -> Decoder {
242 WalletModuleTypes::decoder()
243 }
244}
245
246pub struct WalletModuleTypes;
247
248plugin_types_trait_impl_common!(
249 KIND,
250 WalletModuleTypes,
251 WalletClientConfig,
252 WalletInput,
253 WalletOutput,
254 WalletOutputOutcome,
255 WalletConsensusItem,
256 WalletInputError,
257 WalletOutputError
258);
259
260#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Encodable, Decodable)]
261pub enum WalletConsensusItem {
262 BlockCount(u64),
263 Feerate(Option<u64>),
264 Signatures(Txid, Vec<Signature>),
265 #[encodable_default]
266 Default {
267 variant: u64,
268 bytes: Vec<u8>,
269 },
270}
271
272impl std::fmt::Display for WalletConsensusItem {
273 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274 match self {
275 WalletConsensusItem::BlockCount(count) => {
276 write!(f, "Wallet Block Count {count}")
277 }
278 WalletConsensusItem::Feerate(feerate) => {
279 write!(f, "Wallet Feerate Vote {feerate:?}")
280 }
281 WalletConsensusItem::Signatures(..) => {
282 write!(f, "Wallet Signatures")
283 }
284 WalletConsensusItem::Default { variant, .. } => {
285 write!(f, "Unknown Wallet CI variant={variant}")
286 }
287 }
288 }
289}
290
291extensible_associated_module_type!(WalletInput, WalletInputV0, UnknownWalletInputVariantError);
292
293#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
294pub struct WalletInputV0 {
295 pub output_index: u64,
296 pub tweak: PublicKey,
297 pub fee: bitcoin::Amount,
298}
299
300impl std::fmt::Display for WalletInputV0 {
301 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302 write!(f, "Wallet PegIn for output index {}", self.output_index)
303 }
304}
305
306extensible_associated_module_type!(
307 WalletOutput,
308 WalletOutputV0,
309 UnknownWalletOutputVariantError
310);
311
312#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
313pub struct WalletOutputV0 {
314 pub destination: StandardScript,
315 pub value: bitcoin::Amount,
316 pub fee: bitcoin::Amount,
317}
318
319impl std::fmt::Display for WalletOutputV0 {
320 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321 write!(f, "Wallet PegOut {}", self.value)
322 }
323}
324
325#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize, Serialize, Encodable, Decodable)]
326pub struct WalletOutputOutcome;
327
328impl std::fmt::Display for WalletOutputOutcome {
329 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330 write!(f, "Wallet Output Outcome")
331 }
332}
333
334#[derive(Debug, Error, Encodable, Decodable, Hash, Clone, Eq, PartialEq)]
335pub enum WalletInputError {
336 #[error("The wallet input version is not supported by this federation")]
337 UnknownInputVariant(#[from] UnknownWalletInputVariantError),
338 #[error("The output has already been claimed")]
339 OutputAlreadySpent,
340 #[error("Unknown output index")]
341 UnknownOutputIndex,
342 #[error("The tweak does not match the output script")]
343 WrongTweak,
344 #[error("No up to date feerate is available at the moment. Please try again later.")]
345 NoConsensusFeerateAvailable,
346 #[error("The total transaction fee is too low. Please construct a new transaction.")]
347 InsufficientTotalFee,
348 #[error("Constructing the pegin transaction caused an arithmetic overflow")]
349 ArithmeticOverflow,
350}
351
352#[derive(Debug, Error, Encodable, Decodable, Hash, Clone, Eq, PartialEq)]
353pub enum WalletOutputError {
354 #[error("The wallet output version is not supported by this federation")]
355 UnknownOutputVariant(#[from] UnknownWalletOutputVariantError),
356 #[error("The output value is below the dust limit.")]
357 UnderDustLimit,
358 #[error("The federation does not have any funds yet")]
359 NoFederationUTXO,
360 #[error("No up to date feerate is available at the moment. Please try again later.")]
361 NoConsensusFeerateAvailable,
362 #[error("The total transaction fee is too low. Please construct a new transaction.")]
363 InsufficientTotalFee,
364 #[error("The change value is below the dust limit.")]
365 ChangeUnderDustLimit,
366 #[error("Constructing the pegout transaction caused an arithmetic overflow")]
367 ArithmeticOverflow,
368 #[error("Unknown script variant")]
369 UnknownScriptVariant,
370}
371
372#[derive(Debug, Clone, Eq, PartialEq, Hash, Encodable, Decodable, Serialize, Deserialize)]
373pub enum StandardScript {
374 P2PKH(hash160::Hash),
375 P2SH(hash160::Hash),
376 P2WPKH(hash160::Hash),
377 P2WSH(sha256::Hash),
378 P2TR(XOnlyPublicKey),
379 #[encodable_default]
380 Default {
381 variant: u64,
382 bytes: Vec<u8>,
383 },
384}
385
386impl StandardScript {
387 pub fn from_address(address: &Address) -> Option<Self> {
388 if let Some(hash) = address.pubkey_hash() {
389 return Some(StandardScript::P2PKH(hash.to_raw_hash()));
390 }
391
392 if let Some(hash) = address.script_hash() {
393 return Some(StandardScript::P2SH(hash.to_raw_hash()));
394 }
395
396 let program = address.witness_program()?;
397
398 if program.is_p2wpkh() {
399 return Some(StandardScript::P2WPKH(
400 hash160::Hash::from_slice(program.program().as_bytes())
401 .expect("Witness program is 20 bytes"),
402 ));
403 }
404
405 if program.is_p2wsh() {
406 return Some(StandardScript::P2WSH(
407 sha256::Hash::from_slice(program.program().as_bytes())
408 .expect("Witness program is 32 bytes"),
409 ));
410 }
411
412 if program.is_p2tr() {
413 return Some(StandardScript::P2TR(
414 XOnlyPublicKey::from_slice(program.program().as_bytes())
415 .expect("Witness program is 32 bytes"),
416 ));
417 }
418
419 None
420 }
421
422 pub fn script_pubkey(&self) -> Option<ScriptBuf> {
423 match self {
424 Self::P2PKH(hash) => Some(ScriptBuf::new_p2pkh(&PubkeyHash::from_raw_hash(*hash))),
425 Self::P2SH(hash) => Some(ScriptBuf::new_p2sh(&ScriptHash::from_raw_hash(*hash))),
426 Self::P2WPKH(hash) => Some(ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(*hash))),
427 Self::P2WSH(hash) => Some(ScriptBuf::new_p2wsh(&WScriptHash::from_raw_hash(*hash))),
428 Self::P2TR(pk) => Some(ScriptBuf::new_p2tr_tweaked(pk.dangerous_assume_tweaked())),
429 Self::Default { .. } => None,
430 }
431 }
432}
433
434#[cfg(test)]
435fn assert_standard_script_roundtrip(addr: &str, variant: fn(&StandardScript) -> bool) {
436 let address = addr
437 .parse::<bitcoin::Address<bitcoin::address::NetworkUnchecked>>()
438 .expect("Failed to parse address")
439 .require_network(bitcoin::Network::Bitcoin)
440 .expect("Wrong network");
441
442 let script = StandardScript::from_address(&address)
443 .expect("Failed to convert address to StandardScript");
444
445 assert!(variant(&script), "Unexpected StandardScript variant");
446
447 assert_eq!(Some(address.script_pubkey()), script.script_pubkey());
448}
449
450#[test]
451fn test_standard_script_p2pkh() {
452 assert_standard_script_roundtrip("1QJVDzdqb1VpbDK7uDeyVXy9mR27CJiyhY", |s| {
453 matches!(s, StandardScript::P2PKH(..))
454 });
455}
456
457#[test]
458fn test_standard_script_p2sh() {
459 assert_standard_script_roundtrip("33iFwdLuRpW1uK1RTRqsoi8rR4NpDzk66k", |s| {
460 matches!(s, StandardScript::P2SH(..))
461 });
462}
463
464#[test]
465fn test_standard_script_p2wpkh() {
466 assert_standard_script_roundtrip("bc1qvzvkjn4q3nszqxrv3nraga2r822xjty3ykvkuw", |s| {
467 matches!(s, StandardScript::P2WPKH(..))
468 });
469}
470
471#[test]
472fn test_standard_script_p2wsh() {
473 assert_standard_script_roundtrip(
474 "bc1qwqdg6squsna38e46795at95yu9atm8azzmyvckulcc7kytlcckxswvvzej",
475 |s| matches!(s, StandardScript::P2WSH(..)),
476 );
477}
478
479#[test]
480fn test_standard_script_p2tr() {
481 assert_standard_script_roundtrip(
482 "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr",
483 |s| matches!(s, StandardScript::P2TR(..)),
484 );
485}
486
487#[test]
488fn test_standard_script_unknown_witness_version() {
489 let address = "bc1pw508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7kt5nd6y"
490 .parse::<bitcoin::Address<bitcoin::address::NetworkUnchecked>>()
491 .expect("Failed to parse address")
492 .require_network(bitcoin::Network::Bitcoin)
493 .expect("Wrong network");
494
495 assert!(StandardScript::from_address(&address).is_none());
496}