Skip to main content

fedimint_recoverytool/
main.rs

1#![deny(clippy::pedantic)]
2
3mod key;
4
5use std::collections::BTreeSet;
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, anyhow};
9use bitcoin::OutPoint;
10use bitcoin::network::Network;
11use bitcoin::secp256k1::{PublicKey, SECP256K1, SecretKey};
12use clap::{ArgGroup, Parser, Subcommand};
13use fedimint_core::core::ModuleInstanceId;
14use fedimint_core::db::{Database, IDatabaseTransactionOpsCoreTyped};
15use fedimint_core::fedimint_build_code_version_env;
16use fedimint_core::module::CommonModuleInit;
17use fedimint_core::module::registry::{ModuleDecoderRegistry, ModuleRegistry};
18use fedimint_core::session_outcome::{ConsensusItem, SignedSessionOutcome};
19use fedimint_core::transaction::Transaction;
20use fedimint_core::util::handle_version_hash_command;
21use fedimint_logging::TracingSetup;
22use fedimint_rocksdb::RocksDbReadOnly;
23use fedimint_server::config::ServerConfig;
24use fedimint_server::config::io::read_server_config;
25use fedimint_server::consensus::db::SignedSessionOutcomePrefix;
26use fedimint_server::core::ServerModule;
27use fedimint_wallet_server::common::config::WalletConfig;
28use fedimint_wallet_server::common::keys::CompressedPublicKey;
29use fedimint_wallet_server::common::tweakable::Tweakable;
30use fedimint_wallet_server::common::{
31    PegInDescriptor, SpendableUTXO, WalletCommonInit, WalletInput,
32};
33use fedimint_wallet_server::db::{UTXOKey, UTXOPrefixKey};
34use fedimint_wallet_server::{Wallet, nonce_from_idx};
35use futures::stream::StreamExt;
36use hex::FromHex;
37use miniscript::{Descriptor, MiniscriptKey, TranslatePk, Translator};
38use serde::Serialize;
39use tracing::info;
40
41use crate::key::Key;
42
43/// Tool to recover the on-chain wallet of a Fedimint federation
44#[derive(Debug, Parser)]
45#[command(version)]
46#[command(group(
47    ArgGroup::new("keysource")
48        .required(true)
49        .args(["config", "descriptor"]),
50))]
51struct RecoveryTool {
52    /// Directory containing server config files
53    #[arg(long = "cfg")]
54    config: Option<PathBuf>,
55    /// Wallet descriptor, can be used instead of --cfg
56    #[arg(long)]
57    descriptor: Option<PegInDescriptor>,
58    /// Wallet secret key, can be used instead of config together with
59    /// --descriptor
60    #[arg(long, requires = "descriptor")]
61    key: Option<SecretKey>,
62    /// Network to operate on, has to be specified if --cfg isn't present
63    #[arg(long, default_value = "bitcoin", requires = "descriptor")]
64    network: Network,
65    #[command(subcommand)]
66    strategy: TweakSource,
67}
68
69#[derive(Debug, Clone, Subcommand)]
70enum TweakSource {
71    /// Derive the wallet descriptor using a single tweak
72    Direct {
73        #[arg(long, value_parser = tweak_parser)]
74        tweak: [u8; 33],
75    },
76    /// Derive all wallet descriptors of confirmed UTXOs in the on-chain wallet.
77    /// Note that unconfirmed change UTXOs will not appear here.
78    Utxos {
79        /// Extract UTXOs from a database without module partitioning
80        #[arg(long)]
81        legacy: bool,
82        /// Path to database
83        #[arg(long)]
84        db: PathBuf,
85    },
86    /// Derive all wallet descriptors of tweaks that were ever used according to
87    /// the epoch log. In a long-running and busy federation this list will
88    /// contain many empty descriptors.
89    Epochs {
90        /// Path to database
91        #[arg(long)]
92        db: PathBuf,
93    },
94}
95
96fn tweak_parser(hex: &str) -> anyhow::Result<[u8; 33]> {
97    <Vec<u8> as FromHex>::from_hex(hex)?
98        .try_into()
99        .map_err(|_| anyhow!("tweaks have to be 33 bytes long"))
100}
101
102/// Get the wallet module instance ID from a server config by looking up the
103/// module kind
104fn get_wallet_module_id(cfg: &ServerConfig) -> anyhow::Result<ModuleInstanceId> {
105    cfg.consensus
106        .modules
107        .iter()
108        .find_map(|(id, module_cfg)| {
109            if module_cfg.kind == WalletCommonInit::KIND {
110                Some(*id)
111            } else {
112                None
113            }
114        })
115        .context("Wallet module not found in config")
116}
117
118async fn get_db(path: &Path, module_decoders: ModuleDecoderRegistry) -> Database {
119    Database::new(
120        RocksDbReadOnly::open_read_only(path)
121            .await
122            .expect("Error opening readonly DB"),
123        module_decoders,
124    )
125}
126
127#[tokio::main]
128async fn main() -> anyhow::Result<()> {
129    TracingSetup::default().init()?;
130
131    handle_version_hash_command(fedimint_build_code_version_env!());
132
133    let opts: RecoveryTool = RecoveryTool::parse();
134
135    let (base_descriptor, base_key, network, wallet_module_id) = if let Some(config) = opts.config {
136        let cfg = read_server_config(&config).expect("Could not read config file");
137        let wallet_module_id =
138            get_wallet_module_id(&cfg).expect("Wallet module not found in config");
139        let wallet_cfg: WalletConfig = cfg
140            .get_module_config_typed(wallet_module_id)
141            .expect("Malformed wallet config");
142        let base_descriptor = wallet_cfg.consensus.peg_in_descriptor;
143        let base_key = wallet_cfg.private.peg_in_key;
144        let network = wallet_cfg.consensus.network.0;
145
146        (base_descriptor, base_key, network, wallet_module_id)
147    } else if let (Some(descriptor), Some(key)) = (opts.descriptor, opts.key) {
148        // When using descriptor directly without config, we don't have a known wallet
149        // module ID. Use 0 as a placeholder since it's only used for DB
150        // prefix/decoder matching which isn't needed with direct descriptor usage.
151        (descriptor, key, opts.network, 0)
152    } else {
153        panic!("Either config or descriptor need to be provided by clap");
154    };
155
156    process_and_print_tweak_source(
157        &opts.strategy,
158        &base_descriptor,
159        &base_key,
160        network,
161        wallet_module_id,
162    )
163    .await;
164
165    Ok(())
166}
167
168async fn process_and_print_tweak_source(
169    tweak_source: &TweakSource,
170    base_descriptor: &Descriptor<CompressedPublicKey>,
171    base_key: &SecretKey,
172    network: Network,
173    wallet_module_id: ModuleInstanceId,
174) {
175    match tweak_source {
176        TweakSource::Direct { tweak } => {
177            let descriptor = tweak_descriptor(base_descriptor, base_key, tweak, network);
178            let wallets = vec![ImportableWalletMin { descriptor }];
179
180            serde_json::to_writer(std::io::stdout().lock(), &wallets)
181                .expect("Could not encode to stdout");
182        }
183        TweakSource::Utxos { legacy, db } => {
184            let db = get_db(db, ModuleRegistry::default()).await;
185
186            let db = if *legacy {
187                db
188            } else {
189                db.with_prefix_module_id(wallet_module_id).0
190            };
191
192            let utxos: Vec<ImportableWallet> = db
193                .begin_transaction_nc()
194                .await
195                .find_by_prefix(&UTXOPrefixKey)
196                .await
197                .map(|(UTXOKey(outpoint), SpendableUTXO { tweak, amount })| {
198                    let descriptor = tweak_descriptor(base_descriptor, base_key, &tweak, network);
199
200                    ImportableWallet {
201                        outpoint,
202                        descriptor,
203                        amount_sat: amount,
204                    }
205                })
206                .collect()
207                .await;
208
209            serde_json::to_writer(std::io::stdout().lock(), &utxos)
210                .expect("Could not encode to stdout");
211        }
212        TweakSource::Epochs { db } => {
213            let decoders = ModuleDecoderRegistry::from_iter([(
214                wallet_module_id,
215                WalletCommonInit::KIND,
216                <Wallet as ServerModule>::decoder(),
217            )])
218            .with_fallback();
219
220            let db = get_db(db, decoders).await;
221            let mut dbtx = db.begin_transaction_nc().await;
222
223            let mut change_tweak_idx: u64 = 0;
224
225            let tweaks = dbtx
226                .find_by_prefix(&SignedSessionOutcomePrefix)
227                .await
228                .flat_map(
229                    |(
230                        _key,
231                        SignedSessionOutcome {
232                            session_outcome: block,
233                            ..
234                        },
235                    )| {
236                        let transaction_cis: Vec<Transaction> = block
237                            .items
238                            .into_iter()
239                            .filter_map(|item| match item.item {
240                                ConsensusItem::Transaction(tx) => Some(tx),
241                                ConsensusItem::Module(_) | ConsensusItem::Default { .. } => None,
242                            })
243                            .collect();
244
245                        // Get all user-submitted tweaks and number of peg-out transactions in
246                        // session
247                        let (mut peg_in_tweaks, peg_out_count) = input_tweaks_and_peg_out_count(
248                            transaction_cis.into_iter(),
249                            wallet_module_id,
250                        );
251
252                        for _ in 0..peg_out_count {
253                            info!("Found change output, adding tweak {change_tweak_idx} to list");
254                            peg_in_tweaks.insert(nonce_from_idx(change_tweak_idx));
255                            change_tweak_idx += 1;
256                        }
257
258                        futures::stream::iter(peg_in_tweaks.into_iter())
259                    },
260                );
261
262            let wallets = tweaks
263                .map(|tweak| {
264                    let descriptor = tweak_descriptor(base_descriptor, base_key, &tweak, network);
265                    ImportableWalletMin { descriptor }
266                })
267                .collect::<Vec<_>>()
268                .await;
269
270            serde_json::to_writer(std::io::stdout().lock(), &wallets)
271                .expect("Could not encode to stdout");
272        }
273    }
274}
275
276fn input_tweaks_and_peg_out_count(
277    transactions: impl Iterator<Item = Transaction>,
278    wallet_module_id: ModuleInstanceId,
279) -> (BTreeSet<[u8; 33]>, u64) {
280    let mut peg_out_count = 0;
281    let tweaks = transactions
282        .flat_map(|tx| {
283            tx.outputs.iter().for_each(|output| {
284                if output.module_instance_id() == wallet_module_id {
285                    peg_out_count += 1;
286                }
287            });
288
289            tx.inputs.into_iter().filter_map(|input| {
290                if input.module_instance_id() != wallet_module_id {
291                    return None;
292                }
293
294                Some(
295                    match input
296                        .as_any()
297                        .downcast_ref::<WalletInput>()
298                        .expect("Instance id mapping incorrect")
299                    {
300                        WalletInput::V0(input) => input.tweak_key().serialize(),
301                        WalletInput::V1(input) => input.tweak_key.serialize(),
302                        WalletInput::Default { .. } => {
303                            panic!("recoverytool only supports v0 wallet inputs")
304                        }
305                    },
306                )
307            })
308        })
309        .collect::<BTreeSet<_>>();
310
311    (tweaks, peg_out_count)
312}
313
314fn tweak_descriptor(
315    base_descriptor: &PegInDescriptor,
316    base_sk: &SecretKey,
317    tweak: &[u8; 33],
318    network: Network,
319) -> Descriptor<Key> {
320    let secret_key = base_sk.tweak(tweak, SECP256K1);
321    let pub_key = CompressedPublicKey::new(PublicKey::from_secret_key_global(&secret_key));
322    base_descriptor
323        .tweak(tweak, SECP256K1)
324        .translate_pk(&mut SecretKeyInjector {
325            secret: bitcoin::key::PrivateKey {
326                compressed: true,
327                network: network.into(),
328                inner: secret_key,
329            },
330            public: pub_key,
331        })
332        .expect("can't fail")
333}
334
335/// A UTXO with its Bitcoin Core importable descriptor
336#[derive(Debug, Serialize)]
337struct ImportableWallet {
338    outpoint: OutPoint,
339    descriptor: Descriptor<Key>,
340    #[serde(with = "bitcoin::amount::serde::as_sat")]
341    amount_sat: bitcoin::Amount,
342}
343
344/// A Bitcoin Core importable descriptor
345#[derive(Debug, Serialize)]
346struct ImportableWalletMin {
347    descriptor: Descriptor<Key>,
348}
349
350/// Miniscript [`Translator`] that replaces a public key with a private key we
351/// know.
352#[derive(Debug)]
353struct SecretKeyInjector {
354    secret: bitcoin::key::PrivateKey,
355    public: CompressedPublicKey,
356}
357
358impl Translator<CompressedPublicKey, Key, ()> for SecretKeyInjector {
359    fn pk(&mut self, pk: &CompressedPublicKey) -> Result<Key, ()> {
360        if &self.public == pk {
361            Ok(Key::Private(self.secret))
362        } else {
363            Ok(Key::Public(*pk))
364        }
365    }
366
367    fn sha256(
368        &mut self,
369        _sha256: &<CompressedPublicKey as MiniscriptKey>::Sha256,
370    ) -> Result<<Key as MiniscriptKey>::Sha256, ()> {
371        unimplemented!()
372    }
373
374    fn hash256(
375        &mut self,
376        _hash256: &<CompressedPublicKey as MiniscriptKey>::Hash256,
377    ) -> Result<<Key as MiniscriptKey>::Hash256, ()> {
378        unimplemented!()
379    }
380
381    fn ripemd160(
382        &mut self,
383        _ripemd160: &<CompressedPublicKey as MiniscriptKey>::Ripemd160,
384    ) -> Result<<Key as MiniscriptKey>::Ripemd160, ()> {
385        unimplemented!()
386    }
387
388    fn hash160(
389        &mut self,
390        _hash160: &<CompressedPublicKey as MiniscriptKey>::Hash160,
391    ) -> Result<<Key as MiniscriptKey>::Hash160, ()> {
392        unimplemented!()
393    }
394}
395
396#[test]
397fn parses_valid_length_tweaks() {
398    use hex::ToHex;
399
400    let bad_length_tweak_hex = rand::random::<[u8; 32]>().encode_hex::<String>();
401    // rand::random only supports random byte arrays up to 32 bytes
402    let good_length_tweak: [u8; 33] = core::array::from_fn(|_| rand::random::<u8>());
403    let good_length_tweak_hex = good_length_tweak.encode_hex::<String>();
404    assert_eq!(
405        tweak_parser(good_length_tweak_hex.as_str()).expect("should parse valid length hex"),
406        good_length_tweak
407    );
408    assert!(tweak_parser(bad_length_tweak_hex.as_str()).is_err());
409}