From cbd20eb95926808a57ff1542861b21cd7977fdff Mon Sep 17 00:00:00 2001 From: Anna Shaleva Date: Wed, 15 Jun 2022 21:23:29 +0300 Subject: [PATCH] rpc: implement iterator sessions --- config/protocol.privnet.yml | 2 + config/protocol.unit_testnet.yml | 2 + docs/node-configuration.md | 12 + docs/rpc.md | 2 + go.mod | 1 + internal/basicchain/basic.go | 5 + .../testdata/storage/storage_contract.go | 34 +++ .../testdata/storage/storage_contract.yml | 2 + pkg/config/config.go | 9 +- pkg/core/interop/iterator/interop.go | 21 +- pkg/core/statesync/neotest_test.go | 7 +- pkg/rpc/client/doc.go | 2 + pkg/rpc/client/helper.go | 95 ++++++- pkg/rpc/client/native.go | 2 +- pkg/rpc/client/nep11.go | 6 +- pkg/rpc/client/rpc.go | 51 ++++ pkg/rpc/request/param.go | 14 + pkg/rpc/request/param_test.go | 22 ++ pkg/rpc/response/result/invoke.go | 151 ++++++++--- pkg/rpc/rpc_config.go | 2 + pkg/rpc/server/client_test.go | 241 +++++++++++++++++- pkg/rpc/server/server.go | 157 +++++++++++- pkg/rpc/server/server_helper_test.go | 60 +++-- pkg/rpc/server/server_test.go | 165 ++++++++++-- pkg/rpc/server/testdata/testblocks.acc | Bin 33557 -> 34867 bytes 25 files changed, 956 insertions(+), 109 deletions(-) create mode 100644 internal/basicchain/testdata/storage/storage_contract.go create mode 100644 internal/basicchain/testdata/storage/storage_contract.yml diff --git a/config/protocol.privnet.yml b/config/protocol.privnet.yml index 8a9651197..17970f160 100644 --- a/config/protocol.privnet.yml +++ b/config/protocol.privnet.yml @@ -59,6 +59,8 @@ ApplicationConfiguration: MaxGasInvoke: 15 EnableCORSWorkaround: false Port: 20331 + SessionEnabled: true + SessionExpirationTime: 180 # higher expiration time for manual requests and tests. TLSConfig: Enabled: false Port: 20330 diff --git a/config/protocol.unit_testnet.yml b/config/protocol.unit_testnet.yml index 8d865bd51..345d292e5 100644 --- a/config/protocol.unit_testnet.yml +++ b/config/protocol.unit_testnet.yml @@ -63,6 +63,8 @@ ApplicationConfiguration: MaxGasInvoke: 15 Enabled: true EnableCORSWorkaround: false + SessionEnabled: true + SessionExpirationTime: 2 # enough for tests as they run locally. Port: 0 # let the system choose port dynamically Prometheus: Enabled: false #since it's not useful for unit tests. diff --git a/docs/node-configuration.md b/docs/node-configuration.md index 9659b2744..745b4b3d6 100644 --- a/docs/node-configuration.md +++ b/docs/node-configuration.md @@ -136,6 +136,8 @@ RPC: MaxFindResultItems: 100 MaxNEP11Tokens: 100 Port: 10332 + SessionEnabled: false + SessionExpirationTime: 60 StartWhenSynchronized: false TLSConfig: Address: "" @@ -159,6 +161,16 @@ where: - `MaxNEP11Tokens` - limit for the number of tokens returned from `getnep11balances` call. - `Port` is an RPC server port it should be bound to. +- `SessionEnabled` denotes whether session-based iterator JSON-RPC API is enabled. + If true, then all iterators got from `invoke*` calls will be stored as sessions + on the server side available for further traverse. `traverseiterator` and + `terminatesession` JSON-RPC calls will be handled by the server. It is not + recommended to enable this setting for public RPC servers due to possible DoS + attack. Set to `false` by default. If `false`, iterators are expanded into a + set of values (see `MaxIteratorResultItems` setting). +- `SessionExpirationTime` is a lifetime of iterator session in seconds. It is set + to `60` seconds by default and is relevant only if `SessionEnabled` is set to + `true`. - `StartWhenSynchronized` controls when RPC server will be started, by default (`false` setting) it's started immediately and RPC is availabe during node synchronization. Setting it to `true` will make the node start RPC service only diff --git a/docs/rpc.md b/docs/rpc.md index 963be87a6..7e32d3bb0 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -72,6 +72,8 @@ which would yield the response: | `sendrawtransaction` | | `submitblock` | | `submitoracleresponse` | +| `terminatesession` | +| `traverseiterator` | | `validateaddress` | | `verifyproof` | diff --git a/go.mod b/go.mod index 0b199c4a2..37c8de3e8 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ require ( github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e github.com/coreos/go-semver v0.3.0 github.com/davecgh/go-spew v1.1.1 + github.com/google/uuid v1.2.0 github.com/gorilla/websocket v1.4.2 github.com/hashicorp/golang-lru v0.5.4 github.com/holiman/uint256 v1.2.0 diff --git a/internal/basicchain/basic.go b/internal/basicchain/basic.go index caf56bf36..b617827d3 100644 --- a/internal/basicchain/basic.go +++ b/internal/basicchain/basic.go @@ -218,6 +218,11 @@ func Init(t *testing.T, rootpath string, e *neotest.Executor) { // Block #21: transfer 0.05 NFSO from priv1 back to priv0. nfsPriv1Invoker.Invoke(t, true, "transfer", priv1ScriptHash, priv0ScriptHash, 5, tokenID, nil) // block #21 + // Block #22: deploy storage_contract (Storage contract for `traverseiterator` and `terminatesession` RPC calls test). + storagePath := filepath.Join(testDataPrefix, "storage", "storage_contract.go") + storageCfg := filepath.Join(testDataPrefix, "storage", "storage_contract.yml") + _, _, _ = deployContractFromPriv0(t, storagePath, "Storage", storageCfg, 6) + // Compile contract to test `invokescript` RPC call invokePath := filepath.Join(testDataPrefix, "invoke", "invokescript_contract.go") invokeCfg := filepath.Join(testDataPrefix, "invoke", "invoke.yml") diff --git a/internal/basicchain/testdata/storage/storage_contract.go b/internal/basicchain/testdata/storage/storage_contract.go new file mode 100644 index 000000000..9cb875c64 --- /dev/null +++ b/internal/basicchain/testdata/storage/storage_contract.go @@ -0,0 +1,34 @@ +/* +Package storage contains contract that puts a set of values inside the storage on +deploy. The contract has a single method returning iterator over these values. +The contract is aimed to test iterator sessions RPC API. +*/ +package storage + +import ( + "github.com/nspcc-dev/neo-go/pkg/interop/iterator" + "github.com/nspcc-dev/neo-go/pkg/interop/storage" +) + +// valuesCount is the amount of stored values. +const valuesCount = 255 + +// valuesPrefix is the prefix values are stored by. +var valuesPrefix = []byte{0x01} + +func _deploy(data interface{}, isUpdate bool) { + if !isUpdate { + ctx := storage.GetContext() + for i := 0; i < valuesCount; i++ { + key := append(valuesPrefix, byte(i)) + storage.Put(ctx, key, i) + } + } + +} + +// IterateOverValues returns iterator over contract storage values stored during deploy. +func IterateOverValues() iterator.Iterator { + ctx := storage.GetContext() + return storage.Find(ctx, valuesPrefix, storage.ValuesOnly) +} diff --git a/internal/basicchain/testdata/storage/storage_contract.yml b/internal/basicchain/testdata/storage/storage_contract.yml new file mode 100644 index 000000000..5808eb650 --- /dev/null +++ b/internal/basicchain/testdata/storage/storage_contract.yml @@ -0,0 +1,2 @@ +name: "Storage" +sourceurl: https://github.com/nspcc-dev/neo-go/ diff --git a/pkg/config/config.go b/pkg/config/config.go index f0ee83cad..0e5ba81c4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -16,6 +16,12 @@ const ( UserAgentPrefix = "NEO-GO:" // UserAgentFormat is a formatted string used to generate user agent string. UserAgentFormat = UserAgentWrapper + UserAgentPrefix + "%s" + UserAgentWrapper + // DefaultMaxIteratorResultItems is the default upper bound of traversed + // iterator items per JSON-RPC response. + DefaultMaxIteratorResultItems = 100 + // DefaultSessionExpirationTime is the default session expiration time in + // seconds for iterator RPC-server session. + DefaultSessionExpirationTime = 60 ) // Version is the version of the node, set at the build time. @@ -56,9 +62,10 @@ func LoadFile(configPath string) (Config, error) { PingInterval: 30, PingTimeout: 90, RPC: rpc.Config{ - MaxIteratorResultItems: 100, + MaxIteratorResultItems: DefaultMaxIteratorResultItems, MaxFindResultItems: 100, MaxNEP11Tokens: 100, + SessionExpirationTime: DefaultSessionExpirationTime, }, }, } diff --git a/pkg/core/interop/iterator/interop.go b/pkg/core/interop/iterator/interop.go index 681d147a7..9860c95e2 100644 --- a/pkg/core/interop/iterator/interop.go +++ b/pkg/core/interop/iterator/interop.go @@ -36,14 +36,25 @@ func IsIterator(item stackitem.Item) bool { return ok } -// Values returns an array of up to `max` iterator values. The second -// return parameter denotes whether iterator is truncated. -func Values(item stackitem.Item, max int) ([]stackitem.Item, bool) { +// ValuesTruncated returns an array of up to `max` iterator values. The second +// return parameter denotes whether iterator is truncated, i.e. has more values. +// The provided iterator CAN NOT be reused in the subsequent calls to Values and +// to ValuesTruncated. +func ValuesTruncated(item stackitem.Item, max int) ([]stackitem.Item, bool) { + result := Values(item, max) + arr := item.Value().(iterator) + return result, arr.Next() +} + +// Values returns an array of up to `max` iterator values. The provided +// iterator can safely be reused to retrieve the rest of its values in the +// subsequent calls to Values and to ValuesTruncated. +func Values(item stackitem.Item, max int) []stackitem.Item { var result []stackitem.Item arr := item.Value().(iterator) - for arr.Next() && max > 0 { + for max > 0 && arr.Next() { result = append(result, arr.Value()) max-- } - return result, arr.Next() + return result } diff --git a/pkg/core/statesync/neotest_test.go b/pkg/core/statesync/neotest_test.go index a9dea0cf9..2a95e882d 100644 --- a/pkg/core/statesync/neotest_test.go +++ b/pkg/core/statesync/neotest_test.go @@ -285,7 +285,7 @@ func TestStateSyncModule_RestoreBasicChain(t *testing.T) { var ( stateSyncInterval = 4 maxTraceable uint32 = 6 - stateSyncPoint = 20 + stateSyncPoint = 24 ) spoutCfg := func(c *config.ProtocolConfiguration) { c.StateRootInHeader = true @@ -300,7 +300,10 @@ func TestStateSyncModule_RestoreBasicChain(t *testing.T) { e := neotest.NewExecutor(t, bcSpout, validators, committee) basicchain.Init(t, "../../../", e) - // make spout chain higher that latest state sync point (add several blocks up to stateSyncPoint+2) + // make spout chain higher than latest state sync point (add several blocks up to stateSyncPoint+2) + e.AddNewBlock(t) + e.AddNewBlock(t) + e.AddNewBlock(t) e.AddNewBlock(t) require.Equal(t, stateSyncPoint+2, int(bcSpout.BlockHeight())) diff --git a/pkg/rpc/client/doc.go b/pkg/rpc/client/doc.go index 45c23706c..e08927fdf 100644 --- a/pkg/rpc/client/doc.go +++ b/pkg/rpc/client/doc.go @@ -52,6 +52,8 @@ Supported methods sendrawtransaction submitblock submitoracleresponse + terminatesession + traverseiterator validateaddress Extensions: diff --git a/pkg/rpc/client/helper.go b/pkg/rpc/client/helper.go index 88c6eee2e..7c6d04e09 100644 --- a/pkg/rpc/client/helper.go +++ b/pkg/rpc/client/helper.go @@ -5,10 +5,17 @@ import ( "errors" "fmt" + "github.com/nspcc-dev/neo-go/pkg/core/interop/interopnames" + "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/io" "github.com/nspcc-dev/neo-go/pkg/rpc/client/nns" "github.com/nspcc-dev/neo-go/pkg/rpc/response/result" + "github.com/nspcc-dev/neo-go/pkg/smartcontract" + "github.com/nspcc-dev/neo-go/pkg/smartcontract/callflag" "github.com/nspcc-dev/neo-go/pkg/util" + "github.com/nspcc-dev/neo-go/pkg/vm/emit" + "github.com/nspcc-dev/neo-go/pkg/vm/opcode" "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" ) @@ -97,33 +104,97 @@ func topMapFromStack(st []stackitem.Item) (*stackitem.Map, error) { return st[index].(*stackitem.Map), nil } -// topIterableFromStack returns top list of elements of `resultItemType` type from the stack. +// InvokeAndPackIteratorResults creates a script containing System.Contract.Call +// of the specified contract with the specified arguments. It assumes that the +// specified operation will return iterator. The script traverses the resulting +// iterator, packs all its values into array and pushes the resulting array on +// stack. Constructed script is invoked via `invokescript` JSON-RPC API using +// the provided signers. The result of the script invocation contains single array +// stackitem on stack if invocation HALTed. InvokeAndPackIteratorResults can be +// used to interact with JSON-RPC server where iterator sessions are disabled to +// retrieve iterator values via single `invokescript` JSON-RPC call. +func (c *Client) InvokeAndPackIteratorResults(contract util.Uint160, operation string, params []smartcontract.Parameter, signers []transaction.Signer) (*result.Invoke, error) { + bytes, err := createIteratorUnwrapperScript(contract, operation, params) + if err != nil { + return nil, fmt.Errorf("failed to create iterator unwrapper script: %w", err) + } + return c.InvokeScript(bytes, signers) +} + +func createIteratorUnwrapperScript(contract util.Uint160, operation string, params []smartcontract.Parameter) ([]byte, error) { + script := io.NewBufBinWriter() + // Pack arguments for System.Contract.Call. + arr, err := smartcontract.ExpandParameterToEmitable(smartcontract.Parameter{ + Type: smartcontract.ArrayType, + Value: params, + }) + if err != nil { + return nil, fmt.Errorf("failed to expand parameters array to emitable: %w", err) + } + emit.Array(script.BinWriter, arr.([]interface{})...) + emit.AppCallNoArgs(script.BinWriter, contract, operation, callflag.All) // The System.Contract.Call itself, it will push Iterator on estack. + emit.Opcodes(script.BinWriter, opcode.NEWARRAY0) // Push new empty array to estack. This array will store iterator's elements. + + // Start the iterator traversal cycle. + iteratorTraverseCycleStartOffset := script.Len() + emit.Opcodes(script.BinWriter, opcode.OVER) // Load iterator from 1-st cell of estack. + emit.Syscall(script.BinWriter, interopnames.SystemIteratorNext) // Call System.Iterator.Next, it will pop the iterator from estack and push `true` or `false` to estack. + jmpIfNotOffset := script.Len() + emit.Instruction(script.BinWriter, opcode.JMPIFNOT, // Pop boolean value (from the previous step) from estack, if `false`, then iterator has no more items => jump to the end of program. + []byte{ + 0x00, // jump to loadResultOffset, but we'll fill this byte after script creation. + }) + emit.Opcodes(script.BinWriter, opcode.DUP, // Duplicate the resulting array from 0-th cell of estack and push it to estack. + opcode.PUSH2, opcode.PICK) // Pick iterator from the 2-nd cell of estack. + emit.Syscall(script.BinWriter, interopnames.SystemIteratorValue) // Call System.Iterator.Value, it will pop the iterator from estack and push its current value to estack. + emit.Opcodes(script.BinWriter, opcode.APPEND) // Pop iterator value and the resulting array from estack. Append value to the resulting array. Array is a reference type, thus, value stored at the 1-th cell of local slot will also be updated. + jmpOffset := script.Len() + emit.Instruction(script.BinWriter, opcode.JMP, // Jump to the start of iterator traverse cycle. + []byte{ + uint8(iteratorTraverseCycleStartOffset - jmpOffset), // jump to iteratorTraverseCycleStartOffset; offset is relative to JMP position. + }) + + // End of the program: push the result on stack and return. + loadResultOffset := script.Len() + emit.Opcodes(script.BinWriter, opcode.NIP) // Remove iterator from the 1-st cell of estack, so that only resulting array is left on estack. + if err := script.Err; err != nil { + return nil, fmt.Errorf("failed to build iterator unwrapper script: %w", err) + } + + // Fill in JMPIFNOT instruction parameter. + bytes := script.Bytes() + bytes[jmpIfNotOffset+1] = uint8(loadResultOffset - jmpIfNotOffset) // +1 is for JMPIFNOT itself; offset is relative to JMPIFNOT position. + return bytes, nil +} + +// topIterableFromStack returns the list of elements of `resultItemType` type from the top element +// of the provided stack. The top element is expected to be an Array, otherwise an error is returned. func topIterableFromStack(st []stackitem.Item, resultItemType interface{}) ([]interface{}, error) { - index := len(st) - 1 // top stack element is last in the array - if t := st[index].Type(); t != stackitem.InteropT { - return nil, fmt.Errorf("invalid return stackitem type: %s (InteropInterface expected)", t.String()) + index := len(st) - 1 // top stack element is the last in the array + if t := st[index].Type(); t != stackitem.ArrayT { + return nil, fmt.Errorf("invalid return stackitem type: %s (Array expected)", t.String()) } - iter, ok := st[index].Value().(result.Iterator) + items, ok := st[index].Value().([]stackitem.Item) if !ok { - return nil, fmt.Errorf("failed to deserialize iterable from interop stackitem: invalid value type (Array expected)") + return nil, fmt.Errorf("failed to deserialize iterable from Array stackitem: invalid value type (Array expected)") } - result := make([]interface{}, len(iter.Values)) - for i := range iter.Values { + result := make([]interface{}, len(items)) + for i := range items { switch resultItemType.(type) { case []byte: - bytes, err := iter.Values[i].TryBytes() + bytes, err := items[i].TryBytes() if err != nil { return nil, fmt.Errorf("failed to deserialize []byte from stackitem #%d: %w", i, err) } result[i] = bytes case string: - bytes, err := iter.Values[i].TryBytes() + bytes, err := items[i].TryBytes() if err != nil { return nil, fmt.Errorf("failed to deserialize string from stackitem #%d: %w", i, err) } result[i] = string(bytes) case util.Uint160: - bytes, err := iter.Values[i].TryBytes() + bytes, err := items[i].TryBytes() if err != nil { return nil, fmt.Errorf("failed to deserialize uint160 from stackitem #%d: %w", i, err) } @@ -132,7 +203,7 @@ func topIterableFromStack(st []stackitem.Item, resultItemType interface{}) ([]in return nil, fmt.Errorf("failed to decode uint160 from stackitem #%d: %w", i, err) } case nns.RecordState: - rs, ok := iter.Values[i].Value().([]stackitem.Item) + rs, ok := items[i].Value().([]stackitem.Item) if !ok { return nil, fmt.Errorf("failed to decode RecordState from stackitem #%d: not a struct", i) } diff --git a/pkg/rpc/client/native.go b/pkg/rpc/client/native.go index 314138e87..f154c2633 100644 --- a/pkg/rpc/client/native.go +++ b/pkg/rpc/client/native.go @@ -118,7 +118,7 @@ func (c *Client) NNSIsAvailable(nnsHash util.Uint160, name string) (bool, error) // NNSGetAllRecords returns all records for a given name from NNS service. func (c *Client) NNSGetAllRecords(nnsHash util.Uint160, name string) ([]nns.RecordState, error) { - result, err := c.InvokeFunction(nnsHash, "getAllRecords", []smartcontract.Parameter{ + result, err := c.InvokeAndPackIteratorResults(nnsHash, "getAllRecords", []smartcontract.Parameter{ { Type: smartcontract.StringType, Value: name, diff --git a/pkg/rpc/client/nep11.go b/pkg/rpc/client/nep11.go index f7dd5150f..d663c839e 100644 --- a/pkg/rpc/client/nep11.go +++ b/pkg/rpc/client/nep11.go @@ -84,7 +84,7 @@ func (c *Client) CreateNEP11TransferTx(acc *wallet.Account, tokenHash util.Uint1 // NEP11TokensOf returns an array of token IDs for the specified owner of the specified NFT token. func (c *Client) NEP11TokensOf(tokenHash util.Uint160, owner util.Uint160) ([][]byte, error) { - result, err := c.InvokeFunction(tokenHash, "tokensOf", []smartcontract.Parameter{ + result, err := c.InvokeAndPackIteratorResults(tokenHash, "tokensOf", []smartcontract.Parameter{ { Type: smartcontract.Hash160Type, Value: owner, @@ -161,7 +161,7 @@ func (c *Client) NEP11DBalanceOf(tokenHash, owner util.Uint160, tokenID []byte) // NEP11DOwnerOf returns list of the specified NEP-11 divisible token owners. func (c *Client) NEP11DOwnerOf(tokenHash util.Uint160, tokenID []byte) ([]util.Uint160, error) { - result, err := c.InvokeFunction(tokenHash, "ownerOf", []smartcontract.Parameter{ + result, err := c.InvokeAndPackIteratorResults(tokenHash, "ownerOf", []smartcontract.Parameter{ { Type: smartcontract.ByteArrayType, Value: tokenID, @@ -210,7 +210,7 @@ func (c *Client) NEP11Properties(tokenHash util.Uint160, tokenID []byte) (*stack // NEP11Tokens returns list of the tokens minted by the contract. func (c *Client) NEP11Tokens(tokenHash util.Uint160) ([][]byte, error) { - result, err := c.InvokeFunction(tokenHash, "tokens", []smartcontract.Parameter{}, nil) + result, err := c.InvokeAndPackIteratorResults(tokenHash, "tokens", []smartcontract.Parameter{}, nil) if err != nil { return nil, err } diff --git a/pkg/rpc/client/rpc.go b/pkg/rpc/client/rpc.go index d08c7d03b..a77aeffc4 100644 --- a/pkg/rpc/client/rpc.go +++ b/pkg/rpc/client/rpc.go @@ -3,9 +3,12 @@ package client import ( "encoding/base64" "encoding/hex" + "encoding/json" "errors" "fmt" + "github.com/google/uuid" + "github.com/nspcc-dev/neo-go/pkg/config" "github.com/nspcc-dev/neo-go/pkg/config/netmode" "github.com/nspcc-dev/neo-go/pkg/core/block" "github.com/nspcc-dev/neo-go/pkg/core/fee" @@ -24,6 +27,7 @@ import ( "github.com/nspcc-dev/neo-go/pkg/smartcontract/trigger" "github.com/nspcc-dev/neo-go/pkg/util" "github.com/nspcc-dev/neo-go/pkg/vm/opcode" + "github.com/nspcc-dev/neo-go/pkg/vm/stackitem" "github.com/nspcc-dev/neo-go/pkg/wallet" ) @@ -1142,3 +1146,50 @@ func (c *Client) GetNativeContractHash(name string) (util.Uint160, error) { c.cacheLock.Unlock() return cs.Hash, nil } + +// TraverseIterator returns a set of iterator values (maxItemsCount at max) for +// the specified iterator and session. If result contains no elements, then either +// Iterator has no elements or session was expired and terminated by the server. +// If maxItemsCount is non-positive, then the full set of iterator values will be +// returned using several `traverseiterator` calls if needed. +func (c *Client) TraverseIterator(sessionID, iteratorID uuid.UUID, maxItemsCount int) ([]stackitem.Item, error) { + var traverseAll bool + if maxItemsCount <= 0 { + maxItemsCount = config.DefaultMaxIteratorResultItems + traverseAll = true + } + var ( + result []stackitem.Item + params = request.NewRawParams(sessionID.String(), iteratorID.String(), maxItemsCount) + ) + for { + var resp []json.RawMessage + if err := c.performRequest("traverseiterator", params, &resp); err != nil { + return nil, err + } + for i, iBytes := range resp { + itm, err := stackitem.FromJSONWithTypes(iBytes) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal %d-th iterator value: %w", i, err) + } + result = append(result, itm) + } + if len(resp) < maxItemsCount || !traverseAll { + break + } + } + + return result, nil +} + +// TerminateSession tries to terminate the specified session and returns `true` iff +// the specified session was found on server. +func (c *Client) TerminateSession(sessionID uuid.UUID) (bool, error) { + var resp bool + params := request.NewRawParams(sessionID.String()) + if err := c.performRequest("terminatesession", params, &resp); err != nil { + return false, err + } + + return resp, nil +} diff --git a/pkg/rpc/request/param.go b/pkg/rpc/request/param.go index 2d21617a6..429955ce8 100644 --- a/pkg/rpc/request/param.go +++ b/pkg/rpc/request/param.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" + "github.com/google/uuid" "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" @@ -504,3 +505,16 @@ func (s *SignerWithWitness) MarshalJSON() ([]byte, error) { } return json.Marshal(signer) } + +// GetUUID returns UUID from parameter. +func (p *Param) GetUUID() (uuid.UUID, error) { + s, err := p.GetString() + if err != nil { + return uuid.UUID{}, err + } + id, err := uuid.Parse(s) + if err != nil { + return uuid.UUID{}, fmt.Errorf("not a valid UUID: %w", err) + } + return id, nil +} diff --git a/pkg/rpc/request/param_test.go b/pkg/rpc/request/param_test.go index 91137b05c..59e0a2bcb 100644 --- a/pkg/rpc/request/param_test.go +++ b/pkg/rpc/request/param_test.go @@ -7,6 +7,7 @@ import ( "fmt" "math" "math/big" + "strings" "testing" "github.com/nspcc-dev/neo-go/pkg/core/transaction" @@ -442,3 +443,24 @@ func TestParamGetSigners(t *testing.T) { require.Error(t, err) }) } + +func TestParamGetUUID(t *testing.T) { + t.Run("from null", func(t *testing.T) { + p := Param{RawMessage: []byte("null")} + _, err := p.GetUUID() + require.ErrorIs(t, err, errNotAString) + }) + t.Run("invalid uuid", func(t *testing.T) { + p := Param{RawMessage: []byte(`"not-a-uuid"`)} + _, err := p.GetUUID() + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), "not a valid UUID"), err.Error()) + }) + t.Run("compat", func(t *testing.T) { + expected := "2107da59-4f9c-462c-9c51-7666842519a9" + p := Param{RawMessage: []byte(fmt.Sprintf(`"%s"`, expected))} + id, err := p.GetUUID() + require.NoError(t, err) + require.Equal(t, id.String(), expected) + }) +} diff --git a/pkg/rpc/response/result/invoke.go b/pkg/rpc/response/result/invoke.go index dd2e436e8..c5ba760fe 100644 --- a/pkg/rpc/response/result/invoke.go +++ b/pkg/rpc/response/result/invoke.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" + "github.com/google/uuid" "github.com/nspcc-dev/neo-go/pkg/core/interop" "github.com/nspcc-dev/neo-go/pkg/core/interop/iterator" "github.com/nspcc-dev/neo-go/pkg/core/state" @@ -25,9 +26,13 @@ type Invoke struct { Transaction *transaction.Transaction Diagnostics *InvokeDiag maxIteratorResultItems int + Session uuid.UUID finalize func() + onNewSession OnNewSession } +type OnNewSession func(sessionID string, iterators []ServerIterator, finalize func()) + // InvokeDiag is an additional diagnostic data for invocation. type InvokeDiag struct { Changes []storage.Operation `json:"storagechanges"` @@ -35,7 +40,7 @@ type InvokeDiag struct { } // NewInvoke returns a new Invoke structure with the given fields set. -func NewInvoke(ic *interop.Context, script []byte, faultException string, maxIteratorResultItems int) *Invoke { +func NewInvoke(ic *interop.Context, script []byte, faultException string, registerSession OnNewSession, maxIteratorResultItems int) *Invoke { var diag *InvokeDiag tree := ic.VM.GetInvocationTree() if tree != nil { @@ -56,8 +61,9 @@ func NewInvoke(ic *interop.Context, script []byte, faultException string, maxIte FaultException: faultException, Notifications: notifications, Diagnostics: diag, - maxIteratorResultItems: maxIteratorResultItems, finalize: ic.Finalize, + onNewSession: registerSession, + maxIteratorResultItems: maxIteratorResultItems, } } @@ -70,22 +76,44 @@ type invokeAux struct { Notifications []state.NotificationEvent `json:"notifications"` Transaction []byte `json:"tx,omitempty"` Diagnostics *InvokeDiag `json:"diagnostics,omitempty"` + Session string `json:"session,omitempty"` } +// iteratorInterfaceName is a string used to mark Iterator inside the InteropInterface. +const iteratorInterfaceName = "IIterator" + type iteratorAux struct { Type string `json:"type"` - Value []json.RawMessage `json:"iterator"` - Truncated bool `json:"truncated"` + Interface string `json:"interface,omitempty"` + ID string `json:"id,omitempty"` + Value []json.RawMessage `json:"iterator,omitempty"` + Truncated bool `json:"truncated,omitempty"` } -// Iterator represents deserialized VM iterator values with a truncated flag. +// Iterator represents VM iterator identifier. It either has ID set (for those JSON-RPC servers +// that support sessions) or non-nil Values and Truncated set (for those JSON-RPC servers that +// doesn't support sessions but perform in-place iterator traversing) or doesn't have ID, Values +// and Truncated set at all (for those JSON-RPC servers that doesn't support iterator sessions +// and doesn't perform in-place iterator traversing). type Iterator struct { + // ID represents iterator ID. It is non-nil iff JSON-RPC server support session mechanism. + ID *uuid.UUID + + // Values contains deserialized VM iterator values with a truncated flag. It is non-nil + // iff JSON-RPC server does not support sessions mechanism and able to traverse iterator. Values []stackitem.Item Truncated bool } +// ServerIterator represents Iterator on the server side. It is not for Client usage. +type ServerIterator struct { + ID string + Item stackitem.Item +} + // Finalize releases resources occupied by Iterators created at the script invocation. -// This method will be called automatically on Invoke marshalling. +// This method will be called automatically on Invoke marshalling or by the Server's +// sessions handler. func (r *Invoke) Finalize() { if r.finalize != nil { r.finalize() @@ -94,12 +122,14 @@ func (r *Invoke) Finalize() { // MarshalJSON implements the json.Marshaler. func (r Invoke) MarshalJSON() ([]byte, error) { - defer r.Finalize() var ( - st json.RawMessage - err error - faultSep string - arr = make([]json.RawMessage, len(r.Stack)) + st json.RawMessage + err error + faultSep string + arr = make([]json.RawMessage, len(r.Stack)) + sessionsEnabled = r.onNewSession != nil + sessionID string + iterators []ServerIterator ) if len(r.FaultException) != 0 { faultSep = " / " @@ -108,23 +138,41 @@ arrloop: for i := range arr { var data []byte if (r.Stack[i].Type() == stackitem.InteropT) && iterator.IsIterator(r.Stack[i]) { - iteratorValues, truncated := iterator.Values(r.Stack[i], r.maxIteratorResultItems) - value := make([]json.RawMessage, len(iteratorValues)) - for j := range iteratorValues { - value[j], err = stackitem.ToJSONWithTypes(iteratorValues[j]) + if sessionsEnabled { + iteratorID := uuid.NewString() + data, err = json.Marshal(iteratorAux{ + Type: stackitem.InteropT.String(), + Interface: iteratorInterfaceName, + ID: iteratorID, + }) + if err != nil { + r.FaultException += fmt.Sprintf("%sjson error: failed to marshal iterator: %v", faultSep, err) + break + } + iterators = append(iterators, ServerIterator{ + ID: iteratorID, + Item: r.Stack[i], + }) + } else { + iteratorValues, truncated := iterator.ValuesTruncated(r.Stack[i], r.maxIteratorResultItems) + value := make([]json.RawMessage, len(iteratorValues)) + for j := range iteratorValues { + value[j], err = stackitem.ToJSONWithTypes(iteratorValues[j]) + if err != nil { + r.FaultException += fmt.Sprintf("%sjson error: %v", faultSep, err) + break arrloop + } + } + data, err = json.Marshal(iteratorAux{ + Type: stackitem.InteropT.String(), + Value: value, + Truncated: truncated, + }) if err != nil { r.FaultException += fmt.Sprintf("%sjson error: %v", faultSep, err) - break arrloop + break } } - data, err = json.Marshal(iteratorAux{ - Type: stackitem.InteropT.String(), - Value: value, - Truncated: truncated, - }) - if err != nil { - return nil, fmt.Errorf("failed to marshal iterator: %w", err) - } } else { data, err = stackitem.ToJSONWithTypes(r.Stack[i]) if err != nil { @@ -135,6 +183,13 @@ arrloop: arr[i] = data } + if sessionsEnabled && len(iterators) != 0 { + sessionID = uuid.NewString() + r.onNewSession(sessionID, iterators, r.Finalize) + } else { + defer r.Finalize() + } + if err == nil { st, err = json.Marshal(arr) if err != nil { @@ -153,6 +208,7 @@ arrloop: Notifications: r.Notifications, Transaction: txbytes, Diagnostics: r.Diagnostics, + Session: sessionID, } if len(r.FaultException) != 0 { aux.FaultException = &r.FaultException @@ -167,6 +223,12 @@ func (r *Invoke) UnmarshalJSON(data []byte) error { if err = json.Unmarshal(data, aux); err != nil { return err } + if len(aux.Session) != 0 { + r.Session, err = uuid.Parse(aux.Session) + if err != nil { + return fmt.Errorf("failed to parse session ID: %w", err) + } + } var arr []json.RawMessage if err = json.Unmarshal(aux.Stack, &arr); err == nil { st := make([]stackitem.Item, len(arr)) @@ -178,21 +240,38 @@ func (r *Invoke) UnmarshalJSON(data []byte) error { if st[i].Type() == stackitem.InteropT { iteratorAux := new(iteratorAux) if json.Unmarshal(arr[i], iteratorAux) == nil { - iteratorValues := make([]stackitem.Item, len(iteratorAux.Value)) - for j := range iteratorValues { - iteratorValues[j], err = stackitem.FromJSONWithTypes(iteratorAux.Value[j]) - if err != nil { - err = fmt.Errorf("failed to unmarshal iterator values: %w", err) + if len(iteratorAux.Interface) != 0 { + if iteratorAux.Interface != iteratorInterfaceName { + err = fmt.Errorf("unknown InteropInterface: %s", iteratorAux.Interface) break } + var iID uuid.UUID + iID, err = uuid.Parse(iteratorAux.ID) // iteratorAux.ID is always non-empty, see https://github.com/neo-project/neo-modules/pull/715#discussion_r897635424. + if err != nil { + err = fmt.Errorf("failed to unmarshal iterator ID: %w", err) + break + } + // It's impossible to restore initial iterator type; also iterator is almost + // useless outside the VM, thus let's replace it with a special structure. + st[i] = stackitem.NewInterop(Iterator{ + ID: &iID, + }) + } else { + iteratorValues := make([]stackitem.Item, len(iteratorAux.Value)) + for j := range iteratorValues { + iteratorValues[j], err = stackitem.FromJSONWithTypes(iteratorAux.Value[j]) + if err != nil { + err = fmt.Errorf("failed to unmarshal iterator values: %w", err) + break + } + } + // It's impossible to restore initial iterator type; also iterator is almost + // useless outside the VM, thus let's replace it with a special structure. + st[i] = stackitem.NewInterop(Iterator{ + Values: iteratorValues, + Truncated: iteratorAux.Truncated, + }) } - - // it's impossible to restore initial iterator type; also iterator is almost - // useless outside of the VM, thus let's replace it with a special structure. - st[i] = stackitem.NewInterop(Iterator{ - Values: iteratorValues, - Truncated: iteratorAux.Truncated, - }) } } } diff --git a/pkg/rpc/rpc_config.go b/pkg/rpc/rpc_config.go index 97890c058..06b5f8118 100644 --- a/pkg/rpc/rpc_config.go +++ b/pkg/rpc/rpc_config.go @@ -17,6 +17,8 @@ type ( MaxFindResultItems int `yaml:"MaxFindResultItems"` MaxNEP11Tokens int `yaml:"MaxNEP11Tokens"` Port uint16 `yaml:"Port"` + SessionEnabled bool `yaml:"SessionEnabled"` + SessionExpirationTime int `yaml:"SessionExpirationTime"` StartWhenSynchronized bool `yaml:"StartWhenSynchronized"` TLSConfig TLSConfig `yaml:"TLSConfig"` } diff --git a/pkg/rpc/server/client_test.go b/pkg/rpc/server/client_test.go index 14826ea13..9aee7ac41 100644 --- a/pkg/rpc/server/client_test.go +++ b/pkg/rpc/server/client_test.go @@ -1,13 +1,20 @@ package server import ( + "bytes" "context" "encoding/base64" "encoding/hex" + "math/big" + "sort" "strings" + "sync" "testing" + "time" + "github.com/google/uuid" "github.com/nspcc-dev/neo-go/internal/testchain" + "github.com/nspcc-dev/neo-go/pkg/config" "github.com/nspcc-dev/neo-go/pkg/core" "github.com/nspcc-dev/neo-go/pkg/core/fee" "github.com/nspcc-dev/neo-go/pkg/core/native/nativenames" @@ -18,6 +25,7 @@ import ( "github.com/nspcc-dev/neo-go/pkg/io" "github.com/nspcc-dev/neo-go/pkg/rpc/client" "github.com/nspcc-dev/neo-go/pkg/rpc/client/nns" + "github.com/nspcc-dev/neo-go/pkg/rpc/response/result" "github.com/nspcc-dev/neo-go/pkg/smartcontract" "github.com/nspcc-dev/neo-go/pkg/smartcontract/callflag" "github.com/nspcc-dev/neo-go/pkg/smartcontract/manifest" @@ -551,7 +559,7 @@ func TestSignAndPushInvocationTx(t *testing.T) { } func TestSignAndPushP2PNotaryRequest(t *testing.T) { - chain, rpcSrv, httpSrv := initServerWithInMemoryChainAndServices(t, false, true) + chain, rpcSrv, httpSrv := initServerWithInMemoryChainAndServices(t, false, true, false) defer chain.Close() defer rpcSrv.Shutdown() @@ -1036,6 +1044,153 @@ func TestClient_NNS(t *testing.T) { }) } +func TestClient_IteratorSessions(t *testing.T) { + chain, rpcSrv, httpSrv := initServerWithInMemoryChain(t) + defer chain.Close() + defer rpcSrv.Shutdown() + + c, err := client.New(context.Background(), httpSrv.URL, client.Options{}) + require.NoError(t, err) + require.NoError(t, c.Init()) + + storageHash, err := util.Uint160DecodeStringLE(storageContractHash) + require.NoError(t, err) + + // storageItemsCount is the amount of storage items stored in Storage contract, it's hard-coded in the contract code. + const storageItemsCount = 255 + expected := make([][]byte, storageItemsCount) + for i := 0; i < storageItemsCount; i++ { + expected[i] = stackitem.NewBigInteger(big.NewInt(int64(i))).Bytes() + } + sort.Slice(expected, func(i, j int) bool { + if len(expected[i]) != len(expected[j]) { + return len(expected[i]) < len(expected[j]) + } + return bytes.Compare(expected[i], expected[j]) < 0 + }) + + prepareSession := func(t *testing.T) (uuid.UUID, uuid.UUID) { + res, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil) + require.NoError(t, err) + require.NotEmpty(t, res.Session) + require.Equal(t, 1, len(res.Stack)) + require.Equal(t, stackitem.InteropT, res.Stack[0].Type()) + iterator, ok := res.Stack[0].Value().(result.Iterator) + require.True(t, ok) + require.NotEmpty(t, iterator.ID) + return res.Session, *iterator.ID + } + t.Run("traverse with max constraint", func(t *testing.T) { + sID, iID := prepareSession(t) + check := func(t *testing.T, start, end int) { + max := end - start + set, err := c.TraverseIterator(sID, iID, max) + require.NoError(t, err) + require.Equal(t, max, len(set)) + for i := 0; i < max; i++ { + // According to the Storage contract code. + require.Equal(t, expected[start+i], set[i].Value().([]byte), start+i) + } + } + check(t, 0, 30) + check(t, 30, 48) + check(t, 48, 49) + check(t, 49, 49+config.DefaultMaxIteratorResultItems) + check(t, 49+config.DefaultMaxIteratorResultItems, 49+2*config.DefaultMaxIteratorResultItems-1) + check(t, 49+2*config.DefaultMaxIteratorResultItems-1, 255) + + // Iterator ends on 255-th element, so no more elements should be returned. + set, err := c.TraverseIterator(sID, iID, config.DefaultMaxIteratorResultItems) + require.NoError(t, err) + require.Equal(t, 0, len(set)) + }) + + t.Run("traverse, request more than exists", func(t *testing.T) { + sID, iID := prepareSession(t) + for i := 0; i < storageItemsCount/config.DefaultMaxIteratorResultItems; i++ { + set, err := c.TraverseIterator(sID, iID, config.DefaultMaxIteratorResultItems) + require.NoError(t, err) + require.Equal(t, config.DefaultMaxIteratorResultItems, len(set)) + } + + // Request more items than left untraversed. + set, err := c.TraverseIterator(sID, iID, config.DefaultMaxIteratorResultItems) + require.NoError(t, err) + require.Equal(t, storageItemsCount%config.DefaultMaxIteratorResultItems, len(set)) + }) + + t.Run("traverse, no max constraint", func(t *testing.T) { + sID, iID := prepareSession(t) + + set, err := c.TraverseIterator(sID, iID, -1) + require.NoError(t, err) + require.Equal(t, storageItemsCount, len(set)) + + // No more items should be left. + set, err = c.TraverseIterator(sID, iID, -1) + require.NoError(t, err) + require.Equal(t, 0, len(set)) + }) + + t.Run("traverse, concurrent access", func(t *testing.T) { + sID, iID := prepareSession(t) + wg := sync.WaitGroup{} + wg.Add(storageItemsCount) + check := func(t *testing.T) { + set, err := c.TraverseIterator(sID, iID, 1) + require.NoError(t, err) + require.Equal(t, 1, len(set)) + wg.Done() + } + for i := 0; i < storageItemsCount; i++ { + go check(t) + } + wg.Wait() + }) + + t.Run("terminate session", func(t *testing.T) { + t.Run("manually", func(t *testing.T) { + sID, iID := prepareSession(t) + + // Check session is created. + set, err := c.TraverseIterator(sID, iID, 1) + require.NoError(t, err) + require.Equal(t, 1, len(set)) + + ok, err := c.TerminateSession(sID) + require.NoError(t, err) + require.True(t, ok) + + ok, err = c.TerminateSession(sID) + require.NoError(t, err) + require.False(t, ok) // session has already been terminated. + }) + t.Run("automatically", func(t *testing.T) { + sID, iID := prepareSession(t) + + // Check session is created. + set, err := c.TraverseIterator(sID, iID, 1) + require.NoError(t, err) + require.Equal(t, 1, len(set)) + + require.Eventually(t, func() bool { + rpcSrv.sessionsLock.Lock() + defer rpcSrv.sessionsLock.Unlock() + + _, ok := rpcSrv.sessions[sID.String()] + return !ok + }, time.Duration(rpcSrv.config.SessionExpirationTime)*time.Second*3, + // Sessions list is updated once per SessionExpirationTime, thus, no need to ask for update more frequently than + // sessions cleaning occurs. + time.Duration(rpcSrv.config.SessionExpirationTime)*time.Second/4) + + ok, err := c.TerminateSession(sID) + require.NoError(t, err) + require.False(t, ok) // session has already been terminated. + }) + }) +} + func TestClient_GetNotaryServiceFeePerKey(t *testing.T) { chain, rpcSrv, httpSrv := initServerWithInMemoryChain(t) defer chain.Close() @@ -1065,3 +1220,87 @@ func TestClient_GetOraclePrice(t *testing.T) { require.NoError(t, err) require.Equal(t, defaultOracleRequestPrice, actual) } + +func TestClient_InvokeAndPackIteratorResults(t *testing.T) { + chain, rpcSrv, httpSrv := initServerWithInMemoryChain(t) + defer chain.Close() + defer rpcSrv.Shutdown() + + c, err := client.New(context.Background(), httpSrv.URL, client.Options{}) + require.NoError(t, err) + require.NoError(t, c.Init()) + + // storageItemsCount is the amount of storage items stored in Storage contract, it's hard-coded in the contract code. + const storageItemsCount = 255 + expected := make([][]byte, storageItemsCount) + for i := 0; i < storageItemsCount; i++ { + expected[i] = stackitem.NewBigInteger(big.NewInt(int64(i))).Bytes() + } + sort.Slice(expected, func(i, j int) bool { + if len(expected[i]) != len(expected[j]) { + return len(expected[i]) < len(expected[j]) + } + return bytes.Compare(expected[i], expected[j]) < 0 + }) + + storageHash, err := util.Uint160DecodeStringLE(storageContractHash) + require.NoError(t, err) + res, err := c.InvokeAndPackIteratorResults(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil) + require.NoError(t, err) + require.Equal(t, vm.HaltState.String(), res.State) + require.Equal(t, 1, len(res.Stack)) + require.Equal(t, stackitem.ArrayT, res.Stack[0].Type()) + arr, ok := res.Stack[0].Value().([]stackitem.Item) + require.True(t, ok) + require.Equal(t, storageItemsCount, len(arr)) + + for i := range arr { + require.Equal(t, stackitem.ByteArrayT, arr[i].Type()) + require.Equal(t, expected[i], arr[i].Value().([]byte)) + } +} + +func TestClient_IteratorFromInvocation(t *testing.T) { + chain, rpcSrv, httpSrv := initClearServerWithServices(t, false, false, true) + for _, b := range getTestBlocks(t) { + require.NoError(t, chain.AddBlock(b)) + } + defer chain.Close() + defer rpcSrv.Shutdown() + + c, err := client.New(context.Background(), httpSrv.URL, client.Options{}) + require.NoError(t, err) + require.NoError(t, c.Init()) + + storageHash, err := util.Uint160DecodeStringLE(storageContractHash) + require.NoError(t, err) + + // storageItemsCount is the amount of storage items stored in Storage contract, it's hard-coded in the contract code. + const storageItemsCount = 255 + expected := make([][]byte, storageItemsCount) + for i := 0; i < storageItemsCount; i++ { + expected[i] = stackitem.NewBigInteger(big.NewInt(int64(i))).Bytes() + } + sort.Slice(expected, func(i, j int) bool { + if len(expected[i]) != len(expected[j]) { + return len(expected[i]) < len(expected[j]) + } + return bytes.Compare(expected[i], expected[j]) < 0 + }) + + res, err := c.InvokeFunction(storageHash, "iterateOverValues", []smartcontract.Parameter{}, nil) + require.NoError(t, err) + require.NotEmpty(t, res.Session) + require.Equal(t, 1, len(res.Stack)) + require.Equal(t, stackitem.InteropT, res.Stack[0].Type()) + iterator, ok := res.Stack[0].Value().(result.Iterator) + require.True(t, ok) + require.Empty(t, iterator.ID) + require.NotEmpty(t, iterator.Values) + require.True(t, iterator.Truncated) + require.Equal(t, rpcSrv.config.MaxIteratorResultItems, len(iterator.Values)) + for i := 0; i < rpcSrv.config.MaxIteratorResultItems; i++ { + // According to the Storage contract code. + require.Equal(t, expected[i], iterator.Values[i].Value().([]byte), i) + } +} diff --git a/pkg/rpc/server/server.go b/pkg/rpc/server/server.go index aa3135862..d7e691f87 100644 --- a/pkg/rpc/server/server.go +++ b/pkg/rpc/server/server.go @@ -73,6 +73,9 @@ type ( started *atomic.Bool errChan chan error + sessionsLock sync.Mutex + sessions map[string]*session + subsLock sync.RWMutex subscribers map[*subscriber]bool blockSubs int @@ -86,6 +89,15 @@ type ( transactionCh chan *transaction.Transaction notaryRequestCh chan mempoolevent.Event } + + // session holds a set of iterators got after invoke* call with corresponding + // finalizer and session expiration time. + session struct { + iteratorsLock sync.Mutex + iterators []result.ServerIterator + timer *time.Timer + finalize func() + } ) const ( @@ -150,6 +162,8 @@ var rpcHandlers = map[string]func(*Server, request.Params) (interface{}, *respon "submitblock": (*Server).submitBlock, "submitnotaryrequest": (*Server).submitNotaryRequest, "submitoracleresponse": (*Server).submitOracleResponse, + "terminatesession": (*Server).terminateSession, + "traverseiterator": (*Server).traverseIterator, "validateaddress": (*Server).validateAddress, "verifyproof": (*Server).verifyProof, } @@ -199,6 +213,8 @@ func New(chain blockchainer.Blockchainer, conf rpc.Config, coreServer *network.S started: atomic.NewBool(false), errChan: errChan, + sessions: make(map[string]*session), + subscribers: make(map[*subscriber]bool), // These are NOT buffered to preserve original order of events. blockCh: make(chan *block.Block), @@ -287,6 +303,24 @@ func (s *Server) Shutdown() { s.log.Warn("error during RPC (http) server shutdown", zap.Error(err)) } + // Perform sessions finalisation. + if s.config.SessionEnabled { + s.sessionsLock.Lock() + for _, session := range s.sessions { + // Concurrent iterator traversal may still be in process, thus need to protect iteratorIdentifiers access. + session.iteratorsLock.Lock() + if session.finalize != nil { + session.finalize() + } + if !session.timer.Stop() { + <-session.timer.C + } + session.iteratorsLock.Unlock() + } + s.sessions = nil + s.sessionsLock.Unlock() + } + // Wait for handleSubEvents to finish. <-s.executionCh } @@ -690,7 +724,7 @@ func (s *Server) getNEP11Tokens(h util.Uint160, acc util.Uint160, bw *io.BufBinW if (items[0].Type() != stackitem.InteropT) || !iterator.IsIterator(items[0]) { return nil, "", 0, fmt.Errorf("invalid `tokensOf` result type %s", items[0].String()) } - vals, _ := iterator.Values(items[0], s.config.MaxNEP11Tokens) + vals := iterator.Values(items[0], s.config.MaxNEP11Tokens) sym, err := stackitem.ToString(items[1]) if err != nil { return nil, "", 0, fmt.Errorf("`symbol` return value error: %w", err) @@ -1923,7 +1957,126 @@ func (s *Server) runScriptInVM(t trigger.Type, script []byte, contractScriptHash if err != nil { faultException = err.Error() } - return result.NewInvoke(ic, script, faultException, s.config.MaxIteratorResultItems), nil + var registerSession result.OnNewSession + if s.config.SessionEnabled { + registerSession = s.registerSession + } + return result.NewInvoke(ic, script, faultException, registerSession, s.config.MaxIteratorResultItems), nil +} + +// registerSession is a callback used to add new iterator session to the sessions list. +// It performs no check whether sessions are enabled. +func (s *Server) registerSession(sessionID string, iterators []result.ServerIterator, finalize func()) { + s.sessionsLock.Lock() + timer := time.AfterFunc(time.Second*time.Duration(s.config.SessionExpirationTime), func() { + s.sessionsLock.Lock() + defer s.sessionsLock.Unlock() + if len(s.sessions) == 0 { + return + } + sess, ok := s.sessions[sessionID] + if !ok { + return + } + sess.iteratorsLock.Lock() + if sess.finalize != nil { + sess.finalize() + } + delete(s.sessions, sessionID) + sess.iteratorsLock.Unlock() + }) + sess := &session{ + iterators: iterators, + finalize: finalize, + timer: timer, + } + s.sessions[sessionID] = sess + s.sessionsLock.Unlock() +} + +func (s *Server) traverseIterator(reqParams request.Params) (interface{}, *response.Error) { + if !s.config.SessionEnabled { + return nil, response.NewInvalidRequestError("sessions are disabled") + } + sID, err := reqParams.Value(0).GetUUID() + if err != nil { + return nil, response.NewInvalidParamsError(fmt.Sprintf("invalid session ID: %s", err)) + } + iID, err := reqParams.Value(1).GetUUID() + if err != nil { + return nil, response.NewInvalidParamsError(fmt.Sprintf("invalid iterator ID: %s", err)) + } + count, err := reqParams.Value(2).GetInt() + if err != nil { + return nil, response.NewInvalidParamsError(fmt.Sprintf("invalid iterator items count: %s", err)) + } + if err := checkInt32(count); err != nil { + return nil, response.NewInvalidParamsError("invalid iterator items count: not an int32") + } + if count > s.config.MaxIteratorResultItems { + return nil, response.NewInvalidParamsError(fmt.Sprintf("iterator items count is out of range (%d at max)", s.config.MaxIteratorResultItems)) + } + + s.sessionsLock.Lock() + session, ok := s.sessions[sID.String()] + if !ok { + s.sessionsLock.Unlock() + return []json.RawMessage{}, nil + } + session.iteratorsLock.Lock() + // Perform `till` update only after session.iteratorsLock is taken in order to have more + // precise session lifetime. + session.timer.Reset(time.Second * time.Duration(s.config.SessionExpirationTime)) + s.sessionsLock.Unlock() + + var ( + iIDStr = iID.String() + iVals []stackitem.Item + ) + for _, it := range session.iterators { + if iIDStr == it.ID { + iVals = iterator.Values(it.Item, count) + break + } + } + session.iteratorsLock.Unlock() + + result := make([]json.RawMessage, len(iVals)) + for j := range iVals { + result[j], err = stackitem.ToJSONWithTypes(iVals[j]) + if err != nil { + return nil, response.NewInternalServerError(fmt.Sprintf("failed to marshal iterator value: %s", err)) + } + } + return result, nil +} + +func (s *Server) terminateSession(reqParams request.Params) (interface{}, *response.Error) { + if !s.config.SessionEnabled { + return nil, response.NewInvalidRequestError("sessions are disabled") + } + sID, err := reqParams.Value(0).GetUUID() + if err != nil { + return nil, response.NewInvalidParamsError(fmt.Sprintf("invalid session ID: %s", err)) + } + strSID := sID.String() + s.sessionsLock.Lock() + defer s.sessionsLock.Unlock() + session, ok := s.sessions[strSID] + if ok { + // Iterators access Seek channel under the hood; finalizer closes this channel, thus, + // we need to perform finalisation under iteratorsLock. + session.iteratorsLock.Lock() + if session.finalize != nil { + session.finalize() + } + if !session.timer.Stop() { + <-session.timer.C + } + delete(s.sessions, strSID) + session.iteratorsLock.Unlock() + } + return ok, nil } // submitBlock broadcasts a raw block over the NEO network. diff --git a/pkg/rpc/server/server_helper_test.go b/pkg/rpc/server/server_helper_test.go index 437e02709..64e08c7ad 100644 --- a/pkg/rpc/server/server_helper_test.go +++ b/pkg/rpc/server/server_helper_test.go @@ -29,37 +29,49 @@ const ( notaryPass = "one" ) -func getUnitTestChain(t testing.TB, enableOracle bool, enableNotary bool) (*core.Blockchain, *oracle.Oracle, config.Config, *zap.Logger) { +func getUnitTestChain(t testing.TB, enableOracle bool, enableNotary bool, disableIteratorSessions bool) (*core.Blockchain, *oracle.Oracle, config.Config, *zap.Logger) { + return getUnitTestChainWithCustomConfig(t, enableOracle, enableNotary, func(cfg *config.Config) { + if disableIteratorSessions { + cfg.ApplicationConfiguration.RPC.SessionEnabled = false + } + if enableNotary { + cfg.ProtocolConfiguration.P2PSigExtensions = true + cfg.ProtocolConfiguration.P2PNotaryRequestPayloadPoolSize = 1000 + cfg.ApplicationConfiguration.P2PNotary = config.P2PNotary{ + Enabled: true, + UnlockWallet: config.Wallet{ + Path: notaryPath, + Password: notaryPass, + }, + } + } else { + cfg.ApplicationConfiguration.P2PNotary.Enabled = false + } + if enableOracle { + cfg.ApplicationConfiguration.Oracle.Enabled = true + cfg.ApplicationConfiguration.Oracle.UnlockWallet = config.Wallet{ + Path: "../../services/oracle/testdata/oracle1.json", + Password: "one", + } + } + }) +} +func getUnitTestChainWithCustomConfig(t testing.TB, enableOracle bool, enableNotary bool, customCfg func(configuration *config.Config)) (*core.Blockchain, *oracle.Oracle, config.Config, *zap.Logger) { net := netmode.UnitTestNet configPath := "../../../config" cfg, err := config.Load(configPath, net) require.NoError(t, err, "could not load config") + if customCfg != nil { + customCfg(&cfg) + } memoryStore := storage.NewMemoryStore() logger := zaptest.NewLogger(t) - if enableNotary { - cfg.ProtocolConfiguration.P2PSigExtensions = true - cfg.ProtocolConfiguration.P2PNotaryRequestPayloadPoolSize = 1000 - cfg.ApplicationConfiguration.P2PNotary = config.P2PNotary{ - Enabled: true, - UnlockWallet: config.Wallet{ - Path: notaryPath, - Password: notaryPass, - }, - } - } else { - cfg.ApplicationConfiguration.P2PNotary.Enabled = false - } chain, err := core.NewBlockchain(memoryStore, cfg.ProtocolConfiguration, logger) require.NoError(t, err, "could not create chain") var orc *oracle.Oracle if enableOracle { - cfg.ApplicationConfiguration.Oracle.Enabled = true - cfg.ApplicationConfiguration.Oracle.UnlockWallet = config.Wallet{ - Path: "../../services/oracle/testdata/oracle1.json", - Password: "one", - } orc, err = oracle.NewOracle(oracle.Config{ Log: logger, Network: netmode.UnitTestNet, @@ -98,8 +110,8 @@ func getTestBlocks(t *testing.T) []*block.Block { return blocks } -func initClearServerWithServices(t testing.TB, needOracle bool, needNotary bool) (*core.Blockchain, *Server, *httptest.Server) { - chain, orc, cfg, logger := getUnitTestChain(t, needOracle, needNotary) +func initClearServerWithServices(t testing.TB, needOracle bool, needNotary bool, disableIteratorsSessions bool) (*core.Blockchain, *Server, *httptest.Server) { + chain, orc, cfg, logger := getUnitTestChain(t, needOracle, needNotary, disableIteratorsSessions) serverConfig := network.NewServerConfig(cfg) serverConfig.UserAgent = fmt.Sprintf(config.UserAgentFormat, "0.98.6-test") @@ -117,7 +129,7 @@ func initClearServerWithServices(t testing.TB, needOracle bool, needNotary bool) } func initClearServerWithInMemoryChain(t testing.TB) (*core.Blockchain, *Server, *httptest.Server) { - return initClearServerWithServices(t, false, false) + return initClearServerWithServices(t, false, false, false) } func initServerWithInMemoryChain(t *testing.T) (*core.Blockchain, *Server, *httptest.Server) { @@ -129,8 +141,8 @@ func initServerWithInMemoryChain(t *testing.T) (*core.Blockchain, *Server, *http return chain, rpcServer, srv } -func initServerWithInMemoryChainAndServices(t *testing.T, needOracle bool, needNotary bool) (*core.Blockchain, *Server, *httptest.Server) { - chain, rpcServer, srv := initClearServerWithServices(t, needOracle, needNotary) +func initServerWithInMemoryChainAndServices(t *testing.T, needOracle bool, needNotary bool, disableIteratorSessions bool) (*core.Blockchain, *Server, *httptest.Server) { + chain, rpcServer, srv := initClearServerWithServices(t, needOracle, needNotary, disableIteratorSessions) for _, b := range getTestBlocks(t) { require.NoError(t, chain.AddBlock(b)) diff --git a/pkg/rpc/server/server_test.go b/pkg/rpc/server/server_test.go index 7b659de93..f69af3308 100644 --- a/pkg/rpc/server/server_test.go +++ b/pkg/rpc/server/server_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" gio "io" + "math" "math/big" "net/http" "net/http/httptest" @@ -16,6 +17,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/nspcc-dev/neo-go/internal/testchain" "github.com/nspcc-dev/neo-go/internal/testserdes" @@ -62,7 +64,7 @@ type rpcTestCase struct { check func(t *testing.T, e *executor, result interface{}) } -const genesisBlockHash = "f42e2ae74bbea6aa1789fdc4efa35ad55b04335442637c091eafb5b0e779dae7" +const genesisBlockHash = "0f8fb4e17d2ab9f3097af75ca7fd16064160fb8043db94909e00dd4e257b9dc4" const testContractHash = "2db7d679c538ace5f00495c9e9d8ea95f1e0f5a5" const deploymentTxHash = "496bccb5cb0a008ef9b7a32c459e508ef24fbb0830f82bac9162afa4ca804839" @@ -76,6 +78,7 @@ const ( nfsoToken1ID = "7e244ffd6aa85fb1579d2ed22e9b761ab62e3486" invokescriptContractAVM = "VwIADBQBDAMOBQYMDQIODw0DDgcJAAAAAErZMCQE2zBwaEH4J+yMqiYEEUAMFA0PAwIJAAIBAwcDBAUCAQAOBgwJStkwJATbMHFpQfgn7IyqJgQSQBNA" block20StateRootLE = "af7fad57fc622305b162c4440295964168a07967d07244964e4ed0121b247dee" + storageContractHash = "ebc0c16a76c808cd4dde6bcc063f09e45e331ec7" ) var ( @@ -812,7 +815,7 @@ var rpcTestCases = map[string][]rpcTestCase{ require.True(t, ok) expected := result.UnclaimedGas{ Address: testchain.MultisigScriptHash(), - Unclaimed: *big.NewInt(10500), + Unclaimed: *big.NewInt(11000), } assert.Equal(t, expected, *actual) }, @@ -925,19 +928,19 @@ var rpcTestCases = map[string][]rpcTestCase{ chg := []storage.Operation{{ State: "Changed", Key: []byte{0xfa, 0xff, 0xff, 0xff, 0xb}, - Value: []byte{0x58, 0xe0, 0x6f, 0xeb, 0x53, 0x79, 0x12}, + Value: []byte{0x70, 0xd9, 0x59, 0x9d, 0x51, 0x79, 0x12}, }, { State: "Added", Key: []byte{0xfb, 0xff, 0xff, 0xff, 0x14, 0xd6, 0x24, 0x87, 0x12, 0xff, 0x97, 0x22, 0x80, 0xa0, 0xae, 0xf5, 0x24, 0x1c, 0x96, 0x4d, 0x63, 0x78, 0x29, 0xcd, 0xb}, - Value: []byte{0x41, 0x3, 0x21, 0x1, 0x1, 0x21, 0x1, 0x16, 0}, + Value: []byte{0x41, 0x03, 0x21, 0x01, 0x01, 0x21, 0x01, 0x17, 0}, }, { State: "Changed", Key: []byte{0xfb, 0xff, 0xff, 0xff, 0x14, 0xee, 0x9e, 0xa2, 0x2c, 0x27, 0xe3, 0x4b, 0xd0, 0x14, 0x8f, 0xc4, 0x10, 0x8e, 0x8, 0xf7, 0x4e, 0x8f, 0x50, 0x48, 0xb2}, - Value: []byte{0x41, 0x3, 0x21, 0x4, 0x2f, 0xd9, 0xf5, 0x5, 0x21, 0x1, 0x16, 0}, + Value: []byte{0x41, 0x03, 0x21, 0x04, 0x2f, 0xd9, 0xf5, 0x05, 0x21, 0x01, 0x17, 0}, }, { State: "Changed", Key: []byte{0xfa, 0xff, 0xff, 0xff, 0x14, 0xee, 0x9e, 0xa2, 0x2c, 0x27, 0xe3, 0x4b, 0xd0, 0x14, 0x8f, 0xc4, 0x10, 0x8e, 0x8, 0xf7, 0x4e, 0x8f, 0x50, 0x48, 0xb2}, - Value: []byte{0x41, 0x01, 0x21, 0x05, 0x50, 0x28, 0x27, 0x2d, 0x0b}, + Value: []byte{0x41, 0x01, 0x21, 0x05, 0x88, 0x3e, 0xfa, 0xdb, 0x08}, }} // Can be returned in any order. assert.ElementsMatch(t, chg, res.Diagnostics.Changes) @@ -1592,12 +1595,12 @@ var rpcTestCases = map[string][]rpcTestCase{ "sendrawtransaction": { { name: "positive", - params: `["ABsAAACWP5gAAAAAAEDaEgAAAAAAFgAAAAHunqIsJ+NL0BSPxBCOCPdOj1BIsoAAXgsDAOh2SBcAAAAMFBEmW7QXJQBBvgTo+iQOOPV8HlabDBTunqIsJ+NL0BSPxBCOCPdOj1BIshTAHwwIdHJhbnNmZXIMFPVj6kC8KD1NDgXEjqMFs/Kgc0DvQWJ9W1IBQgxAOv87rSn7OV7Y/wuVE58QaSz0o0wv37hWY08RZFP2kYYgSPvemZiT69wf6QeAUTABJ1JosxgIUory9vXv0kkpXSgMIQKzYiv0AXvf4xfFiu1fTHU/IGt9uJYEb6fXdLvEv3+NwkFW57Mn"]`, + params: `["ABwAAACWP5gAAAAAAEDaEgAAAAAAFwAAAAHunqIsJ+NL0BSPxBCOCPdOj1BIsoAAXgsDAOh2SBcAAAAMFBEmW7QXJQBBvgTo+iQOOPV8HlabDBTunqIsJ+NL0BSPxBCOCPdOj1BIshTAHwwIdHJhbnNmZXIMFPVj6kC8KD1NDgXEjqMFs/Kgc0DvQWJ9W1IBQgxAEh2U53FB2sU+eeLwTAUqMM5518nsDGil4Oi5IoBiMM7hvl6lKGoYIEaVkf7cS6x4MX1RmSHcoOabKFTyuEXI3SgMIQKzYiv0AXvf4xfFiu1fTHU/IGt9uJYEb6fXdLvEv3+NwkFW57Mn"]`, result: func(e *executor) interface{} { return &result.RelayResult{} }, check: func(t *testing.T, e *executor, inv interface{}) { res, ok := inv.(*result.RelayResult) require.True(t, ok) - expectedHash := "acc3e13102c211068d06ff64034d6f7e2d4db00c1703d0dec8afa73560664fe1" + expectedHash := "e4418a8bdad8cdf401aabb277c7bec279d0b0113812c09607039c4ad87204d90" assert.Equal(t, expectedHash, res.Hash.StringLE()) }, }, @@ -1689,7 +1692,7 @@ func TestRPC(t *testing.T) { } func TestSubmitOracle(t *testing.T) { - chain, rpcSrv, httpSrv := initClearServerWithServices(t, true, false) + chain, rpcSrv, httpSrv := initClearServerWithServices(t, true, false, false) defer chain.Close() defer rpcSrv.Shutdown() @@ -1725,7 +1728,7 @@ func TestSubmitNotaryRequest(t *testing.T) { rpc := `{"jsonrpc": "2.0", "id": 1, "method": "submitnotaryrequest", "params": %s}` t.Run("disabled P2PSigExtensions", func(t *testing.T) { - chain, rpcSrv, httpSrv := initClearServerWithServices(t, false, false) + chain, rpcSrv, httpSrv := initClearServerWithServices(t, false, false, false) defer chain.Close() defer rpcSrv.Shutdown() req := fmt.Sprintf(rpc, "[]") @@ -1733,7 +1736,7 @@ func TestSubmitNotaryRequest(t *testing.T) { checkErrGetResult(t, body, true) }) - chain, rpcSrv, httpSrv := initServerWithInMemoryChainAndServices(t, false, true) + chain, rpcSrv, httpSrv := initServerWithInMemoryChainAndServices(t, false, true, false) defer chain.Close() defer rpcSrv.Shutdown() @@ -2219,7 +2222,7 @@ func testRPCProtocol(t *testing.T, doRPCCall func(string, string, *testing.T) [] require.NoErrorf(t, err, "could not parse response: %s", txOut) assert.Equal(t, *block.Transactions[0], actual.Transaction) - assert.Equal(t, 22, actual.Confirmations) + assert.Equal(t, 23, actual.Confirmations) assert.Equal(t, TXHash, actual.Transaction.Hash()) }) @@ -2332,12 +2335,114 @@ func testRPCProtocol(t *testing.T, doRPCCall func(string, string, *testing.T) [] require.NoError(t, json.Unmarshal(res, actual)) checkNep17TransfersAux(t, e, actual, sent, rcvd) } - t.Run("time frame only", func(t *testing.T) { testNEP17T(t, 4, 5, 0, 0, []int{17, 18, 19, 20}, []int{3, 4}) }) + t.Run("time frame only", func(t *testing.T) { testNEP17T(t, 4, 5, 0, 0, []int{18, 19, 20, 21}, []int{3, 4}) }) t.Run("no res", func(t *testing.T) { testNEP17T(t, 100, 100, 0, 0, []int{}, []int{}) }) - t.Run("limit", func(t *testing.T) { testNEP17T(t, 1, 7, 3, 0, []int{14, 15}, []int{2}) }) - t.Run("limit 2", func(t *testing.T) { testNEP17T(t, 4, 5, 2, 0, []int{17}, []int{3}) }) - t.Run("limit with page", func(t *testing.T) { testNEP17T(t, 1, 7, 3, 1, []int{16, 17}, []int{3}) }) - t.Run("limit with page 2", func(t *testing.T) { testNEP17T(t, 1, 7, 3, 2, []int{18, 19}, []int{4}) }) + t.Run("limit", func(t *testing.T) { testNEP17T(t, 1, 7, 3, 0, []int{15, 16}, []int{2}) }) + t.Run("limit 2", func(t *testing.T) { testNEP17T(t, 4, 5, 2, 0, []int{18}, []int{3}) }) + t.Run("limit with page", func(t *testing.T) { testNEP17T(t, 1, 7, 3, 1, []int{17, 18}, []int{3}) }) + t.Run("limit with page 2", func(t *testing.T) { testNEP17T(t, 1, 7, 3, 2, []int{19, 20}, []int{4}) }) + }) + + prepareIteratorSession := func(t *testing.T) (uuid.UUID, uuid.UUID) { + rpc := fmt.Sprintf(`{"jsonrpc": "2.0", "id": 1, "method": "invokefunction", "params": ["%s", "iterateOverValues"]}"`, storageContractHash) + body := doRPCCall(rpc, httpSrv.URL, t) + resp := checkErrGetResult(t, body, false) + res := new(result.Invoke) + err := json.Unmarshal(resp, &res) + require.NoErrorf(t, err, "could not parse response: %s", resp) + require.NotEmpty(t, res.Session) + require.Equal(t, 1, len(res.Stack)) + require.Equal(t, stackitem.InteropT, res.Stack[0].Type()) + iterator, ok := res.Stack[0].Value().(result.Iterator) + require.True(t, ok) + require.NotEmpty(t, iterator.ID) + return res.Session, *iterator.ID + } + t.Run("traverseiterator", func(t *testing.T) { + t.Run("good", func(t *testing.T) { + sID, iID := prepareIteratorSession(t) + expectedCount := 99 + rpc := fmt.Sprintf(`{"jsonrpc": "2.0", "id": 1, "method": "traverseiterator", "params": ["%s", "%s", %d]}"`, sID.String(), iID.String(), expectedCount) + body := doRPCCall(rpc, httpSrv.URL, t) + resp := checkErrGetResult(t, body, false) + res := new([]json.RawMessage) + require.NoError(t, json.Unmarshal(resp, res)) + require.Equal(t, expectedCount, len(*res)) + }) + t.Run("invalid session id", func(t *testing.T) { + _, iID := prepareIteratorSession(t) + rpc := fmt.Sprintf(`{"jsonrpc": "2.0", "id": 1, "method": "traverseiterator", "params": ["not-a-uuid", "%s", %d]}"`, iID.String(), 1) + body := doRPCCall(rpc, httpSrv.URL, t) + checkErrGetResult(t, body, true, "invalid session ID: not a valid UUID") + }) + t.Run("invalid iterator id", func(t *testing.T) { + sID, _ := prepareIteratorSession(t) + rpc := fmt.Sprintf(`{"jsonrpc": "2.0", "id": 1, "method": "traverseiterator", "params": ["%s", "not-a-uuid", %d]}"`, sID.String(), 1) + body := doRPCCall(rpc, httpSrv.URL, t) + checkErrGetResult(t, body, true, "invalid iterator ID: not a valid UUID") + }) + t.Run("invalid items count", func(t *testing.T) { + sID, iID := prepareIteratorSession(t) + rpc := fmt.Sprintf(`{"jsonrpc": "2.0", "id": 1, "method": "traverseiterator", "params": ["%s", "%s"]}"`, sID.String(), iID.String()) + body := doRPCCall(rpc, httpSrv.URL, t) + checkErrGetResult(t, body, true, "invalid iterator items count") + }) + t.Run("items count is not an int32", func(t *testing.T) { + sID, iID := prepareIteratorSession(t) + rpc := fmt.Sprintf(`{"jsonrpc": "2.0", "id": 1, "method": "traverseiterator", "params": ["%s", "%s", %d]}"`, sID.String(), iID.String(), math.MaxInt32+1) + body := doRPCCall(rpc, httpSrv.URL, t) + checkErrGetResult(t, body, true, "invalid iterator items count: not an int32") + }) + t.Run("count is out of range", func(t *testing.T) { + sID, iID := prepareIteratorSession(t) + rpc := fmt.Sprintf(`{"jsonrpc": "2.0", "id": 1, "method": "traverseiterator", "params": ["%s", "%s", %d]}"`, sID.String(), iID.String(), rpcSrv.config.MaxIteratorResultItems+1) + body := doRPCCall(rpc, httpSrv.URL, t) + checkErrGetResult(t, body, true, fmt.Sprintf("iterator items count is out of range (%d at max)", rpcSrv.config.MaxIteratorResultItems)) + }) + t.Run("unknown session", func(t *testing.T) { + _, iID := prepareIteratorSession(t) + rpc := fmt.Sprintf(`{"jsonrpc": "2.0", "id": 1, "method": "traverseiterator", "params": ["%s", "%s", %d]}"`, uuid.NewString(), iID.String(), 1) + body := doRPCCall(rpc, httpSrv.URL, t) + resp := checkErrGetResult(t, body, false) + res := new([]json.RawMessage) + require.NoError(t, json.Unmarshal(resp, res)) + require.Equal(t, 0, len(*res)) // No errors expected, no elements should be returned. + }) + t.Run("unknown iterator", func(t *testing.T) { + sID, _ := prepareIteratorSession(t) + rpc := fmt.Sprintf(`{"jsonrpc": "2.0", "id": 1, "method": "traverseiterator", "params": ["%s", "%s", %d]}"`, sID.String(), uuid.NewString(), 1) + body := doRPCCall(rpc, httpSrv.URL, t) + resp := checkErrGetResult(t, body, false) + res := new([]json.RawMessage) + require.NoError(t, json.Unmarshal(resp, res)) + require.Equal(t, 0, len(*res)) // No errors expected, no elements should be returned. + }) + }) + t.Run("terminatesession", func(t *testing.T) { + check := func(t *testing.T, id string, expected bool) { + rpc := fmt.Sprintf(`{"jsonrpc": "2.0", "id": 1, "method": "terminatesession", "params": ["%s"]}"`, id) + body := doRPCCall(rpc, httpSrv.URL, t) + resp := checkErrGetResult(t, body, false) + res := new(bool) + require.NoError(t, json.Unmarshal(resp, res)) + require.Equal(t, expected, *res) + } + t.Run("true", func(t *testing.T) { + sID, _ := prepareIteratorSession(t) + check(t, sID.String(), true) + }) + t.Run("false", func(t *testing.T) { + check(t, uuid.NewString(), false) + }) + t.Run("expired", func(t *testing.T) { + _, _ = prepareIteratorSession(t) + // Wait until session is terminated by timer. + require.Eventually(t, func() bool { + rpcSrv.sessionsLock.Lock() + defer rpcSrv.sessionsLock.Unlock() + return len(rpcSrv.sessions) == 0 + }, 2*time.Duration(rpcSrv.config.SessionExpirationTime)*time.Second, 10*time.Millisecond) + }) }) } @@ -2367,7 +2472,7 @@ func (tc rpcTestCase) getResultPair(e *executor) (expected interface{}, res inte return expected, res } -func checkErrGetResult(t *testing.T, body []byte, expectingFail bool) json.RawMessage { +func checkErrGetResult(t *testing.T, body []byte, expectingFail bool, expectedErr ...string) json.RawMessage { var resp response.Raw err := json.Unmarshal(body, &resp) require.Nil(t, err) @@ -2375,6 +2480,9 @@ func checkErrGetResult(t *testing.T, body []byte, expectingFail bool) json.RawMe require.NotNil(t, resp.Error) assert.NotEqual(t, 0, resp.Error.Code) assert.NotEqual(t, "", resp.Error.Message) + if len(expectedErr) != 0 { + assert.True(t, strings.Contains(resp.Error.Error(), expectedErr[0]), fmt.Sprintf("expected: %s, got: %s", expectedErr[0], resp.Error.Error())) + } } else { assert.Nil(t, resp.Error) } @@ -2483,9 +2591,9 @@ func checkNep17Balances(t *testing.T, e *executor, acc interface{}) { }, { Asset: e.chain.UtilityTokenHash(), - Amount: "47102199200", + Amount: "37100367680", + LastUpdated: 22, Decimals: 8, - LastUpdated: 19, Name: "GasToken", Symbol: "GAS", }}, @@ -2598,7 +2706,7 @@ func checkNep11TransfersAux(t *testing.T, e *executor, acc interface{}, sent, rc } func checkNep17Transfers(t *testing.T, e *executor, acc interface{}) { - checkNep17TransfersAux(t, e, acc, []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22}, []int{0, 1, 2, 3, 4, 5, 6, 7, 8}) + checkNep17TransfersAux(t, e, acc, []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23}, []int{0, 1, 2, 3, 4, 5, 6, 7, 8}) } func checkNep17TransfersAux(t *testing.T, e *executor, acc interface{}, sent, rcvd []int) { @@ -2607,6 +2715,11 @@ func checkNep17TransfersAux(t *testing.T, e *executor, acc interface{}, sent, rc rublesHash, err := util.Uint160DecodeStringLE(testContractHash) require.NoError(t, err) + blockDeploy6, err := e.chain.GetBlock(e.chain.GetHeaderHash(22)) // deploy Storage contract (storage_contract.go) + require.NoError(t, err) + require.Equal(t, 1, len(blockDeploy6.Transactions)) + txDeploy6 := blockDeploy6.Transactions[0] + blockTransferNFSO, err := e.chain.GetBlock(e.chain.GetHeaderHash(19)) // transfer 0.25 NFSO from priv0 to priv1. require.NoError(t, err) require.Equal(t, 1, len(blockTransferNFSO.Transactions)) @@ -2706,6 +2819,14 @@ func checkNep17TransfersAux(t *testing.T, e *executor, acc interface{}, sent, rc // duplicate the Server method. expected := result.NEP17Transfers{ Sent: []result.NEP17Transfer{ + { + Timestamp: blockDeploy6.Timestamp, + Asset: e.chain.UtilityTokenHash(), + Address: "", // burn + Amount: big.NewInt(txDeploy6.SystemFee + txDeploy6.NetworkFee).String(), + Index: 22, + TxHash: blockDeploy6.Hash(), + }, { Timestamp: blockTransferNFSO.Timestamp, Asset: e.chain.UtilityTokenHash(), @@ -3002,7 +3123,7 @@ func TestEscapeForLog(t *testing.T) { } func BenchmarkHandleIn(b *testing.B) { - chain, orc, cfg, logger := getUnitTestChain(b, false, false) + chain, orc, cfg, logger := getUnitTestChain(b, false, false, false) serverConfig := network.NewServerConfig(cfg) serverConfig.UserAgent = fmt.Sprintf(config.UserAgentFormat, "0.98.6-test") diff --git a/pkg/rpc/server/testdata/testblocks.acc b/pkg/rpc/server/testdata/testblocks.acc index f4a0b9c6bfc457a9703cafc4abae47af5b2888eb..62dbbe3dfbc4fcddabdf39bdc2b6db94a5d5ed6d 100644 GIT binary patch delta 628 zcmbQ*#-7DOLtBIFo&0;?*O~)41oYwq$wb^`xcs`ll$@J+}^hiT=Dg zF}e43W>oxV*@aW8HuqoK>Av|b)5XlVgh%V;rYfuMy;;ZhY~Cx$_o0jsvp}TSDoYe(pPenNH&plNsKwDm$;c z`nUer@5pKN`f94mmup8}g~ll?U3UMx`=o{y=6oRSA2D$;b@`RsjEg_ zC5@&`vs_m7dh}eMb!SG{o27PQZpz8mI`%aDcWKTO?oUy_a6gN&K$UehSN_z>jGn)f zFG^1nl?Hk033mt+kV^Oj<^la^FE1^+fr*><24l5SUSe*ll9f_$Nq$jc`s6qR`N^k~ zgGAy}QVVkOE0uJV^3&3aQ%jVr3{5608cIyw#VI^_mqCzRW=U#MVo9ohS!z*OVoqsl zF_Kbq)5#B$l$_j|Ej0PQP6Fea$qu@n$#G$fJdBK9Hw{!+ZW|;xgflZ$u2M61 zoc%5RT!BDgCgXpGt!f$(UN_WOU3nOI7#W}{y#z8ZbQfi0Wd=JwvzKVeoKvWD)ghdb z0irP@LL}JnzQK-J4v$Wnte?EkPs`DXMKEi}8O_(%rL^Ua9=od{@ao}4`|RQ=HG<8S z!R^;2bu?Ys*YUf(51Y~Y?zr#I<&Ud>ZLjhcv2^{Ewc)<*@n0H~6)Th{*VnKD0A?%! A8UO$Q delta 12 TcmdlyfoW