muun-recovery/main.go

538 lines
13 KiB
Go
Raw Normal View History

2020-11-09 08:05:29 -05:00
package main
import (
2022-10-04 13:55:21 -04:00
"bytes"
2021-03-17 14:28:04 -04:00
"flag"
2020-11-09 08:05:29 -05:00
"fmt"
"os"
2021-03-17 14:28:04 -04:00
"regexp"
2020-11-09 08:05:29 -05:00
"strconv"
"strings"
"github.com/btcsuite/btcutil"
2021-04-23 14:22:35 -04:00
"github.com/gookit/color"
2021-01-29 16:51:08 -05:00
"github.com/muun/libwallet"
2021-11-12 17:06:13 -05:00
"github.com/muun/libwallet/btcsuitew/btcutilw"
2021-03-17 14:28:04 -04:00
"github.com/muun/libwallet/emergencykit"
2023-02-20 14:57:43 -05:00
"github.com/muun/recovery/electrum"
2021-03-17 14:28:04 -04:00
"github.com/muun/recovery/scanner"
2021-11-12 17:06:13 -05:00
"github.com/muun/recovery/utils"
2020-11-09 08:05:29 -05:00
)
2023-02-20 14:57:43 -05:00
const electrumPoolSize = 6
2022-10-04 13:55:21 -04:00
var debugOutputStream = bytes.NewBuffer(nil)
2021-03-17 14:28:04 -04:00
2023-02-20 14:57:43 -05:00
type config struct {
generateContacts bool
providedElectrum string
usesProvidedElectrum bool
onlyScan bool
}
2020-11-09 08:05:29 -05:00
func main() {
2022-10-04 13:55:21 -04:00
utils.SetOutputStream(debugOutputStream)
2023-02-20 14:57:43 -05:00
var config config
2022-10-04 13:55:21 -04:00
2021-03-17 14:28:04 -04:00
// Pick up command-line arguments:
2023-02-20 14:57:43 -05:00
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")
2022-10-04 13:55:21 -04:00
flag.Usage = printUsage
2021-03-17 14:28:04 -04:00
flag.Parse()
args := flag.Args()
// Ensure correct form:
if len(args) > 1 {
printUsage()
os.Exit(0)
}
// Welcome!
2020-11-09 08:05:29 -05:00
printWelcomeMessage()
2023-02-20 14:57:43 -05:00
config.usesProvidedElectrum = len(strings.TrimSpace(config.providedElectrum)) > 0
if config.usesProvidedElectrum {
validateProvidedElectrum(config.providedElectrum)
}
2021-03-17 14:28:04 -04:00
// 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.
2020-11-09 08:05:29 -05:00
2023-02-20 14:57:43 -05:00
if !config.onlyScan {
// Finally, we need the destination address to sweep the funds:
destinationAddress = readAddress()
}
2020-11-09 08:05:29 -05:00
2021-03-17 14:28:04 -04:00
sayBlock(`
Starting scan of all possible addresses. This will take a few minutes.
`)
2020-11-09 08:05:29 -05:00
2023-02-20 14:57:43 -05:00
doRecovery(decryptedKeys, destinationAddress, config)
2021-03-17 14:28:04 -04:00
2022-10-04 13:55:21 -04:00
sayBlock("We appreciate all kinds of feedback. If you have any, send it to {blue contact@muun.com}\n")
2021-03-17 14:28:04 -04:00
}
// doRecovery runs the scan & sweep process, and returns the ID of the broadcasted transaction.
2022-10-04 13:55:21 -04:00
func doRecovery(
2023-02-20 14:57:43 -05:00
decryptedKeys []*libwallet.DecryptedPrivateKey,
destinationAddress btcutil.Address,
config config,
2022-10-04 13:55:21 -04:00
) {
2023-02-20 14:57:43 -05:00
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)
2021-03-17 14:28:04 -04:00
addresses := addrGen.Stream()
2020-11-09 08:05:29 -05:00
sweeper := Sweeper{
2021-03-17 14:28:04 -04:00
UserKey: decryptedKeys[0].Key,
MuunKey: decryptedKeys[1].Key,
Birthday: decryptedKeys[1].Birthday,
SweepAddress: destinationAddress,
2020-11-09 08:05:29 -05:00
}
2021-03-17 14:28:04 -04:00
reports := utxoScanner.Scan(addresses)
2020-11-09 08:05:29 -05:00
2021-03-17 14:28:04 -04:00
say("► {white Finding servers...}")
2020-11-09 08:05:29 -05:00
2021-03-17 14:28:04 -04:00
var lastReport *scanner.Report
for lastReport = range reports {
printReport(lastReport)
2020-11-09 08:05:29 -05:00
}
2021-03-17 14:28:04 -04:00
fmt.Println()
2020-11-09 08:05:29 -05:00
fmt.Println()
2021-03-17 14:28:04 -04:00
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")
2022-10-04 13:55:21 -04:00
return
2021-03-17 14:28:04 -04:00
}
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)
2023-02-20 14:57:43 -05:00
if config.onlyScan {
return
}
2021-03-17 14:28:04 -04:00
2020-11-09 08:05:29 -05:00
txOutputAmount, txWeightInBytes, err := sweeper.GetSweepTxAmountAndWeightInBytes(utxos)
if err != nil {
2021-03-17 14:28:04 -04:00
exitWithError(err)
2020-11-09 08:05:29 -05:00
}
fee := readFee(txOutputAmount, txWeightInBytes)
// Then we re-build the sweep tx with the actual fee
sweepTx, err := sweeper.BuildSweepTx(utxos, fee)
if err != nil {
2021-03-17 14:28:04 -04:00
exitWithError(err)
2020-11-09 08:05:29 -05:00
}
2021-03-17 14:28:04 -04:00
sayBlock("Sending transaction...")
2020-11-09 08:05:29 -05:00
err = sweeper.BroadcastTx(sweepTx)
if err != nil {
2021-03-17 14:28:04 -04:00
exitWithError(err)
2020-11-09 08:05:29 -05:00
}
2022-10-04 13:55:21 -04:00
sayBlock(`
Transaction sent! You can check the status here: https://mempool.space/tx/%v
2023-02-20 14:57:43 -05:00
(it will appear in mempool.space after a short delay)
2022-10-04 13:55:21 -04:00
`, sweepTx.TxHash().String())
2020-11-09 08:05:29 -05:00
}
2023-02-20 14:57:43 -05:00
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)
}
}
2021-03-17 14:28:04 -04:00
func exitWithError(err error) {
sayBlock(`
{red Error!}
The Recovery Tool encountered a problem. Please, try again.
2022-10-04 13:55:21 -04:00
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.
2021-03-17 14:28:04 -04:00
{white error report}
%v
We're always there to help.
`, err)
2022-10-04 13:55:21 -04:00
// 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)
2021-03-17 14:28:04 -04:00
os.Exit(1)
2020-11-09 08:05:29 -05:00
}
func printWelcomeMessage() {
2021-03-17 14:28:04 -04:00
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]")
2022-10-04 13:55:21 -04:00
flag.PrintDefaults()
2021-03-17 14:28:04 -04:00
}
func printReport(report *scanner.Report) {
2021-11-12 17:06:13 -05:00
if utils.DebugMode {
return // don't print reports while debugging, there's richer information in the logs
}
2021-03-17 14:28:04 -04:00
var total int64
for _, utxo := range report.UtxosFound {
total += utxo.Amount
}
say("\r► {white Scanned addresses}: %d | {white Sats found}: %d", report.ScannedAddresses, total)
2020-11-09 08:05:29 -05:00
}
func readRecoveryCode() string {
2021-03-17 14:28:04 -04:00
sayBlock(`
{yellow Enter your Recovery Code}
(it looks like this: 'ABCD-1234-POW2-R561-P120-JK26-12RW-45TT')
`)
2020-11-09 08:05:29 -05:00
var userInput string
2021-03-17 14:28:04 -04:00
ask(&userInput)
2020-11-09 08:05:29 -05:00
2021-03-17 14:28:04 -04:00
userInput = strings.TrimSpace(userInput)
2020-11-09 08:05:29 -05:00
finalRC := strings.ToUpper(userInput)
if strings.Count(finalRC, "-") != 7 {
2021-03-17 14:28:04 -04:00
say(`
Invalid recovery code. Did you add the '-' separator between each 4-characters segment?
Please, try again
`)
2020-11-09 08:05:29 -05:00
return readRecoveryCode()
}
if len(finalRC) != 39 {
2021-03-17 14:28:04 -04:00
say(`
Your recovery code must have 39 characters
Please, try again
`)
2020-11-09 08:05:29 -05:00
return readRecoveryCode()
}
return finalRC
}
2021-03-17 14:28:04 -04:00
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
}
2021-01-29 16:51:08 -05:00
func readKey(keyType string) string {
2021-03-17 14:28:04 -04:00
sayBlock(`
{yellow Enter your %v}
(it looks like this: '9xzpc7y6sNtRvh8Fh...')
`, keyType)
2020-11-09 08:05:29 -05:00
2021-01-29 16:51:08 -05:00
// 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.
2021-03-17 14:28:04 -04:00
userInput := askMultiline(libwallet.EncodedKeyLengthLegacy)
2021-01-29 16:51:08 -05:00
if len(userInput) < libwallet.EncodedKeyLengthLegacy {
// This is obviously invalid. Other problems will be detected later on, during the actual
// decoding and decryption stage.
2021-03-17 14:28:04 -04:00
say(`
The key you entered doesn't look valid
Please, try again
`)
2021-01-29 16:51:08 -05:00
return readKey(keyType)
2020-11-09 08:05:29 -05:00
}
return userInput
}
2021-03-17 14:28:04 -04:00
func readAddress() btcutil.Address {
sayBlock(`
{yellow Enter your destination bitcoin address}
`)
2020-11-09 08:05:29 -05:00
var userInput string
2021-03-17 14:28:04 -04:00
ask(&userInput)
2020-11-09 08:05:29 -05:00
userInput = strings.TrimSpace(userInput)
2021-11-12 17:06:13 -05:00
addr, err := btcutilw.DecodeAddress(userInput, &chainParams)
2020-11-09 08:05:29 -05:00
if err != nil {
2021-03-17 14:28:04 -04:00
say(`
This is not a valid bitcoin address
Please, try again
`)
2020-11-09 08:05:29 -05:00
2021-03-17 14:28:04 -04:00
return readAddress()
2020-11-09 08:05:29 -05:00
}
return addr
}
func readFee(totalBalance, weight int64) int64 {
2021-03-17 14:28:04 -04:00
sayBlock(`
{yellow Enter the fee rate (sats/byte)}
2023-02-20 14:57:43 -05:00
Your transaction weighs %v bytes. You can get suggestions in https://mempool.space/ under "Transaction fees".
2021-03-17 14:28:04 -04:00
`, weight)
2020-11-09 08:05:29 -05:00
var userInput string
2021-03-17 14:28:04 -04:00
ask(&userInput)
2020-11-09 08:05:29 -05:00
feeInSatsPerByte, err := strconv.ParseInt(userInput, 10, 64)
if err != nil || feeInSatsPerByte <= 0 {
2021-03-17 14:28:04 -04:00
say(`
The fee must be a whole number
Please, try again
`)
2020-11-09 08:05:29 -05:00
return readFee(totalBalance, weight)
}
totalFee := feeInSatsPerByte * weight
if totalBalance-totalFee < 546 {
2021-03-17 14:28:04 -04:00
say(`
The fee is too high. The remaining amount after deducting is too low to send.
Please, try again
`)
2020-11-09 08:05:29 -05:00
return readFee(totalBalance, weight)
}
return totalFee
}
func readConfirmation(value, fee int64, address string) {
2021-03-17 14:28:04 -04:00
sayBlock(`
{whiteUnderline Summary}
{white Amount}: %v sats
{white Fee}: %v sats
{white Destination}: %v
{yellow Confirm?} (y/n)
`, value, fee, address)
2020-11-09 08:05:29 -05:00
var userInput string
2021-03-17 14:28:04 -04:00
ask(&userInput)
2020-11-09 08:05:29 -05:00
if userInput == "y" || userInput == "Y" {
return
}
if userInput == "n" || userInput == "N" {
2021-03-17 14:28:04 -04:00
sayBlock(`
Recovery tool stopped
You can try again or contact us at {blue support@muun.com}
`)
2020-11-09 08:05:29 -05:00
os.Exit(1)
}
2021-03-17 14:28:04 -04:00
say(`You can only enter 'y' to confirm or 'n' to cancel`)
fmt.Print("\n\n")
2020-11-09 08:05:29 -05:00
readConfirmation(value, fee, address)
}
2021-03-17 14:28:04 -04:00
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":
2021-04-23 14:22:35 -04:00
return color.New(color.FgRed, color.BgDefault, color.OpBold).Sprint(text)
2021-03-17 14:28:04 -04:00
case "blue":
2021-04-23 14:22:35 -04:00
return color.New(color.FgBlue, color.BgDefault, color.OpBold).Sprint(text)
2021-03-17 14:28:04 -04:00
case "yellow":
2021-04-23 14:22:35 -04:00
return color.New(color.FgYellow, color.BgDefault, color.OpBold).Sprint(text)
2021-03-17 14:28:04 -04:00
case "green":
2021-04-23 14:22:35 -04:00
return color.New(color.FgGreen, color.BgDefault, color.OpBold).Sprint(text)
2021-03-17 14:28:04 -04:00
case "white":
2021-04-23 14:22:35 -04:00
return color.New(color.FgWhite, color.BgDefault, color.OpBold).Sprint(text)
2021-03-17 14:28:04 -04:00
case "whiteUnderline":
2021-04-23 14:22:35 -04:00
return color.New(color.FgWhite, color.BgDefault, color.OpBold, color.OpUnderscore).Sprint(text)
2021-03-17 14:28:04 -04:00
}
panic("No such color: " + colorName)
}
func askMultiline(minChars int) string {
fmt.Print("➜ ")
2020-11-09 08:05:29 -05:00
var result strings.Builder
for result.Len() < minChars {
var line string
fmt.Scan(&line)
result.WriteString(strings.TrimSpace(line))
}
return result.String()
}
2021-01-29 16:51:08 -05:00
2021-03-17 14:28:04 -04:00
func ask(result *string) {
fmt.Print("➜ ")
fmt.Scan(result)
2021-01-29 16:51:08 -05:00
}