cli: add complete support for offline signing, fix #2662

See documentation update for an example. Some code is made generic as well,
GetCompleteTransaction can now be used directly on ParameterContext.
This commit is contained in:
Roman Khimov 2022-08-31 22:38:35 +03:00
parent f7cff022c0
commit 411ebdf51e
9 changed files with 303 additions and 27 deletions

View file

@ -13,18 +13,21 @@ import (
)
// InitAndSave creates an incompletely signed transaction which can be used
// as an input to `multisig sign`.
// as an input to `multisig sign`. If a wallet.Account is given and can sign,
// it's signed as well using it.
func InitAndSave(net netmode.Magic, tx *transaction.Transaction, acc *wallet.Account, filename string) error {
priv := acc.PrivateKey()
pub := priv.PublicKey()
sign := priv.SignHashable(uint32(net), tx)
scCtx := context.NewParameterContext("Neo.Network.P2P.Payloads.Transaction", net, tx)
h, err := address.StringToUint160(acc.Address)
if err != nil {
return fmt.Errorf("invalid address: %s", acc.Address)
}
if err := scCtx.AddSignature(h, acc.Contract, pub, sign); err != nil {
return fmt.Errorf("can't add signature: %w", err)
if acc != nil && acc.CanSign() {
priv := acc.PrivateKey()
pub := priv.PublicKey()
sign := priv.SignHashable(uint32(net), tx)
h, err := address.StringToUint160(acc.Address)
if err != nil {
return fmt.Errorf("invalid address: %s", acc.Address)
}
if err := scCtx.AddSignature(h, acc.Contract, pub, sign); err != nil {
return fmt.Errorf("can't add signature: %w", err)
}
}
return Save(scCtx, filename)
}

View file

@ -25,6 +25,18 @@ func NewCommands() []cli.Command {
and converted to other formats. Strings are escaped and output in quotes.`,
Action: handleParse,
},
{
Name: "sendtx",
Usage: "Send complete transaction stored in a context file",
UsageText: "sendtx [-r <endpoint>] <file.in>",
Description: `Sends the transaction from the given context file to the given RPC node if it's
completely signed and ready. This command expects a ContractParametersContext
JSON file for input, it can't handle binary (or hex- or base64-encoded)
transactions.
`,
Action: sendTx,
Flags: txDumpFlags,
},
{
Name: "txdump",
Usage: "Dump transaction stored in file",

42
cli/util/send.go Normal file
View file

@ -0,0 +1,42 @@
package util
import (
"fmt"
"github.com/nspcc-dev/neo-go/cli/options"
"github.com/nspcc-dev/neo-go/cli/paramcontext"
"github.com/urfave/cli"
)
func sendTx(ctx *cli.Context) error {
args := ctx.Args()
if len(args) == 0 {
return cli.NewExitError("missing input file", 1)
} else if len(args) > 1 {
return cli.NewExitError("only one input file is accepted", 1)
}
pc, err := paramcontext.Read(args[0])
if err != nil {
return cli.NewExitError(err, 1)
}
tx, err := pc.GetCompleteTransaction()
if err != nil {
return cli.NewExitError(fmt.Errorf("failed to complete transaction: %w", err), 1)
}
gctx, cancel := options.GetTimeoutContext(ctx)
defer cancel()
c, err := options.GetRPCClient(gctx, ctx)
if err != nil {
return cli.NewExitError(fmt.Errorf("failed to create RPC client: %w", err), 1)
}
res, err := c.SendRawTransaction(tx)
if err != nil {
return cli.NewExitError(fmt.Errorf("failed to submit transaction to RPC node: %w", err), 1)
}
fmt.Fprintln(ctx.App.Writer, res.StringLE())
return nil
}

View file

@ -57,10 +57,14 @@ func signStoredTransaction(ctx *cli.Context) error {
return cli.NewExitError("tx signers don't contain provided account", 1)
}
priv := acc.PrivateKey()
sign := priv.SignHashable(uint32(pc.Network), tx)
if err := pc.AddSignature(ch, acc.Contract, priv.PublicKey(), sign); err != nil {
return cli.NewExitError(fmt.Errorf("can't add signature: %w", err), 1)
if acc.CanSign() {
priv := acc.PrivateKey()
sign := priv.SignHashable(uint32(pc.Network), pc.Verifiable)
if err := pc.AddSignature(ch, acc.Contract, priv.PublicKey(), sign); err != nil {
return cli.NewExitError(fmt.Errorf("can't add signature: %w", err), 1)
}
} else if rpcNode == "" {
return cli.NewExitError(fmt.Errorf("can't sign transactions with the given account and no RPC endpoing given to send anything signed"), 1)
}
// Not saving and not sending, print.
if out == "" && rpcNode == "" {
@ -77,12 +81,9 @@ func signStoredTransaction(ctx *cli.Context) error {
}
}
if rpcNode != "" {
for i := range tx.Signers {
w, err := pc.GetWitness(tx.Signers[i].Account)
if err != nil {
return cli.NewExitError(fmt.Errorf("failed to construct witness for signer #%d: %w", i, err), 1)
}
tx.Scripts = append(tx.Scripts, *w)
tx, err = pc.GetCompleteTransaction()
if err != nil {
return cli.NewExitError(fmt.Errorf("failed to complete transaction: %w", err), 1)
}
gctx, cancel := options.GetTimeoutContext(ctx)

View file

@ -150,13 +150,20 @@ func handleVote(ctx *cli.Context) error {
})
}
// getDecryptedAccount tries to unlock the specified account. If password is nil, it will be requested via terminal.
// getDecryptedAccount tries to get and unlock the specified account if it has a
// key inside (otherwise it's returned as is, without an ability to sign). If
// password is nil, it will be requested via terminal.
func getDecryptedAccount(wall *wallet.Wallet, addr util.Uint160, password *string) (*wallet.Account, error) {
acc := wall.GetAccount(addr)
if acc == nil {
return nil, fmt.Errorf("can't find account for the address: %s", address.Uint160ToString(addr))
}
// No private key available, nothing to decrypt, but it's still a useful account for many purposes.
if acc.EncryptedWIF == "" {
return acc, nil
}
if password == nil {
pass, err := input.ReadPassword(EnterPasswordPrompt)
if err != nil {

View file

@ -660,6 +660,98 @@ func TestStripKeys(t *testing.T) {
}
}
func TestOfflineSigning(t *testing.T) {
e := newExecutor(t, true)
tmpDir := t.TempDir()
walletPath := filepath.Join(tmpDir, "wallet.json")
txPath := filepath.Join(tmpDir, "tx.json")
// Copy wallet.
w, err := wallet.NewWalletFromFile(validatorWallet)
require.NoError(t, err)
jOut, err := w.JSON()
require.NoError(t, err)
require.NoError(t, os.WriteFile(walletPath, jOut, 0644))
// And remove keys from it.
e.Run(t, "neo-go", "wallet", "strip-keys", "--wallet", walletPath, "--force")
t.Run("1/1 multisig", func(t *testing.T) {
args := []string{"neo-go", "wallet", "nep17", "transfer",
"--rpc-endpoint", "http://" + e.RPC.Addr,
"--wallet", walletPath,
"--from", validatorAddr,
"--to", w.Accounts[0].Address,
"--token", "NEO",
"--amount", "1",
"--force",
}
// walletPath has no keys, so this can't be sent.
e.RunWithError(t, args...)
// But can be saved.
e.Run(t, append(args, "--out", txPath)...)
// It can't be signed with the original wallet.
e.RunWithError(t, "neo-go", "wallet", "sign",
"--wallet", walletPath, "--address", validatorAddr,
"--in", txPath, "--out", txPath)
t.Run("sendtx", func(t *testing.T) {
// And it can't be sent.
e.RunWithError(t, "neo-go", "util", "sendtx",
"--rpc-endpoint", "http://"+e.RPC.Addr,
txPath)
// Even with too many arguments.
e.RunWithError(t, "neo-go", "util", "sendtx",
"--rpc-endpoint", "http://"+e.RPC.Addr,
txPath, txPath)
// Or no arguments at all.
e.RunWithError(t, "neo-go", "util", "sendtx",
"--rpc-endpoint", "http://"+e.RPC.Addr)
})
// But it can be signed with a proper wallet.
e.In.WriteString("one\r")
e.Run(t, "neo-go", "wallet", "sign",
"--wallet", validatorWallet, "--address", validatorAddr,
"--in", txPath, "--out", txPath)
// And then anyone can send (even via wallet sign).
e.Run(t, "neo-go", "wallet", "sign",
"--rpc-endpoint", "http://"+e.RPC.Addr,
"--wallet", walletPath, "--address", validatorAddr,
"--in", txPath)
})
e.checkTxPersisted(t)
t.Run("simple signature", func(t *testing.T) {
simpleAddr := w.Accounts[0].Address
args := []string{"neo-go", "wallet", "nep17", "transfer",
"--rpc-endpoint", "http://" + e.RPC.Addr,
"--wallet", walletPath,
"--from", simpleAddr,
"--to", validatorAddr,
"--token", "NEO",
"--amount", "1",
"--force",
}
// walletPath has no keys, so this can't be sent.
e.RunWithError(t, args...)
// But can be saved.
e.Run(t, append(args, "--out", txPath)...)
// It can't be signed with the original wallet.
e.RunWithError(t, "neo-go", "wallet", "sign",
"--wallet", walletPath, "--address", simpleAddr,
"--in", txPath, "--out", txPath)
// But can be with a proper one.
e.In.WriteString("one\r")
e.Run(t, "neo-go", "wallet", "sign",
"--wallet", validatorWallet, "--address", simpleAddr,
"--in", txPath, "--out", txPath)
// Sending without an RPC node is not likely to succeed.
e.RunWithError(t, "neo-go", "util", "sendtx", txPath)
// But it requires no wallet at all.
e.Run(t, "neo-go", "util", "sendtx",
"--rpc-endpoint", "http://"+e.RPC.Addr,
txPath)
})
}
func TestWalletDump(t *testing.T) {
e := newExecutor(t, false)

View file

@ -382,6 +382,67 @@ $ ./bin/neo-go query voter -r http://localhost:20332 Nj91C8TxQSxW1jCE1ytFre6mg5q
Block: 3970
```
### Transaction signing
`wallet sign` command allows to sign arbitary transactions stored in JSON
format (also known as ContractParametersContext). Usually it's used in one of
the two cases: multisignature signing (when you don't have all keys for an
account and need to share the context with others until enough signatures
collected) or offline signing (when the node with a key is completely offline
and can't interact with the RPC node directly).
#### Multisignature collection
For example, you have a four-node default network setup and want to set some
key for the oracle role, you create transaction with:
```
$ neo-go contract invokefunction -w .docker/wallets/wallet1.json --out some.part.json -a NVTiAjNgagDkTr5HTzDmQP9kPwPHN5BgVq -r http://localhost:30333 0x49cf4e5378ffcd4dec034fd98a174c5491e395e2 designateAsRole 8 \[ 02b3622bf4017bdfe317c58aed5f4c753f206b7db896046fa7d774bbc4bf7f8dc2 \] -- NVTiAjNgagDkTr5HTzDmQP9kPwPHN5BgVq:CalledByEntry
```
And then sign it with two more keys:
```
$ neo-go wallet sign -w .docker/wallets/wallet2.json --in some.part.json --out some.part.json -a NVTiAjNgagDkTr5HTzDmQP9kPwPHN5BgVq
$ neo-go wallet sign -w .docker/wallets/wallet3.json --in some.part.json -r http://localhost:30333 -a NVTiAjNgagDkTr5HTzDmQP9kPwPHN5BgVq
```
Notice that the last command sends the transaction (which has a complete set
of singatures for 3/4 multisignature account by that time) to the network.
#### Offline signing
You want to do a transfer from a single-key account, but the key is on a
different (offline) machine. Create a stripped wallet first on the key-holding
machine:
```
$ cp wallet.json wallet.stripped.json # don't lose the original wallet
$ neo-go wallet strip-keys --wallet wallet.stripped.json
```
This wallet has no keys inside (but has appropriate scripts/addresses), so it
can be safely shared with anyone or transferred to network-enabled machine
where you then can create a transfer transaction:
```
$ neo-go wallet nep17 transfer --rpc-endpoint http://localhost:20332 \
--wallet wallet.stripped.json --from NjEQfanGEXihz85eTnacQuhqhNnA6LxpLp \
--to Nj91C8TxQSxW1jCE1ytFre6mg5qxTypg1Y --token NEO --amount 1 --out context.json
```
`context.json` can now be transferred to the machine with the `wallet.json`
containing proper keys and signed:
```
$ neo-go wallet sign --wallet wallet.json \
-address NjEQfanGEXihz85eTnacQuhqhNnA6LxpLp --in context.json --out context.json
```
Now `context.json` contains a transaction with a complete set of signatures
(just one in this case, but of course you can do multisignature collection as
well). It can be transferred to network-enabled machine again and the
transaction can be sent to the network:
```
$ neo-go util sendtx --rpc-endpoint http://localhost:20332 context.json
```
### NEP-17 token functions
`wallet nep17` contains a set of commands to use for NEP-17 tokens.
@ -605,6 +666,19 @@ INDEX OPCODE PARAMETER
It always outputs the basic data and also can perform test-invocation if an
RPC endpoint is given to it.
### Sending signed transaction to the network
If you have a completely finished (with all signatures collected) transaction
signing context saved in a file you can send it to the network (without any
wallet) using `util sendtx` command:
```
$ ./bin/neo-go util sendtx -r http://localhost:30333 some.part.json
```
This is useful in offline signing scenario, where the signing party doesn't
have any network access, so you can make a signature there, transfer the file
to another machine that has network access and then push the transaction out
to the network.
## VM CLI
There is a VM CLI that you can use to load/analyze/run/step through some code:

View file

@ -56,6 +56,24 @@ func NewParameterContext(typ string, network netmode.Magic, verif crypto.Verifia
}
}
func (c *ParameterContext) GetCompleteTransaction() (*transaction.Transaction, error) {
tx, ok := c.Verifiable.(*transaction.Transaction)
if !ok {
return nil, errors.New("verifiable item is not a transaction")
}
if len(tx.Scripts) > 0 {
tx.Scripts = tx.Scripts[:0]
}
for i := range tx.Signers {
w, err := c.GetWitness(tx.Signers[i].Account)
if err != nil {
return nil, fmt.Errorf("can't create witness for signer #%d: %w", i, err)
}
tx.Scripts = append(tx.Scripts, *w)
}
return tx, nil
}
// GetWitness returns invocation and verification scripts for the specified contract.
func (c *ParameterContext) GetWitness(h util.Uint160) (*transaction.Witness, error) {
item, ok := c.Items[h]

View file

@ -19,11 +19,17 @@ import (
"github.com/stretchr/testify/require"
)
type verifStub struct{}
func (v verifStub) Hash() util.Uint256 { return util.Uint256{1, 2, 3} }
func (v verifStub) EncodeHashableFields() ([]byte, error) { return []byte{1}, nil }
func (v verifStub) DecodeHashableFields([]byte) error { return nil }
func TestParameterContext_AddSignatureSimpleContract(t *testing.T) {
tx := getContractTx()
priv, err := keys.NewPrivateKey()
require.NoError(t, err)
pub := priv.PublicKey()
tx := getContractTx(pub.GetScriptHash())
sig := priv.SignHashable(uint32(netmode.UnitTestNet), tx)
t.Run("invalid contract", func(t *testing.T) {
@ -75,9 +81,13 @@ func TestParameterContext_AddSignatureSimpleContract(t *testing.T) {
})
}
func TestGetCompleteTransactionForNonTx(t *testing.T) {
c := NewParameterContext("Neo.Network.P2P.Payloads.Block", netmode.UnitTestNet, verifStub{})
_, err := c.GetCompleteTransaction()
require.Error(t, err)
}
func TestParameterContext_AddSignatureMultisig(t *testing.T) {
tx := getContractTx()
c := NewParameterContext("Neo.Network.P2P.Payloads.Transaction", netmode.UnitTestNet, tx)
privs, pubs := getPrivateKeys(t, 4)
pubsCopy := keys.PublicKeys(pubs).Copy()
script, err := smartcontract.CreateMultiSigRedeemScript(3, pubsCopy)
@ -91,6 +101,8 @@ func TestParameterContext_AddSignatureMultisig(t *testing.T) {
newParam(smartcontract.SignatureType, "parameter2"),
},
}
tx := getContractTx(ctr.ScriptHash())
c := NewParameterContext("Neo.Network.P2P.Payloads.Transaction", netmode.UnitTestNet, tx)
priv, err := keys.NewPrivateKey()
require.NoError(t, err)
sig := priv.SignHashable(uint32(c.Network), tx)
@ -98,6 +110,10 @@ func TestParameterContext_AddSignatureMultisig(t *testing.T) {
indices := []int{2, 3, 0, 1} // random order
testSigWit := func(t *testing.T, num int) {
t.Run("GetCompleteTransaction, bad", func(t *testing.T) {
_, err := c.GetCompleteTransaction()
require.Error(t, err)
})
for _, i := range indices[:num] {
sig := privs[i].SignHashable(uint32(c.Network), tx)
require.NoError(t, c.AddSignature(ctr.ScriptHash(), ctr, pubs[i], sig))
@ -116,6 +132,17 @@ func TestParameterContext_AddSignatureMultisig(t *testing.T) {
require.Equal(t, 1, v.Estack().Len())
require.Equal(t, true, v.Estack().Pop().Value())
})
t.Run("GetCompleteTransaction, good", func(t *testing.T) {
tx, err := c.GetCompleteTransaction()
require.NoError(t, err)
require.Equal(t, 1, len(tx.Scripts))
scripts1 := make([]transaction.Witness, len(tx.Scripts))
copy(scripts1, tx.Scripts)
// Doing it twice shouldn't be a problem.
tx, err = c.GetCompleteTransaction()
require.NoError(t, err)
require.Equal(t, scripts1, tx.Scripts)
})
}
t.Run("exact number of sigs", func(t *testing.T) {
testSigWit(t, 3)
@ -143,7 +170,7 @@ func TestParameterContext_MarshalJSON(t *testing.T) {
priv, err := keys.NewPrivateKey()
require.NoError(t, err)
tx := getContractTx()
tx := getContractTx(priv.GetScriptHash())
sign := priv.SignHashable(uint32(netmode.UnitTestNet), tx)
expected := &ParameterContext{
@ -232,11 +259,11 @@ func newParam(typ smartcontract.ParamType, name string) wallet.ContractParam {
}
}
func getContractTx() *transaction.Transaction {
func getContractTx(signer util.Uint160) *transaction.Transaction {
tx := transaction.New([]byte{byte(opcode.PUSH1)}, 0)
tx.Attributes = make([]transaction.Attribute, 0)
tx.Scripts = make([]transaction.Witness, 0)
tx.Signers = []transaction.Signer{{Account: util.Uint160{1, 2, 3}}}
tx.Signers = []transaction.Signer{{Account: signer}}
tx.Hash()
return tx
}