mirror of
https://github.com/muun/recovery.git
synced 2025-11-13 07:11:45 -05:00
Update project structure and build process
This commit is contained in:
6
recovery_tool/.gitignore
vendored
Normal file
6
recovery_tool/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.data
|
||||
.idea
|
||||
neutrino_test
|
||||
|
||||
/bin
|
||||
recovery
|
||||
79
recovery_tool/Dockerfile
Normal file
79
recovery_tool/Dockerfile
Normal file
@@ -0,0 +1,79 @@
|
||||
# Building this Dockerfile executes a full build of the Recovery Tool, cross-compiling inside
|
||||
# the container. The resulting executable is copied to the output directory using Docker BuildKit.
|
||||
|
||||
# You need to pass 3 parameters via --build-arg:
|
||||
# 1. `os` : the GOOS env var -- `linux`, `windows` or `darwin`.
|
||||
# 2. `arch`: the GOARCH env var -- `386` or `amd64` (note that darwin/386 is not a thing).
|
||||
# 3. `cc` : the CC env var -- a C compiler for CGO to use, empty to use the default.
|
||||
# 4. `out` : the name of the resulting executable, placed in the output directory on the host.
|
||||
|
||||
# For example, to build a linux/386 binary into `bin/rt`:
|
||||
# docker build . --output bin --build-arg os=linux --build-arg arch=386 --build-arg out=rt
|
||||
|
||||
# Note that the --output <dir> flag refers to the host, outside the container.
|
||||
|
||||
# --------------------------------------------------------------------------------------------------
|
||||
|
||||
# We have to different base images: one for arm and one for amd.
|
||||
# Turns out that debian gcc packages assume the current is always the "gcc" package, but
|
||||
# for other archs it's "gcc-multilib-${arch}-linux-gnu". This makes it impossible to build
|
||||
# one command that downloads all the archs we want for both amd64 and arm64 runners.
|
||||
# The solution is to have one base layer per arch.
|
||||
|
||||
FROM golang:1.22.6-bookworm AS rtool-build-base-arm64
|
||||
|
||||
# Avoid prompts during package installation:
|
||||
ENV DEBIAN_FRONTEND="noninteractive"
|
||||
|
||||
# Upgrade indices:
|
||||
RUN apt-get update
|
||||
|
||||
# Install the various compilers we're going to use, with specific versions:
|
||||
RUN apt-get install -y \
|
||||
gcc-mingw-w64 \
|
||||
gcc-12-multilib-i686-linux-gnu \
|
||||
gcc-12-multilib-x86-64-linux-gnu
|
||||
|
||||
FROM golang:1.22.6-bookworm AS rtool-build-base-amd64
|
||||
|
||||
# Avoid prompts during package installation:
|
||||
ENV DEBIAN_FRONTEND="noninteractive"
|
||||
|
||||
# Upgrade indices:
|
||||
RUN apt-get update
|
||||
|
||||
# Install the various compilers we're going to use, with specific versions:
|
||||
RUN apt-get install -y \
|
||||
gcc-mingw-w64 \
|
||||
gcc-12-multilib-i686-linux-gnu \
|
||||
gcc-12-aarch64-linux-gnu
|
||||
|
||||
FROM rtool-build-base-${TARGETARCH} AS rtool-build-base
|
||||
|
||||
# Copy the source code into the container:
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
|
||||
RUN /bin/bash
|
||||
|
||||
# --------------------------------------------------------------------------------------------------
|
||||
|
||||
FROM rtool-build-base AS rtool-build
|
||||
ARG os
|
||||
ARG arch
|
||||
ARG cc
|
||||
|
||||
# Enable and configure C support:
|
||||
ENV CGO_ENABLED=1
|
||||
ENV GO386=softfloat
|
||||
|
||||
# Do the thing:
|
||||
RUN env GOOS=${os} GOARCH=${arch} CC=${cc} go build -mod=vendor -a -trimpath -o /out ./recovery_tool/
|
||||
|
||||
# --------------------------------------------------------------------------------------------------
|
||||
|
||||
FROM scratch
|
||||
ARG out
|
||||
|
||||
# Copy the resulting executable back to the host:
|
||||
COPY --from=rtool-build /out ${out}
|
||||
21
recovery_tool/LICENSE
Normal file
21
recovery_tool/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2021 Muun Wallet, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
74
recovery_tool/Makefile
Normal file
74
recovery_tool/Makefile
Normal file
@@ -0,0 +1,74 @@
|
||||
# (Default) build the Recovery Tool to run on this system.
|
||||
build:
|
||||
mkdir -p bin
|
||||
|
||||
echo "Building recovery tool"
|
||||
go build -mod=vendor -a -trimpath -o "bin/recovery-tool"
|
||||
|
||||
echo "Success! Built to bin/recovery-tool"
|
||||
|
||||
# Cross-compile and checksum the Recovery Tool for a range of OS/archs.
|
||||
build-checksum-all: export DOCKER_BUILDKIT=1
|
||||
build-checksum-all:
|
||||
# Get vendor dependencies:
|
||||
go work vendor -v
|
||||
|
||||
# Linux 32-bit:
|
||||
docker build -f Dockerfile .. -o bin \
|
||||
--build-arg os=linux \
|
||||
--build-arg arch=386 \
|
||||
--build-arg cc=i686-linux-gnu-gcc-12 \
|
||||
--build-arg out=recovery-tool-linux32
|
||||
|
||||
/bin/echo -n '✓ Linux 32-bit ' && sha256sum "bin/recovery-tool-linux32"
|
||||
|
||||
# Linux 64-bit:
|
||||
docker build -f Dockerfile .. -o bin \
|
||||
--build-arg os=linux \
|
||||
--build-arg arch=amd64 \
|
||||
--build-arg cc=x86_64-linux-gnu-gcc-12 \
|
||||
--build-arg out=recovery-tool-linux64
|
||||
|
||||
/bin/echo -n '✓ Linux 64-bit ' && sha256sum "bin/recovery-tool-linux64"
|
||||
|
||||
# Linux arm64:
|
||||
docker build -f Dockerfile .. -o bin \
|
||||
--build-arg os=linux \
|
||||
--build-arg arch=arm64 \
|
||||
--build-arg cc=aarch64-linux-gnu-gcc-12 \
|
||||
--build-arg out=recovery-tool-linuxaarch64
|
||||
|
||||
/bin/echo -n '✓ Linux arm64' && sha256sum "bin/recovery-tool-linuxaarch64"
|
||||
|
||||
# Windows 32-bit:
|
||||
docker build -f Dockerfile .. -o bin \
|
||||
--build-arg os=windows \
|
||||
--build-arg arch=386 \
|
||||
--build-arg cc=i686-w64-mingw32-gcc \
|
||||
--build-arg out=recovery-tool-windows32.exe
|
||||
|
||||
/bin/echo -n '✓ Windows 32-bit ' && sha256sum "bin/recovery-tool-windows32.exe"
|
||||
|
||||
# Windows 64-bit:
|
||||
docker build -f Dockerfile .. -o bin \
|
||||
--build-arg os=windows \
|
||||
--build-arg arch=amd64 \
|
||||
--build-arg cc=x86_64-w64-mingw32-gcc \
|
||||
--build-arg out=recovery-tool-windows64.exe
|
||||
|
||||
/bin/echo -n '✓ Windows 64-bit ' && sha256sum "bin/recovery-tool-windows64.exe"
|
||||
|
||||
# NOTE:
|
||||
# Darwin reproducible builds are disabled for now, since the inclusion of C code in the latest
|
||||
# release made building the tool inside a Linux container extremely difficult. We'll be moving the
|
||||
# process to GitHub actions, where we can build on MacOS.
|
||||
|
||||
# Darwin 64-bit:
|
||||
# docker build . -o bin \
|
||||
# --build-arg os=darwin \
|
||||
# --build-arg arch=amd64 \
|
||||
# --build-arg out=recovery-tool-macos64
|
||||
|
||||
# /bin/echo -n '✓ MacOS 64-bit ' && sha256sum "bin/recovery-tool-macos64"
|
||||
|
||||
.SILENT:
|
||||
129
recovery_tool/address_generator.go
Normal file
129
recovery_tool/address_generator.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/muun/libwallet"
|
||||
"github.com/muun/recovery/utils"
|
||||
)
|
||||
|
||||
type AddressGenerator struct {
|
||||
addressCount int
|
||||
userKey *libwallet.HDPrivateKey
|
||||
muunKey *libwallet.HDPrivateKey
|
||||
generateContacts bool
|
||||
}
|
||||
|
||||
func NewAddressGenerator(userKey, muunKey *libwallet.HDPrivateKey, generateContacts bool) *AddressGenerator {
|
||||
return &AddressGenerator{
|
||||
addressCount: 0,
|
||||
userKey: userKey,
|
||||
muunKey: muunKey,
|
||||
generateContacts: generateContacts,
|
||||
}
|
||||
}
|
||||
|
||||
// Stream returns a channel that emits all addresses generated.
|
||||
func (g *AddressGenerator) Stream() chan libwallet.MuunAddress {
|
||||
ch := make(chan libwallet.MuunAddress)
|
||||
|
||||
go func() {
|
||||
g.generate(ch)
|
||||
utils.NewLogger("ADDR").Printf("Addresses %v\n", g.addressCount)
|
||||
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
func (g *AddressGenerator) generate(consumer chan libwallet.MuunAddress) {
|
||||
g.generateChangeAddrs(consumer)
|
||||
g.generateExternalAddrs(consumer)
|
||||
if g.generateContacts {
|
||||
g.generateContactAddrs(consumer, 100)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *AddressGenerator) generateChangeAddrs(consumer chan libwallet.MuunAddress) {
|
||||
const changePath = "m/1'/1'/0"
|
||||
changeUserKey, _ := g.userKey.DeriveTo(changePath)
|
||||
changeMuunKey, _ := g.muunKey.DeriveTo(changePath)
|
||||
|
||||
g.deriveTree(consumer, changeUserKey, changeMuunKey, 2500, "change")
|
||||
}
|
||||
|
||||
func (g *AddressGenerator) generateExternalAddrs(consumer chan libwallet.MuunAddress) {
|
||||
const externalPath = "m/1'/1'/1"
|
||||
externalUserKey, _ := g.userKey.DeriveTo(externalPath)
|
||||
externalMuunKey, _ := g.muunKey.DeriveTo(externalPath)
|
||||
|
||||
g.deriveTree(consumer, externalUserKey, externalMuunKey, 2500, "external")
|
||||
}
|
||||
|
||||
func (g *AddressGenerator) generateContactAddrs(consumer chan libwallet.MuunAddress, numContacts int64) {
|
||||
const addressPath = "m/1'/1'/2"
|
||||
contactUserKey, _ := g.userKey.DeriveTo(addressPath)
|
||||
contactMuunKey, _ := g.muunKey.DeriveTo(addressPath)
|
||||
for i := int64(0); i <= numContacts; i++ {
|
||||
partialContactUserKey, _ := contactUserKey.DerivedAt(i, false)
|
||||
partialMuunUserKey, _ := contactMuunKey.DerivedAt(i, false)
|
||||
|
||||
branch := fmt.Sprintf("contacts-%v", i)
|
||||
g.deriveTree(consumer, partialContactUserKey, partialMuunUserKey, 200, branch)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *AddressGenerator) deriveTree(
|
||||
consumer chan libwallet.MuunAddress,
|
||||
rootUserKey, rootMuunKey *libwallet.HDPrivateKey,
|
||||
count int64,
|
||||
name string,
|
||||
) {
|
||||
|
||||
for i := int64(0); i <= count; i++ {
|
||||
userKey, err := rootUserKey.DerivedAt(i, false)
|
||||
if err != nil {
|
||||
log.Printf("skipping child %v for %v due to %v", i, name, err)
|
||||
continue
|
||||
}
|
||||
muunKey, err := rootMuunKey.DerivedAt(i, false)
|
||||
if err != nil {
|
||||
log.Printf("skipping child %v for %v due to %v", i, name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
addrV2, err := libwallet.CreateAddressV2(userKey.PublicKey(), muunKey.PublicKey())
|
||||
if err == nil {
|
||||
consumer <- addrV2
|
||||
g.addressCount++
|
||||
} else {
|
||||
log.Printf("failed to generate %v v2 for %v due to %v", name, i, err)
|
||||
}
|
||||
|
||||
addrV3, err := libwallet.CreateAddressV3(userKey.PublicKey(), muunKey.PublicKey())
|
||||
if err == nil {
|
||||
consumer <- addrV3
|
||||
g.addressCount++
|
||||
} else {
|
||||
log.Printf("failed to generate %v v3 for %v due to %v", name, i, err)
|
||||
}
|
||||
|
||||
addrV4, err := libwallet.CreateAddressV4(userKey.PublicKey(), muunKey.PublicKey())
|
||||
if err == nil {
|
||||
consumer <- addrV4
|
||||
g.addressCount++
|
||||
} else {
|
||||
log.Printf("failed to generate %v v4 for %v due to %v", name, i, err)
|
||||
}
|
||||
|
||||
addrV5, err := libwallet.CreateAddressV5(userKey.PublicKey(), muunKey.PublicKey())
|
||||
if err == nil {
|
||||
consumer <- addrV5
|
||||
g.addressCount++
|
||||
} else {
|
||||
log.Printf("failed to generate %v v5 for %v due to %v", name, i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
51
recovery_tool/cmd/survey/main.go
Normal file
51
recovery_tool/cmd/survey/main.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/muun/recovery/electrum"
|
||||
"github.com/muun/recovery/survey"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config := &survey.Config{
|
||||
InitialServers: electrum.PublicServers,
|
||||
Workers: 30,
|
||||
SpeedTestDuration: time.Second * 20,
|
||||
SpeedTestBatchSize: 100,
|
||||
}
|
||||
|
||||
survey := survey.NewSurvey(config)
|
||||
results := survey.Run()
|
||||
|
||||
fmt.Println("\n\n// Worthy servers:")
|
||||
for _, result := range results {
|
||||
if result.IsWorthy {
|
||||
fmt.Println(toCodeLine(result))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n\n// Unworthy servers:")
|
||||
for _, result := range results {
|
||||
if !result.IsWorthy {
|
||||
fmt.Println(toCodeLine(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toCodeLine(r *survey.Result) string {
|
||||
if r.Err != nil {
|
||||
return fmt.Sprintf("\"%s\", // %v", r.Server, r.Err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"\"%s\", // impl: %s, batching: %v, ttc: %.2f, speed: %d, from: %s",
|
||||
r.Server,
|
||||
r.Impl,
|
||||
r.BatchSupport,
|
||||
r.TimeToConnect.Seconds(),
|
||||
r.Speed,
|
||||
r.FromPeer,
|
||||
)
|
||||
}
|
||||
540
recovery_tool/electrum/client.go
Normal file
540
recovery_tool/electrum/client.go
Normal file
@@ -0,0 +1,540 @@
|
||||
package electrum
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/muun/recovery/utils"
|
||||
)
|
||||
|
||||
const defaultLoggerTag = "Electrum/?"
|
||||
const connectionTimeout = time.Second * 30
|
||||
const callTimeout = time.Second * 30
|
||||
const messageDelim = byte('\n')
|
||||
const noTimeout = 0
|
||||
|
||||
var implsWithBatching = []string{"ElectrumX"}
|
||||
|
||||
// Client is a TLS client that implements a subset of the Electrum protocol.
|
||||
//
|
||||
// It includes a minimal implementation of a JSON-RPC client, since the one provided by the
|
||||
// standard library doesn't support features such as batching.
|
||||
//
|
||||
// It is absolutely not thread-safe. Every Client should have a single owner.
|
||||
type Client struct {
|
||||
Server string
|
||||
ServerImpl string
|
||||
ProtoVersion string
|
||||
nextRequestID int
|
||||
conn net.Conn
|
||||
log *utils.Logger
|
||||
requireTls bool
|
||||
}
|
||||
|
||||
// Request models the structure of all Electrum protocol requests.
|
||||
type Request struct {
|
||||
ID int `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params []Param `json:"params"`
|
||||
}
|
||||
|
||||
// ErrorResponse models the structure of a generic error response.
|
||||
type ErrorResponse struct {
|
||||
ID int `json:"id"`
|
||||
Error interface{} `json:"error"` // type varies among Electrum implementations.
|
||||
}
|
||||
|
||||
// ServerVersionResponse models the structure of a `server.version` response.
|
||||
type ServerVersionResponse struct {
|
||||
ID int `json:"id"`
|
||||
Result []string `json:"result"`
|
||||
}
|
||||
|
||||
// ServerFeaturesResponse models the structure of a `server.features` response.
|
||||
type ServerFeaturesResponse struct {
|
||||
ID int `json:"id"`
|
||||
Result ServerFeatures `json:"result"`
|
||||
}
|
||||
|
||||
// ServerPeersResponse models the structure (or lack thereof) of a `server.peers.subscribe` response
|
||||
type ServerPeersResponse struct {
|
||||
ID int `json:"id"`
|
||||
Result []interface{} `json:"result"`
|
||||
}
|
||||
|
||||
// ListUnspentResponse models a `blockchain.scripthash.listunspent` response.
|
||||
type ListUnspentResponse struct {
|
||||
ID int `json:"id"`
|
||||
Result []UnspentRef `json:"result"`
|
||||
}
|
||||
|
||||
// GetTransactionResponse models the structure of a `blockchain.transaction.get` response.
|
||||
type GetTransactionResponse struct {
|
||||
ID int `json:"id"`
|
||||
Result string `json:"result"`
|
||||
}
|
||||
|
||||
// BroadcastResponse models the structure of a `blockchain.transaction.broadcast` response.
|
||||
type BroadcastResponse struct {
|
||||
ID int `json:"id"`
|
||||
Result string `json:"result"`
|
||||
}
|
||||
|
||||
// UnspentRef models an item in the `ListUnspentResponse` results.
|
||||
type UnspentRef struct {
|
||||
TxHash string `json:"tx_hash"`
|
||||
TxPos int `json:"tx_pos"`
|
||||
Value int64 `json:"value"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// ServerFeatures contains the relevant information from `ServerFeatures` results.
|
||||
type ServerFeatures struct {
|
||||
ID int `json:"id"`
|
||||
GenesisHash string `json:"genesis_hash"`
|
||||
HashFunction string `json:"hash_function"`
|
||||
ServerVersion string `json:"server_version"`
|
||||
ProcotolMin string `json:"protocol_min"`
|
||||
ProtocolMax string `json:"protocol_max"`
|
||||
Pruning int `json:"pruning"`
|
||||
}
|
||||
|
||||
// Param is a convenience type that models an item in the `Params` array of an Request.
|
||||
type Param = interface{}
|
||||
|
||||
// NewClient creates an initialized Client instance.
|
||||
func NewClient(requireTls bool) *Client {
|
||||
return &Client{
|
||||
log: utils.NewLogger(defaultLoggerTag),
|
||||
requireTls: requireTls,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect establishes a TLS connection to an Electrum server.
|
||||
func (c *Client) Connect(server string) error {
|
||||
c.Disconnect()
|
||||
|
||||
c.log.SetTag("Electrum/" + server)
|
||||
c.Server = server
|
||||
|
||||
c.log.Printf("Connecting")
|
||||
|
||||
err := c.establishConnection()
|
||||
if err != nil {
|
||||
c.Disconnect()
|
||||
return c.log.Errorf("Connect failed: %w", err)
|
||||
}
|
||||
|
||||
// Before calling it a day send a test request (trust me), and as we do identify the server:
|
||||
err = c.identifyServer()
|
||||
if err != nil {
|
||||
c.Disconnect()
|
||||
return c.log.Errorf("Identifying server failed: %w", err)
|
||||
}
|
||||
|
||||
c.log.Printf("Identified as %s (%s)", c.ServerImpl, c.ProtoVersion)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect cuts the connection (if connected) to the Electrum server.
|
||||
func (c *Client) Disconnect() error {
|
||||
if c.conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.log.Printf("Disconnecting")
|
||||
|
||||
err := c.conn.Close()
|
||||
if err != nil {
|
||||
return c.log.Errorf("Disconnect failed: %w", err)
|
||||
}
|
||||
|
||||
c.conn = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// SupportsBatching returns whether this client can process batch requests.
|
||||
func (c *Client) SupportsBatching() bool {
|
||||
for _, implName := range implsWithBatching {
|
||||
if strings.HasPrefix(c.ServerImpl, implName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ServerVersion calls the `server.version` method and returns the [impl, protocol version] tuple.
|
||||
func (c *Client) ServerVersion() ([]string, error) {
|
||||
request := Request{
|
||||
Method: "server.version",
|
||||
Params: []Param{},
|
||||
}
|
||||
|
||||
var response ServerVersionResponse
|
||||
|
||||
err := c.call(&request, &response, callTimeout)
|
||||
if err != nil {
|
||||
return nil, c.log.Errorf("ServerVersion failed: %w", err)
|
||||
}
|
||||
|
||||
return response.Result, nil
|
||||
}
|
||||
|
||||
// ServerFeatures calls the `server.features` method and returns the relevant part of the result.
|
||||
func (c *Client) ServerFeatures() (*ServerFeatures, error) {
|
||||
request := Request{
|
||||
Method: "server.features",
|
||||
Params: []Param{},
|
||||
}
|
||||
|
||||
var response ServerFeaturesResponse
|
||||
|
||||
err := c.call(&request, &response, callTimeout)
|
||||
if err != nil {
|
||||
return nil, c.log.Errorf("ServerFeatures failed: %w", err)
|
||||
}
|
||||
|
||||
return &response.Result, nil
|
||||
}
|
||||
|
||||
// ServerPeers calls the `server.peers.subscribe` method and returns a list of server addresses.
|
||||
func (c *Client) ServerPeers() ([]string, error) {
|
||||
res, err := c.rawServerPeers()
|
||||
if err != nil {
|
||||
return nil, err // note that, besides I/O errors, some servers close the socket on this request
|
||||
}
|
||||
|
||||
var peers []string
|
||||
|
||||
for _, entry := range res {
|
||||
// Get ready for some hot casting action. Not for the faint of heart.
|
||||
addr := entry.([]interface{})[1].(string)
|
||||
port := entry.([]interface{})[2].([]interface{})[1].(string)[1:]
|
||||
|
||||
peers = append(peers, addr+":"+port)
|
||||
}
|
||||
|
||||
return peers, nil
|
||||
}
|
||||
|
||||
// rawServerPeers calls the `server.peers.subscribe` method and returns this monstrosity:
|
||||
//
|
||||
// [ "<ip>", "<domain>", ["<version>", "s<SSL port>", "t<TLS port>"] ]
|
||||
//
|
||||
// Ports can be in any order, or absent if the protocol is not supported
|
||||
func (c *Client) rawServerPeers() ([]interface{}, error) {
|
||||
request := Request{
|
||||
Method: "server.peers.subscribe",
|
||||
Params: []Param{},
|
||||
}
|
||||
|
||||
var response ServerPeersResponse
|
||||
|
||||
err := c.call(&request, &response, callTimeout)
|
||||
if err != nil {
|
||||
return nil, c.log.Errorf("rawServerPeers failed: %w", err)
|
||||
}
|
||||
|
||||
return response.Result, nil
|
||||
}
|
||||
|
||||
// Broadcast calls the `blockchain.transaction.broadcast` endpoint and returns the transaction hash.
|
||||
func (c *Client) Broadcast(rawTx string) (string, error) {
|
||||
request := Request{
|
||||
Method: "blockchain.transaction.broadcast",
|
||||
Params: []Param{rawTx},
|
||||
}
|
||||
|
||||
var response BroadcastResponse
|
||||
|
||||
err := c.call(&request, &response, callTimeout)
|
||||
if err != nil {
|
||||
return "", c.log.Errorf("Broadcast failed: %w", err)
|
||||
}
|
||||
|
||||
return response.Result, nil
|
||||
}
|
||||
|
||||
// GetTransaction calls the `blockchain.transaction.get` endpoint and returns the transaction hex.
|
||||
func (c *Client) GetTransaction(txID string) (string, error) {
|
||||
request := Request{
|
||||
Method: "blockchain.transaction.get",
|
||||
Params: []Param{txID},
|
||||
}
|
||||
|
||||
var response GetTransactionResponse
|
||||
|
||||
err := c.call(&request, &response, callTimeout)
|
||||
if err != nil {
|
||||
return "", c.log.Errorf("GetTransaction failed: %w", err)
|
||||
}
|
||||
|
||||
return response.Result, nil
|
||||
}
|
||||
|
||||
// ListUnspent calls `blockchain.scripthash.listunspent` and returns the UTXO results.
|
||||
func (c *Client) ListUnspent(indexHash string) ([]UnspentRef, error) {
|
||||
request := Request{
|
||||
Method: "blockchain.scripthash.listunspent",
|
||||
Params: []Param{indexHash},
|
||||
}
|
||||
var response ListUnspentResponse
|
||||
|
||||
err := c.call(&request, &response, callTimeout)
|
||||
if err != nil {
|
||||
return nil, c.log.Errorf("ListUnspent failed: %w", err)
|
||||
}
|
||||
|
||||
return response.Result, nil
|
||||
}
|
||||
|
||||
// ListUnspentBatch is like `ListUnspent`, but using batching.
|
||||
func (c *Client) ListUnspentBatch(indexHashes []string) ([][]UnspentRef, error) {
|
||||
requests := make([]*Request, len(indexHashes))
|
||||
method := "blockchain.scripthash.listunspent"
|
||||
|
||||
for i, indexHash := range indexHashes {
|
||||
requests[i] = &Request{
|
||||
Method: method,
|
||||
Params: []Param{indexHash},
|
||||
}
|
||||
}
|
||||
|
||||
var responses []ListUnspentResponse
|
||||
|
||||
// Give it a little more time than non-batch calls
|
||||
timeout := callTimeout * 2
|
||||
|
||||
err := c.callBatch(method, requests, &responses, timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ListUnspentBatch failed: %w", err)
|
||||
}
|
||||
|
||||
// Don't forget to sort responses:
|
||||
sort.Slice(responses, func(i, j int) bool {
|
||||
return responses[i].ID < responses[j].ID
|
||||
})
|
||||
|
||||
// Now we can collect all results:
|
||||
var unspentRefs [][]UnspentRef
|
||||
|
||||
for _, response := range responses {
|
||||
unspentRefs = append(unspentRefs, response.Result)
|
||||
}
|
||||
|
||||
return unspentRefs, nil
|
||||
}
|
||||
|
||||
func (c *Client) establishConnection() error {
|
||||
// We first try to connect over TCP+TLS
|
||||
// If we fail and requireTls is false, we try over TCP
|
||||
|
||||
// TODO: check if insecure is necessary
|
||||
config := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{
|
||||
Timeout: connectionTimeout,
|
||||
}
|
||||
|
||||
tlsConn, err := tls.DialWithDialer(dialer, "tcp", c.Server, config)
|
||||
if err == nil {
|
||||
c.conn = tlsConn
|
||||
return nil
|
||||
}
|
||||
if c.requireTls {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", c.Server, connectionTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.conn = conn
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) identifyServer() error {
|
||||
serverVersion, err := c.ServerVersion()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.ServerImpl = serverVersion[0]
|
||||
c.ProtoVersion = serverVersion[1]
|
||||
|
||||
c.log.Printf("Identified %s %s", c.ServerImpl, c.ProtoVersion)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsConnected returns whether this client is connected to a server.
|
||||
// It does not guarantee the next request will succeed.
|
||||
func (c *Client) IsConnected() bool {
|
||||
return c.conn != nil
|
||||
}
|
||||
|
||||
// call executes a request with JSON marshalling, and loads the response into a pointer.
|
||||
func (c *Client) call(request *Request, response interface{}, timeout time.Duration) error {
|
||||
// Assign a fresh request ID:
|
||||
request.ID = c.incRequestID()
|
||||
|
||||
// Serialize the request:
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return c.log.Errorf("Marshal failed %v: %w", request, err)
|
||||
}
|
||||
|
||||
// Make the call, obtain the serialized response:
|
||||
responseBytes, err := c.callRaw(request.Method, requestBytes, timeout)
|
||||
if err != nil {
|
||||
return c.log.Errorf("Send failed %s: %w", request.Method, err)
|
||||
}
|
||||
|
||||
// Deserialize into an error, to see if there's any:
|
||||
var maybeErrorResponse ErrorResponse
|
||||
|
||||
err = json.Unmarshal(responseBytes, &maybeErrorResponse)
|
||||
if err != nil {
|
||||
return c.log.Errorf("Unmarshal of potential error failed: %s %w", request.Method, err)
|
||||
}
|
||||
|
||||
if maybeErrorResponse.Error != nil {
|
||||
return c.log.Errorf("Electrum error: %v", maybeErrorResponse.Error)
|
||||
}
|
||||
|
||||
// Deserialize the response:
|
||||
err = json.Unmarshal(responseBytes, response)
|
||||
if err != nil {
|
||||
return c.log.Errorf("Unmarshal failed %s: %w", string(responseBytes), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// call executes a batch request with JSON marshalling, and loads the response into a pointer.
|
||||
// Response may not match request order, so callers MUST sort them by ID.
|
||||
func (c *Client) callBatch(
|
||||
method string, requests []*Request, response interface{}, timeout time.Duration,
|
||||
) error {
|
||||
// Assign fresh request IDs:
|
||||
for _, request := range requests {
|
||||
request.ID = c.incRequestID()
|
||||
}
|
||||
|
||||
// Serialize the request:
|
||||
requestBytes, err := json.Marshal(requests)
|
||||
if err != nil {
|
||||
return c.log.Errorf("Marshal failed %v: %w", requests, err)
|
||||
}
|
||||
|
||||
// Make the call, obtain the serialized response:
|
||||
responseBytes, err := c.callRaw(method, requestBytes, timeout)
|
||||
if err != nil {
|
||||
return c.log.Errorf("Send failed %s: %w", method, err)
|
||||
}
|
||||
|
||||
// Deserialize into an array of errors, to see if there's any:
|
||||
var maybeErrorResponses []ErrorResponse
|
||||
|
||||
err = json.Unmarshal(responseBytes, &maybeErrorResponses)
|
||||
if err != nil {
|
||||
return c.log.Errorf("Unmarshal of potential error failed: %s %w", string(responseBytes), err)
|
||||
}
|
||||
|
||||
// Walk the responses, returning the first error found:
|
||||
for _, maybeErrorResponse := range maybeErrorResponses {
|
||||
if maybeErrorResponse.Error != nil {
|
||||
return c.log.Errorf("Electrum error: %v", maybeErrorResponse.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// Deserialize the response:
|
||||
err = json.Unmarshal(responseBytes, response)
|
||||
if err != nil {
|
||||
return c.log.Errorf("Unmarshal failed %s: %w", string(responseBytes), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// callRaw sends a raw request in bytes, and returns a raw response (or an error).
|
||||
func (c *Client) callRaw(method string, request []byte, timeout time.Duration) ([]byte, error) {
|
||||
c.log.Printf("Sending %s request", method)
|
||||
c.log.Tracef("Sending %s body: %s", method, string(request))
|
||||
|
||||
if !c.IsConnected() {
|
||||
return nil, c.log.Errorf("Send failed %s: not connected", method)
|
||||
}
|
||||
|
||||
request = append(request, messageDelim)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// SetDeadline is an absolute time based timeout. That is, we set an exact
|
||||
// time we want it to fail.
|
||||
var deadline time.Time
|
||||
if timeout == noTimeout {
|
||||
// This means no deadline
|
||||
deadline = time.Time{}
|
||||
} else {
|
||||
deadline = start.Add(timeout)
|
||||
}
|
||||
err := c.conn.SetDeadline(deadline)
|
||||
if err != nil {
|
||||
return nil, c.log.Errorf("Send failed %s: SetDeadline failed", method)
|
||||
}
|
||||
|
||||
_, err = c.conn.Write(request)
|
||||
|
||||
if err != nil {
|
||||
duration := time.Now().Sub(start)
|
||||
return nil, c.log.Errorf("Send failed %s after %vms: %w", method, duration.Milliseconds(), err)
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(c.conn)
|
||||
|
||||
response, err := reader.ReadBytes(messageDelim)
|
||||
duration := time.Now().Sub(start)
|
||||
if err != nil {
|
||||
return nil, c.log.Errorf("Receive failed %s after %vms: %w", method, duration.Milliseconds(), err)
|
||||
}
|
||||
|
||||
c.log.Printf("Received %s after %vms", method, duration.Milliseconds())
|
||||
c.log.Tracef("Received %s: %s", method, string(response))
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *Client) incRequestID() int {
|
||||
c.nextRequestID++
|
||||
return c.nextRequestID
|
||||
}
|
||||
|
||||
// GetIndexHash returns the script parameter to use with Electrum, given a Bitcoin address.
|
||||
func GetIndexHash(script []byte) string {
|
||||
indexHash := sha256.Sum256(script)
|
||||
reverse(&indexHash)
|
||||
|
||||
return hex.EncodeToString(indexHash[:])
|
||||
}
|
||||
|
||||
// reverse the order of the provided byte array, in place.
|
||||
func reverse(a *[32]byte) {
|
||||
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
|
||||
a[i], a[j] = a[j], a[i]
|
||||
}
|
||||
}
|
||||
28
recovery_tool/electrum/pool.go
Normal file
28
recovery_tool/electrum/pool.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package electrum
|
||||
|
||||
// Pool provides a shared pool of Clients that callers can acquire and release, limiting
|
||||
// the amount of concurrent Clients in active use.
|
||||
type Pool struct {
|
||||
nextClient chan *Client
|
||||
}
|
||||
|
||||
// NewPool creates an initialized Pool with a `size` number of clients.
|
||||
func NewPool(size int, requireTls bool) *Pool {
|
||||
nextClient := make(chan *Client, size)
|
||||
|
||||
for i := 0; i < size; i++ {
|
||||
nextClient <- NewClient(requireTls)
|
||||
}
|
||||
|
||||
return &Pool{nextClient}
|
||||
}
|
||||
|
||||
// Acquire obtains an unused Client, blocking until one is released.
|
||||
func (p *Pool) Acquire() <-chan *Client {
|
||||
return p.nextClient
|
||||
}
|
||||
|
||||
// Release returns a Client to the pool, unblocking the next caller trying to `Acquire()`.
|
||||
func (p *Pool) Release(client *Client) {
|
||||
p.nextClient <- client
|
||||
}
|
||||
105
recovery_tool/electrum/servers.go
Normal file
105
recovery_tool/electrum/servers.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package electrum
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
// ServerProvider manages a rotating server list, from which callers can pull server addresses.
|
||||
type ServerProvider struct {
|
||||
nextIndex int32
|
||||
servers []string
|
||||
}
|
||||
|
||||
// NewServerProvider returns an initialized ServerProvider.
|
||||
func NewServerProvider(servers []string) *ServerProvider {
|
||||
return &ServerProvider{
|
||||
nextIndex: -1,
|
||||
servers: servers,
|
||||
}
|
||||
}
|
||||
|
||||
// NextServer returns an address from the rotating list. It's thread-safe.
|
||||
func (p *ServerProvider) NextServer() string {
|
||||
index := int(atomic.AddInt32(&p.nextIndex, 1))
|
||||
return p.servers[index%len(p.servers)]
|
||||
}
|
||||
|
||||
// PublicServers list.
|
||||
//
|
||||
// This list was taken from Electrum repositories, keeping TLS servers and excluding onion URIs.
|
||||
// It was then sorted into sections using the `cmd/survey` program, to prioritize the more reliable
|
||||
// servers with batch support.
|
||||
//
|
||||
// See https://github.com/spesmilo/electrum/blob/master/electrum/servers.json
|
||||
// See https://github.com/kyuupichan/electrumx/blob/master/electrumx/lib/coins.py
|
||||
// See `cmd/survey/main.go`
|
||||
var PublicServers = []string{
|
||||
// Fast servers with batching
|
||||
"electrum.coinext.com.br:50002", // impl: ElectrumX 1.14.0, batching: true, ttc: 0.15, speed: 113, from:
|
||||
"fulcrum.sethforprivacy.com:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.55, speed: 96, from:
|
||||
"mainnet.foundationdevices.com:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.54, speed: 88, from:
|
||||
"btc.lastingcoin.net:50002", // impl: Fulcrum 1.7.0, batching: true, ttc: 0.74, speed: 73, from:
|
||||
"vmd71287.contaboserver.net:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.60, speed: 70, from:
|
||||
"de.poiuty.com:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.87, speed: 70, from:
|
||||
"electrum.jochen-hoenicke.de:50006", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.83, speed: 69, from:
|
||||
"btc.cr.ypto.tech:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.81, speed: 65, from:
|
||||
"e.keff.org:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.82, speed: 65, from:
|
||||
"vmd104014.contaboserver.net:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.58, speed: 64, from:
|
||||
"e2.keff.org:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.83, speed: 64, from:
|
||||
"fulcrum.grey.pw:51002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.81, speed: 63, from:
|
||||
"fortress.qtornado.com:443", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.84, speed: 62, from:
|
||||
"f.keff.org:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.89, speed: 62, from:
|
||||
"2ex.digitaleveryware.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.71, speed: 61, from:
|
||||
"electrum.petrkr.net:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.84, speed: 58, from:
|
||||
"electrum.stippy.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.80, speed: 57, from:
|
||||
"electrum0.snel.it:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.80, speed: 56, from:
|
||||
"ru.poiuty.com:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.99, speed: 56, from:
|
||||
"electrum.privateservers.network:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 0.85, speed: 49, from:
|
||||
"btc.electroncash.dk:60002", // impl: Fulcrum 1.9.0, batching: true, ttc: 0.92, speed: 48, from:
|
||||
"bitcoin.aranguren.org:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 1.19, speed: 48, from:
|
||||
"electrum.bitcoinserver.nl:50514", // impl: Fulcrum 1.8.1, batching: true, ttc: 0.85, speed: 44, from:
|
||||
"btc.prompt.cash:61002", // impl: Fulcrum 1.8.1, batching: true, ttc: 1.22, speed: 44, from:
|
||||
"fulc.bot.nu:50002", // impl: Fulcrum 1.7.0, batching: true, ttc: 1.04, speed: 35, from:
|
||||
"bolt.schulzemic.net:50002", // impl: Fulcrum 1.8.2, batching: true, ttc: 0.96, speed: 33, from:
|
||||
"node1.btccuracao.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.90, speed: 25, from:
|
||||
|
||||
// Other servers
|
||||
"xtrum.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.91, speed: 19, from: fulcrum.sethforprivacy.com:50002
|
||||
"electrum.bitaroo.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.04, speed: 19, from:
|
||||
"btce.iiiiiii.biz:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.07, speed: 19, from: electrum.coinext.com.br:50002
|
||||
"electrum.emzy.de:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.17, speed: 19, from:
|
||||
"alviss.coinjoined.com:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 1.15, speed: 17, from:
|
||||
"2AZZARITA.hopto.org:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.81, speed: 16, from: electrum.coinext.com.br:50002
|
||||
"vmd104012.contaboserver.net:50002", // impl: Fulcrum 1.9.0, batching: true, ttc: 1.25, speed: 16, from:
|
||||
"electrum.bitcoinlizard.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 4.99, speed: 14, from:
|
||||
"btc.ocf.sh:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.19, speed: 12, from:
|
||||
"bitcoins.sk:56002", // impl: ElectrumX 1.14.0, batching: true, ttc: 0.80, speed: 11, from:
|
||||
"electrum-btc.leblancnet.us:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.04, speed: 11, from:
|
||||
"helicarrier.bauerj.eu:50002", // impl: ElectrumX 1.10.0, batching: true, ttc: 0.96, speed: 10, from:
|
||||
"electrum.neocrypto.io:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.69, speed: 7, from:
|
||||
"caleb.vegas:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.71, speed: 7, from:
|
||||
"smmalis37.ddns.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.77, speed: 7, from:
|
||||
"2azzarita.hopto.org:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.78, speed: 7, from: fulcrum.sethforprivacy.com:50002
|
||||
"electrum.kendigisland.xyz:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.79, speed: 7, from:
|
||||
"electrum.hsmiths.com:50002", // impl: ElectrumX 1.10.0, batching: true, ttc: 0.90, speed: 7, from:
|
||||
"vmd63185.contaboserver.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.92, speed: 7, from:
|
||||
"blkhub.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.94, speed: 7, from:
|
||||
"electrum.mmitech.info:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.95, speed: 7, from:
|
||||
"elx.bitske.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.17, speed: 7, from:
|
||||
"bitcoin.lu.ke:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.21, speed: 7, from:
|
||||
"ex05.axalgo.com:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.21, speed: 7, from:
|
||||
"walle.dedyn.io:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.23, speed: 7, from:
|
||||
"eai.coincited.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.34, speed: 6, from:
|
||||
"2electrumx.hopto.me:56022", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.93, speed: 6, from:
|
||||
"hodlers.beer:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.03, speed: 6, from:
|
||||
"kareoke.qoppa.org:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.13, speed: 6, from:
|
||||
"ASSUREDLY.not.fyi:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.85, speed: 4, from:
|
||||
"electrumx.alexridevski.net:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.87, speed: 4, from:
|
||||
"assuredly.not.fyi:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 0.90, speed: 4, from:
|
||||
"ragtor.duckdns.org:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.07, speed: 4, from:
|
||||
"surely.not.fyi:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.20, speed: 4, from:
|
||||
"btc.electrum.bitbitnet.net:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 0.71, speed: 3, from:
|
||||
"gods-of-rock.screaminglemur.net:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 0.92, speed: 3, from:
|
||||
"SURELY.not.fyi:50002", // impl: ElectrumX 1.16.0, batching: true, ttc: 1.35, speed: 3, from:
|
||||
"horsey.cryptocowboys.net:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 0.61, speed: 2, from:
|
||||
"electrumx-btc.cryptonermal.net:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 0.83, speed: 1, from:
|
||||
"electrum.coineuskal.com:50002", // impl: ElectrumX 1.15.0, batching: true, ttc: 1.73, speed: 0, from: electrum.coinext.com.br:50002
|
||||
}
|
||||
14
recovery_tool/go.mod
Normal file
14
recovery_tool/go.mod
Normal file
@@ -0,0 +1,14 @@
|
||||
module github.com/muun/recovery
|
||||
|
||||
go 1.12
|
||||
|
||||
require (
|
||||
github.com/btcsuite/btcd v0.21.0-beta
|
||||
github.com/btcsuite/btcutil v1.0.2
|
||||
github.com/gookit/color v1.4.2
|
||||
github.com/muun/libwallet v0.11.0
|
||||
)
|
||||
|
||||
replace github.com/muun/libwallet => ../libwallet
|
||||
|
||||
replace github.com/lightninglabs/neutrino => github.com/muun/neutrino v0.0.0-20190914162326-7082af0fa257
|
||||
388
recovery_tool/go.sum
Normal file
388
recovery_tool/go.sum
Normal file
@@ -0,0 +1,388 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.33.1/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
git.schwanenlied.me/yawning/bsaes.git v0.0.0-20180720073208-c0276d75487e/go.mod h1:BWqTsj8PgcPriQJGl7el20J/7TuT1d/hSyFDXMEpoEo=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/NebulousLabs/fastrand v0.0.0-20181203155948-6fb6489aac4e/go.mod h1:Bdzq+51GR4/0DIhaICZEOm+OHvXGwwB2trKZ8B4Y6eQ=
|
||||
github.com/NebulousLabs/go-upnp v0.0.0-20180202185039-29b680b06c82/go.mod h1:GbuBk21JqF+driLX3XtJYNZjGa45YDoa9IqCTzNSfEc=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||
github.com/Yawning/aez v0.0.0-20180114000226-4dad034d9db2/go.mod h1:9pIqrY6SXNL8vjRQE5Hd/OL5GyK/9MrGUWs87z/eFfk=
|
||||
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY=
|
||||
github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA=
|
||||
github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg=
|
||||
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/btcsuite/btcd v0.0.0-20190629003639-c26ffa870fd8/go.mod h1:3J08xEfcugPacsc34/LKRU2yO7YmuT8yt28J8k2+rrI=
|
||||
github.com/btcsuite/btcd v0.0.0-20190824003749-130ea5bddde3/go.mod h1:3J08xEfcugPacsc34/LKRU2yO7YmuT8yt28J8k2+rrI=
|
||||
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||
github.com/btcsuite/btcd v0.20.1-beta.0.20200513120220-b470eee47728/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
|
||||
github.com/btcsuite/btcd v0.20.1-beta.0.20200515232429-9f0179fd2c46/go.mod h1:Yktc19YNjh/Iz2//CX0vfRTS4IJKM/RKO5YZ9Fn+Pgo=
|
||||
github.com/btcsuite/btcd v0.21.0-beta h1:At9hIZdJW0s9E/fAz28nrz6AmcNlSVucCH796ZteX1M=
|
||||
github.com/btcsuite/btcd v0.21.0-beta/go.mod h1:ZSWyehm27aAuS9bvkATT+Xte3hjHZ+MRgMY/8NJ7K94=
|
||||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo=
|
||||
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
|
||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
|
||||
github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts=
|
||||
github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts=
|
||||
github.com/btcsuite/btcutil/psbt v1.0.2 h1:gCVY3KxdoEVU7Q6TjusPO+GANIwVgr9yTLqM+a6CZr8=
|
||||
github.com/btcsuite/btcutil/psbt v1.0.2/go.mod h1:LVveMu4VaNSkIRTZu2+ut0HDBRuYjqGocxDMNS1KuGQ=
|
||||
github.com/btcsuite/btcwallet v0.11.1-0.20200612012534-48addcd5591a h1:AZ1Mf0gd9mgJqrTTIFUc17ep9EKUbQusVAIzJ6X+x3Q=
|
||||
github.com/btcsuite/btcwallet v0.11.1-0.20200612012534-48addcd5591a/go.mod h1:9+AH3V5mcTtNXTKe+fe63fDLKGOwQbZqmvOVUef+JFE=
|
||||
github.com/btcsuite/btcwallet/wallet/txauthor v1.0.0 h1:KGHMW5sd7yDdDMkCZ/JpP0KltolFsQcB973brBnfj4c=
|
||||
github.com/btcsuite/btcwallet/wallet/txauthor v1.0.0/go.mod h1:VufDts7bd/zs3GV13f/lXc/0lXrPnvxD/NvmpG/FEKU=
|
||||
github.com/btcsuite/btcwallet/wallet/txrules v1.0.0 h1:2VsfS0sBedcM5KmDzRMT3+b6xobqWveZGvjb+jFez5w=
|
||||
github.com/btcsuite/btcwallet/wallet/txrules v1.0.0/go.mod h1:UwQE78yCerZ313EXZwEiu3jNAtfXj2n2+c8RWiE/WNA=
|
||||
github.com/btcsuite/btcwallet/wallet/txsizes v1.0.0 h1:6DxkcoMnCPY4E9cUDPB5tbuuf40SmmMkSQkoE8vCT+s=
|
||||
github.com/btcsuite/btcwallet/wallet/txsizes v1.0.0/go.mod h1:pauEU8UuMFiThe5PB3EO+gO5kx87Me5NvdQDsTuq6cs=
|
||||
github.com/btcsuite/btcwallet/walletdb v1.0.0/go.mod h1:bZTy9RyYZh9fLnSua+/CD48TJtYJSHjjYcSaszuxCCk=
|
||||
github.com/btcsuite/btcwallet/walletdb v1.3.1/go.mod h1:9cwc1Yyg4uvd4ZdfdoMnALji+V9gfWSMfxEdLdR5Vwc=
|
||||
github.com/btcsuite/btcwallet/walletdb v1.3.2/go.mod h1:GZCMPNpUu5KE3ASoVd+k06p/1OW8OwNGCCaNWRto2cQ=
|
||||
github.com/btcsuite/btcwallet/walletdb v1.3.3 h1:u6e7vRIKBF++cJy+hOHaMGg+88ZTwvpaY27AFvtB668=
|
||||
github.com/btcsuite/btcwallet/walletdb v1.3.3/go.mod h1:oJDxAEUHVtnmIIBaa22wSBPTVcs6hUp5NKWmI8xDwwU=
|
||||
github.com/btcsuite/btcwallet/wtxmgr v1.0.0/go.mod h1:vc4gBprll6BP0UJ+AIGDaySoc7MdAmZf8kelfNb8CFY=
|
||||
github.com/btcsuite/btcwallet/wtxmgr v1.2.0 h1:ZUYPsSv8GjF9KK7lboB2OVHF0uYEcHxgrCfFWqPd9NA=
|
||||
github.com/btcsuite/btcwallet/wtxmgr v1.2.0/go.mod h1:h8hkcKUE3X7lMPzTUoGnNiw5g7VhGrKEW3KpR2r0VnY=
|
||||
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd h1:R/opQEbFEy9JGkIguV40SvRY1uliPX8ifOvi6ICsFCw=
|
||||
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
|
||||
github.com/btcsuite/golangcrypto v0.0.0-20150304025918-53f62d9b43e8/go.mod h1:tYvUd8KLhm/oXvUeSEs2VlLghFjQt9+ZaF9ghH0JNjc=
|
||||
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
|
||||
github.com/btcsuite/goleveldb v1.0.0 h1:Tvd0BfvqX9o823q1j2UZ/epQo09eJh6dTcRp79ilIN4=
|
||||
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
|
||||
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/snappy-go v1.0.0 h1:ZxaA6lo2EpxGddsA8JwWOcxlzRybb444sgmeJQMJGQE=
|
||||
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
|
||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc=
|
||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/coreos/bbolt v1.3.3/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
|
||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/lru v1.0.0 h1:Kbsb1SFDsIlaupWPwsPp+dkxiBY1frcS07PCPgotKz8=
|
||||
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20181014144952-4e0d7dc8888f/go.mod h1:xN/JuLBIz4bjkxNmByTiV1IbhfnYb6oo99phBn4Eqhc=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM=
|
||||
github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
|
||||
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
|
||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
|
||||
github.com/fiatjaf/go-lnurl v1.3.1 h1:9Qn4n1ZyzTMW/YuVX2Wr9cE+LEAzpE1hrCbxVK/yBKE=
|
||||
github.com/fiatjaf/go-lnurl v1.3.1/go.mod h1:BqA8WXAOzntF7Z3EkVO7DfP4y5rhWUmJ/Bu9KBke+rs=
|
||||
github.com/frankban/quicktest v1.2.2/go.mod h1:Qh/WofXFeiAFII1aEBu529AtJo6Zg2VHscnEsbBnJ20=
|
||||
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-openapi/errors v0.19.2/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94=
|
||||
github.com/go-openapi/strfmt v0.19.5/go.mod h1:eftuHTlB/dI8Uq8JJOyRlieZf+WkkxUuk0dgdHXr2Qk=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
|
||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1 h1:ZFgWrT+bLgsYPirOnRfKLYJLvssAegOj/hgyMFdJZe0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.2.1-0.20190312032427-6f77996f0c42/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gookit/color v1.4.2 h1:tXy44JFSFkKnELV6WaMo/lLfu/meqITX3iAV52do7lk=
|
||||
github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.8.6/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
|
||||
github.com/hhrutter/lzw v0.0.0-20190827003112-58b82c5a41cc/go.mod h1:yJBvOcu1wLQ9q9XZmfiPfur+3dQJuIhYQsMGLYcItZk=
|
||||
github.com/hhrutter/lzw v0.0.0-20190829144645-6f07a24e8650 h1:1yY/RQWNSBjJe2GDCIYoLmpWVidrooriUr4QS/zaATQ=
|
||||
github.com/hhrutter/lzw v0.0.0-20190829144645-6f07a24e8650/go.mod h1:yJBvOcu1wLQ9q9XZmfiPfur+3dQJuIhYQsMGLYcItZk=
|
||||
github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7 h1:o1wMw7uTNyA58IlEdDpxIrtFHTgnvYzA8sCQz8luv94=
|
||||
github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7/go.mod h1:WkUxfS2JUu3qPo6tRld7ISb8HiC0gVSU91kooBMDVok=
|
||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/jackpal/gateway v1.0.5/go.mod h1:lTpwd4ACLXmpyiCTRtfiNyVnUmqT9RivzCDQetPfnjA=
|
||||
github.com/jackpal/go-nat-pmp v0.0.0-20170405195558-28a68d0c24ad/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc=
|
||||
github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag=
|
||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jinzhu/gorm v1.9.2/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo=
|
||||
github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o=
|
||||
github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs=
|
||||
github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v0.0.0-20181116074157-8ec929ed50c3/go.mod h1:oHTiXerJ20+SfYcrdlBO7rzZRJWGwSTQ0iUY2jI6Gfc=
|
||||
github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
|
||||
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
|
||||
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||
github.com/jrick/logrotate v1.0.0 h1:lQ1bL/n9mBNeIXoTUoYRlK4dHuNJVofX9oWqBtPnSzI=
|
||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||
github.com/juju/clock v0.0.0-20190205081909-9c5c9712527c/go.mod h1:nD0vlnrUjcjJhqN5WuCWZyzfd5AHZAC9/ajvbSx69xA=
|
||||
github.com/juju/errors v0.0.0-20190806202954-0232dcc7464d/go.mod h1:W54LbzXuIE0boCoNJfwqpmkKJ1O4TCTZMetAt6jGk7Q=
|
||||
github.com/juju/loggo v0.0.0-20190526231331-6e530bcce5d8/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U=
|
||||
github.com/juju/retry v0.0.0-20180821225755-9058e192b216/go.mod h1:OohPQGsr4pnxwD5YljhQ+TZnuVRYpa5irjugL1Yuif4=
|
||||
github.com/juju/testing v0.0.0-20190723135506-ce30eb24acd2/go.mod h1:63prj8cnj0tU0S9OHjGJn+b1h0ZghCndfnbQolrYTwA=
|
||||
github.com/juju/utils v0.0.0-20180820210520-bf9cc5bdd62d/go.mod h1:6/KLg8Wz/y2KVGWEpkK9vMNGkOnu4k/cqs8Z1fKjTOk=
|
||||
github.com/juju/version v0.0.0-20180108022336-b64dbd566305/go.mod h1:kE8gK5X0CImdr7qpSKl3xB2PmpySSmfj7zVbkZFs81U=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
github.com/kkdai/bstream v0.0.0-20181106074824-b3251f7901ec h1:n1NeQ3SgUHyISrjFFoO5dR748Is8dBL9qpaTNfphQrs=
|
||||
github.com/kkdai/bstream v0.0.0-20181106074824-b3251f7901ec/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4=
|
||||
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf h1:HZKvJUHlcXI/f/O0Avg7t8sqkPo78HFzjmeYFl6DPnc=
|
||||
github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf/go.mod h1:vxmQPeIQxPf6Jf9rM8R+B4rKBqLA2AjttNxkFBL2Plk=
|
||||
github.com/lightninglabs/protobuf-hex-display v1.3.3-0.20191212020323-b444784ce75d/go.mod h1:KDb67YMzoh4eudnzClmvs2FbiLG9vxISmLApUkCa4uI=
|
||||
github.com/lightningnetwork/lightning-onion v1.0.1 h1:qChGgS5+aPxFeR6JiUsGvanei1bn6WJpYbvosw/1604=
|
||||
github.com/lightningnetwork/lightning-onion v1.0.1/go.mod h1:rigfi6Af/KqsF7Za0hOgcyq2PNH4AN70AaMRxcJkff4=
|
||||
github.com/lightningnetwork/lnd v0.10.4-beta h1:Af2zOCPePeaU8Tkl8IqtTjr4BP3zYfi+hAtQYcCMM58=
|
||||
github.com/lightningnetwork/lnd v0.10.4-beta/go.mod h1:4d02pduRVtZwgTJ+EimKJTsEAY0jDwi0SPE9h5aRneM=
|
||||
github.com/lightningnetwork/lnd/cert v1.0.2/go.mod h1:fmtemlSMf5t4hsQmcprSoOykypAPp+9c+0d0iqTScMo=
|
||||
github.com/lightningnetwork/lnd/clock v1.0.1 h1:QQod8+m3KgqHdvVMV+2DRNNZS1GRFir8mHZYA+Z2hFo=
|
||||
github.com/lightningnetwork/lnd/clock v1.0.1/go.mod h1:KnQudQ6w0IAMZi1SgvecLZQZ43ra2vpDNj7H/aasemg=
|
||||
github.com/lightningnetwork/lnd/queue v1.0.1/go.mod h1:vaQwexir73flPW43Mrm7JOgJHmcEFBWWSl9HlyASoms=
|
||||
github.com/lightningnetwork/lnd/queue v1.0.4 h1:8Dq3vxAFSACPy+pKN88oPFhuCpCoAAChPBwa4BJxH4k=
|
||||
github.com/lightningnetwork/lnd/queue v1.0.4/go.mod h1:YTkTVZCxz8tAYreH27EO3s8572ODumWrNdYW2E/YKxg=
|
||||
github.com/lightningnetwork/lnd/ticker v1.0.0 h1:S1b60TEGoTtCe2A0yeB+ecoj/kkS4qpwh6l+AkQEZwU=
|
||||
github.com/lightningnetwork/lnd/ticker v1.0.0/go.mod h1:iaLXJiVgI1sPANIF2qYYUJXjoksPNvGNYowB8aRbpX0=
|
||||
github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 h1:sjOGyegMIhvgfq5oaue6Td+hxZuf3tDC8lAPrFldqFw=
|
||||
github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796/go.mod h1:3p7ZTf9V1sNPI5H8P3NkTFF4LuwMdPl2DodF60qAKqY=
|
||||
github.com/ltcsuite/ltcutil v0.0.0-20181217130922-17f3b04680b6/go.mod h1:8Vg/LTOO0KYa/vlHWJ6XZAevPQThGH5sufO0Hrou/lA=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA=
|
||||
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/miekg/dns v0.0.0-20171125082028-79bfde677fa8/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
github.com/miekg/dns v1.1.29 h1:xHBEhR+t5RzcFJjBLJlax2daXOrTYtr9z4WdKEfWFzg=
|
||||
github.com/miekg/dns v1.1.29/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/muun/libwallet v0.11.0 h1:P3WdPhoUhO5T/jNu+2DWZPJoaKjCR3MZLahYjnKxnWM=
|
||||
github.com/muun/libwallet v0.11.0/go.mod h1:zZDt5CpHETILSCYrRsb3r7Fl278hoU4gdR96jFyqyR8=
|
||||
github.com/muun/neutrino v0.0.0-20190914162326-7082af0fa257 h1:NW17wq2gZlEFeW3/Zx3wSmqlD0wKGf7YvhpP+CNCsbE=
|
||||
github.com/muun/neutrino v0.0.0-20190914162326-7082af0fa257/go.mod h1:awTrhbCWjWNH4yVwZ4IE7nZbvpQ27e7OyD+jao7wRxA=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
||||
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/pdfcpu/pdfcpu v0.3.11 h1:T5XLD5blrB61tBjkSrQnwikrQO4gmwQm61fsyGZa04w=
|
||||
github.com/pdfcpu/pdfcpu v0.3.11/go.mod h1:SZ51teSs9l709Xim2VEuOYGf+uf7RdH2eY0LrXvz7n8=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
|
||||
github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
|
||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tidwall/gjson v1.6.0 h1:9VEQWz6LLMUsUl6PueE49ir4Ka6CzLymOAZDxpFsTDc=
|
||||
github.com/tidwall/gjson v1.6.0/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls=
|
||||
github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc=
|
||||
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
|
||||
github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/tv42/zbase32 v0.0.0-20160707012821-501572607d02/go.mod h1:tHlrkM198S068ZqfrO6S8HsoJq2bF3ETfTL+kt4tInY=
|
||||
github.com/urfave/cli v1.18.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
||||
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
go.etcd.io/bbolt v1.3.5-0.20200615073812-232d8fc87f50 h1:ASw9n1EHMftwnP3Az4XW6e308+gNsrHzmdhd0Olz9Hs=
|
||||
go.etcd.io/bbolt v1.3.5-0.20200615073812-232d8fc87f50/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM=
|
||||
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181112202954-3d3f9f413869/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/image v0.0.0-20190823064033-3a9bac650e44/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb h1:fqpd0EBDzlHRCjiphRR5Zo/RSWWQlWv34418dnEixWk=
|
||||
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY=
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 h1:nonptSpoQ4vQjyraW20DXPAglgQfVnM9ZC6MmNLMR60=
|
||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190201180003-4b09977fb922/go.mod h1:L3J43x8/uS+qIUoksaLKe6OS3nUKxOKuIFz1sl2/jx4=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio=
|
||||
google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v1 v1.0.1/go.mod h1:3NjfXwocQRYAPTq4/fzX+CwUhPRcR/azYRhj8G+LqMo=
|
||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/gormigrate.v1 v1.6.0 h1:XpYM6RHQPmzwY7Uyu+t+xxMXc86JYFJn4nEc9HzQjsI=
|
||||
gopkg.in/gormigrate.v1 v1.6.0/go.mod h1:Lf00lQrHqfSYWiTtPcyQabsDdM6ejZaMgV0OU6JMSlw=
|
||||
gopkg.in/macaroon-bakery.v2 v2.0.1/go.mod h1:B4/T17l+ZWGwxFSZQmlBwp25x+og7OkhETfr3S9MbIA=
|
||||
gopkg.in/macaroon.v2 v2.0.0/go.mod h1:+I6LnTMkm/uV5ew/0nsulNjL16SK4+C8yDmRUzHR17I=
|
||||
gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
|
||||
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
64
recovery_tool/keys_generator.go
Normal file
64
recovery_tool/keys_generator.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/muun/libwallet"
|
||||
"github.com/muun/libwallet/emergencykit"
|
||||
)
|
||||
|
||||
var defaultNetwork = libwallet.Mainnet()
|
||||
|
||||
func decodeKeysFromInput(rawKey1 string, rawKey2 string) ([]*libwallet.EncryptedPrivateKeyInfo, error) {
|
||||
key1, err := libwallet.DecodeEncryptedPrivateKey(rawKey1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode first key: %w", err)
|
||||
}
|
||||
|
||||
key2, err := libwallet.DecodeEncryptedPrivateKey(rawKey2)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode second key: %w", err)
|
||||
}
|
||||
|
||||
return []*libwallet.EncryptedPrivateKeyInfo{key1, key2}, nil
|
||||
}
|
||||
|
||||
func decodeKeysFromMetadata(meta *emergencykit.Metadata) ([]*libwallet.EncryptedPrivateKeyInfo, error) {
|
||||
decodedKeys := make([]*libwallet.EncryptedPrivateKeyInfo, len(meta.EncryptedKeys))
|
||||
|
||||
for i, metaKey := range meta.EncryptedKeys {
|
||||
decodedKeys[i] = &libwallet.EncryptedPrivateKeyInfo{
|
||||
Version: meta.Version,
|
||||
Birthday: meta.BirthdayBlock,
|
||||
EphPublicKey: metaKey.DhPubKey,
|
||||
CipherText: metaKey.EncryptedPrivKey,
|
||||
Salt: metaKey.Salt,
|
||||
}
|
||||
}
|
||||
|
||||
return decodedKeys, nil
|
||||
}
|
||||
|
||||
func decryptKeys(encryptedKeys []*libwallet.EncryptedPrivateKeyInfo, recoveryCode string) ([]*libwallet.DecryptedPrivateKey, error) {
|
||||
// Always take the salt from the second key (the same salt was used for all keys, but our legacy
|
||||
// key format did not include it in the first key):
|
||||
salt := encryptedKeys[1].Salt
|
||||
|
||||
decryptionKey, err := libwallet.RecoveryCodeToKey(recoveryCode, salt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process recovery code: %w", err)
|
||||
}
|
||||
|
||||
decryptedKeys := make([]*libwallet.DecryptedPrivateKey, len(encryptedKeys))
|
||||
|
||||
for i, encryptedKey := range encryptedKeys {
|
||||
decryptedKey, err := decryptionKey.DecryptKey(encryptedKey, defaultNetwork)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt key %d: %w", i, err)
|
||||
}
|
||||
|
||||
decryptedKeys[i] = decryptedKey
|
||||
}
|
||||
|
||||
return decryptedKeys, nil
|
||||
}
|
||||
537
recovery_tool/main.go
Normal file
537
recovery_tool/main.go
Normal file
@@ -0,0 +1,537 @@
|
||||
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)
|
||||
}
|
||||
BIN
recovery_tool/readme/demo.gif
Normal file
BIN
recovery_tool/readme/demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
505
recovery_tool/readme/demo.yml
Normal file
505
recovery_tool/readme/demo.yml
Normal file
@@ -0,0 +1,505 @@
|
||||
# This is a Terminalizer (https://github.com/faressoft/terminalizer) configuration file.
|
||||
|
||||
# Render with:
|
||||
# $ terminalizer render demo.yml -o demo-unoptimized.gif
|
||||
|
||||
# Make background transparent with:
|
||||
# https://onlinegiftools.com/create-transparent-gif (use white, 1%)
|
||||
|
||||
# Optimize with:
|
||||
# $ gifsicle --optimize=3 --colors=32 -i demo-unoptimized.gif -o demo.gif
|
||||
|
||||
config:
|
||||
env:
|
||||
recording: true
|
||||
|
||||
rows: 24
|
||||
cols: 100
|
||||
|
||||
repeat: 0
|
||||
quality: 100
|
||||
|
||||
frameDelay: auto
|
||||
maxIdleTime: auto
|
||||
|
||||
frameBox:
|
||||
type: solid
|
||||
title: null
|
||||
style:
|
||||
border: 0px black solid
|
||||
boxShadow: none
|
||||
|
||||
watermark:
|
||||
imagePath: null
|
||||
style:
|
||||
position: absolute
|
||||
right: 15px
|
||||
bottom: 15px
|
||||
opacity: 0.9
|
||||
|
||||
cursorStyle: block
|
||||
fontFamily: "Monaco, Lucida Console, Ubuntu Mono, Monospace"
|
||||
fontSize: 12
|
||||
lineHeight: 1
|
||||
letterSpacing: 0
|
||||
|
||||
theme:
|
||||
background: "transparent"
|
||||
foreground: "#afafaf"
|
||||
cursor: "#c7c7c7"
|
||||
black: "#232628"
|
||||
red: "#fc4384"
|
||||
green: "#b3e33b"
|
||||
yellow: "#ffa727"
|
||||
blue: "#75dff2"
|
||||
magenta: "#ae89fe"
|
||||
cyan: "#708387"
|
||||
white: "#d5d5d0"
|
||||
brightBlack: "#626566"
|
||||
brightRed: "#ff7fac"
|
||||
brightGreen: "#c8ed71"
|
||||
brightYellow: "#ebdf86"
|
||||
brightBlue: "#75dff2"
|
||||
brightMagenta: "#ae89fe"
|
||||
brightCyan: "#b1c6ca"
|
||||
brightWhite: "#f9f9f4"
|
||||
|
||||
records:
|
||||
- delay: 100
|
||||
content: "\e[1;32mrecovery\e[00m$ "
|
||||
- delay: "600"
|
||||
content: "."
|
||||
- delay: "20"
|
||||
content: "/"
|
||||
- delay: "20"
|
||||
content: "r"
|
||||
- delay: "20"
|
||||
content: "e"
|
||||
- delay: "20"
|
||||
content: "c"
|
||||
- delay: "20"
|
||||
content: "o"
|
||||
- delay: "20"
|
||||
content: "v"
|
||||
- delay: "20"
|
||||
content: "e"
|
||||
- delay: "20"
|
||||
content: "r"
|
||||
- delay: "20"
|
||||
content: "y"
|
||||
- delay: "20"
|
||||
content: "-"
|
||||
- delay: "20"
|
||||
content: "t"
|
||||
- delay: "20"
|
||||
content: "o"
|
||||
- delay: "20"
|
||||
content: "o"
|
||||
- delay: "20"
|
||||
content: "l"
|
||||
- delay: "20"
|
||||
content: " "
|
||||
- delay: "500"
|
||||
content: "\e[1;37m~"
|
||||
- delay: "20"
|
||||
content: "/"
|
||||
- delay: "20"
|
||||
content: "d"
|
||||
- delay: "20"
|
||||
content: "o"
|
||||
- delay: "20"
|
||||
content: "w"
|
||||
- delay: "20"
|
||||
content: "n"
|
||||
- delay: "20"
|
||||
content: "l"
|
||||
- delay: "20"
|
||||
content: "o"
|
||||
- delay: "20"
|
||||
content: "a"
|
||||
- delay: "20"
|
||||
content: "d"
|
||||
- delay: "20"
|
||||
content: "s"
|
||||
- delay: "20"
|
||||
content: "/"
|
||||
- delay: "20"
|
||||
content: "M"
|
||||
- delay: "20"
|
||||
content: "u"
|
||||
- delay: "20"
|
||||
content: "u"
|
||||
- delay: "20"
|
||||
content: "n"
|
||||
- delay: "20"
|
||||
content: "-"
|
||||
- delay: "20"
|
||||
content: "E"
|
||||
- delay: "20"
|
||||
content: "m"
|
||||
- delay: "20"
|
||||
content: "e"
|
||||
- delay: "20"
|
||||
content: "r"
|
||||
- delay: "20"
|
||||
content: "g"
|
||||
- delay: "20"
|
||||
content: "e"
|
||||
- delay: "20"
|
||||
content: "n"
|
||||
- delay: "20"
|
||||
content: "c"
|
||||
- delay: "20"
|
||||
content: "y"
|
||||
- delay: "20"
|
||||
content: "-"
|
||||
- delay: "20"
|
||||
content: "K"
|
||||
- delay: "20"
|
||||
content: "i"
|
||||
- delay: "20"
|
||||
content: "t"
|
||||
- delay: "20"
|
||||
content: "."
|
||||
- delay: "20"
|
||||
content: "p"
|
||||
- delay: "20"
|
||||
content: "d"
|
||||
- delay: "20"
|
||||
content: "f"
|
||||
- delay: 1200
|
||||
content: "\e[0m\r\n\n"
|
||||
- delay: 500
|
||||
content: "\e[1;34mMuun Recovery Tool v2.1.0\e[0m\r\n\r\nTo recover your funds, you will need:\r\n\r\n1. \e[1;33mYour Recovery Code\e[0m, which you wrote down during your security setup\r\n2. \e[1;33mYour Emergency Kit PDF\e[0m, which you exported from the app\r\n3. \e[1;33mYour destination bitcoin address\e[0m, where all your funds will be sent\r\n\r\nIf you have any questions, we'll be happy to answer them. Contact us at \e[1;34msupport@muun.com\e[0m\r\n\r\n\e[1;33mEnter your Recovery Code\e[0m\r\n(it looks like this: 'ABCD-1234-POW2-R561-P120-JK26-12RW-45TT')\r\n➜ "
|
||||
- delay: 2500
|
||||
content: "L"
|
||||
- delay: "20"
|
||||
content: "A"
|
||||
- delay: "20"
|
||||
content: "8"
|
||||
- delay: "20"
|
||||
content: "H"
|
||||
- delay: "20"
|
||||
content: "-"
|
||||
- delay: "20"
|
||||
content: "D"
|
||||
- delay: "20"
|
||||
content: "B"
|
||||
- delay: "20"
|
||||
content: "9"
|
||||
- delay: "20"
|
||||
content: "M"
|
||||
- delay: "20"
|
||||
content: "-"
|
||||
- delay: "20"
|
||||
content: "E"
|
||||
- delay: "20"
|
||||
content: "J"
|
||||
- delay: "20"
|
||||
content: "R"
|
||||
- delay: "20"
|
||||
content: "P"
|
||||
- delay: "20"
|
||||
content: "-"
|
||||
- delay: "20"
|
||||
content: "Y"
|
||||
- delay: "20"
|
||||
content: "J"
|
||||
- delay: "20"
|
||||
content: "R"
|
||||
- delay: "20"
|
||||
content: "Q"
|
||||
- delay: "20"
|
||||
content: "-"
|
||||
- delay: "20"
|
||||
content: "B"
|
||||
- delay: "20"
|
||||
content: "W"
|
||||
- delay: "20"
|
||||
content: "T"
|
||||
- delay: "20"
|
||||
content: "4"
|
||||
- delay: "20"
|
||||
content: "-"
|
||||
- delay: "20"
|
||||
content: "L"
|
||||
- delay: "20"
|
||||
content: "J"
|
||||
- delay: "20"
|
||||
content: "9"
|
||||
- delay: "20"
|
||||
content: "B"
|
||||
- delay: "20"
|
||||
content: "-"
|
||||
- delay: "20"
|
||||
content: "F"
|
||||
- delay: "20"
|
||||
content: "C"
|
||||
- delay: "20"
|
||||
content: "P"
|
||||
- delay: "20"
|
||||
content: "Z"
|
||||
- delay: "20"
|
||||
content: "-"
|
||||
- delay: "20"
|
||||
content: "R"
|
||||
- delay: "20"
|
||||
content: "8"
|
||||
- delay: "20"
|
||||
content: "V"
|
||||
- delay: "20"
|
||||
content: "R"
|
||||
- delay: 405
|
||||
content: "\r\n"
|
||||
- delay: 6
|
||||
content: "\r\n\e[1;33mEnter your destination bitcoin address\e[0m\r\n➜ "
|
||||
- delay: 1500
|
||||
content: bc1qepja3llne3xp27car3gvvy7kzg7nfsacqvfe5l5jx4fa2f2v3wlskh9le7
|
||||
- delay: 700
|
||||
content: "\r\n\r\nStarting scan of all possible addresses. This will take a few minutes.\r\n► \e[1;37mFinding servers...\e[0m"
|
||||
- delay: 2500
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 100 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 900 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 1200 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 1500 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 2000 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 3400 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 3500 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 5400 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 5500 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 6200 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 6500 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 6900 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 7400 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 7800 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 8100 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 8400 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 8900 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 9300 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 9700 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 10100 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 11100 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 11200 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 11400 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 11600 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 12100 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 12400 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 13100 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 13300 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 13400 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 13500 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 13800 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 14100 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 14500 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 14900 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 15000 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 15500 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 15800 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 16000 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 16200 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 16300 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 16900 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 17200 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 17400 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 17500 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 17600 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 17700 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 17800 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 18200 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 18300 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 18800 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 19100 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 19500 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 19900 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 20000 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 20300 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 20600 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 20700 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 21400 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 21800 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 21900 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 22100 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 22400 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 24100 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 24500 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 24600 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 25000 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 25400 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 25500 | \e[1;37mSats found\e[0m: 0"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 26000 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 26100 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 26300 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 26600 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 26900 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 27400 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 28000 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 28600 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 28800 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 29400 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 29600 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 30900 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 31200 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 31400 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 31900 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 32300 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 33000 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 33400 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 33800 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 34100 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 34200 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 34600 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 34700 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 35000 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 35100 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 35700 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 36000 | \e[1;37mSats found\e[0m: 11155"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 37400 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 37700 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 38000 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 38300 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 38400 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 38500 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 39000 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 39700 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 40100 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 40400 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 41600 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 42100 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 43000 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 43600 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 43900 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 44000 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 44400 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 44900 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 45900 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 47500 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 10
|
||||
content: "\r► \e[1;37mScanned addresses\e[0m: 48200 | \e[1;37mSats found\e[0m: 56307"
|
||||
- delay: 71
|
||||
content: "\r\n\r\n\e[1;32m✓ Scan complete\e[0m\r\n• \e[1;37m11155\e[0m sats in bc1qefj50ll0a6pf0jyd7x96w5ejzkelc6fxxypyu0fl7uspdtr8efvqfcykst\r\n• \e[1;37m45152\e[0m sats in bc1ql4hjt3newq69a9ty9ymrpjwxex4msuk9jd6z7d8570zkh995v6cs576m62\r\n— \e[1;37m56307\e[0m sats total\r\n\r\n\e[1;33mEnter the fee rate (sats/byte)\e[0m\r\nYour transaction weighs 315 bytes. You can get suggestions in https://bitcoinfees.earn.com/#fees\r\n➜ "
|
||||
- delay: 2500
|
||||
content: '5'
|
||||
- delay: 1000
|
||||
content: "\r\n\r\n\e[1;4;37mSummary\e[0m\r\n \e[1;37mAmount\e[0m: 54732 sats\r\n \e[1;37mFee\e[0m: 1575 sats\r\n \e[1;37mDestination\e[0m: bc1qepja3llne3xp27car3gvvy7kzg7nfsacqvfe5l5jx4fa2f2v3wlskh9le7\r\n\r\n\e[1;33mConfirm?\e[0m (y/n)\r\n➜ "
|
||||
- delay: 2500
|
||||
content: 'y'
|
||||
- delay: 1200
|
||||
content: "\r\n\r\nSending transaction..."
|
||||
- delay: 2000
|
||||
content: "\r\n\nTransaction sent! You can check the status here:\r\n\e[4mhttps://blockstream.info/tx/7316aae315f2c6280a4a0199ea233f84d2d5af12f0d2588b92cbcc8c0e441da3\e[0m\r\n(it will appear in Blockstream after a short delay)\r\n\r\nWe appreciate all kinds of feedback. If you have any, send it to \e[1;34mcontact@muun.com\e[0m\r\n"
|
||||
- delay: 6000
|
||||
content: "\r"
|
||||
7
recovery_tool/recovery-tool
Executable file
7
recovery_tool/recovery-tool
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Move to the repository root:
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")" || exit
|
||||
|
||||
# Go!
|
||||
go run -mod=vendor . -- "$@"
|
||||
144
recovery_tool/recovery_tool.go
Normal file
144
recovery_tool/recovery_tool.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/btcsuite/btcutil"
|
||||
|
||||
"github.com/muun/libwallet"
|
||||
"github.com/muun/libwallet/btcsuitew/txscriptw"
|
||||
"github.com/muun/recovery/scanner"
|
||||
)
|
||||
|
||||
func buildSweepTx(utxos []*scanner.Utxo, sweepAddress btcutil.Address, fee int64) ([]byte, error) {
|
||||
|
||||
tx := wire.NewMsgTx(2)
|
||||
value := int64(0)
|
||||
|
||||
for _, utxo := range utxos {
|
||||
chainHash, err := chainhash.NewHashFromStr(utxo.TxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outpoint := wire.OutPoint{
|
||||
Hash: *chainHash,
|
||||
Index: uint32(utxo.OutputIndex),
|
||||
}
|
||||
|
||||
tx.AddTxIn(wire.NewTxIn(&outpoint, []byte{}, [][]byte{}))
|
||||
value += utxo.Amount
|
||||
}
|
||||
|
||||
value -= fee
|
||||
|
||||
script, err := txscriptw.PayToAddrScript(sweepAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx.AddTxOut(wire.NewTxOut(value, script))
|
||||
|
||||
writer := &bytes.Buffer{}
|
||||
err = tx.Serialize(writer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if fee != 0 {
|
||||
readConfirmation(value, fee, sweepAddress.String())
|
||||
}
|
||||
|
||||
return writer.Bytes(), nil
|
||||
}
|
||||
|
||||
func buildSignedTx(utxos []*scanner.Utxo, sweepTx []byte, userKey *libwallet.HDPrivateKey,
|
||||
muunKey *libwallet.HDPrivateKey) (*wire.MsgTx, error) {
|
||||
|
||||
inputList := &libwallet.InputList{}
|
||||
for _, utxo := range utxos {
|
||||
inputList.Add(&input{
|
||||
utxo,
|
||||
[]byte{},
|
||||
})
|
||||
}
|
||||
|
||||
nonces := libwallet.GenerateMusigNonces(len(utxos))
|
||||
pstx, err := libwallet.NewPartiallySignedTransaction(inputList, sweepTx, nonces)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signedTx, err := pstx.FullySign(userKey, muunKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wireTx := wire.NewMsgTx(0)
|
||||
wireTx.BtcDecode(bytes.NewReader(signedTx.Bytes), 0, wire.WitnessEncoding)
|
||||
return wireTx, nil
|
||||
}
|
||||
|
||||
// input is a minimal type that implements libwallet.Input
|
||||
type input struct {
|
||||
utxo *scanner.Utxo
|
||||
muunSignature []byte
|
||||
}
|
||||
|
||||
func (i *input) OutPoint() libwallet.Outpoint {
|
||||
return &outpoint{utxo: i.utxo}
|
||||
}
|
||||
|
||||
func (i *input) Address() libwallet.MuunAddress {
|
||||
return i.utxo.Address
|
||||
}
|
||||
|
||||
func (i *input) UserSignature() []byte {
|
||||
return []byte{}
|
||||
}
|
||||
|
||||
func (i *input) MuunSignature() []byte {
|
||||
return i.muunSignature
|
||||
}
|
||||
|
||||
func (i *input) SubmarineSwapV1() libwallet.InputSubmarineSwapV1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *input) SubmarineSwapV2() libwallet.InputSubmarineSwapV2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *input) IncomingSwap() libwallet.InputIncomingSwap {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *input) MuunPublicNonce() []byte {
|
||||
// Will always be nil in the context of the tool
|
||||
// Look at coinV5.signFirstWith for the reason why.
|
||||
return nil
|
||||
}
|
||||
|
||||
// outpoint is a minimal type that implements libwallet.Outpoint
|
||||
type outpoint struct {
|
||||
utxo *scanner.Utxo
|
||||
}
|
||||
|
||||
func (o *outpoint) TxId() []byte {
|
||||
raw, err := hex.DecodeString(o.utxo.TxID)
|
||||
if err != nil {
|
||||
panic(err) // we wrote this hex value ourselves, no input from anywhere else
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
func (o *outpoint) Index() int {
|
||||
return o.utxo.OutputIndex
|
||||
}
|
||||
|
||||
func (o *outpoint) Amount() int64 {
|
||||
return o.utxo.Amount
|
||||
}
|
||||
217
recovery_tool/scanner/scanner.go
Normal file
217
recovery_tool/scanner/scanner.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/muun/libwallet"
|
||||
"github.com/muun/recovery/electrum"
|
||||
"github.com/muun/recovery/utils"
|
||||
)
|
||||
|
||||
const taskTimeout = 15 * time.Minute
|
||||
const batchSize = 100
|
||||
|
||||
// Scanner finds unspent outputs and their transactions when given a map of addresses.
|
||||
//
|
||||
// It implements multi-server support, batching feature detection and use, concurrency control,
|
||||
// timeouts and cancellations, and provides a channel-based interface.
|
||||
//
|
||||
// Servers are provided by a ServerProvider instance, and rotated when unreachable or faulty. We
|
||||
// trust ServerProvider to prioritize good targets.
|
||||
//
|
||||
// Batching is leveraged when supported by a particular server, falling back to sequential requests
|
||||
// for single addresses (which is much slower, but can get us out of trouble when better servers are
|
||||
// not available).
|
||||
//
|
||||
// Timeouts and cancellations are an internal affair, not configurable by callers. See taskTimeout
|
||||
// declared above.
|
||||
//
|
||||
// Concurrency control works by using an electrum.Pool, limiting access to clients, and not an
|
||||
// internal worker pool. This is the Go way (limiting access to resources rather than having a fixed
|
||||
// number of parallel goroutines), and (more to the point) semantically correct. We don't care
|
||||
// about the number of concurrent workers, what we want to avoid is too many connections to
|
||||
// Electrum servers.
|
||||
type Scanner struct {
|
||||
pool *electrum.Pool
|
||||
servers *electrum.ServerProvider
|
||||
log *utils.Logger
|
||||
}
|
||||
|
||||
// Report contains information about an ongoing scan.
|
||||
type Report struct {
|
||||
ScannedAddresses int
|
||||
UtxosFound []*Utxo
|
||||
Err error
|
||||
}
|
||||
|
||||
// Utxo references a transaction output, plus the associated MuunAddress and script.
|
||||
type Utxo struct {
|
||||
TxID string
|
||||
OutputIndex int
|
||||
Amount int64
|
||||
Address libwallet.MuunAddress
|
||||
Script []byte
|
||||
}
|
||||
|
||||
// scanContext contains the synchronization objects for a single Scanner round, to manage Tasks.
|
||||
type scanContext struct {
|
||||
// Task management:
|
||||
addresses chan libwallet.MuunAddress
|
||||
results chan *scanTaskResult
|
||||
stopScan chan struct{}
|
||||
stopCollect chan struct{}
|
||||
wg *sync.WaitGroup
|
||||
|
||||
// Progress reporting:
|
||||
reports chan *Report
|
||||
reportCache *Report
|
||||
}
|
||||
|
||||
// NewScanner creates an initialized Scanner.
|
||||
func NewScanner(connectionPool *electrum.Pool, electrumProvider *electrum.ServerProvider) *Scanner {
|
||||
return &Scanner{
|
||||
pool: connectionPool,
|
||||
servers: electrumProvider,
|
||||
log: utils.NewLogger("Scanner"),
|
||||
}
|
||||
}
|
||||
|
||||
// Scan an address space and return all relevant transactions for a sweep.
|
||||
func (s *Scanner) Scan(addresses chan libwallet.MuunAddress) <-chan *Report {
|
||||
var waitGroup sync.WaitGroup
|
||||
|
||||
// Create the Context that goroutines will share:
|
||||
ctx := &scanContext{
|
||||
addresses: addresses,
|
||||
results: make(chan *scanTaskResult),
|
||||
stopScan: make(chan struct{}),
|
||||
stopCollect: make(chan struct{}),
|
||||
wg: &waitGroup,
|
||||
|
||||
reports: make(chan *Report),
|
||||
reportCache: &Report{
|
||||
ScannedAddresses: 0,
|
||||
UtxosFound: []*Utxo{},
|
||||
},
|
||||
}
|
||||
|
||||
// Start the scan in background:
|
||||
go s.startCollect(ctx)
|
||||
go s.startScan(ctx)
|
||||
|
||||
return ctx.reports
|
||||
}
|
||||
|
||||
func (s *Scanner) startCollect(ctx *scanContext) {
|
||||
// Collect all results until the done signal, or abort on the first error:
|
||||
for {
|
||||
select {
|
||||
case result := <-ctx.results:
|
||||
s.log.Printf("Scanned %d, found %d (err %v)", len(result.Task.addresses), len(result.Utxos), result.Err)
|
||||
|
||||
newReport := *ctx.reportCache // create a new private copy
|
||||
ctx.reportCache = &newReport
|
||||
|
||||
if result.Err != nil {
|
||||
ctx.reportCache.Err = s.log.Errorf("Scan failed: %w", result.Err)
|
||||
ctx.reports <- ctx.reportCache
|
||||
|
||||
close(ctx.stopScan) // failed after several retries, we give up and terminate all tasks
|
||||
close(ctx.reports) // close the report channel to let callers know we're done
|
||||
return
|
||||
}
|
||||
|
||||
ctx.reportCache.ScannedAddresses += len(result.Task.addresses)
|
||||
ctx.reportCache.UtxosFound = append(ctx.reportCache.UtxosFound, result.Utxos...)
|
||||
ctx.reports <- ctx.reportCache
|
||||
|
||||
case <-ctx.stopCollect:
|
||||
close(ctx.reports) // close the report channel to let callers know we're done
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scanner) startScan(ctx *scanContext) {
|
||||
s.log.Printf("Scan started")
|
||||
|
||||
batches := streamBatches(ctx.addresses)
|
||||
|
||||
var client *electrum.Client
|
||||
|
||||
for batch := range batches {
|
||||
// Stop the loop until a client becomes available, or the scan is canceled:
|
||||
select {
|
||||
case <-ctx.stopScan:
|
||||
return
|
||||
|
||||
case client = <-s.pool.Acquire():
|
||||
}
|
||||
|
||||
// Start scanning this address in background:
|
||||
ctx.wg.Add(1)
|
||||
|
||||
go func(batch []libwallet.MuunAddress) {
|
||||
defer s.pool.Release(client)
|
||||
defer ctx.wg.Done()
|
||||
|
||||
s.scanBatch(ctx, client, batch)
|
||||
}(batch)
|
||||
}
|
||||
|
||||
// Wait for all tasks that are still executing to complete:
|
||||
ctx.wg.Wait()
|
||||
s.log.Printf("Scan complete")
|
||||
|
||||
// Signal to the collector that this Context has no more pending work:
|
||||
close(ctx.stopCollect)
|
||||
}
|
||||
|
||||
func (s *Scanner) scanBatch(ctx *scanContext, client *electrum.Client, batch []libwallet.MuunAddress) {
|
||||
// NOTE:
|
||||
// We begin by building the task, passing our selected Client. Since we're choosing the instance,
|
||||
// it's our job to control acquisition and release of Clients to prevent sharing (remember,
|
||||
// clients are single-user). The task won't enforce this safety measure (it can't), it's fully
|
||||
// up to us.
|
||||
task := &scanTask{
|
||||
servers: s.servers,
|
||||
client: client,
|
||||
addresses: batch,
|
||||
timeout: taskTimeout,
|
||||
exit: ctx.stopCollect,
|
||||
}
|
||||
|
||||
// Do the thing and send back the result:
|
||||
ctx.results <- task.Execute()
|
||||
}
|
||||
|
||||
func streamBatches(addresses chan libwallet.MuunAddress) chan []libwallet.MuunAddress {
|
||||
batches := make(chan []libwallet.MuunAddress)
|
||||
|
||||
go func() {
|
||||
var nextBatch []libwallet.MuunAddress
|
||||
|
||||
for address := range addresses {
|
||||
// Add items to the batch until we reach the limit:
|
||||
nextBatch = append(nextBatch, address)
|
||||
|
||||
if len(nextBatch) < batchSize {
|
||||
continue
|
||||
}
|
||||
|
||||
// Send back the batch and start over:
|
||||
batches <- nextBatch
|
||||
nextBatch = []libwallet.MuunAddress{}
|
||||
}
|
||||
|
||||
// Send back an incomplete batch with any remaining addresses:
|
||||
if len(nextBatch) > 0 {
|
||||
batches <- nextBatch
|
||||
}
|
||||
|
||||
close(batches)
|
||||
}()
|
||||
|
||||
return batches
|
||||
}
|
||||
197
recovery_tool/scanner/task.go
Normal file
197
recovery_tool/scanner/task.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
"github.com/muun/libwallet"
|
||||
"github.com/muun/libwallet/btcsuitew/btcutilw"
|
||||
"github.com/muun/libwallet/btcsuitew/txscriptw"
|
||||
"github.com/muun/recovery/electrum"
|
||||
)
|
||||
|
||||
// scanTask encapsulates a parallelizable Scanner unit of work.
|
||||
type scanTask struct {
|
||||
servers *electrum.ServerProvider
|
||||
client *electrum.Client
|
||||
addresses []libwallet.MuunAddress
|
||||
timeout time.Duration
|
||||
exit chan struct{}
|
||||
}
|
||||
|
||||
// scanTaskResult contains a summary of the execution of a task.
|
||||
type scanTaskResult struct {
|
||||
Task *scanTask
|
||||
Utxos []*Utxo
|
||||
Err error
|
||||
}
|
||||
|
||||
// Execute obtains the Utxo set for the Task address, implementing a retry strategy.
|
||||
func (t *scanTask) Execute() *scanTaskResult {
|
||||
results := make(chan *scanTaskResult)
|
||||
timeout := time.After(t.timeout)
|
||||
|
||||
// Keep the last error around, in case we reach the timeout and want to know the reason:
|
||||
var lastError error
|
||||
|
||||
for {
|
||||
// Attempt to run the task:
|
||||
go t.tryExecuteAsync(results)
|
||||
|
||||
// Wait until a result is sent, the timeout is reached or the task canceled, capturing errors
|
||||
// errors along the way:
|
||||
select {
|
||||
case <-t.exit:
|
||||
return t.exitResult() // stop retrying when we get the done signal
|
||||
|
||||
case result := <-results:
|
||||
if result.Err == nil {
|
||||
return result // we're done! nice work everyone.
|
||||
}
|
||||
|
||||
lastError = result.Err // keep retrying when an attempt fails
|
||||
|
||||
case <-timeout:
|
||||
return t.errorResult(fmt.Errorf("Task timed out. Last error: %w", lastError)) // stop on timeout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *scanTask) tryExecuteAsync(results chan *scanTaskResult) {
|
||||
// Errors will almost certainly arise from Electrum server failures, which are extremely
|
||||
// common. Unreachable IPs, dropped connections, sudden EOFs, etc. We'll run this task, assuming
|
||||
// the servers are at fault when something fails, disconnecting and cycling them as we retry.
|
||||
result := t.tryExecute()
|
||||
|
||||
if result.Err != nil {
|
||||
t.client.Disconnect()
|
||||
}
|
||||
|
||||
results <- result
|
||||
}
|
||||
|
||||
func (t *scanTask) tryExecute() *scanTaskResult {
|
||||
// If our client is not connected, make an attempt to connect to a server:
|
||||
if !t.client.IsConnected() {
|
||||
err := t.client.Connect(t.servers.NextServer())
|
||||
|
||||
if err != nil {
|
||||
return t.errorResult(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the output scripts for all given addresses:
|
||||
outputScripts, err := getOutputScripts(t.addresses)
|
||||
if err != nil {
|
||||
return t.errorResult(err)
|
||||
}
|
||||
|
||||
// Prepare the index hashes that Electrum requires to list outputs:
|
||||
indexHashes, err := getIndexHashes(outputScripts)
|
||||
if err != nil {
|
||||
return t.errorResult(err)
|
||||
}
|
||||
|
||||
// Call Electrum to get the unspent output list, grouped by index for each address:
|
||||
var unspentRefGroups [][]electrum.UnspentRef
|
||||
|
||||
if t.client.SupportsBatching() {
|
||||
unspentRefGroups, err = t.listUnspentWithBatching(indexHashes)
|
||||
} else {
|
||||
unspentRefGroups, err = t.listUnspentWithoutBatching(indexHashes)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return t.errorResult(err)
|
||||
}
|
||||
|
||||
// Compile the results into a list of `Utxos`:
|
||||
var utxos []*Utxo
|
||||
|
||||
for i, unspentRefGroup := range unspentRefGroups {
|
||||
for _, unspentRef := range unspentRefGroup {
|
||||
newUtxo := &Utxo{
|
||||
TxID: unspentRef.TxHash,
|
||||
OutputIndex: unspentRef.TxPos,
|
||||
Amount: unspentRef.Value,
|
||||
Script: outputScripts[i],
|
||||
Address: t.addresses[i],
|
||||
}
|
||||
|
||||
utxos = append(utxos, newUtxo)
|
||||
}
|
||||
}
|
||||
|
||||
return t.successResult(utxos)
|
||||
}
|
||||
|
||||
func (t *scanTask) listUnspentWithBatching(indexHashes []string) ([][]electrum.UnspentRef, error) {
|
||||
unspentRefGroups, err := t.client.ListUnspentBatch(indexHashes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Listing with batching failed: %w", err)
|
||||
}
|
||||
|
||||
return unspentRefGroups, nil
|
||||
}
|
||||
|
||||
func (t *scanTask) listUnspentWithoutBatching(indexHashes []string) ([][]electrum.UnspentRef, error) {
|
||||
var unspentRefGroups [][]electrum.UnspentRef
|
||||
|
||||
for _, indexHash := range indexHashes {
|
||||
newGroup, err := t.client.ListUnspent(indexHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Listing without batching failed: %w", err)
|
||||
}
|
||||
|
||||
unspentRefGroups = append(unspentRefGroups, newGroup)
|
||||
}
|
||||
|
||||
return unspentRefGroups, nil
|
||||
}
|
||||
|
||||
func (t *scanTask) errorResult(err error) *scanTaskResult {
|
||||
return &scanTaskResult{Task: t, Err: err}
|
||||
}
|
||||
|
||||
func (t *scanTask) successResult(utxos []*Utxo) *scanTaskResult {
|
||||
return &scanTaskResult{Task: t, Utxos: utxos}
|
||||
}
|
||||
|
||||
func (t *scanTask) exitResult() *scanTaskResult {
|
||||
return &scanTaskResult{Task: t}
|
||||
}
|
||||
|
||||
// getIndexHashes calculates all the Electrum index hashes for a list of output scripts.
|
||||
func getIndexHashes(outputScripts [][]byte) ([]string, error) {
|
||||
indexHashes := make([]string, len(outputScripts))
|
||||
|
||||
for i, outputScript := range outputScripts {
|
||||
indexHashes[i] = electrum.GetIndexHash(outputScript)
|
||||
}
|
||||
|
||||
return indexHashes, nil
|
||||
}
|
||||
|
||||
// getOutputScripts creates all the scripts that send to an list of Bitcoin address.
|
||||
func getOutputScripts(addresses []libwallet.MuunAddress) ([][]byte, error) {
|
||||
outputScripts := make([][]byte, len(addresses))
|
||||
|
||||
for i, address := range addresses {
|
||||
rawAddress := address.Address()
|
||||
|
||||
decodedAddress, err := btcutilw.DecodeAddress(rawAddress, &chaincfg.MainNetParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to decode address %s: %w", rawAddress, err)
|
||||
}
|
||||
|
||||
outputScript, err := txscriptw.PayToAddrScript(decodedAddress)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to craft script for %s: %w", rawAddress, err)
|
||||
}
|
||||
|
||||
outputScripts[i] = outputScript
|
||||
}
|
||||
|
||||
return outputScripts, nil
|
||||
}
|
||||
341
recovery_tool/survey/survey.go
Normal file
341
recovery_tool/survey/survey.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package survey
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/muun/recovery/electrum"
|
||||
)
|
||||
|
||||
type Survey struct {
|
||||
config *Config
|
||||
tasks chan *surveyTask
|
||||
taskWg sync.WaitGroup
|
||||
results chan *Result
|
||||
visited map[string]bool
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
InitialServers []string
|
||||
Workers int
|
||||
SpeedTestDuration time.Duration
|
||||
SpeedTestBatchSize int
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Server string
|
||||
FromPeer string
|
||||
IsWorthy bool
|
||||
Err error
|
||||
Impl string
|
||||
Version string
|
||||
TimeToConnect time.Duration
|
||||
Speed int
|
||||
BatchSupport bool
|
||||
peers []string
|
||||
}
|
||||
|
||||
type surveyTask struct {
|
||||
server string
|
||||
fromPeer string
|
||||
}
|
||||
|
||||
// Values to check whether we're in the same chain (in a previous version, SV servers snuck in)
|
||||
var mainnetSomeTx = "985eb411473fa1bbd73efa5e3685edc00366c86b8d4d3f5b969ad59c23f4d959"
|
||||
var mainnetGenesisHash = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"
|
||||
|
||||
func NewSurvey(config *Config) *Survey {
|
||||
return &Survey{
|
||||
config: config,
|
||||
tasks: make(chan *surveyTask),
|
||||
results: make(chan *Result),
|
||||
visited: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Survey) Run() []*Result {
|
||||
// Add initial tasks:
|
||||
for _, server := range s.config.InitialServers {
|
||||
s.addTask(server, "")
|
||||
}
|
||||
|
||||
// Start collecting results in background:
|
||||
results := []*Result{}
|
||||
go s.startCollect(&results)
|
||||
|
||||
// Launch workers to process tasks and send back results:
|
||||
for i := 0; i < s.config.Workers; i++ {
|
||||
go s.startWorker()
|
||||
}
|
||||
|
||||
// Wait until there's no tasks left, and signal everyone to stop:
|
||||
s.taskWg.Wait()
|
||||
close(s.tasks)
|
||||
close(s.results)
|
||||
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].IsBetterThan(results[j])
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Survey) addTask(server string, fromPeer string) {
|
||||
task := &surveyTask{server, fromPeer}
|
||||
|
||||
if _, ok := s.visited[task.server]; ok {
|
||||
return
|
||||
}
|
||||
s.visited[task.server] = true
|
||||
|
||||
s.taskWg.Add(1)
|
||||
go func() { s.tasks <- task }() // scheduling tasks is non-blocking for users of the type
|
||||
}
|
||||
|
||||
func (s *Survey) notifyResult(result *Result) {
|
||||
s.results <- result
|
||||
s.taskWg.Done()
|
||||
}
|
||||
|
||||
func (s *Survey) startCollect(resultsRef *[]*Result) {
|
||||
for result := range s.results {
|
||||
*resultsRef = append(*resultsRef, result)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Survey) startWorker() {
|
||||
for task := range s.tasks {
|
||||
log("• %s\n", task.server)
|
||||
|
||||
result := s.processTask(task)
|
||||
|
||||
if result.Err != nil {
|
||||
log("✕ %s\n", task.server)
|
||||
} else {
|
||||
log("✓ %s\n", task.server)
|
||||
}
|
||||
|
||||
s.notifyResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Survey) processTask(task *surveyTask) *Result {
|
||||
|
||||
// We're going to perform a number of tests an measurements:
|
||||
//
|
||||
// 1. How much time does it take to establish a connection?
|
||||
// 2. Does the server support batching?
|
||||
// 3. Is the server willing to share its peers? If so, crawl.
|
||||
// 4. How many requests can the server handle in a given time interval?
|
||||
// 5. Did the server fail at any point during testing?
|
||||
//
|
||||
// Each test can result in a closed socket (since Electrum communicates errors by slapping you
|
||||
// in the face with no explanation), so we'll be connecting separately for each attempt.
|
||||
//
|
||||
// When a testing method returns an error, it means the server failed completely and we couldn't
|
||||
// obtain meaningful results (while some internal errors in a test are expected and handled).
|
||||
|
||||
impl, version, timeToConnect, err := testConnection(task)
|
||||
if err != nil {
|
||||
return &Result{Server: task.server, Err: err}
|
||||
}
|
||||
|
||||
isBitcoinMainnet, err := testBitcoinMainnet(task)
|
||||
if err != nil || !isBitcoinMainnet {
|
||||
return &Result{Server: task.server, Err: fmt.Errorf("not on Bitcoin mainnet: %w", err)}
|
||||
}
|
||||
|
||||
batchSupport, err := testBatchSupport(task)
|
||||
if err != nil {
|
||||
return &Result{Server: task.server, Err: err}
|
||||
}
|
||||
|
||||
speed, err := s.measureSpeed(task)
|
||||
if err != nil {
|
||||
return &Result{Server: task.server, Err: err}
|
||||
}
|
||||
|
||||
peers, err := getPeers(task)
|
||||
if err != nil {
|
||||
return &Result{Server: task.server, Err: err}
|
||||
}
|
||||
|
||||
for _, peer := range peers {
|
||||
if strings.Contains(peer, ".onion:") {
|
||||
continue
|
||||
}
|
||||
|
||||
s.addTask(peer, task.server)
|
||||
}
|
||||
|
||||
isWorthy := err == nil &&
|
||||
batchSupport &&
|
||||
timeToConnect.Seconds() < 5.0 &&
|
||||
speed >= int(s.config.SpeedTestDuration.Seconds())
|
||||
|
||||
return &Result{
|
||||
IsWorthy: isWorthy,
|
||||
Server: task.server,
|
||||
FromPeer: task.fromPeer,
|
||||
Impl: impl,
|
||||
Version: version,
|
||||
TimeToConnect: timeToConnect,
|
||||
BatchSupport: batchSupport,
|
||||
Speed: speed,
|
||||
peers: peers,
|
||||
}
|
||||
}
|
||||
|
||||
// testConnection returns the server implementation, protocol version and time to connect
|
||||
func testConnection(task *surveyTask) (string, string, time.Duration, error) {
|
||||
client := electrum.NewClient(true)
|
||||
|
||||
start := time.Now()
|
||||
err := client.Connect(task.server)
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
|
||||
return client.ServerImpl, client.ProtoVersion, time.Since(start), nil
|
||||
}
|
||||
|
||||
// testsBlockchain returns whether this server is operating on Bitcoin mainnet
|
||||
func testBitcoinMainnet(task *surveyTask) (bool, error) {
|
||||
client := electrum.NewClient(true)
|
||||
|
||||
err := client.Connect(task.server)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
features, err := client.ServerFeatures()
|
||||
if err != nil || features.GenesisHash != mainnetGenesisHash {
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, err = client.GetTransaction(mainnetSomeTx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// testBatchSupport returns whether the server successfully responded to a batched request
|
||||
func testBatchSupport(task *surveyTask) (bool, error) {
|
||||
client := electrum.NewClient(true)
|
||||
|
||||
err := client.Connect(task.server)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, err = client.ListUnspentBatch(createFakeHashes(2))
|
||||
if err != nil {
|
||||
return false, nil // an error here suggests lack of support for this call
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// measureSpeed returns the amount of successful ListUnspentBatch calls in SPEED_TEST_DURATION
|
||||
// seconds. It assumes batch support was verified beforehand.
|
||||
func (s *Survey) measureSpeed(task *surveyTask) (int, error) {
|
||||
client := electrum.NewClient(true)
|
||||
|
||||
err := client.Connect(task.server)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
responseCount := 0
|
||||
|
||||
for time.Since(start) < s.config.SpeedTestDuration {
|
||||
fakeHashes := createFakeHashes(s.config.SpeedTestBatchSize)
|
||||
|
||||
_, err := client.ListUnspentBatch(fakeHashes) // TODO: is the faking affecting the result?
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
responseCount++
|
||||
}
|
||||
|
||||
return responseCount - 1, nil // the last one was over the time limit
|
||||
}
|
||||
|
||||
// getPeers returns the list of peers from a server, or empty if it doesn't responds to the request
|
||||
func getPeers(task *surveyTask) ([]string, error) {
|
||||
client := electrum.NewClient(true)
|
||||
|
||||
err := client.Connect(task.server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
peers, err := client.ServerPeers()
|
||||
if err != nil {
|
||||
return []string{}, nil // an error here suggests lack of support for this call
|
||||
}
|
||||
|
||||
return peers, nil
|
||||
}
|
||||
|
||||
func (r *Result) IsBetterThan(other *Result) bool {
|
||||
if r.Err != nil {
|
||||
return false
|
||||
}
|
||||
if other.Err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if r.IsWorthy != other.IsWorthy {
|
||||
return r.IsWorthy
|
||||
}
|
||||
|
||||
if r.BatchSupport != other.BatchSupport {
|
||||
return r.BatchSupport
|
||||
}
|
||||
|
||||
if r.Speed != other.Speed {
|
||||
return (r.Speed > other.Speed)
|
||||
}
|
||||
|
||||
return (r.TimeToConnect < other.TimeToConnect)
|
||||
}
|
||||
|
||||
func (r *Result) String() string {
|
||||
return fmt.Sprintf(
|
||||
"%s, %s, %s, %v, %v, %d, %v",
|
||||
r.Server,
|
||||
r.Impl,
|
||||
r.Version,
|
||||
r.BatchSupport,
|
||||
r.TimeToConnect.Seconds(),
|
||||
r.Speed,
|
||||
r.Err,
|
||||
)
|
||||
}
|
||||
|
||||
func createFakeHashes(count int) []string {
|
||||
randomBuffer := make([]byte, 32)
|
||||
fakeHashes := make([]string, count)
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
rand.Read(randomBuffer)
|
||||
fakeHashes[i] = hex.EncodeToString(randomBuffer)
|
||||
}
|
||||
|
||||
return fakeHashes
|
||||
}
|
||||
|
||||
func log(msg string, args ...interface{}) {
|
||||
fmt.Fprintf(os.Stderr, msg, args...)
|
||||
}
|
||||
81
recovery_tool/sweeper.go
Normal file
81
recovery_tool/sweeper.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"github.com/muun/recovery/electrum"
|
||||
"github.com/muun/recovery/scanner"
|
||||
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
|
||||
"github.com/btcsuite/btcd/wire"
|
||||
"github.com/btcsuite/btcutil"
|
||||
"github.com/muun/libwallet"
|
||||
)
|
||||
|
||||
var (
|
||||
chainParams = chaincfg.MainNetParams
|
||||
)
|
||||
|
||||
type Sweeper struct {
|
||||
UserKey *libwallet.HDPrivateKey
|
||||
MuunKey *libwallet.HDPrivateKey
|
||||
Birthday int
|
||||
SweepAddress btcutil.Address
|
||||
}
|
||||
|
||||
func (s *Sweeper) GetSweepTxAmountAndWeightInBytes(utxos []*scanner.Utxo) (outputAmount int64, weightInBytes int64, err error) {
|
||||
// we build a sweep tx with 0 fee with the only purpose of checking its signed size
|
||||
zeroFeeSweepTx, err := s.BuildSweepTx(utxos, 0)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
outputAmount = zeroFeeSweepTx.TxOut[0].Value
|
||||
weightInBytes = int64(zeroFeeSweepTx.SerializeSize())
|
||||
|
||||
return outputAmount, weightInBytes, nil
|
||||
}
|
||||
|
||||
func (s *Sweeper) BuildSweepTx(utxos []*scanner.Utxo, fee int64) (*wire.MsgTx, error) {
|
||||
derivedMuunKey, err := s.MuunKey.DeriveTo("m/1'/1'")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sweepTx, err := buildSweepTx(utxos, s.SweepAddress, fee)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildSignedTx(utxos, sweepTx, s.UserKey, derivedMuunKey)
|
||||
}
|
||||
|
||||
func (s *Sweeper) BroadcastTx(tx *wire.MsgTx) error {
|
||||
// Connect to an Electurm server using a fresh client and provider pair:
|
||||
sp := electrum.NewServerProvider(electrum.PublicServers) // TODO create servers module, for provider and pool
|
||||
client := electrum.NewClient(true)
|
||||
|
||||
for !client.IsConnected() {
|
||||
client.Connect(sp.NextServer())
|
||||
}
|
||||
|
||||
// Encode the transaction for broadcast:
|
||||
txBytes := new(bytes.Buffer)
|
||||
|
||||
err := tx.BtcEncode(txBytes, wire.ProtocolVersion, wire.WitnessEncoding)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while encoding tx: %w", err)
|
||||
}
|
||||
|
||||
txHex := hex.EncodeToString(txBytes.Bytes())
|
||||
|
||||
// Do the thing!
|
||||
_, err = client.Broadcast(txHex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while broadcasting: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
78
recovery_tool/utils/logger.go
Normal file
78
recovery_tool/utils/logger.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DebugMode is true when the `DEBUG` environment variable is set to "true".
|
||||
var DebugMode bool = os.Getenv("DEBUG") == "true"
|
||||
var outputStream io.Writer = io.Discard
|
||||
|
||||
// SetOutputStream set a writer to record all logs (except Tracef).
|
||||
func SetOutputStream(s io.Writer) {
|
||||
outputStream = s
|
||||
}
|
||||
|
||||
// Logger provides logging methods. Logs are written to the stream set by
|
||||
// SetOutputStream. If `DebugMode` is true, we also print to stdout/stderr.
|
||||
// This allows callers to log detailed information without displaying it to
|
||||
// users during normal execution.
|
||||
type Logger struct {
|
||||
tag string
|
||||
}
|
||||
|
||||
// NewLogger returns an initialized Logger instance.
|
||||
func NewLogger(tag string) *Logger {
|
||||
return &Logger{tag}
|
||||
}
|
||||
|
||||
// SetTag updates the tag of this Logger.
|
||||
func (l *Logger) SetTag(newTag string) {
|
||||
l.tag = newTag
|
||||
}
|
||||
|
||||
// Tracef works like fmt.Printf, but only prints when `DebugMode` is true. These logs
|
||||
// are *not* recorded to the output stream.
|
||||
func (l *Logger) Tracef(format string, v ...interface{}) {
|
||||
if !DebugMode {
|
||||
return
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(fmt.Sprintf(format, v...))
|
||||
|
||||
fmt.Printf("%s %s %s\n", time.Now().Format(time.RFC3339Nano), l.getPrefix(), message)
|
||||
}
|
||||
|
||||
// Printf works like fmt.Printf, but only prints when `DebugMode` is true. These logs
|
||||
// are recorded to the output stream, so they should not include sensitive information.
|
||||
func (l *Logger) Printf(format string, v ...interface{}) {
|
||||
message := strings.TrimSpace(fmt.Sprintf(format, v...))
|
||||
|
||||
log := fmt.Sprintf("%s %s %s\n", time.Now().Format(time.RFC3339Nano), l.getPrefix(), message)
|
||||
_, _ = outputStream.Write([]byte(log))
|
||||
if DebugMode {
|
||||
print(log)
|
||||
}
|
||||
}
|
||||
|
||||
// Errorf works like fmt.Errorf, but prints the error to the console if `DebugMode` is true.
|
||||
// These logs are recorded to the output stream, so they should not include sensitive information.
|
||||
func (l *Logger) Errorf(format string, v ...interface{}) error {
|
||||
err := fmt.Errorf(format, v...)
|
||||
|
||||
log := fmt.Sprintf("ERROR: %s %s %v\n", time.Now().Format(time.RFC3339Nano), l.getPrefix(), err)
|
||||
_, _ = outputStream.Write([]byte(log))
|
||||
if DebugMode {
|
||||
print(log)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (l *Logger) getPrefix() string {
|
||||
return fmt.Sprintf("[%s]", l.tag)
|
||||
}
|
||||
6
recovery_tool/version.go
Normal file
6
recovery_tool/version.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package main
|
||||
|
||||
// The OSS tool depends on this file with this content. If you need to change
|
||||
// it, change the tool too.
|
||||
|
||||
const version = "2.2.4"
|
||||
Reference in New Issue
Block a user