diff --git a/cli/options/options.go b/cli/options/options.go index adafb6166..de7e5e24d 100644 --- a/cli/options/options.go +++ b/cli/options/options.go @@ -32,6 +32,15 @@ const DefaultTimeout = 10 * time.Second // check for flag presence in the context. const RPCEndpointFlag = "rpc-endpoint" +// Wallet is a set of flags used for wallet operations. +var Wallet = []cli.Flag{cli.StringFlag{ + Name: "wallet, w", + Usage: "wallet to use to get the key for transaction signing; conflicts with --wallet-config flag", +}, cli.StringFlag{ + Name: "wallet-config", + Usage: "path to wallet config to use to get the key for transaction signing; conflicts with --wallet flag"}, +} + // Network is a set of flags for choosing the network to operate on // (privnet/mainnet/testnet). var Network = []cli.Flag{ diff --git a/cli/smartcontract/manifest.go b/cli/smartcontract/manifest.go index 76c7fdc68..6c4e623a9 100644 --- a/cli/smartcontract/manifest.go +++ b/cli/smartcontract/manifest.go @@ -37,7 +37,7 @@ func manifestAddGroup(ctx *cli.Context) error { h := state.CreateContractHash(sender, nf.Checksum, m.Name) - gAcc, w, err := getAccFromContext(ctx) + gAcc, w, err := GetAccFromContext(ctx) if err != nil { return cli.NewExitError(fmt.Errorf("can't get account to sign group with: %w", err), 1) } diff --git a/cli/smartcontract/smart_contract.go b/cli/smartcontract/smart_contract.go index 38dea5f13..dc811fb1b 100644 --- a/cli/smartcontract/smart_contract.go +++ b/cli/smartcontract/smart_contract.go @@ -48,16 +48,7 @@ var ( errNoScriptHash = errors.New("no smart contract hash was provided, specify one as the first argument") errNoSmartContractName = errors.New("no name was provided, specify the '--name or -n' flag") errFileExist = errors.New("A file with given smart-contract name already exists") - - walletFlag = cli.StringFlag{ - Name: "wallet, w", - Usage: "wallet to use to get the key for transaction signing; conflicts with --wallet-config flag", - } - walletConfigFlag = cli.StringFlag{ - Name: "wallet-config", - Usage: "path to wallet config to use to get the key for transaction signing; conflicts with --wallet flag", - } - addressFlag = flags.AddressFlag{ + addressFlag = flags.AddressFlag{ Name: addressFlagName, Usage: "address to use as transaction signee (and gas source)", } @@ -100,14 +91,13 @@ func NewCommands() []cli.Command { testInvokeFunctionFlags := []cli.Flag{options.Historic} testInvokeFunctionFlags = append(testInvokeFunctionFlags, options.RPC...) invokeFunctionFlags := []cli.Flag{ - walletFlag, - walletConfigFlag, addressFlag, txctx.GasFlag, txctx.SysGasFlag, txctx.OutFlag, txctx.ForceFlag, } + invokeFunctionFlags = append(invokeFunctionFlags, options.Wallet...) invokeFunctionFlags = append(invokeFunctionFlags, options.RPC...) deployFlags := append(invokeFunctionFlags, []cli.Flag{ cli.StringFlag{ @@ -119,6 +109,24 @@ func NewCommands() []cli.Command { Usage: "Manifest input file (*.manifest.json)", }, }...) + manifestAddGroupFlags := append([]cli.Flag{ + cli.StringFlag{ + Name: "sender, s", + Usage: "deploy transaction sender", + }, + flags.AddressFlag{ + Name: addressFlagName, // use the same name for handler code unification. + Usage: "account to sign group with", + }, + cli.StringFlag{ + Name: "nef, n", + Usage: "path to the NEF file", + }, + cli.StringFlag{ + Name: "manifest, m", + Usage: "path to the manifest", + }, + }, options.Wallet...) return []cli.Command{{ Name: "contract", Usage: "compile - debug - deploy smart contracts", @@ -301,26 +309,7 @@ func NewCommands() []cli.Command { Usage: "adds group to the manifest", UsageText: "neo-go contract manifest add-group -w wallet [--wallet-config path] -n nef -m manifest -a address -s address", Action: manifestAddGroup, - Flags: []cli.Flag{ - walletFlag, - walletConfigFlag, - cli.StringFlag{ - Name: "sender, s", - Usage: "deploy transaction sender", - }, - flags.AddressFlag{ - Name: addressFlagName, // use the same name for handler code unification. - Usage: "account to sign group with", - }, - cli.StringFlag{ - Name: "nef, n", - Usage: "path to the NEF file", - }, - cli.StringFlag{ - Name: "manifest, m", - Usage: "path to the manifest", - }, - }, + Flags: manifestAddGroupFlags, }, }, }, @@ -581,7 +570,7 @@ func invokeInternal(ctx *cli.Context, signAndPush bool) error { w *wallet.Wallet ) if signAndPush { - acc, w, err = getAccFromContext(ctx) + acc, w, err = GetAccFromContext(ctx) if err != nil { return cli.NewExitError(err, 1) } @@ -757,7 +746,8 @@ func inspect(ctx *cli.Context) error { return nil } -func getAccFromContext(ctx *cli.Context) (*wallet.Account, *wallet.Wallet, error) { +// GetAccFromContext returns account and wallet from context. If address is not set, default address is used. +func GetAccFromContext(ctx *cli.Context) (*wallet.Account, *wallet.Wallet, error) { var addr util.Uint160 wPath := ctx.String("wallet") @@ -789,11 +779,13 @@ func getAccFromContext(ctx *cli.Context) (*wallet.Account, *wallet.Wallet, error addr = wall.GetChangeAddress() } - acc, err := getUnlockedAccount(wall, addr, pass) + acc, err := GetUnlockedAccount(wall, addr, pass) return acc, wall, err } -func getUnlockedAccount(wall *wallet.Wallet, addr util.Uint160, pass *string) (*wallet.Account, error) { +// GetUnlockedAccount returns account from wallet, address and uses pass to unlock specified account if given. +// If the password is not given, then it is requested from user. +func GetUnlockedAccount(wall *wallet.Wallet, addr util.Uint160, pass *string) (*wallet.Account, error) { acc := wall.GetAccount(addr) if acc == nil { return nil, fmt.Errorf("wallet contains no account for '%s'", address.Uint160ToString(addr)) @@ -844,7 +836,7 @@ func contractDeploy(ctx *cli.Context) error { appCallParams = append(appCallParams, data[0]) } - acc, w, err := getAccFromContext(ctx) + acc, w, err := GetAccFromContext(ctx) if err != nil { return cli.NewExitError(fmt.Errorf("can't get sender address: %w", err), 1) } diff --git a/cli/util/cancel.go b/cli/util/cancel.go new file mode 100644 index 000000000..4dd5e3ef8 --- /dev/null +++ b/cli/util/cancel.go @@ -0,0 +1,74 @@ +package util + +import ( + "fmt" + "strings" + + "github.com/nspcc-dev/neo-go/cli/flags" + "github.com/nspcc-dev/neo-go/cli/options" + "github.com/nspcc-dev/neo-go/cli/smartcontract" + "github.com/nspcc-dev/neo-go/pkg/core/transaction" + "github.com/nspcc-dev/neo-go/pkg/neorpc/result" + "github.com/nspcc-dev/neo-go/pkg/rpcclient/actor" + "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm/opcode" + "github.com/urfave/cli" +) + +func cancelTx(ctx *cli.Context) error { + args := ctx.Args() + if len(args) == 0 { + return cli.NewExitError("transaction hash is missing", 1) + } else if len(args) > 1 { + return cli.NewExitError("only one transaction hash is accepted", 1) + } + + txHash, err := util.Uint256DecodeStringLE(strings.TrimPrefix(args[0], "0x")) + if err != nil { + return cli.NewExitError(fmt.Sprintf("invalid tx hash: %s", args[0]), 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) + } + + mainTx, _ := c.GetRawTransactionVerbose(txHash) + if mainTx != nil && !mainTx.Blockhash.Equals(util.Uint256{}) { + return cli.NewExitError(fmt.Errorf("transaction %s is already accepted at block %s", txHash, mainTx.Blockhash.StringLE()), 1) + } + acc, w, err := smartcontract.GetAccFromContext(ctx) + if err != nil { + return cli.NewExitError(fmt.Errorf("failed to get account from context to sign the conflicting transaction: %w", err), 1) + } + defer w.Close() + + if mainTx != nil && !mainTx.HasSigner(acc.ScriptHash()) { + return cli.NewExitError(fmt.Errorf("account %s is not a signer of the conflicting transaction", acc.Address), 1) + } + + a, err := actor.NewSimple(c, acc) + if err != nil { + return cli.NewExitError(fmt.Errorf("failed to create Actor: %w", err), 1) + } + + resHash, _, err := a.SendTunedRun([]byte{byte(opcode.RET)}, []transaction.Attribute{{Type: transaction.ConflictsT, Value: &transaction.Conflicts{Hash: txHash}}}, func(r *result.Invoke, t *transaction.Transaction) error { + err := actor.DefaultCheckerModifier(r, t) + if err != nil { + return err + } + if mainTx != nil && t.NetworkFee < mainTx.NetworkFee+1 { + t.NetworkFee = mainTx.NetworkFee + 1 + } + t.NetworkFee += int64(flags.Fixed8FromContext(ctx, "gas")) + return nil + }) + if err != nil { + return cli.NewExitError(fmt.Errorf("failed to send conflicting transaction: %w", err), 1) + } + fmt.Fprintln(ctx.App.Writer, resHash.StringLE()) + return nil +} diff --git a/cli/util/convert.go b/cli/util/convert.go index 71a1c9547..002711b52 100644 --- a/cli/util/convert.go +++ b/cli/util/convert.go @@ -6,7 +6,9 @@ import ( "fmt" "os" + "github.com/nspcc-dev/neo-go/cli/flags" "github.com/nspcc-dev/neo-go/cli/options" + "github.com/nspcc-dev/neo-go/cli/txctx" vmcli "github.com/nspcc-dev/neo-go/cli/vm" "github.com/nspcc-dev/neo-go/pkg/vm" "github.com/urfave/cli" @@ -15,6 +17,14 @@ import ( // NewCommands returns util commands for neo-go CLI. func NewCommands() []cli.Command { txDumpFlags := append([]cli.Flag{}, options.RPC...) + txCancelFlags := append([]cli.Flag{ + flags.AddressFlag{ + Name: "address, a", + Usage: "address to use as conflicting transaction signee (and gas source)", + }, + txctx.GasFlag, + }, options.RPC...) + txCancelFlags = append(txCancelFlags, options.Wallet...) return []cli.Command{ { Name: "util", @@ -41,6 +51,24 @@ func NewCommands() []cli.Command { Action: sendTx, Flags: txDumpFlags, }, + { + Name: "canceltx", + Usage: "Cancel transaction by sending conflicting transaction", + UsageText: "canceltx -r --wallet [--account ] [--wallet-config ] [--gas ]", + Description: `Aims to prevent a transaction from being added to the blockchain by dispatching a more +prioritized conflicting transaction to the specified RPC node. The input for this command should +be the transaction hash. If another account is not specified, the conflicting transaction is +automatically generated and signed by the default account in the wallet. If the target transaction +is in the memory pool of the provided RPC node, the NetworkFee value of the conflicting transaction +is set to the target transaction's NetworkFee value plus one (if it's sufficient for the +conflicting transaction itself). If the target transaction is not in the memory pool, standard +NetworkFee calculations are performed based on the calculatenetworkfee RPC request. If the --gas +flag is included, the specified value is added to the resulting conflicting transaction network fee +in both scenarios. +`, + Action: cancelTx, + Flags: txCancelFlags, + }, { Name: "txdump", Usage: "Dump transaction stored in file", diff --git a/cli/util/util_test.go b/cli/util/util_test.go index 609937fc9..33b3ade74 100644 --- a/cli/util/util_test.go +++ b/cli/util/util_test.go @@ -4,9 +4,12 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/nspcc-dev/neo-go/internal/testcli" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/trigger" "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/wallet" "github.com/stretchr/testify/require" ) @@ -63,3 +66,73 @@ func TestUtilOps(t *testing.T) { e.Run(t, "neo-go", "util", "ops", "--hex", "--in", tmp) // hex from file check(t) } + +func TestUtilCancelTx(t *testing.T) { + e := testcli.NewExecutorSuspended(t) + + w, err := wallet.NewWalletFromFile("../testdata/testwallet.json") + require.NoError(t, err) + + transferArgs := []string{ + "neo-go", "wallet", "nep17", "transfer", + "--rpc-endpoint", "http://" + e.RPC.Addresses()[0], + "--wallet", testcli.ValidatorWallet, + "--to", w.Accounts[0].Address, + "--token", "NEO", + "--from", testcli.ValidatorAddr, + "--force", + } + args := []string{"neo-go", "util", "canceltx", + "-r", "http://" + e.RPC.Addresses()[0], + "--wallet", testcli.ValidatorWallet, + "--address", testcli.ValidatorAddr} + + e.In.WriteString("one\r") + e.Run(t, append(transferArgs, "--amount", "1")...) + line := e.GetNextLine(t) + txHash, err := util.Uint256DecodeStringLE(line) + require.NoError(t, err) + + _, ok := e.Chain.GetMemPool().TryGetValue(txHash) + require.True(t, ok) + + t.Run("invalid", func(t *testing.T) { + t.Run("missing tx argument", func(t *testing.T) { + e.RunWithError(t, args...) + }) + t.Run("excessive arguments", func(t *testing.T) { + e.RunWithError(t, append(args, txHash.StringLE(), txHash.StringLE())...) + }) + t.Run("invalid hash", func(t *testing.T) { + e.RunWithError(t, append(args, "notahash")...) + }) + t.Run("not signed by main signer", func(t *testing.T) { + e.In.WriteString("one\r") + e.RunWithError(t, "neo-go", "util", "canceltx", + "-r", "http://"+e.RPC.Addresses()[0], + "--wallet", testcli.ValidatorWallet, + "--address", testcli.MultisigAddr, txHash.StringLE()) + }) + t.Run("wrong rpc endpoint", func(t *testing.T) { + e.In.WriteString("one\r") + e.RunWithError(t, "neo-go", "util", "canceltx", + "-r", "http://localhost:20331", + "--wallet", testcli.ValidatorWallet, txHash.StringLE()) + }) + }) + + e.In.WriteString("one\r") + e.Run(t, append(args, txHash.StringLE())...) + resHash, err := util.Uint256DecodeStringLE(e.GetNextLine(t)) + require.NoError(t, err) + + _, _, err = e.Chain.GetTransaction(resHash) + require.NoError(t, err) + e.CheckEOF(t) + go e.Chain.Run() + + require.Eventually(t, func() bool { + _, aerErr := e.Chain.GetAppExecResults(resHash, trigger.Application) + return aerErr == nil + }, time.Second*2, time.Millisecond*50) +} diff --git a/docs/rpc.md b/docs/rpc.md index 84f401538..84a135323 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -214,7 +214,7 @@ and we're not accepting issues related to them. | Method | Reason | | ------- | ------------| -| `canceltransaction` | Doesn't fit neo-go wallet model | +| `canceltransaction` | Doesn't fit neo-go wallet model, use CLI to do that (`neo-go util canceltx`) | | `closewallet` | Doesn't fit neo-go wallet model | | `dumpprivkey` | Shouldn't exist for security reasons, see `closewallet` comment also | | `getnewaddress` | See `closewallet` comment, use CLI to do that |