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
107async fn get_db(path: &Path, module_decoders: ModuleDecoderRegistry) -> Database {
108    Database::new(
109        RocksDbReadOnly::open_read_only(path)
110            .await
111            .expect("Error opening readonly DB"),
112        module_decoders,
113    )
114}
115
116#[tokio::main]
117async fn main() -> anyhow::Result<()> {
118    TracingSetup::default().init()?;
119
120    handle_version_hash_command(fedimint_build_code_version_env!());
121
122    let opts: RecoveryTool = RecoveryTool::parse();
123
124    let (base_descriptor, base_key, network) = if let Some(config) = opts.config {
125        let cfg = read_server_config(&opts.password, &config).expect("Could not read config file");
126        let wallet_cfg: WalletConfig = cfg
127            .get_module_config_typed(LEGACY_HARDCODED_INSTANCE_ID_WALLET)
128            .expect("Malformed wallet config");
129        let base_descriptor = wallet_cfg.consensus.peg_in_descriptor;
130        let base_key = wallet_cfg.private.peg_in_key;
131        let network = wallet_cfg.consensus.network.0;
132
133        (base_descriptor, base_key, network)
134    } else if let (Some(descriptor), Some(key)) = (opts.descriptor, opts.key) {
135        (descriptor, key, opts.network)
136    } else {
137        panic!("Either config or descriptor need to be provided by clap");
138    };
139
140    process_and_print_tweak_source(&opts.strategy, &base_descriptor, &base_key, network).await;
141
142    Ok(())
143}
144
145async fn process_and_print_tweak_source(
146    tweak_source: &TweakSource,
147    base_descriptor: &Descriptor<CompressedPublicKey>,
148    base_key: &SecretKey,
149    network: Network,
150) {
151    match tweak_source {
152        TweakSource::Direct { tweak } => {
153            let descriptor = tweak_descriptor(base_descriptor, base_key, tweak, network);
154            let wallets = vec![ImportableWalletMin { descriptor }];
155
156            serde_json::to_writer(std::io::stdout().lock(), &wallets)
157                .expect("Could not encode to stdout");
158        }
159        TweakSource::Utxos { legacy, db } => {
160            let db = get_db(db, ModuleRegistry::default()).await;
161
162            let db = if *legacy {
163                db
164            } else {
165                db.with_prefix_module_id(LEGACY_HARDCODED_INSTANCE_ID_WALLET)
166                    .0
167            };
168
169            let utxos: Vec<ImportableWallet> = db
170                .begin_transaction_nc()
171                .await
172                .find_by_prefix(&UTXOPrefixKey)
173                .await
174                .map(|(UTXOKey(outpoint), SpendableUTXO { tweak, amount })| {
175                    let descriptor = tweak_descriptor(base_descriptor, base_key, &tweak, network);
176
177                    ImportableWallet {
178                        outpoint,
179                        descriptor,
180                        amount_sat: amount,
181                    }
182                })
183                .collect()
184                .await;
185
186            serde_json::to_writer(std::io::stdout().lock(), &utxos)
187                .expect("Could not encode to stdout");
188        }
189        TweakSource::Epochs { db } => {
190            // FIXME: read config to figure out instance ids
191            let decoders = ModuleDecoderRegistry::from_iter([(
192                LEGACY_HARDCODED_INSTANCE_ID_WALLET,
193                WalletCommonInit::KIND,
194                <Wallet as ServerModule>::decoder(),
195            )])
196            .with_fallback();
197
198            let db = get_db(db, decoders).await;
199            let mut dbtx = db.begin_transaction_nc().await;
200
201            let mut change_tweak_idx: u64 = 0;
202
203            let tweaks = dbtx
204                .find_by_prefix(&SignedSessionOutcomePrefix)
205                .await
206                .flat_map(
207                    |(
208                        _key,
209                        SignedSessionOutcome {
210                            session_outcome: block,
211                            ..
212                        },
213                    )| {
214                        let transaction_cis: Vec<Transaction> = block
215                            .items
216                            .into_iter()
217                            .filter_map(|item| match item.item {
218                                ConsensusItem::Transaction(tx) => Some(tx),
219                                ConsensusItem::Module(_) | ConsensusItem::Default { .. } => None,
220                            })
221                            .collect();
222
223                        // Get all user-submitted tweaks and number of peg-out transactions in
224                        // session
225                        let (mut peg_in_tweaks, peg_out_count) =
226                            input_tweaks_and_peg_out_count(transaction_cis.into_iter());
227
228                        for _ in 0..peg_out_count {
229                            info!("Found change output, adding tweak {change_tweak_idx} to list");
230                            peg_in_tweaks.insert(nonce_from_idx(change_tweak_idx));
231                            change_tweak_idx += 1;
232                        }
233
234                        futures::stream::iter(peg_in_tweaks.into_iter())
235                    },
236                );
237
238            let wallets = tweaks
239                .map(|tweak| {
240                    let descriptor = tweak_descriptor(base_descriptor, base_key, &tweak, network);
241                    ImportableWalletMin { descriptor }
242                })
243                .collect::<Vec<_>>()
244                .await;
245
246            serde_json::to_writer(std::io::stdout().lock(), &wallets)
247                .expect("Could not encode to stdout");
248        }
249    }
250}
251
252fn input_tweaks_and_peg_out_count(
253    transactions: impl Iterator<Item = Transaction>,
254) -> (BTreeSet<[u8; 33]>, u64) {
255    let mut peg_out_count = 0;
256    let tweaks = transactions
257        .flat_map(|tx| {
258            tx.outputs.iter().for_each(|output| {
259                if output.module_instance_id() == LEGACY_HARDCODED_INSTANCE_ID_WALLET {
260                    peg_out_count += 1;
261                }
262            });
263
264            tx.inputs.into_iter().filter_map(|input| {
265                if input.module_instance_id() != LEGACY_HARDCODED_INSTANCE_ID_WALLET {
266                    return None;
267                }
268
269                Some(
270                    match input
271                        .as_any()
272                        .downcast_ref::<WalletInput>()
273                        .expect("Instance id mapping incorrect")
274                    {
275                        WalletInput::V0(input) => input.tweak_contract_key().serialize(),
276                        WalletInput::V1(input) => input.tweak_contract_key.serialize(),
277                        WalletInput::Default { .. } => {
278                            panic!("recoverytool only supports v0 wallet inputs")
279                        }
280                    },
281                )
282            })
283        })
284        .collect::<BTreeSet<_>>();
285
286    (tweaks, peg_out_count)
287}
288
289fn tweak_descriptor(
290    base_descriptor: &PegInDescriptor,
291    base_sk: &SecretKey,
292    tweak: &[u8; 33],
293    network: Network,
294) -> Descriptor<Key> {
295    let secret_key = base_sk.tweak(tweak, SECP256K1);
296    let pub_key = CompressedPublicKey::new(PublicKey::from_secret_key_global(&secret_key));
297    base_descriptor
298        .tweak(tweak, SECP256K1)
299        .translate_pk(&mut SecretKeyInjector {
300            secret: bitcoin::key::PrivateKey {
301                compressed: true,
302                network: network.into(),
303                inner: secret_key,
304            },
305            public: pub_key,
306        })
307        .expect("can't fail")
308}
309
310/// A UTXO with its Bitcoin Core importable descriptor
311#[derive(Debug, Serialize)]
312struct ImportableWallet {
313    outpoint: OutPoint,
314    descriptor: Descriptor<Key>,
315    #[serde(with = "bitcoin::amount::serde::as_sat")]
316    amount_sat: bitcoin::Amount,
317}
318
319/// A Bitcoin Core importable descriptor
320#[derive(Debug, Serialize)]
321struct ImportableWalletMin {
322    descriptor: Descriptor<Key>,
323}
324
325/// Miniscript [`Translator`] that replaces a public key with a private key we
326/// know.
327#[derive(Debug)]
328struct SecretKeyInjector {
329    secret: bitcoin::key::PrivateKey,
330    public: CompressedPublicKey,
331}
332
333impl Translator<CompressedPublicKey, Key, ()> for SecretKeyInjector {
334    fn pk(&mut self, pk: &CompressedPublicKey) -> Result<Key, ()> {
335        if &self.public == pk {
336            Ok(Key::Private(self.secret))
337        } else {
338            Ok(Key::Public(*pk))
339        }
340    }
341
342    fn sha256(
343        &mut self,
344        _sha256: &<CompressedPublicKey as MiniscriptKey>::Sha256,
345    ) -> Result<<Key as MiniscriptKey>::Sha256, ()> {
346        unimplemented!()
347    }
348
349    fn hash256(
350        &mut self,
351        _hash256: &<CompressedPublicKey as MiniscriptKey>::Hash256,
352    ) -> Result<<Key as MiniscriptKey>::Hash256, ()> {
353        unimplemented!()
354    }
355
356    fn ripemd160(
357        &mut self,
358        _ripemd160: &<CompressedPublicKey as MiniscriptKey>::Ripemd160,
359    ) -> Result<<Key as MiniscriptKey>::Ripemd160, ()> {
360        unimplemented!()
361    }
362
363    fn hash160(
364        &mut self,
365        _hash160: &<CompressedPublicKey as MiniscriptKey>::Hash160,
366    ) -> Result<<Key as MiniscriptKey>::Hash160, ()> {
367        unimplemented!()
368    }
369}
370
371#[test]
372fn parses_valid_length_tweaks() {
373    use hex::ToHex;
374
375    let bad_length_tweak_hex = rand::random::<[u8; 32]>().encode_hex::<String>();
376    // rand::random only supports random byte arrays up to 32 bytes
377    let good_length_tweak: [u8; 33] = core::array::from_fn(|_| rand::random::<u8>());
378    let good_length_tweak_hex = good_length_tweak.encode_hex::<String>();
379    assert_eq!(
380        tweak_parser(good_length_tweak_hex.as_str()).expect("should parse valid length hex"),
381        good_length_tweak
382    );
383    assert!(tweak_parser(bad_length_tweak_hex.as_str()).is_err());
384}