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
34pub 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
63pub 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}