fedimint_recoverytool/
main.rs

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