From 9862b40f2c6b7fc4557950e2e7bb7062990cf3e0 Mon Sep 17 00:00:00 2001 From: Anna Shaleva Date: Tue, 26 Apr 2022 12:32:06 +0300 Subject: [PATCH] rpc: support InitialGasDistribution response from old Neo-Go nodes https://github.com/nspcc-dev/neo-go/pull/2435 breaks compatibility between newer RPC clients and older RPC servers with the following error: ``` failed to get network magic: json: cannot unmarshal string into Go struct field Protocol.protocol.initialgasdistribution of type int64 ``` This behaviour is expected, but we can't allow this radical change. Thus, the following solution is implemented: 1. RPC server responds with proper non-stringified InitialGasDistribution value. The value represents an integral of fixed8 multiplied by the decimals. 2. RPC client is able to distinguish older and newer responses. For older one the stringified value without decimals part is expected. For newer responses the int64 value with decimal part is expected. The cludge will be present in the code for a while until nodes of version <=0.98.3 become completely absolete. --- cli/executor_test.go | 2 + go.mod | 1 + go.sum | 2 + pkg/config/config.go | 11 +- pkg/rpc/response/result/version.go | 163 ++++++++++++++++++++++-- pkg/rpc/response/result/version_test.go | 143 +++++++++++++++++++++ pkg/rpc/server/server.go | 2 +- pkg/rpc/server/server_helper_test.go | 2 + pkg/rpc/server/server_test.go | 4 +- 9 files changed, 316 insertions(+), 14 deletions(-) create mode 100644 pkg/rpc/response/result/version_test.go diff --git a/cli/executor_test.go b/cli/executor_test.go index 978568b09..a61b7f0a2 100644 --- a/cli/executor_test.go +++ b/cli/executor_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "errors" + "fmt" "io" "math" "strings" @@ -137,6 +138,7 @@ func newTestChain(t *testing.T, f func(*config.Config), run bool) (*core.Blockch } serverConfig := network.NewServerConfig(cfg) + serverConfig.UserAgent = fmt.Sprintf(config.UserAgentFormat, "0.98.3-test") netSrv, err := network.NewServer(serverConfig, chain, chain.GetStateSyncModule(), zap.NewNop()) require.NoError(t, err) cons, err := consensus.NewService(consensus.Config{ diff --git a/go.mod b/go.mod index aaceb2fa0..3fcdf5c56 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/nspcc-dev/neo-go require ( github.com/btcsuite/btcd v0.22.0-beta 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/gorilla/websocket v1.4.2 github.com/hashicorp/golang-lru v0.5.4 diff --git a/go.sum b/go.sum index 31641fef4..949627592 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= diff --git a/pkg/config/config.go b/pkg/config/config.go index 73d7f9ff8..bbaaf2cd6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,7 +9,14 @@ import ( "gopkg.in/yaml.v2" ) -const userAgentFormat = "/NEO-GO:%s/" +const ( + // UserAgentWrapper is a string that user agent string should be wrapped into. + UserAgentWrapper = "/" + // UserAgentPrefix is a prefix used to generate user agent string. + UserAgentPrefix = "NEO-GO:" + // UserAgentFormat is a formatted string used to generate user agent string. + UserAgentFormat = UserAgentWrapper + UserAgentPrefix + "%s" + UserAgentWrapper +) // Version the version of the node, set at build time. var Version string @@ -23,7 +30,7 @@ type Config struct { // GenerateUserAgent creates user agent string based on build time environment. func (c Config) GenerateUserAgent() string { - return fmt.Sprintf(userAgentFormat, Version) + return fmt.Sprintf(UserAgentFormat, Version) } // Load attempts to load the config from the given diff --git a/pkg/rpc/response/result/version.go b/pkg/rpc/response/result/version.go index cb0142f08..b2823a810 100644 --- a/pkg/rpc/response/result/version.go +++ b/pkg/rpc/response/result/version.go @@ -1,6 +1,12 @@ package result import ( + "encoding/json" + "fmt" + "strings" + + "github.com/coreos/go-semver/semver" + "github.com/nspcc-dev/neo-go/pkg/config" "github.com/nspcc-dev/neo-go/pkg/config/netmode" "github.com/nspcc-dev/neo-go/pkg/encoding/fixedn" ) @@ -11,19 +17,47 @@ type ( Version struct { // Magic contains network magic. // Deprecated: use Protocol.StateRootInHeader instead - Magic netmode.Magic `json:"network"` - TCPPort uint16 `json:"tcpport"` - WSPort uint16 `json:"wsport,omitempty"` - Nonce uint32 `json:"nonce"` - UserAgent string `json:"useragent"` - Protocol Protocol `json:"protocol"` + Magic netmode.Magic + TCPPort uint16 + WSPort uint16 + Nonce uint32 + UserAgent string + Protocol Protocol // StateRootInHeader is true if state root is contained in block header. // Deprecated: use Protocol.StateRootInHeader instead - StateRootInHeader bool `json:"staterootinheader,omitempty"` + StateRootInHeader bool } // Protocol represents network-dependent parameters. Protocol struct { + AddressVersion byte + Network netmode.Magic + MillisecondsPerBlock int + MaxTraceableBlocks uint32 + MaxValidUntilBlockIncrement uint32 + MaxTransactionsPerBlock uint16 + MemoryPoolMaxTransactions int + ValidatorsCount byte + InitialGasDistribution fixedn.Fixed8 + // StateRootInHeader is true if state root is contained in block header. + StateRootInHeader bool + } +) + +type ( + // versionMarshallerAux is an auxiliary struct used for Version JSON marshalling. + versionMarshallerAux struct { + Magic netmode.Magic `json:"network"` + TCPPort uint16 `json:"tcpport"` + WSPort uint16 `json:"wsport,omitempty"` + Nonce uint32 `json:"nonce"` + UserAgent string `json:"useragent"` + Protocol protocolMarshallerAux `json:"protocol"` + StateRootInHeader bool `json:"staterootinheader,omitempty"` + } + + // protocolMarshallerAux is an auxiliary struct used for Protocol JSON marshalling. + protocolMarshallerAux struct { AddressVersion byte `json:"addressversion"` Network netmode.Magic `json:"network"` MillisecondsPerBlock int `json:"msperblock"` @@ -32,8 +66,117 @@ type ( MaxTransactionsPerBlock uint16 `json:"maxtransactionsperblock"` MemoryPoolMaxTransactions int `json:"memorypoolmaxtransactions"` ValidatorsCount byte `json:"validatorscount"` - InitialGasDistribution fixedn.Fixed8 `json:"initialgasdistribution"` - // StateRootInHeader is true if state root is contained in block header. - StateRootInHeader bool `json:"staterootinheader,omitempty"` + InitialGasDistribution int64 `json:"initialgasdistribution"` + StateRootInHeader bool `json:"staterootinheader,omitempty"` + } + + // versionUnmarshallerAux is an auxiliary struct used for Version JSON unmarshalling. + versionUnmarshallerAux struct { + Magic netmode.Magic `json:"network"` + TCPPort uint16 `json:"tcpport"` + WSPort uint16 `json:"wsport,omitempty"` + Nonce uint32 `json:"nonce"` + UserAgent string `json:"useragent"` + Protocol protocolUnmarshallerAux `json:"protocol"` + StateRootInHeader bool `json:"staterootinheader,omitempty"` + } + + // protocolUnmarshallerAux is an auxiliary struct used for Protocol JSON unmarshalling. + protocolUnmarshallerAux struct { + AddressVersion byte `json:"addressversion"` + Network netmode.Magic `json:"network"` + MillisecondsPerBlock int `json:"msperblock"` + MaxTraceableBlocks uint32 `json:"maxtraceableblocks"` + MaxValidUntilBlockIncrement uint32 `json:"maxvaliduntilblockincrement"` + MaxTransactionsPerBlock uint16 `json:"maxtransactionsperblock"` + MemoryPoolMaxTransactions int `json:"memorypoolmaxtransactions"` + ValidatorsCount byte `json:"validatorscount"` + InitialGasDistribution json.RawMessage `json:"initialgasdistribution"` + StateRootInHeader bool `json:"staterootinheader,omitempty"` } ) + +// latestNonBreakingVersion is a latest NeoGo revision that keeps older RPC +// clients compatibility with newer RPC servers (https://github.com/nspcc-dev/neo-go/pull/2435). +var latestNonBreakingVersion = *semver.New("0.98.2") + +// MarshalJSON implements the json marshaller interface. +func (v *Version) MarshalJSON() ([]byte, error) { + aux := versionMarshallerAux{ + Magic: v.Magic, + TCPPort: v.TCPPort, + WSPort: v.WSPort, + Nonce: v.Nonce, + UserAgent: v.UserAgent, + Protocol: protocolMarshallerAux{ + AddressVersion: v.Protocol.AddressVersion, + Network: v.Protocol.Network, + MillisecondsPerBlock: v.Protocol.MillisecondsPerBlock, + MaxTraceableBlocks: v.Protocol.MaxTraceableBlocks, + MaxValidUntilBlockIncrement: v.Protocol.MaxValidUntilBlockIncrement, + MaxTransactionsPerBlock: v.Protocol.MaxTransactionsPerBlock, + MemoryPoolMaxTransactions: v.Protocol.MemoryPoolMaxTransactions, + ValidatorsCount: v.Protocol.ValidatorsCount, + InitialGasDistribution: int64(v.Protocol.InitialGasDistribution), + StateRootInHeader: v.Protocol.StateRootInHeader, + }, + StateRootInHeader: v.StateRootInHeader, + } + return json.Marshal(aux) +} + +// UnmarshalJSON implements the json unmarshaller interface. +func (v *Version) UnmarshalJSON(data []byte) error { + var aux versionUnmarshallerAux + err := json.Unmarshal(data, &aux) + if err != nil { + return err + } + v.Magic = aux.Magic + v.TCPPort = aux.TCPPort + v.WSPort = aux.WSPort + v.Nonce = aux.Nonce + v.UserAgent = aux.UserAgent + v.Protocol.AddressVersion = aux.Protocol.AddressVersion + v.Protocol.Network = aux.Protocol.Network + v.Protocol.MillisecondsPerBlock = aux.Protocol.MillisecondsPerBlock + v.Protocol.MaxTraceableBlocks = aux.Protocol.MaxTraceableBlocks + v.Protocol.MaxValidUntilBlockIncrement = aux.Protocol.MaxValidUntilBlockIncrement + v.Protocol.MaxTransactionsPerBlock = aux.Protocol.MaxTransactionsPerBlock + v.Protocol.MemoryPoolMaxTransactions = aux.Protocol.MemoryPoolMaxTransactions + v.Protocol.ValidatorsCount = aux.Protocol.ValidatorsCount + v.Protocol.StateRootInHeader = aux.Protocol.StateRootInHeader + v.StateRootInHeader = aux.StateRootInHeader + if len(aux.Protocol.InitialGasDistribution) == 0 { + return nil + } + + if strings.HasPrefix(v.UserAgent, config.UserAgentWrapper+config.UserAgentPrefix) { + ver, err := userAgentToVersion(v.UserAgent) + if err == nil && ver.Compare(latestNonBreakingVersion) <= 0 { + err := json.Unmarshal(aux.Protocol.InitialGasDistribution, &v.Protocol.InitialGasDistribution) + if err != nil { + return fmt.Errorf("failed to unmarshal InitialGASDistribution into fixed8: %w", err) + } + return nil + } + } + var val int64 + err = json.Unmarshal(aux.Protocol.InitialGasDistribution, &val) + if err != nil { + return fmt.Errorf("failed to unmarshal InitialGASDistribution into int64: %w", err) + } + v.Protocol.InitialGasDistribution = fixedn.Fixed8(val) + + return nil +} + +func userAgentToVersion(userAgent string) (*semver.Version, error) { + verStr := strings.Trim(userAgent, config.UserAgentWrapper) + verStr = strings.TrimPrefix(verStr, config.UserAgentPrefix) + ver, err := semver.NewVersion(verStr) + if err != nil { + return nil, fmt.Errorf("can't retrieve neo-go version from UserAgent: %w", err) + } + return ver, nil +} diff --git a/pkg/rpc/response/result/version_test.go b/pkg/rpc/response/result/version_test.go new file mode 100644 index 000000000..1c6ddfe10 --- /dev/null +++ b/pkg/rpc/response/result/version_test.go @@ -0,0 +1,143 @@ +package result + +import ( + "encoding/json" + "testing" + + "github.com/nspcc-dev/neo-go/pkg/encoding/fixedn" + "github.com/stretchr/testify/require" +) + +func TestVersion_MarshalUnmarshalJSON(t *testing.T) { + responseFromGoOld := `{ + "network": 860833102, + "nonce": 1677922561, + "protocol": { + "addressversion": 53, + "initialgasdistribution": "52000000", + "maxtraceableblocks": 2102400, + "maxtransactionsperblock": 512, + "maxvaliduntilblockincrement": 5760, + "memorypoolmaxtransactions": 50000, + "msperblock": 15000, + "network": 860833102, + "validatorscount": 7 + }, + "tcpport": 10333, + "useragent": "/NEO-GO:0.98.2/", + "wsport": 10334 + }` + responseFromGoNew := `{ + "network": 860833102, + "nonce": 1677922561, + "protocol": { + "addressversion": 53, + "initialgasdistribution": 5200000000000000, + "maxtraceableblocks": 2102400, + "maxtransactionsperblock": 512, + "maxvaliduntilblockincrement": 5760, + "memorypoolmaxtransactions": 50000, + "msperblock": 15000, + "network": 860833102, + "validatorscount": 7 + }, + "tcpport": 10333, + "useragent": "/NEO-GO:0.98.3/", + "wsport": 10334 + }` + responseFromSharp := `{ + "nonce": 1677922561, + "protocol": { + "addressversion": 53, + "initialgasdistribution": 5200000000000000, + "maxtraceableblocks": 2102400, + "maxtransactionsperblock": 512, + "maxvaliduntilblockincrement": 5760, + "memorypoolmaxtransactions": 50000, + "msperblock": 15000, + "network": 860833102, + "validatorscount": 7 + }, + "tcpport": 10333, + "useragent": "/Neo:3.1.0/", + "wsport": 10334 + }` + v := &Version{ + Magic: 860833102, + TCPPort: 10333, + WSPort: 10334, + Nonce: 1677922561, + UserAgent: "/NEO-GO:0.98.3/", + Protocol: Protocol{ + AddressVersion: 53, + Network: 860833102, + MillisecondsPerBlock: 15000, + MaxTraceableBlocks: 2102400, + MaxValidUntilBlockIncrement: 5760, + MaxTransactionsPerBlock: 512, + MemoryPoolMaxTransactions: 50000, + ValidatorsCount: 7, + // Unmarshalled InitialGasDistribution should always be a valid Fixed8 for both old and new clients. + InitialGasDistribution: fixedn.Fixed8FromInt64(52000000), + StateRootInHeader: false, + }, + StateRootInHeader: false, + } + t.Run("MarshalJSON", func(t *testing.T) { + actual, err := json.Marshal(v) + require.NoError(t, err) + require.JSONEq(t, responseFromGoNew, string(actual)) + }) + t.Run("UnmarshalJSON", func(t *testing.T) { + t.Run("Go node response", func(t *testing.T) { + t.Run("old RPC server", func(t *testing.T) { + actual := &Version{} + require.NoError(t, json.Unmarshal([]byte(responseFromGoOld), actual)) + expected := new(Version) + *expected = *v + expected.UserAgent = "/NEO-GO:0.98.2/" + require.Equal(t, expected, actual) + }) + t.Run("new RPC server", func(t *testing.T) { + actual := &Version{} + require.NoError(t, json.Unmarshal([]byte(responseFromGoNew), actual)) + require.Equal(t, v, actual) + }) + }) + t.Run("Sharp node response", func(t *testing.T) { + actual := &Version{} + require.NoError(t, json.Unmarshal([]byte(responseFromSharp), actual)) + expected := new(Version) + *expected = *v + expected.UserAgent = "/Neo:3.1.0/" + expected.Magic = 0 // No magic in C#. + require.Equal(t, expected, actual) + }) + }) +} + +func TestVersionFromUserAgent(t *testing.T) { + type testCase struct { + success bool + cmpWithBreaking int + } + var testcases = map[string]testCase{ + "/Neo:3.1.0/": {success: false}, + "/NEO-GO:0.98.4": {success: true, cmpWithBreaking: 1}, + "/NEO-GO:0.98.4-pre-12344/": {success: true, cmpWithBreaking: 1}, + "/NEO-GO:0.98.3/": {success: true, cmpWithBreaking: 1}, + "/NEO-GO:0.98.3-pre-123/": {success: true, cmpWithBreaking: 1}, + "/NEO-GO:0.98.2/": {success: true, cmpWithBreaking: 0}, + "/NEO-GO:0.98.2-pre-12345/": {success: true, cmpWithBreaking: -1}, + "/NEO-GO:123456": {success: false}, + } + for str, tc := range testcases { + ver, err := userAgentToVersion(str) + if tc.success { + require.NoError(t, err) + require.Equal(t, ver.Compare(latestNonBreakingVersion), tc.cmpWithBreaking) + } else { + require.Error(t, err) + } + } +} diff --git a/pkg/rpc/server/server.go b/pkg/rpc/server/server.go index 079b05fb7..d08758173 100644 --- a/pkg/rpc/server/server.go +++ b/pkg/rpc/server/server.go @@ -536,7 +536,7 @@ func (s *Server) getVersion(_ request.Params) (interface{}, *response.Error) { } cfg := s.chain.GetConfig() - return result.Version{ + return &result.Version{ Magic: s.network, TCPPort: port, Nonce: s.coreServer.ID(), diff --git a/pkg/rpc/server/server_helper_test.go b/pkg/rpc/server/server_helper_test.go index 4ea76cc46..56bd4aadb 100644 --- a/pkg/rpc/server/server_helper_test.go +++ b/pkg/rpc/server/server_helper_test.go @@ -1,6 +1,7 @@ package server import ( + "fmt" "math/big" "net/http" "net/http/httptest" @@ -101,6 +102,7 @@ func initClearServerWithServices(t testing.TB, needOracle bool, needNotary bool) chain, orc, cfg, logger := getUnitTestChain(t, needOracle, needNotary) serverConfig := network.NewServerConfig(cfg) + serverConfig.UserAgent = fmt.Sprintf(config.UserAgentFormat, "0.98.3-test") serverConfig.Port = 0 server, err := network.NewServer(serverConfig, chain, chain.GetStateSyncModule(), logger) require.NoError(t, err) diff --git a/pkg/rpc/server/server_test.go b/pkg/rpc/server/server_test.go index 0b28ab710..868e19eb4 100644 --- a/pkg/rpc/server/server_test.go +++ b/pkg/rpc/server/server_test.go @@ -19,6 +19,7 @@ import ( "github.com/gorilla/websocket" "github.com/nspcc-dev/neo-go/internal/testchain" "github.com/nspcc-dev/neo-go/internal/testserdes" + "github.com/nspcc-dev/neo-go/pkg/config" "github.com/nspcc-dev/neo-go/pkg/core" "github.com/nspcc-dev/neo-go/pkg/core/block" "github.com/nspcc-dev/neo-go/pkg/core/fee" @@ -849,7 +850,7 @@ var rpcTestCases = map[string][]rpcTestCase{ check: func(t *testing.T, e *executor, ver interface{}) { resp, ok := ver.(*result.Version) require.True(t, ok) - require.Equal(t, "/NEO-GO:/", resp.UserAgent) + require.Equal(t, "/NEO-GO:0.98.3-test/", resp.UserAgent) cfg := e.chain.GetConfig() require.EqualValues(t, address.NEO3Prefix, resp.Protocol.AddressVersion) @@ -2605,6 +2606,7 @@ func BenchmarkHandleIn(b *testing.B) { chain, orc, cfg, logger := getUnitTestChain(b, false, false) serverConfig := network.NewServerConfig(cfg) + serverConfig.UserAgent = fmt.Sprintf(config.UserAgentFormat, "0.98.3-test") serverConfig.LogLevel = zapcore.FatalLevel server, err := network.NewServer(serverConfig, chain, chain.GetStateSyncModule(), logger) require.NoError(b, err)