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