2020-11-09 08:05:29 -05:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
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-03-17 14:28:04 -04:00
|
|
|
"github.com/logrusorgru/aurora"
|
2021-01-29 16:51:08 -05:00
|
|
|
"github.com/muun/libwallet"
|
2021-03-17 14:28:04 -04:00
|
|
|
"github.com/muun/libwallet/emergencykit"
|
|
|
|
"github.com/muun/recovery/scanner"
|
2020-11-09 08:05:29 -05:00
|
|
|
)
|
|
|
|
|
2021-03-17 14:28:04 -04:00
|
|
|
const version = "2.1.0"
|
|
|
|
|
2020-11-09 08:05:29 -05:00
|
|
|
func main() {
|
2021-03-17 14:28:04 -04:00
|
|
|
// Pick up command-line arguments:
|
|
|
|
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()
|
|
|
|
|
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
|
|
|
|
2021-03-17 14:28:04 -04:00
|
|
|
// 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
|
|
|
|
2021-03-17 14:28:04 -04:00
|
|
|
transactionID := doRecovery(decryptedKeys, destinationAddress)
|
2020-11-09 08:05:29 -05:00
|
|
|
|
2021-03-17 14:28:04 -04:00
|
|
|
sayBlock(`
|
|
|
|
Transaction sent! You can check the status here: https://blockstream.info/tx/%v
|
|
|
|
(it will appear in Blockstream after a short delay)
|
|
|
|
|
|
|
|
We appreciate all kinds of feedback. If you have any, send it to {blue contact@muun.com}
|
|
|
|
`, transactionID)
|
|
|
|
}
|
|
|
|
|
|
|
|
// doRecovery runs the scan & sweep process, and returns the ID of the broadcasted transaction.
|
|
|
|
func doRecovery(decryptedKeys []*libwallet.DecryptedPrivateKey, destinationAddress btcutil.Address) string {
|
|
|
|
addrGen := NewAddressGenerator(decryptedKeys[0].Key, decryptedKeys[1].Key)
|
|
|
|
utxoScanner := scanner.NewScanner()
|
|
|
|
|
|
|
|
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")
|
|
|
|
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)
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
2021-03-17 14:28:04 -04:00
|
|
|
return sweepTx.TxHash().String()
|
2020-11-09 08:05:29 -05:00
|
|
|
}
|
|
|
|
|
2021-03-17 14:28:04 -04:00
|
|
|
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 this:
|
|
|
|
|
|
|
|
――― {white error report} ―――
|
|
|
|
%v
|
|
|
|
――――――――――――――――――――
|
|
|
|
|
|
|
|
We're always there to help.
|
|
|
|
`, err)
|
|
|
|
|
|
|
|
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]")
|
|
|
|
}
|
|
|
|
|
|
|
|
func printReport(report *scanner.Report) {
|
|
|
|
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)
|
|
|
|
|
|
|
|
addr, err := btcutil.DecodeAddress(userInput, &chainParams)
|
|
|
|
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)}
|
|
|
|
Your transaction weighs %v bytes. You can get suggestions in https://bitcoinfees.earn.com/#fees
|
|
|
|
`, 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 {
|
|
|
|
boldText := aurora.Bold(text) // in most terminals, bold colors are prettier and highlight better
|
|
|
|
|
|
|
|
switch colorName {
|
|
|
|
case "red":
|
|
|
|
return aurora.Red(boldText).String()
|
|
|
|
case "blue":
|
|
|
|
return aurora.Blue(boldText).String()
|
|
|
|
case "yellow":
|
|
|
|
return aurora.Yellow(boldText).String()
|
|
|
|
case "green":
|
|
|
|
return aurora.Green(boldText).String()
|
|
|
|
case "white":
|
|
|
|
return aurora.White(boldText).String()
|
|
|
|
case "whiteUnderline":
|
|
|
|
return aurora.Underline(boldText).White().String()
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
}
|