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