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