forked from TrueCloudLab/neoneo-go
519b27fe0e
To work with NEP5 tokens, one need to save some info e.g. token Name. This way we will not make additional RPC just to get Decimals.
638 lines
14 KiB
Go
638 lines
14 KiB
Go
package wallet
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"os"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"github.com/nspcc-dev/neo-go/cli/flags"
|
|
"github.com/nspcc-dev/neo-go/pkg/core"
|
|
"github.com/nspcc-dev/neo-go/pkg/core/transaction"
|
|
"github.com/nspcc-dev/neo-go/pkg/crypto/keys"
|
|
"github.com/nspcc-dev/neo-go/pkg/encoding/address"
|
|
"github.com/nspcc-dev/neo-go/pkg/rpc/client"
|
|
"github.com/nspcc-dev/neo-go/pkg/rpc/request"
|
|
context2 "github.com/nspcc-dev/neo-go/pkg/smartcontract/context"
|
|
"github.com/nspcc-dev/neo-go/pkg/util"
|
|
"github.com/nspcc-dev/neo-go/pkg/wallet"
|
|
"github.com/urfave/cli"
|
|
"golang.org/x/crypto/ssh/terminal"
|
|
)
|
|
|
|
var (
|
|
errNoPath = errors.New("target path where the wallet should be stored is mandatory and should be passed using (--path, -p) flags")
|
|
errPhraseMismatch = errors.New("the entered pass-phrases do not match. Maybe you have misspelled them")
|
|
)
|
|
|
|
var (
|
|
walletPathFlag = cli.StringFlag{
|
|
Name: "path, p",
|
|
Usage: "Target location of the wallet file.",
|
|
}
|
|
wifFlag = cli.StringFlag{
|
|
Name: "wif",
|
|
Usage: "WIF to import",
|
|
}
|
|
decryptFlag = cli.BoolFlag{
|
|
Name: "decrypt, d",
|
|
Usage: "Decrypt encrypted keys.",
|
|
}
|
|
rpcFlag = cli.StringFlag{
|
|
Name: "rpc, r",
|
|
Usage: "RPC node address",
|
|
}
|
|
timeoutFlag = cli.DurationFlag{
|
|
Name: "timeout, t",
|
|
Usage: "Timeout for the operation",
|
|
}
|
|
outFlag = cli.StringFlag{
|
|
Name: "out",
|
|
Usage: "file to put JSON transaction to",
|
|
}
|
|
inFlag = cli.StringFlag{
|
|
Name: "in",
|
|
Usage: "file with JSON transaction",
|
|
}
|
|
)
|
|
|
|
// NewCommands returns 'wallet' command.
|
|
func NewCommands() []cli.Command {
|
|
return []cli.Command{{
|
|
Name: "wallet",
|
|
Usage: "create, open and manage a NEO wallet",
|
|
Subcommands: []cli.Command{
|
|
{
|
|
Name: "claim",
|
|
Usage: "claim GAS",
|
|
Action: claimGas,
|
|
Flags: []cli.Flag{
|
|
walletPathFlag,
|
|
rpcFlag,
|
|
timeoutFlag,
|
|
flags.AddressFlag{
|
|
Name: "address, a",
|
|
Usage: "Address to claim GAS for",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "create",
|
|
Usage: "create a new wallet",
|
|
Action: createWallet,
|
|
Flags: []cli.Flag{
|
|
walletPathFlag,
|
|
cli.BoolFlag{
|
|
Name: "account, a",
|
|
Usage: "Create a new account",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "create-account",
|
|
Usage: "add an account to the existing wallet",
|
|
Action: addAccount,
|
|
Flags: []cli.Flag{
|
|
walletPathFlag,
|
|
},
|
|
},
|
|
{
|
|
Name: "dump",
|
|
Usage: "check and dump an existing NEO wallet",
|
|
Action: dumpWallet,
|
|
Flags: []cli.Flag{
|
|
walletPathFlag,
|
|
decryptFlag,
|
|
},
|
|
},
|
|
{
|
|
Name: "export",
|
|
Usage: "export keys for address",
|
|
UsageText: "export --path <path> [--decrypt] [<address>]",
|
|
Action: exportKeys,
|
|
Flags: []cli.Flag{
|
|
walletPathFlag,
|
|
decryptFlag,
|
|
},
|
|
},
|
|
{
|
|
Name: "import",
|
|
Usage: "import WIF",
|
|
Action: importWallet,
|
|
Flags: []cli.Flag{
|
|
walletPathFlag,
|
|
wifFlag,
|
|
cli.StringFlag{
|
|
Name: "name, n",
|
|
Usage: "Optional account name",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "import-multisig",
|
|
Usage: "import multisig contract",
|
|
UsageText: "import-multisig --path <path> --wif <wif> --min <n>" +
|
|
" [<pubkey1> [<pubkey2> [...]]]",
|
|
Action: importMultisig,
|
|
Flags: []cli.Flag{
|
|
walletPathFlag,
|
|
wifFlag,
|
|
cli.StringFlag{
|
|
Name: "name, n",
|
|
Usage: "Optional account name",
|
|
},
|
|
cli.IntFlag{
|
|
Name: "min, m",
|
|
Usage: "Minimal number of signatures",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "transfer",
|
|
Usage: "transfer NEO/GAS",
|
|
UsageText: "transfer --path <path> --from <addr> --to <addr>" +
|
|
" --amount <amount> --asset [NEO|GAS|<hex-id>] [--out <path>]",
|
|
Action: transferAsset,
|
|
Flags: []cli.Flag{
|
|
walletPathFlag,
|
|
rpcFlag,
|
|
timeoutFlag,
|
|
outFlag,
|
|
flags.AddressFlag{
|
|
Name: "from",
|
|
Usage: "Address to send an asset from",
|
|
},
|
|
flags.AddressFlag{
|
|
Name: "to",
|
|
Usage: "Address to send an asset to",
|
|
},
|
|
cli.StringFlag{
|
|
Name: "amount",
|
|
Usage: "Amount of asset to send",
|
|
},
|
|
cli.StringFlag{
|
|
Name: "asset",
|
|
Usage: "Asset ID",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "multisig",
|
|
Usage: "work with multisig address",
|
|
Subcommands: newMultisigCommands(),
|
|
},
|
|
{
|
|
Name: "nep5",
|
|
Usage: "work with NEP5 contracts",
|
|
Subcommands: newNEP5Commands(),
|
|
},
|
|
},
|
|
}}
|
|
}
|
|
|
|
func claimGas(ctx *cli.Context) error {
|
|
wall, err := openWallet(ctx.String("path"))
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
defer wall.Close()
|
|
|
|
addrFlag := ctx.Generic("address").(*flags.Address)
|
|
if !addrFlag.IsSet {
|
|
return cli.NewExitError("address was not provided", 1)
|
|
}
|
|
scriptHash := addrFlag.Uint160()
|
|
acc := wall.GetAccount(scriptHash)
|
|
if acc == nil {
|
|
return cli.NewExitError(fmt.Errorf("wallet contains no account for '%s'", addrFlag), 1)
|
|
}
|
|
|
|
pass, err := readPassword("Enter password > ")
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
} else if err := acc.Decrypt(pass); err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
gctx, cancel := getGoContext(ctx)
|
|
defer cancel()
|
|
|
|
c, err := client.New(gctx, ctx.String("rpc"), client.Options{})
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
info, err := c.GetClaimable(scriptHash.String())
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
} else if info.Unclaimed == 0 || len(info.Spents) == 0 {
|
|
fmt.Println("Nothing to claim")
|
|
return nil
|
|
}
|
|
|
|
var claim transaction.ClaimTX
|
|
for i := range info.Spents {
|
|
claim.Claims = append(claim.Claims, transaction.Input{
|
|
PrevHash: info.Spents[i].Tx,
|
|
PrevIndex: uint16(info.Spents[i].N),
|
|
})
|
|
}
|
|
|
|
tx := &transaction.Transaction{
|
|
Type: transaction.ClaimType,
|
|
Data: &claim,
|
|
}
|
|
|
|
tx.AddOutput(&transaction.Output{
|
|
AssetID: core.UtilityTokenID(),
|
|
Amount: info.Unclaimed,
|
|
ScriptHash: scriptHash,
|
|
})
|
|
|
|
_ = acc.SignTx(tx)
|
|
if err := c.SendRawTransaction(tx); err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
fmt.Println(tx.Hash().StringLE())
|
|
return nil
|
|
}
|
|
|
|
func addAccount(ctx *cli.Context) error {
|
|
wall, err := openWallet(ctx.String("path"))
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
defer wall.Close()
|
|
|
|
if err := createAccount(ctx, wall); err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func exportKeys(ctx *cli.Context) error {
|
|
wall, err := openWallet(ctx.String("path"))
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
var addr string
|
|
|
|
decrypt := ctx.Bool("decrypt")
|
|
if ctx.NArg() == 0 && decrypt {
|
|
return cli.NewExitError(errors.New("address must be provided if '--decrypt' flag is used"), 1)
|
|
} else if ctx.NArg() > 0 {
|
|
// check address format just to catch possible typos
|
|
addr = ctx.Args().First()
|
|
_, err := address.StringToUint160(addr)
|
|
if err != nil {
|
|
return cli.NewExitError(fmt.Errorf("can't parse address: %v", err), 1)
|
|
}
|
|
}
|
|
|
|
var wifs []string
|
|
|
|
loop:
|
|
for _, a := range wall.Accounts {
|
|
if addr != "" && a.Address != addr {
|
|
continue
|
|
}
|
|
|
|
for i := range wifs {
|
|
if a.EncryptedWIF == wifs[i] {
|
|
continue loop
|
|
}
|
|
}
|
|
|
|
wifs = append(wifs, a.EncryptedWIF)
|
|
}
|
|
|
|
for _, wif := range wifs {
|
|
if decrypt {
|
|
pass, err := readPassword("Enter password > ")
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
pk, err := keys.NEP2Decrypt(wif, pass)
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
wif = pk.WIF()
|
|
}
|
|
|
|
fmt.Println(wif)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func importMultisig(ctx *cli.Context) error {
|
|
wall, err := openWallet(ctx.String("path"))
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
defer wall.Close()
|
|
|
|
m := ctx.Int("min")
|
|
if ctx.NArg() < m {
|
|
return cli.NewExitError(errors.New("insufficient number of public keys"), 1)
|
|
}
|
|
|
|
args := []string(ctx.Args())
|
|
pubs := make([]*keys.PublicKey, len(args))
|
|
|
|
for i := range args {
|
|
pubs[i], err = keys.NewPublicKeyFromString(args[i])
|
|
if err != nil {
|
|
return cli.NewExitError(fmt.Errorf("can't decode public key %d: %v", i, err), 1)
|
|
}
|
|
}
|
|
|
|
acc, err := newAccountFromWIF(ctx.String("wif"))
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
if err := acc.ConvertMultisig(m, pubs); err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
if err := addAccountAndSave(wall, acc); err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func importWallet(ctx *cli.Context) error {
|
|
wall, err := openWallet(ctx.String("path"))
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
defer wall.Close()
|
|
|
|
acc, err := newAccountFromWIF(ctx.String("wif"))
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
acc.Label = ctx.String("name")
|
|
if err := addAccountAndSave(wall, acc); err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func transferAsset(ctx *cli.Context) error {
|
|
wall, err := openWallet(ctx.String("path"))
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
defer wall.Close()
|
|
|
|
fromFlag := ctx.Generic("from").(*flags.Address)
|
|
if !fromFlag.IsSet {
|
|
return cli.NewExitError("'from' address was not provided", 1)
|
|
}
|
|
from := fromFlag.Uint160()
|
|
acc := wall.GetAccount(from)
|
|
if acc == nil {
|
|
return cli.NewExitError(fmt.Errorf("wallet contains no account for '%s'", from), 1)
|
|
}
|
|
|
|
asset, err := getAssetID(ctx.String("asset"))
|
|
if err != nil {
|
|
return cli.NewExitError(fmt.Errorf("invalid asset id: %v", err), 1)
|
|
}
|
|
|
|
amount, err := util.Fixed8FromString(ctx.String("amount"))
|
|
if err != nil {
|
|
return cli.NewExitError(fmt.Errorf("invalid amount: %v", err), 1)
|
|
}
|
|
|
|
pass, err := readPassword("Enter wallet password > ")
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
} else if err := acc.Decrypt(pass); err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
gctx, cancel := getGoContext(ctx)
|
|
defer cancel()
|
|
|
|
c, err := client.New(gctx, ctx.String("rpc"), client.Options{})
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
tx := transaction.NewContractTX()
|
|
if err := request.AddInputsAndUnspentsToTx(tx, fromFlag.String(), asset, amount, c); err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
toFlag := ctx.Generic("to").(*flags.Address)
|
|
if !toFlag.IsSet {
|
|
return cli.NewExitError("'to' address was not provided", 1)
|
|
}
|
|
toAddr := toFlag.Uint160()
|
|
tx.AddOutput(&transaction.Output{
|
|
AssetID: asset,
|
|
Amount: amount,
|
|
ScriptHash: toAddr,
|
|
Position: 1,
|
|
})
|
|
|
|
if outFile := ctx.String("out"); outFile != "" {
|
|
priv := acc.PrivateKey()
|
|
pub := priv.PublicKey()
|
|
sign := priv.Sign(tx.GetSignedPart())
|
|
c := context2.NewParameterContext("Neo.Core.ContractTransaction", tx)
|
|
if err := c.AddSignature(acc.Contract, pub, sign); err != nil {
|
|
return cli.NewExitError(fmt.Errorf("can't add signature: %v", err), 1)
|
|
} else if data, err := json.Marshal(c); err != nil {
|
|
return cli.NewExitError(fmt.Errorf("can't marshal tx to JSON: %v", err), 1)
|
|
} else if err := ioutil.WriteFile(outFile, data, 0644); err != nil {
|
|
return cli.NewExitError(fmt.Errorf("can't write tx to file: %v", err), 1)
|
|
}
|
|
} else {
|
|
_ = acc.SignTx(tx)
|
|
if err := c.SendRawTransaction(tx); err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
}
|
|
|
|
fmt.Println(tx.Hash().StringLE())
|
|
return nil
|
|
}
|
|
|
|
func getGoContext(ctx *cli.Context) (context.Context, func()) {
|
|
if dur := ctx.Duration("timeout"); dur != 0 {
|
|
return context.WithTimeout(context.Background(), dur)
|
|
}
|
|
return context.Background(), func() {}
|
|
}
|
|
|
|
func dumpWallet(ctx *cli.Context) error {
|
|
wall, err := openWallet(ctx.String("path"))
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
if ctx.Bool("decrypt") {
|
|
pass, err := readPassword("Enter wallet password > ")
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
for i := range wall.Accounts {
|
|
// Just testing the decryption here.
|
|
err := wall.Accounts[i].Decrypt(pass)
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
}
|
|
}
|
|
fmtPrintWallet(wall)
|
|
return nil
|
|
}
|
|
|
|
func createWallet(ctx *cli.Context) error {
|
|
path := ctx.String("path")
|
|
if len(path) == 0 {
|
|
return cli.NewExitError(errNoPath, 1)
|
|
}
|
|
wall, err := wallet.NewWallet(path)
|
|
if err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
if err := wall.Save(); err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
|
|
if ctx.Bool("account") {
|
|
if err := createAccount(ctx, wall); err != nil {
|
|
return cli.NewExitError(err, 1)
|
|
}
|
|
}
|
|
|
|
fmtPrintWallet(wall)
|
|
fmt.Printf("wallet successfully created, file location is %s\n", wall.Path())
|
|
return nil
|
|
}
|
|
|
|
func readAccountInfo() (string, string, error) {
|
|
buf := bufio.NewReader(os.Stdin)
|
|
fmt.Print("Enter the name of the account > ")
|
|
rawName, _ := buf.ReadBytes('\n')
|
|
phrase, err := readPassword("Enter passphrase > ")
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
phraseCheck, err := readPassword("Confirm passphrase > ")
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
if phrase != phraseCheck {
|
|
return "", "", errPhraseMismatch
|
|
}
|
|
|
|
name := strings.TrimRight(string(rawName), "\n")
|
|
return name, phrase, nil
|
|
}
|
|
|
|
func createAccount(ctx *cli.Context, wall *wallet.Wallet) error {
|
|
name, phrase, err := readAccountInfo()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return wall.CreateAccount(name, phrase)
|
|
}
|
|
|
|
func openWallet(path string) (*wallet.Wallet, error) {
|
|
if len(path) == 0 {
|
|
return nil, errNoPath
|
|
}
|
|
return wallet.NewWalletFromFile(path)
|
|
}
|
|
|
|
func getAssetID(s string) (util.Uint256, error) {
|
|
s = strings.ToLower(s)
|
|
switch {
|
|
case s == "neo":
|
|
return core.GoverningTokenID(), nil
|
|
case s == "gas":
|
|
return core.UtilityTokenID(), nil
|
|
default:
|
|
return util.Uint256DecodeStringLE(s)
|
|
}
|
|
}
|
|
|
|
func newAccountFromWIF(wif string) (*wallet.Account, error) {
|
|
// note: NEP2 strings always have length of 58 even though
|
|
// base58 strings can have different lengths even if slice lengths are equal
|
|
if len(wif) == 58 {
|
|
pass, err := readPassword("Enter password > ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return wallet.NewAccountFromEncryptedWIF(wif, pass)
|
|
}
|
|
|
|
acc, err := wallet.NewAccountFromWIF(wif)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fmt.Println("Provided WIF was unencrypted. Wallet can contain only encrypted keys.")
|
|
name, pass, err := readAccountInfo()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
acc.Label = name
|
|
if err := acc.Encrypt(pass); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return acc, nil
|
|
}
|
|
|
|
func addAccountAndSave(w *wallet.Wallet, acc *wallet.Account) error {
|
|
for i := range w.Accounts {
|
|
if w.Accounts[i].Address == acc.Address {
|
|
return fmt.Errorf("address '%s' is already in wallet", acc.Address)
|
|
}
|
|
}
|
|
|
|
w.AddAccount(acc)
|
|
return w.Save()
|
|
}
|
|
|
|
func readPassword(prompt string) (string, error) {
|
|
fmt.Print(prompt)
|
|
rawPass, err := terminal.ReadPassword(syscall.Stdin)
|
|
fmt.Println()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimRight(string(rawPass), "\n"), nil
|
|
}
|
|
|
|
func fmtPrintWallet(wall *wallet.Wallet) {
|
|
b, _ := wall.JSON()
|
|
fmt.Println("")
|
|
fmt.Println(string(b))
|
|
fmt.Println("")
|
|
}
|