diff --git a/common/json_rpc.go b/common/json_rpc.go index 027799441..5a0021939 100644 --- a/common/json_rpc.go +++ b/common/json_rpc.go @@ -371,7 +371,10 @@ func (r *JsonRpcResponse) ParseFromStream(ctx []context.Context, reader io.Reade } } - if len(temp.Error) > 0 { + // Bitcoin-family nodes often emit `"error": null` on success. Treat that + // (and missing error) as no error — ParseError("null") used to invent a + // server-side exception and fail the whole upstream attempt. + if len(temp.Error) > 0 && string(temp.Error) != "null" { if err := r.ParseError(string(temp.Error)); err != nil { return err } @@ -399,11 +402,17 @@ func (r *JsonRpcResponse) ParseError(raw string) error { r.errBytes = nil + // JSON-RPC allows "error": null (Bitcoin Core / dogecoind / litecoind). + // That means success — do not fabricate a server-side exception. + if raw == "null" { + return nil + } + // First attempt to unmarshal the error as a typical JSON-RPC error var rpcErr ErrJsonRpcExceptionExternal if err := SonicCfg.UnmarshalFromString(raw, &rpcErr); err != nil { // Special case: check for non-standard error structures in the raw data - if raw == "" || raw == "null" { + if raw == "" { r.Error = NewErrJsonRpcExceptionExternal( int(JsonRpcErrorServerSideException), "unexpected empty response from upstream endpoint", @@ -1205,7 +1214,10 @@ type JsonRpcRequest struct { JSONRPC string `json:"jsonrpc,omitempty"` ID interface{} `json:"id,omitempty"` Method string `json:"method"` - Params []interface{} `json:"params"` + // omitempty: Stellar (and some other non-EVM) reject "params":[] — they + // expect the field absent (or an object). Empty/nil params are omitted on + // the wire; EVM nodes accept both forms. + Params []interface{} `json:"params,omitempty"` // idRaw stores the verbatim bytes of the id as received from the client. // This is used to round-trip the id back without precision loss for ids diff --git a/common/json_rpc_test.go b/common/json_rpc_test.go index f2b5c72da..183be0902 100644 --- a/common/json_rpc_test.go +++ b/common/json_rpc_test.go @@ -219,10 +219,23 @@ func TestJsonRpcRequest_MarshalParams(t *testing.T) { }) assert.NoError(t, err) - expectedRawReq := `{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}` + // Empty params omitted — required for Stellar / some non-EVM nodes + // that reject "params":[] (expect absent field or object). + expectedRawReq := `{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber"}` assert.Equal(t, expectedRawReq, string(rawReq)) }) + t.Run("Nil", func(t *testing.T) { + rawReq, err := SonicCfg.Marshal(JsonRpcRequest{ + JSONRPC: "2.0", + ID: 1, + Method: "getLatestLedger", + Params: nil, + }) + assert.NoError(t, err) + assert.Equal(t, `{"jsonrpc":"2.0","id":1,"method":"getLatestLedger"}`, string(rawReq)) + }) + t.Run("Value", func(t *testing.T) { rawReq, err := SonicCfg.Marshal(JsonRpcRequest{ JSONRPC: "2.0", @@ -237,6 +250,20 @@ func TestJsonRpcRequest_MarshalParams(t *testing.T) { }) } +func TestJsonRpcResponse_ErrorNullIsSuccess(t *testing.T) { + // Bitcoin-family (dogecoind/litecoind) returns "error":null on success. + raw := `{"result":67851510,"error":null,"id":1}` + r := &JsonRpcResponse{} + err := r.ParseFromStream(nil, bytes.NewReader([]byte(raw)), len(raw)) + require.NoError(t, err) + assert.Nil(t, r.Error) + assert.Equal(t, "67851510", string(r.result)) + + r2 := &JsonRpcResponse{} + require.NoError(t, r2.ParseError("null")) + assert.Nil(t, r2.Error) +} + func TestJsonRpcResponse_CanonicalHash_EmptyishNormalization(t *testing.T) { // Test cases that should produce the same hash due to emptyish normalization testGroups := []struct {