From e63b25d5adad4a72d3458a4f0c06b3c5895bd5de Mon Sep 17 00:00:00 2001 From: Roman Khimov Date: Wed, 27 Nov 2019 12:52:15 +0300 Subject: [PATCH] smartcontract: add user-facing testinvokefunction command With a very special syntax. --- cli/smartcontract/smart_contract.go | 122 +++++++++ pkg/smartcontract/param_context.go | 205 ++++++++++++++ pkg/smartcontract/param_context_test.go | 343 ++++++++++++++++++++++++ 3 files changed, 670 insertions(+) create mode 100644 pkg/smartcontract/param_context_test.go diff --git a/cli/smartcontract/smart_contract.go b/cli/smartcontract/smart_contract.go index c4b9ac7c9..accac7614 100644 --- a/cli/smartcontract/smart_contract.go +++ b/cli/smartcontract/smart_contract.go @@ -14,6 +14,7 @@ import ( "github.com/CityOfZion/neo-go/pkg/crypto/hash" "github.com/CityOfZion/neo-go/pkg/crypto/keys" "github.com/CityOfZion/neo-go/pkg/rpc" + "github.com/CityOfZion/neo-go/pkg/smartcontract" "github.com/CityOfZion/neo-go/pkg/util" "github.com/CityOfZion/neo-go/pkg/vm" "github.com/CityOfZion/neo-go/pkg/vm/compiler" @@ -27,6 +28,7 @@ var ( errNoInput = errors.New("no input file was found, specify an input file with the '--in or -i' flag") errNoConfFile = errors.New("no config file was found, specify a config file with the '--config' or '-c' flag") errNoWIF = errors.New("no WIF parameter found, specify it with the '--wif or -w' flag") + 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") ) @@ -95,6 +97,80 @@ func NewCommands() []cli.Command { }, }, }, + { + Name: "testinvokefunction", + Usage: "invoke deployed contract on the blockchain (test mode)", + UsageText: "neo-go contract testinvokefunction -e endpoint scripthash [method] [arguments...]", + Description: `Executes given (as a script hash) deployed script with the given method and + arguments. If no method is given "" is passed to the script, if no arguments + are given, an empty array is passed. All of the given arguments are + encapsulated into array before invoking the script. The script thus should + follow the regular convention of smart contract arguments (method string and + an array of other arguments). + + Arguments always do have regular Neo smart contract parameter types, either + specified explicitly or being inferred from the value. To specify the type + manually use "type:value" syntax where the type is one of the following: + 'signature', 'bool', 'int', 'hash160', 'hash256', 'bytes', 'key' or 'string'. + Array types are not currently supported. + + Given values are type-checked against given types with the following + restrictions applied: + * 'signature' type values should be hex-encoded and have a (decoded) + length of 64 bytes. + * 'bool' type values are 'true' and 'false'. + * 'int' values are decimal integers that can be successfully converted + from the string. + * 'hash160' values are Neo addresses and hex-encoded 20-bytes long (after + decoding) strings. + * 'hash256' type values should be hex-encoded and have a (decoded) + length of 32 bytes. + * 'bytes' type values are any hex-encoded things. + * 'key' type values are hex-encoded marshalled public keys. + * 'string' type values are any valid UTF-8 strings. In the value's part of + the string the colon looses it's special meaning as a separator between + type and value and is taken literally. + + If no type is explicitly specified, it is inferred from the value using the + following logic: + - anything that can be interpreted as a decimal integer gets + an 'int' type + - 'true' and 'false' strings get 'bool' type + - valid Neo addresses and 20 bytes long hex-encoded strings get 'hash160' + type + - valid hex-encoded public keys get 'key' type + - 32 bytes long hex-encoded values get 'hash256' type + - 64 bytes long hex-encoded values get 'signature' type + - any other valid hex-encoded values get 'bytes' type + - anything else is a 'string' + + Backslash character is used as an escape character and allows to use colon in + an implicitly typed string. For any other characters it has no special + meaning, to get a literal backslash in the string use the '\\' sequence. + + Examples: + * 'int:42' is an integer with a value of 42 + * '42' is an integer with a value of 42 + * 'bad' is a string with a value of 'bad' + * 'dead' is a byte array with a value of 'dead' + * 'string:dead' is a string with a value of 'dead' + * 'AK2nJJpJr6o664CWJKi1QRXjqeic2zRp8y' is a hash160 with a value + of '23ba2703c53263e8d6e522dc32203339dcd8eee9' + * '\4\2' is an integer with a value of 42 + * '\\4\2' is a string with a value of '\42' + * 'string:string' is a string with a value of 'string' + * 'string\:string' is a string with a value of 'string:string' + * '03b209fd4f53a7170ea4444e0cb0a6bb6a53c2bd016926989cf85f9b0fba17a70c' is a + key with a value of '03b209fd4f53a7170ea4444e0cb0a6bb6a53c2bd016926989cf85f9b0fba17a70c' +`, + Action: testInvokeFunction, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "endpoint, e", + Usage: "RPC endpoint address (like 'http://seed4.ngd.network:20332')", + }, + }, + }, { Name: "testinvokescript", Usage: "Invoke compiled AVM code on the blockchain (test mode, not creating a transaction for it)", @@ -211,6 +287,52 @@ func contractCompile(ctx *cli.Context) error { return nil } +func testInvokeFunction(ctx *cli.Context) error { + endpoint := ctx.String("endpoint") + if len(endpoint) == 0 { + return cli.NewExitError(errNoEndpoint, 1) + } + + args := ctx.Args() + if !args.Present() { + return cli.NewExitError(errNoScriptHash, 1) + } + script := args[0] + operation := "" + if len(args) > 1 { + operation = args[1] + } + params := make([]smartcontract.Parameter, 0) + if len(args) > 2 { + for k, s := range args[2:] { + param, err := smartcontract.NewParameterFromString(s) + if err != nil { + return cli.NewExitError(fmt.Errorf("failed to parse argument #%d: %v", k+2+1, err), 1) + } + params = append(params, *param) + } + } + + client, err := rpc.NewClient(context.TODO(), endpoint, rpc.ClientOptions{}) + if err != nil { + return cli.NewExitError(err, 1) + } + + resp, err := client.InvokeFunction(script, operation, params) + if err != nil { + return cli.NewExitError(err, 1) + } + + b, err := json.MarshalIndent(resp.Result, "", " ") + if err != nil { + return cli.NewExitError(err, 1) + } + + fmt.Println(string(b)) + + return nil +} + func testInvokeScript(ctx *cli.Context) error { src := ctx.String("in") if len(src) == 0 { diff --git a/pkg/smartcontract/param_context.go b/pkg/smartcontract/param_context.go index 98d32a1f9..ad509f17d 100644 --- a/pkg/smartcontract/param_context.go +++ b/pkg/smartcontract/param_context.go @@ -1,6 +1,14 @@ package smartcontract import ( + "encoding/hex" + "errors" + "strconv" + "strings" + "unicode/utf8" + + "github.com/CityOfZion/neo-go/pkg/crypto" + "github.com/CityOfZion/neo-go/pkg/crypto/keys" "github.com/CityOfZion/neo-go/pkg/io" "github.com/CityOfZion/neo-go/pkg/util" ) @@ -89,6 +97,203 @@ func NewParameter(t ParamType) Parameter { } } +// parseParamType is a user-friendly string to ParamType converter, it's +// case-insensitive and makes the following conversions: +// signature -> SignatureType +// bool -> BoolType +// int -> IntegerType +// hash160 -> Hash160Type +// hash256 -> Hash256Type +// bytes -> ByteArrayType +// key -> PublicKeyType +// string -> StringType +// anything else generates an error. +func parseParamType(typ string) (ParamType, error) { + switch strings.ToLower(typ) { + case "signature": + return SignatureType, nil + case "bool": + return BoolType, nil + case "int": + return IntegerType, nil + case "hash160": + return Hash160Type, nil + case "hash256": + return Hash256Type, nil + case "bytes": + return ByteArrayType, nil + case "key": + return PublicKeyType, nil + case "string": + return StringType, nil + default: + // We deliberately don't support array here. + return 0, errors.New("wrong or unsupported parameter type") + } +} + +// adjustValToType is a value type-checker and converter. +func adjustValToType(typ ParamType, val string) (interface{}, error) { + switch typ { + case SignatureType: + b, err := hex.DecodeString(val) + if err != nil { + return nil, err + } + if len(b) != 64 { + return nil, errors.New("not a signature") + } + return val, nil + case BoolType: + switch val { + case "true": + return true, nil + case "false": + return false, nil + default: + return nil, errors.New("invalid boolean value") + } + case IntegerType: + return strconv.Atoi(val) + case Hash160Type: + u, err := crypto.Uint160DecodeAddress(val) + if err == nil { + return hex.EncodeToString(u.Bytes()), nil + } + b, err := hex.DecodeString(val) + if err != nil { + return nil, err + } + if len(b) != 20 { + return nil, errors.New("not a hash160") + } + return val, nil + case Hash256Type: + b, err := hex.DecodeString(val) + if err != nil { + return nil, err + } + if len(b) != 32 { + return nil, errors.New("not a hash256") + } + return val, nil + case ByteArrayType: + _, err := hex.DecodeString(val) + if err != nil { + return nil, err + } + return val, nil + case PublicKeyType: + _, err := keys.NewPublicKeyFromString(val) + if err != nil { + return nil, err + } + return val, nil + case StringType: + return val, nil + default: + return nil, errors.New("unsupported parameter type") + } +} + +// inferParamType tries to infer the value type from its contents. It returns +// IntegerType for anything that looks like decimal integer (can be converted +// with strconv.Atoi), BoolType for true and false values, Hash160Type for +// addresses and hex strings encoding 20 bytes long values, PublicKeyType for +// valid hex-encoded public keys, Hash256Type for hex-encoded 32 bytes values, +// SignatureType for hex-encoded 64 bytes values, ByteArrayType for any other +// valid hex-encoded values and StringType for anything else. +func inferParamType(val string) ParamType { + var err error + + _, err = strconv.Atoi(val) + if err == nil { + return IntegerType + } + + if val == "true" || val == "false" { + return BoolType + } + + _, err = crypto.Uint160DecodeAddress(val) + if err == nil { + return Hash160Type + } + + _, err = keys.NewPublicKeyFromString(val) + if err == nil { + return PublicKeyType + } + + unhexed, err := hex.DecodeString(val) + if err == nil { + switch len(unhexed) { + case 20: + return Hash160Type + case 32: + return Hash256Type + case 64: + return SignatureType + default: + return ByteArrayType + } + } + // Anything can be a string. + return StringType +} + +// NewParameterFromString returns a new Parameter initialized from the given +// string in neo-go-specific format. It is intended to be used in user-facing +// interfaces and has some heuristics in it to simplify parameter passing. Exact +// syntax is documented in the cli documentation. +func NewParameterFromString(in string) (*Parameter, error) { + var ( + char rune + val string + err error + r *strings.Reader + buf strings.Builder + escaped bool + hadType bool + res = &Parameter{} + ) + r = strings.NewReader(in) + for char, _, err = r.ReadRune(); err == nil && char != utf8.RuneError; char, _, err = r.ReadRune() { + if char == '\\' && !escaped { + escaped = true + continue + } + if char == ':' && !escaped && !hadType { + typStr := buf.String() + res.Type, err = parseParamType(typStr) + if err != nil { + return nil, err + } + buf.Reset() + hadType = true + continue + } + escaped = false + // We don't care about length and it never fails. + _, _ = buf.WriteRune(char) + } + if char == utf8.RuneError { + return nil, errors.New("bad UTF-8 string") + } + // The only other error `ReadRune` returns is io.EOF, which is fine and + // expected, so we don't check err here. + + val = buf.String() + if !hadType { + res.Type = inferParamType(val) + } + res.Value, err = adjustValToType(res.Type, val) + if err != nil { + return nil, err + } + return res, nil +} + // ContextItem represents a transaction context item. type ContextItem struct { Script util.Uint160 diff --git a/pkg/smartcontract/param_context_test.go b/pkg/smartcontract/param_context_test.go new file mode 100644 index 000000000..1f95d3128 --- /dev/null +++ b/pkg/smartcontract/param_context_test.go @@ -0,0 +1,343 @@ +package smartcontract + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseParamType(t *testing.T) { + var inouts = []struct { + in string + out ParamType + err bool + }{{ + in: "signature", + out: SignatureType, + }, { + in: "Signature", + out: SignatureType, + }, { + in: "SiGnAtUrE", + out: SignatureType, + }, { + in: "bool", + out: BoolType, + }, { + in: "int", + out: IntegerType, + }, { + in: "hash160", + out: Hash160Type, + }, { + in: "hash256", + out: Hash256Type, + }, { + in: "bytes", + out: ByteArrayType, + }, { + in: "key", + out: PublicKeyType, + }, { + in: "string", + out: StringType, + }, { + in: "array", + err: true, + }, { + in: "qwerty", + err: true, + }} + for _, inout := range inouts { + out, err := parseParamType(inout.in) + if inout.err { + assert.NotNil(t, err, "should error on '%s' input", inout.in) + } else { + assert.Nil(t, err, "shouldn't error on '%s' input", inout.in) + assert.Equal(t, inout.out, out, "bad output for '%s' input", inout.in) + } + } +} + +func TestInferParamType(t *testing.T) { + var inouts = []struct { + in string + out ParamType + }{{ + in: "42", + out: IntegerType, + }, { + in: "-42", + out: IntegerType, + }, { + in: "0", + out: IntegerType, + }, { + in: "2e10", + out: ByteArrayType, + }, { + in: "true", + out: BoolType, + }, { + in: "false", + out: BoolType, + }, { + in: "truee", + out: StringType, + }, { + in: "AK2nJJpJr6o664CWJKi1QRXjqeic2zRp8y", + out: Hash160Type, + }, { + in: "ZK2nJJpJr6o664CWJKi1QRXjqeic2zRp8y", + out: StringType, + }, { + in: "50befd26fdf6e4d957c11e078b24ebce6291456f", + out: Hash160Type, + }, { + in: "03b209fd4f53a7170ea4444e0cb0a6bb6a53c2bd016926989cf85f9b0fba17a70c", + out: PublicKeyType, + }, { + in: "30b209fd4f53a7170ea4444e0cb0a6bb6a53c2bd016926989cf85f9b0fba17a70c", + out: ByteArrayType, + }, { + in: "602c79718b16e442de58778e148d0b1084e3b2dffd5de6b7b16cee7969282de7", + out: Hash256Type, + }, { + in: "602c79718b16e442de58778e148d0b1084e3b2dffd5de6b7b16cee7969282de7da", + out: ByteArrayType, + }, { + in: "602c79718b16e442de58778e148d0b1084e3b2dffd5de6b7b16cee7969282de7c56f33fc6ecfcd0c225c4ab356fee59390af8560be0e930faebe74a6daff7c9b", + out: SignatureType, + }, { + in: "qwerty", + out: StringType, + }, { + in: "ab", + out: ByteArrayType, + }, { + in: "az", + out: StringType, + }, { + in: "bad", + out: StringType, + }, { + in: "фыва", + out: StringType, + }, { + in: "dead", + out: ByteArrayType, + }} + for _, inout := range inouts { + out := inferParamType(inout.in) + assert.Equal(t, inout.out, out, "bad output for '%s' input", inout.in) + } +} + +func TestAdjustValToType(t *testing.T) { + var inouts = []struct { + typ ParamType + val string + out interface{} + err bool + }{{ + typ: SignatureType, + val: "602c79718b16e442de58778e148d0b1084e3b2dffd5de6b7b16cee7969282de7c56f33fc6ecfcd0c225c4ab356fee59390af8560be0e930faebe74a6daff7c9b", + out: "602c79718b16e442de58778e148d0b1084e3b2dffd5de6b7b16cee7969282de7c56f33fc6ecfcd0c225c4ab356fee59390af8560be0e930faebe74a6daff7c9b", + }, { + typ: SignatureType, + val: "602c79718b16e442de58778e148d0b1084e3b2dffd5de6b7b16cee7969282de7c56f33fc6ecfcd0c225c4ab356fee59390af8560be0e930faebe74a6daff7c", + err: true, + }, { + typ: SignatureType, + val: "qwerty", + err: true, + }, { + typ: BoolType, + val: "false", + out: false, + }, { + typ: BoolType, + val: "true", + out: true, + }, { + typ: BoolType, + val: "qwerty", + err: true, + }, { + typ: BoolType, + val: "42", + err: true, + }, { + typ: BoolType, + val: "0", + err: true, + }, { + typ: IntegerType, + val: "0", + out: 0, + }, { + typ: IntegerType, + val: "42", + out: 42, + }, { + typ: IntegerType, + val: "-42", + out: -42, + }, { + typ: IntegerType, + val: "q", + err: true, + }, { + typ: Hash160Type, + val: "AK2nJJpJr6o664CWJKi1QRXjqeic2zRp8y", + out: "23ba2703c53263e8d6e522dc32203339dcd8eee9", + }, { + typ: Hash160Type, + val: "50befd26fdf6e4d957c11e078b24ebce6291456f", + out: "50befd26fdf6e4d957c11e078b24ebce6291456f", + }, { + typ: Hash160Type, + val: "befd26fdf6e4d957c11e078b24ebce6291456f", + err: true, + }, { + typ: Hash160Type, + val: "q", + err: true, + }, { + typ: Hash256Type, + val: "602c79718b16e442de58778e148d0b1084e3b2dffd5de6b7b16cee7969282de7", + out: "602c79718b16e442de58778e148d0b1084e3b2dffd5de6b7b16cee7969282de7", + }, { + typ: Hash256Type, + val: "602c79718b16e442de58778e148d0b1084e3b2dffd5de6b7b16cee7969282d", + err: true, + }, { + typ: Hash256Type, + val: "q", + err: true, + }, { + typ: ByteArrayType, + val: "602c79718b16e442de58778e148d0b1084e3b2dffd5de6b7b16cee7969282d", + out: "602c79718b16e442de58778e148d0b1084e3b2dffd5de6b7b16cee7969282d", + }, { + typ: ByteArrayType, + val: "602c79718b16e442de58778e148d0b1084e3b2dffd5de6b7b16cee7969282de7", + out: "602c79718b16e442de58778e148d0b1084e3b2dffd5de6b7b16cee7969282de7", + }, { + typ: ByteArrayType, + val: "50befd26fdf6e4d957c11e078b24ebce6291456f", + out: "50befd26fdf6e4d957c11e078b24ebce6291456f", + }, { + typ: ByteArrayType, + val: "AK2nJJpJr6o664CWJKi1QRXjqeic2zRp8y", + err: true, + }, { + typ: ByteArrayType, + val: "q", + err: true, + }, { + typ: ByteArrayType, + val: "ab", + out: "ab", + }, { + typ: PublicKeyType, + val: "03b209fd4f53a7170ea4444e0cb0a6bb6a53c2bd016926989cf85f9b0fba17a70c", + out: "03b209fd4f53a7170ea4444e0cb0a6bb6a53c2bd016926989cf85f9b0fba17a70c", + }, { + typ: PublicKeyType, + val: "01b209fd4f53a7170ea4444e0cb0a6bb6a53c2bd016926989cf85f9b0fba17a70c", + err: true, + }, { + typ: PublicKeyType, + val: "q", + err: true, + }, { + typ: StringType, + val: "q", + out: "q", + }, { + typ: StringType, + val: "dead", + out: "dead", + }, { + typ: StringType, + val: "йцукен", + out: "йцукен", + }, { + typ: ArrayType, + val: "", + err: true, + }} + + for _, inout := range inouts { + out, err := adjustValToType(inout.typ, inout.val) + if inout.err { + assert.NotNil(t, err, "should error on '%s/%s' input", inout.typ, inout.val) + } else { + assert.Nil(t, err, "shouldn't error on '%s/%s' input", inout.typ, inout.val) + assert.Equal(t, inout.out, out, "bad output for '%s/%s' input", inout.typ, inout.val) + } + } +} + +func TestNewParameterFromString(t *testing.T) { + var inouts = []struct { + in string + out Parameter + err bool + }{{ + in: "qwerty", + out: Parameter{StringType, "qwerty"}, + }, { + in: "42", + out: Parameter{IntegerType, 42}, + }, { + in: "Hello, 世界", + out: Parameter{StringType, "Hello, 世界"}, + }, { + in: `\4\2`, + out: Parameter{IntegerType, 42}, + }, { + in: `\\4\2`, + out: Parameter{StringType, `\42`}, + }, { + in: `\\\4\2`, + out: Parameter{StringType, `\42`}, + }, { + in: "int:42", + out: Parameter{IntegerType, 42}, + }, { + in: "true", + out: Parameter{BoolType, true}, + }, { + in: "string:true", + out: Parameter{StringType, "true"}, + }, { + in: "\xfe\xff", + err: true, + }, { + in: `string\:true`, + out: Parameter{StringType, "string:true"}, + }, { + in: "string:true:true", + out: Parameter{StringType, "true:true"}, + }, { + in: `string\\:true`, + err: true, + }, { + in: `qwerty:asdf`, + err: true, + }, { + in: `bool:asdf`, + err: true, + }} + for _, inout := range inouts { + out, err := NewParameterFromString(inout.in) + if inout.err { + assert.NotNil(t, err, "should error on '%s' input", inout.in) + } else { + assert.Nil(t, err, "shouldn't error on '%s' input", inout.in) + assert.Equal(t, inout.out, *out, "bad output for '%s' input", inout.in) + } + } +}