Add sweepaccount tool.
This tool creates on-chain transactions, one per used address in a source account, to sweep all output value to new addresses in a different destination account.
This commit is contained in:
parent
94d813b2d4
commit
4ee9ce59fb
2 changed files with 386 additions and 0 deletions
348
cmd/sweepaccount/main.go
Normal file
348
cmd/sweepaccount/main.go
Normal file
|
@ -0,0 +1,348 @@
|
||||||
|
// Copyright (c) 2015-2016 The btcsuite developers
|
||||||
|
// Use of this source code is governed by an ISC
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/btcsuite/btcd/btcjson"
|
||||||
|
"github.com/btcsuite/btcd/txscript"
|
||||||
|
"github.com/btcsuite/btcd/wire"
|
||||||
|
"github.com/btcsuite/btcrpcclient"
|
||||||
|
"github.com/btcsuite/btcutil"
|
||||||
|
"github.com/btcsuite/btcwallet/internal/cfgutil"
|
||||||
|
"github.com/btcsuite/btcwallet/netparams"
|
||||||
|
"github.com/btcsuite/btcwallet/wallet/txauthor"
|
||||||
|
"github.com/btcsuite/btcwallet/wallet/txrules"
|
||||||
|
"github.com/btcsuite/go-flags"
|
||||||
|
"github.com/btcsuite/golangcrypto/ssh/terminal"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
walletDataDirectory = btcutil.AppDataDir("btcwallet", false)
|
||||||
|
newlineBytes = []byte{'\n'}
|
||||||
|
)
|
||||||
|
|
||||||
|
func fatalf(format string, args ...interface{}) {
|
||||||
|
fmt.Fprintf(os.Stderr, format, args...)
|
||||||
|
os.Stderr.Write(newlineBytes)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func errContext(err error, context string) error {
|
||||||
|
return fmt.Errorf("%s: %v", context, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flags.
|
||||||
|
var opts = struct {
|
||||||
|
TestNet3 bool `long:"testnet" description:"Use the test bitcoin network (version 3)"`
|
||||||
|
SimNet bool `long:"simnet" description:"Use the simulation bitcoin network"`
|
||||||
|
RPCConnect string `short:"c" long:"connect" description:"Hostname[:port] of wallet RPC server"`
|
||||||
|
RPCUsername string `short:"u" long:"rpcuser" description:"Wallet RPC username"`
|
||||||
|
RPCCertificateFile string `long:"cafile" description:"Wallet RPC TLS certificate"`
|
||||||
|
FeeRate *cfgutil.AmountFlag `long:"feerate" description:"Transaction fee per kilobyte"`
|
||||||
|
SourceAccount string `long:"sourceacct" description:"Account to sweep outputs from"`
|
||||||
|
DestinationAccount string `long:"destacct" description:"Account to send sweeped outputs to"`
|
||||||
|
RequiredConfirmations int64 `long:"minconf" description:"Required confirmations to include an output"`
|
||||||
|
}{
|
||||||
|
TestNet3: false,
|
||||||
|
SimNet: false,
|
||||||
|
RPCConnect: "localhost",
|
||||||
|
RPCUsername: "",
|
||||||
|
RPCCertificateFile: filepath.Join(walletDataDirectory, "rpc.cert"),
|
||||||
|
FeeRate: &cfgutil.AmountFlag{txrules.DefaultRelayFeePerKb},
|
||||||
|
SourceAccount: "imported",
|
||||||
|
DestinationAccount: "default",
|
||||||
|
RequiredConfirmations: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate flags.
|
||||||
|
func init() {
|
||||||
|
// Unset localhost defaults if certificate file can not be found.
|
||||||
|
certFileExists, err := cfgutil.FileExists(opts.RPCCertificateFile)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("%v", err)
|
||||||
|
}
|
||||||
|
if !certFileExists {
|
||||||
|
opts.RPCConnect = ""
|
||||||
|
opts.RPCCertificateFile = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = flags.Parse(&opts)
|
||||||
|
if err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.TestNet3 && opts.SimNet {
|
||||||
|
fatalf("Multiple bitcoin networks may not be used simultaneously")
|
||||||
|
}
|
||||||
|
var activeNet = &netparams.MainNetParams
|
||||||
|
if opts.TestNet3 {
|
||||||
|
activeNet = &netparams.TestNet3Params
|
||||||
|
} else if opts.SimNet {
|
||||||
|
activeNet = &netparams.SimNetParams
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.RPCConnect == "" {
|
||||||
|
fatalf("RPC hostname[:port] is required")
|
||||||
|
}
|
||||||
|
rpcConnect, err := cfgutil.NormalizeAddress(opts.RPCConnect, activeNet.RPCServerPort)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("Invalid RPC network address `%v`: %v", opts.RPCConnect, err)
|
||||||
|
}
|
||||||
|
opts.RPCConnect = rpcConnect
|
||||||
|
|
||||||
|
if opts.RPCUsername == "" {
|
||||||
|
fatalf("RPC username is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
certFileExists, err = cfgutil.FileExists(opts.RPCCertificateFile)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("%v", err)
|
||||||
|
}
|
||||||
|
if !certFileExists {
|
||||||
|
fatalf("RPC certificate file `%s` not found", opts.RPCCertificateFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.FeeRate.Amount > 1e6 {
|
||||||
|
fatalf("Fee rate `%v/kB` is exceptionally high", opts.FeeRate.Amount)
|
||||||
|
}
|
||||||
|
if opts.FeeRate.Amount < 1e2 {
|
||||||
|
fatalf("Fee rate `%v/kB` is exceptionally low", opts.FeeRate.Amount)
|
||||||
|
}
|
||||||
|
if opts.SourceAccount == opts.DestinationAccount {
|
||||||
|
fatalf("Source and destination accounts should not be equal")
|
||||||
|
}
|
||||||
|
if opts.RequiredConfirmations < 0 {
|
||||||
|
fatalf("Required confirmations must be non-negative")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noInputValue describes an error returned by the input source when no inputs
|
||||||
|
// were selected because each previous output value was zero. Callers of
|
||||||
|
// txauthor.NewUnsignedTransaction need not report these errors to the user.
|
||||||
|
type noInputValue struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (noInputValue) Error() string { return "no input value" }
|
||||||
|
|
||||||
|
// makeInputSource creates an InputSource that creates inputs for every unspent
|
||||||
|
// output with non-zero output values. The target amount is ignored since every
|
||||||
|
// output is consumed. The InputSource does not return any previous output
|
||||||
|
// scripts as they are not needed for creating the unsinged transaction and are
|
||||||
|
// looked up again by the wallet during the call to signrawtransaction.
|
||||||
|
func makeInputSource(outputs []btcjson.ListUnspentResult) txauthor.InputSource {
|
||||||
|
var (
|
||||||
|
totalInputValue btcutil.Amount
|
||||||
|
inputs = make([]*wire.TxIn, 0, len(outputs))
|
||||||
|
sourceErr error
|
||||||
|
)
|
||||||
|
for _, output := range outputs {
|
||||||
|
outputAmount, err := btcutil.NewAmount(output.Amount)
|
||||||
|
if err != nil {
|
||||||
|
sourceErr = fmt.Errorf(
|
||||||
|
"invalid amount `%v` in listunspent result",
|
||||||
|
output.Amount)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if outputAmount == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !saneOutputValue(outputAmount) {
|
||||||
|
sourceErr = fmt.Errorf(
|
||||||
|
"impossible output amount `%v` in listunspent result",
|
||||||
|
outputAmount)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
totalInputValue += outputAmount
|
||||||
|
|
||||||
|
previousOutPoint, err := parseOutPoint(&output)
|
||||||
|
if err != nil {
|
||||||
|
sourceErr = fmt.Errorf(
|
||||||
|
"invalid data in listunspent result: %v",
|
||||||
|
err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs = append(inputs, wire.NewTxIn(&previousOutPoint, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
if sourceErr == nil && totalInputValue == 0 {
|
||||||
|
sourceErr = noInputValue{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(btcutil.Amount) (btcutil.Amount, []*wire.TxIn, [][]byte, error) {
|
||||||
|
return totalInputValue, inputs, nil, sourceErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeDestinationScriptSource creates a ChangeSource which is used to receive
|
||||||
|
// all correlated previous input value. A non-change address is created by this
|
||||||
|
// function.
|
||||||
|
func makeDestinationScriptSource(rpcClient *btcrpcclient.Client, accountName string) txauthor.ChangeSource {
|
||||||
|
return func() ([]byte, error) {
|
||||||
|
destinationAddress, err := rpcClient.GetNewAddress(accountName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return txscript.PayToAddrScript(destinationAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
err := sweep()
|
||||||
|
if err != nil {
|
||||||
|
fatalf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sweep() error {
|
||||||
|
rpcPassword, err := promptSecret("Wallet RPC password")
|
||||||
|
if err != nil {
|
||||||
|
return errContext(err, "failed to read RPC password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open RPC client.
|
||||||
|
rpcCertificate, err := ioutil.ReadFile(opts.RPCCertificateFile)
|
||||||
|
if err != nil {
|
||||||
|
return errContext(err, "failed to read RPC certificate")
|
||||||
|
}
|
||||||
|
rpcClient, err := btcrpcclient.New(&btcrpcclient.ConnConfig{
|
||||||
|
Host: opts.RPCConnect,
|
||||||
|
User: opts.RPCUsername,
|
||||||
|
Pass: rpcPassword,
|
||||||
|
Certificates: rpcCertificate,
|
||||||
|
HTTPPostMode: true,
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return errContext(err, "failed to create RPC client")
|
||||||
|
}
|
||||||
|
defer rpcClient.Shutdown()
|
||||||
|
|
||||||
|
// Fetch all unspent outputs, ignore those not from the source
|
||||||
|
// account, and group by their destination address. Each grouping of
|
||||||
|
// outputs will be used as inputs for a single transaction sending to a
|
||||||
|
// new destination account address.
|
||||||
|
unspentOutputs, err := rpcClient.ListUnspent()
|
||||||
|
if err != nil {
|
||||||
|
return errContext(err, "failed to fetch unspent outputs")
|
||||||
|
}
|
||||||
|
sourceOutputs := make(map[string][]btcjson.ListUnspentResult)
|
||||||
|
for _, unspentOutput := range unspentOutputs {
|
||||||
|
if !unspentOutput.Spendable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if unspentOutput.Confirmations < opts.RequiredConfirmations {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if unspentOutput.Account != opts.SourceAccount {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sourceAddressOutputs := sourceOutputs[unspentOutput.Address]
|
||||||
|
sourceOutputs[unspentOutput.Address] = append(sourceAddressOutputs, unspentOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
var privatePassphrase string
|
||||||
|
if len(sourceOutputs) != 0 {
|
||||||
|
privatePassphrase, err = promptSecret("Wallet private passphrase")
|
||||||
|
if err != nil {
|
||||||
|
return errContext(err, "failed to read private passphrase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalSwept btcutil.Amount
|
||||||
|
var numErrors int
|
||||||
|
var reportError = func(format string, args ...interface{}) {
|
||||||
|
fmt.Fprintf(os.Stderr, format, args...)
|
||||||
|
os.Stderr.Write(newlineBytes)
|
||||||
|
numErrors++
|
||||||
|
}
|
||||||
|
for _, previousOutputs := range sourceOutputs {
|
||||||
|
inputSource := makeInputSource(previousOutputs)
|
||||||
|
destinationSource := makeDestinationScriptSource(rpcClient, opts.DestinationAccount)
|
||||||
|
tx, err := txauthor.NewUnsignedTransaction(nil, opts.FeeRate.Amount,
|
||||||
|
inputSource, destinationSource)
|
||||||
|
if err != nil {
|
||||||
|
if err != (noInputValue{}) {
|
||||||
|
reportError("Failed to create unsigned transaction: %v", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock the wallet, sign the transaction, and immediately lock.
|
||||||
|
err = rpcClient.WalletPassphrase(privatePassphrase, 60)
|
||||||
|
if err != nil {
|
||||||
|
reportError("Failed to unlock wallet: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
signedTransaction, complete, err := rpcClient.SignRawTransaction(tx.Tx)
|
||||||
|
_ = rpcClient.WalletLock()
|
||||||
|
if err != nil {
|
||||||
|
reportError("Failed to sign transaction: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !complete {
|
||||||
|
reportError("Failed to sign every input")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish the signed sweep transaction.
|
||||||
|
txHash, err := rpcClient.SendRawTransaction(signedTransaction, false)
|
||||||
|
if err != nil {
|
||||||
|
reportError("Failed to publish transaction: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
outputAmount := btcutil.Amount(tx.Tx.TxOut[0].Value)
|
||||||
|
fmt.Printf("Swept %v to destination account with transaction %v\n",
|
||||||
|
outputAmount, txHash)
|
||||||
|
totalSwept += outputAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
numPublished := len(sourceOutputs) - numErrors
|
||||||
|
transactionNoun := pickNoun(numErrors, "transaction", "transactions")
|
||||||
|
if numPublished != 0 {
|
||||||
|
fmt.Printf("Swept %v to destination account across %d %s\n",
|
||||||
|
totalSwept, numPublished, transactionNoun)
|
||||||
|
}
|
||||||
|
if numErrors > 0 {
|
||||||
|
return fmt.Errorf("Failed to publish %d %s", numErrors, transactionNoun)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptSecret(what string) (string, error) {
|
||||||
|
fmt.Printf("%s: ", what)
|
||||||
|
fd := int(os.Stdin.Fd())
|
||||||
|
input, err := terminal.ReadPassword(fd)
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(input), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saneOutputValue(amount btcutil.Amount) bool {
|
||||||
|
return amount >= 0 && amount <= btcutil.MaxSatoshi
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOutPoint(input *btcjson.ListUnspentResult) (wire.OutPoint, error) {
|
||||||
|
txHash, err := wire.NewShaHashFromStr(input.TxID)
|
||||||
|
if err != nil {
|
||||||
|
return wire.OutPoint{}, err
|
||||||
|
}
|
||||||
|
return wire.OutPoint{*txHash, input.Vout}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickNoun(n int, singularForm, pluralForm string) string {
|
||||||
|
if n == 1 {
|
||||||
|
return singularForm
|
||||||
|
}
|
||||||
|
return pluralForm
|
||||||
|
}
|
38
internal/cfgutil/amount.go
Normal file
38
internal/cfgutil/amount.go
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
// Copyright (c) 2015-2016 The btcsuite developers
|
||||||
|
// Use of this source code is governed by an ISC
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package cfgutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/btcsuite/btcutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AmountFlag embeds a btcutil.Amount and implements the flags.Marshaler and
|
||||||
|
// Unmarshaler interfaces so it can be used as a config struct field.
|
||||||
|
type AmountFlag struct {
|
||||||
|
btcutil.Amount
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalFlag satisifes the flags.Marshaler interface.
|
||||||
|
func (a *AmountFlag) MarshalFlag() (string, error) {
|
||||||
|
return a.Amount.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalFlag satisifes the flags.Unmarshaler interface.
|
||||||
|
func (a *AmountFlag) UnmarshalFlag(value string) error {
|
||||||
|
value = strings.TrimSuffix(value, " BTC")
|
||||||
|
valueF64, err := strconv.ParseFloat(value, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
amount, err := btcutil.NewAmount(valueF64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.Amount = amount
|
||||||
|
return nil
|
||||||
|
}
|
Loading…
Reference in a new issue