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