538 lines
13 KiB
Go
538 lines
13 KiB
Go
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)
|
|
}
|