package main import ( "bytes" "flag" "fmt" "os" "regexp" "strconv" "strings" "github.com/btcsuite/btcutil" "github.com/gookit/color" "github.com/muun/libwallet" "github.com/muun/libwallet/btcsuitew/btcutilw" "github.com/muun/libwallet/emergencykit" "github.com/muun/recovery/electrum" "github.com/muun/recovery/scanner" "github.com/muun/recovery/utils" ) const electrumPoolSize = 6 var debugOutputStream = bytes.NewBuffer(nil) type config struct { generateContacts bool providedElectrum string usesProvidedElectrum bool onlyScan bool } func main() { utils.SetOutputStream(debugOutputStream) var config config // Pick up command-line arguments: flag.BoolVar(&config.generateContacts, "generate-contacts", false, "Generate contact addresses") flag.StringVar(&config.providedElectrum, "electrum-server", "", "Connect to this electrum server to find funds") flag.BoolVar(&config.onlyScan, "only-scan", false, "Only scan for UTXOs without generating a transaction") flag.Usage = printUsage flag.Parse() args := flag.Args() // Ensure correct form: if len(args) > 1 { printUsage() os.Exit(0) } // Welcome! printWelcomeMessage() config.usesProvidedElectrum = len(strings.TrimSpace(config.providedElectrum)) > 0 if config.usesProvidedElectrum { validateProvidedElectrum(config.providedElectrum) } // We're going to need a few things to move forward with the recovery process. Let's make a list // so we keep them in mind: var recoveryCode string var encryptedKeys []*libwallet.EncryptedPrivateKeyInfo var destinationAddress btcutil.Address // First on our list is the Recovery Code. This is the time to go looking for that piece of paper: recoveryCode = readRecoveryCode() // Good! Now, on to those keys. We need to read them and decrypt them: encryptedKeys, err := readBackupFromInputOrPDF(flag.Arg(0)) if err != nil { exitWithError(err) } decryptedKeys, err := decryptKeys(encryptedKeys, recoveryCode) if err != nil { exitWithError(err) } decryptedKeys[0].Key.Path = "m/1'/1'" // a little adjustment for legacy users. if !config.onlyScan { // Finally, we need the destination address to sweep the funds: destinationAddress = readAddress() } sayBlock(` Starting scan of all possible addresses. This will take a few minutes. `) doRecovery(decryptedKeys, destinationAddress, config) sayBlock("We appreciate all kinds of feedback. If you have any, send it to {blue contact@muun.com}\n") } // doRecovery runs the scan & sweep process, and returns the ID of the broadcasted transaction. func doRecovery( decryptedKeys []*libwallet.DecryptedPrivateKey, destinationAddress btcutil.Address, config config, ) { addrGen := NewAddressGenerator(decryptedKeys[0].Key, decryptedKeys[1].Key, config.generateContacts) var electrumProvider *electrum.ServerProvider if config.usesProvidedElectrum { electrumProvider = electrum.NewServerProvider([]string{ config.providedElectrum, }) } else { electrumProvider = electrum.NewServerProvider(electrum.PublicServers) } connectionPool := electrum.NewPool(electrumPoolSize, !config.usesProvidedElectrum) utxoScanner := scanner.NewScanner(connectionPool, electrumProvider) addresses := addrGen.Stream() sweeper := Sweeper{ UserKey: decryptedKeys[0].Key, MuunKey: decryptedKeys[1].Key, Birthday: decryptedKeys[1].Birthday, SweepAddress: destinationAddress, } reports := utxoScanner.Scan(addresses) say("► {white Finding servers...}") var lastReport *scanner.Report for lastReport = range reports { printReport(lastReport) } fmt.Println() fmt.Println() if lastReport.Err != nil { exitWithError(fmt.Errorf("error while scanning addresses: %w", lastReport.Err)) } say("{green ✓ Scan complete}\n") utxos := lastReport.UtxosFound if len(utxos) == 0 { sayBlock("No funds were discovered\n\n") return } var total int64 for _, utxo := range utxos { total += utxo.Amount say("• {white %d} sats in %s\n", utxo.Amount, utxo.Address.Address()) } say("\n— {white %d} sats total\n", total) if config.onlyScan { return } txOutputAmount, txWeightInBytes, err := sweeper.GetSweepTxAmountAndWeightInBytes(utxos) if err != nil { exitWithError(err) } fee := readFee(txOutputAmount, txWeightInBytes) // Then we re-build the sweep tx with the actual fee sweepTx, err := sweeper.BuildSweepTx(utxos, fee) if err != nil { exitWithError(err) } sayBlock("Sending transaction...") err = sweeper.BroadcastTx(sweepTx) if err != nil { exitWithError(err) } sayBlock(` Transaction sent! You can check the status here: https://mempool.space/tx/%v (it will appear in mempool.space after a short delay) `, sweepTx.TxHash().String()) } func validateProvidedElectrum(providedElectrum string) { client := electrum.NewClient(false) err := client.Connect(providedElectrum) defer func(client *electrum.Client) { _ = client.Disconnect() }(client) if err != nil { sayBlock(` {red Error!} The Recovery Tool couldn't connect to the provided Electrum server %v. If the problem persists, contact {blue support@muun.com}. ――― {white error report} ――― %v ―――――――――――――――――――― We're always there to help. `, providedElectrum, err) os.Exit(2) } } func exitWithError(err error) { sayBlock(` {red Error!} The Recovery Tool encountered a problem. Please, try again. If the problem persists, contact {blue support@muun.com} and include the file called error_log you can find in the same folder as this tool. ――― {white error report} ――― %v ―――――――――――――――――――― We're always there to help. `, err) // Ensure we always log the error in the file _ = utils.NewLogger("").Errorf("exited with error: %s", err.Error()) _ = os.WriteFile("error_log", debugOutputStream.Bytes(), 0600) os.Exit(1) } func printWelcomeMessage() { say(` {blue Muun Recovery Tool v%s} To recover your funds, you will need: 1. {yellow Your Recovery Code}, which you wrote down during your security setup 2. {yellow Your Emergency Kit PDF}, which you exported from the app 3. {yellow Your destination bitcoin address}, where all your funds will be sent If you have any questions, we'll be happy to answer them. Contact us at {blue support@muun.com} `, version) } func printUsage() { fmt.Println("Usage: recovery-tool [optional: path to Emergency Kit PDF]") flag.PrintDefaults() } func printReport(report *scanner.Report) { if utils.DebugMode { return // don't print reports while debugging, there's richer information in the logs } var total int64 for _, utxo := range report.UtxosFound { total += utxo.Amount } say("\r► {white Scanned addresses}: %d | {white Sats found}: %d", report.ScannedAddresses, total) } func readRecoveryCode() string { sayBlock(` {yellow Enter your Recovery Code} (it looks like this: 'ABCD-1234-POW2-R561-P120-JK26-12RW-45TT') `) var userInput string ask(&userInput) userInput = strings.TrimSpace(userInput) finalRC := strings.ToUpper(userInput) if strings.Count(finalRC, "-") != 7 { say(` Invalid recovery code. Did you add the '-' separator between each 4-characters segment? Please, try again `) return readRecoveryCode() } if len(finalRC) != 39 { say(` Your recovery code must have 39 characters Please, try again `) return readRecoveryCode() } return finalRC } func readBackupFromInputOrPDF(optionalPDF string) ([]*libwallet.EncryptedPrivateKeyInfo, error) { // Here we have two possible flows, depending on whether the PDF was provided (pick up the // encrypted backup automatically) or not (manual input). If we try for the automatic flow and fail, // we can fall back to the manual one. // Read metadata from the PDF, if given: if optionalPDF != "" { encryptedKeys, err := readBackupFromPDF(optionalPDF) if err == nil { return encryptedKeys, nil } // Hmm. Okay, we'll confess and fall back to manual input. say(` Couldn't read the PDF automatically: %v Please, enter your data manually `, err) } // Ask for manual input, if we have no PDF or couldn't read it: encryptedKeys, err := readBackupFromInput() if err != nil { return nil, err } return encryptedKeys, nil } func readBackupFromInput() ([]*libwallet.EncryptedPrivateKeyInfo, error) { firstRawKey := readKey("first encrypted private key") secondRawKey := readKey("second encrypted private key") decodedKeys, err := decodeKeysFromInput(firstRawKey, secondRawKey) if err != nil { return nil, err } return decodedKeys, nil } func readBackupFromPDF(path string) ([]*libwallet.EncryptedPrivateKeyInfo, error) { reader := &emergencykit.MetadataReader{SrcFile: path} metadata, err := reader.ReadMetadata() if err != nil { return nil, err } decodedKeys, err := decodeKeysFromMetadata(metadata) if err != nil { return nil, err } return decodedKeys, nil } func readKey(keyType string) string { sayBlock(` {yellow Enter your %v} (it looks like this: '9xzpc7y6sNtRvh8Fh...') `, keyType) // NOTE: // Users will most likely copy and paste their keys from the Emergency Kit PDF. In this case, // input will come suddenly in multiple lines, so a simple scan & retry (let's say 3 lines // were pasted) will attempt to parse a key and fail 2 times in a row, with leftover characters // until the user presses enter to fail for a 3rd time. // Given the line lengths actually found in our Emergency Kits, we have a simple solution for now: // scan a minimum length of characters. Pasing from current versions of the Emergency Kit will // only go past a minimum length when the key being entered is complete, in all cases. userInput := askMultiline(libwallet.EncodedKeyLengthLegacy) if len(userInput) < libwallet.EncodedKeyLengthLegacy { // This is obviously invalid. Other problems will be detected later on, during the actual // decoding and decryption stage. say(` The key you entered doesn't look valid Please, try again `) return readKey(keyType) } return userInput } func readAddress() btcutil.Address { sayBlock(` {yellow Enter your destination bitcoin address} `) var userInput string ask(&userInput) userInput = strings.TrimSpace(userInput) addr, err := btcutilw.DecodeAddress(userInput, &chainParams) if err != nil { say(` This is not a valid bitcoin address Please, try again `) return readAddress() } return addr } func readFee(totalBalance, weight int64) int64 { sayBlock(` {yellow Enter the fee rate (sats/byte)} Your transaction weighs %v bytes. You can get suggestions in https://mempool.space/ under "Transaction fees". `, weight) var userInput string ask(&userInput) feeInSatsPerByte, err := strconv.ParseInt(userInput, 10, 64) if err != nil || feeInSatsPerByte <= 0 { say(` The fee must be a whole number Please, try again `) return readFee(totalBalance, weight) } totalFee := feeInSatsPerByte * weight if totalBalance-totalFee < 546 { say(` The fee is too high. The remaining amount after deducting is too low to send. Please, try again `) return readFee(totalBalance, weight) } return totalFee } func readConfirmation(value, fee int64, address string) { sayBlock(` {whiteUnderline Summary} {white Amount}: %v sats {white Fee}: %v sats {white Destination}: %v {yellow Confirm?} (y/n) `, value, fee, address) var userInput string ask(&userInput) if userInput == "y" || userInput == "Y" { return } if userInput == "n" || userInput == "N" { sayBlock(` Recovery tool stopped You can try again or contact us at {blue support@muun.com} `) os.Exit(1) } say(`You can only enter 'y' to confirm or 'n' to cancel`) fmt.Print("\n\n") readConfirmation(value, fee, address) } var leadingIndentRe = regexp.MustCompile("^[ \t]+") var colorRe = regexp.MustCompile(`\{(\w+?) ([^\}]+?)\}`) func say(message string, v ...interface{}) { noEmptyLine := strings.TrimLeft(message, " \n") firstIndent := leadingIndentRe.FindString(noEmptyLine) noIndent := strings.ReplaceAll(noEmptyLine, firstIndent, "") noTrailingSpace := strings.TrimRight(noIndent, " \t") withColors := colorRe.ReplaceAllStringFunc(noTrailingSpace, func(match string) string { groups := colorRe.FindStringSubmatch(match) return applyColor(groups[1], groups[2]) }) fmt.Printf(withColors, v...) } func sayBlock(message string, v ...interface{}) { fmt.Println() say(message, v...) } func applyColor(colorName string, text string) string { switch colorName { case "red": return color.New(color.FgRed, color.BgDefault, color.OpBold).Sprint(text) case "blue": return color.New(color.FgBlue, color.BgDefault, color.OpBold).Sprint(text) case "yellow": return color.New(color.FgYellow, color.BgDefault, color.OpBold).Sprint(text) case "green": return color.New(color.FgGreen, color.BgDefault, color.OpBold).Sprint(text) case "white": return color.New(color.FgWhite, color.BgDefault, color.OpBold).Sprint(text) case "whiteUnderline": return color.New(color.FgWhite, color.BgDefault, color.OpBold, color.OpUnderscore).Sprint(text) } panic("No such color: " + colorName) } func askMultiline(minChars int) string { fmt.Print("➜ ") var result strings.Builder for result.Len() < minChars { var line string fmt.Scan(&line) result.WriteString(strings.TrimSpace(line)) } return result.String() } func ask(result *string) { fmt.Print("➜ ") fmt.Scan(result) }