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