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}
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}