From 733a8f4fdde6ec64ea5a49377e646ae71608e622 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:27:15 -0300 Subject: [PATCH 01/43] feat(jsonrpc): add support for JSON-RPC 2.0 batch requests --- internal/jsonrpc/batchcalls_test.go | 430 ++++++++++++++ internal/jsonrpc/jsonrpc-discover.json | 183 +++++- internal/jsonrpc/jsonrpc.go | 767 +++++++++++++------------ internal/jsonrpc/jsonrpc_test.go | 5 +- internal/jsonrpc/limitedwriter.go | 58 ++ internal/jsonrpc/limitedwriter_test.go | 121 ++++ internal/jsonrpc/types.go | 20 +- 7 files changed, 1200 insertions(+), 384 deletions(-) create mode 100644 internal/jsonrpc/batchcalls_test.go create mode 100644 internal/jsonrpc/limitedwriter.go create mode 100644 internal/jsonrpc/limitedwriter_test.go diff --git a/internal/jsonrpc/batchcalls_test.go b/internal/jsonrpc/batchcalls_test.go new file mode 100644 index 000000000..9fe8a5824 --- /dev/null +++ b/internal/jsonrpc/batchcalls_test.go @@ -0,0 +1,430 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/cartesi/rollups-node/pkg/service" + "github.com/stretchr/testify/require" +) + +const ( + testBatchSize = 100 + testBatchSuccessCount = 10 + testLargeResultSize = 1<<20 - 38 // 1 MB - `,{"jsonrpc":"2.0","result":"...","id":??}` + testResponseBudgetSlack = 1 << 20 +) + +func serveRPC(t *testing.T, s *Service, body []byte) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/rpc", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.handleRPC(rr, req) + return rr +} + +func newBatchTestService() *Service { + return &Service{ + Service: service.Service{ + Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)), + }, + } +} + +func decodeRPCResponse(t *testing.T, body []byte) RPCResponse { + t.Helper() + var response RPCResponse + require.NoError(t, json.Unmarshal(body, &response)) + return response +} + +func decodeRPCBatch(t *testing.T, body []byte) []RPCResponse { + t.Helper() + var responses []RPCResponse + require.NoError(t, json.Unmarshal(body, &responses)) + return responses +} + +func requireRPCError(t *testing.T, response RPCResponse, id any, code int) { + t.Helper() + require.Equal(t, "2.0", response.JSONRPC) + require.Equal(t, id, response.ID) + require.NotNil(t, response.Error) + require.Equal(t, code, response.Error.Code) +} + +func TestJSONRPCBatchRejectsEmptyBatchWithSingleObject(t *testing.T) { + s := newBatchTestService() + rr := serveRPC(t, s, []byte(`[]`)) + + require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, "application/json", rr.Header().Get("Content-Type")) + requireRPCError(t, decodeRPCResponse(t, rr.Body.Bytes()), nil, JSONRPC_INVALID_BATCH) + + var array []RPCResponse + require.Error(t, json.Unmarshal(rr.Body.Bytes(), &array), + "an empty batch error must be one JSON-RPC object, not an array") +} + +func TestJSONRPCBatchRejectsMoreThanMaximumBeforeDispatch(t *testing.T) { + s := newBatchTestService() + var calls atomic.Int32 + const method = "test_batch_cap" + withTestRPCHandler(t, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + calls.Add(1) + return true, nil + }) + + requests := make([]json.RawMessage, testBatchSize+1) + for i := range requests { + requests[i] = json.RawMessage(fmt.Sprintf( + `{"jsonrpc":"2.0","method":%q,"id":%d}`, method, i)) + } + body, err := json.Marshal(requests) + require.NoError(t, err) + rr := serveRPC(t, s, body) + + require.Equal(t, http.StatusOK, rr.Code) + requireRPCError(t, decodeRPCResponse(t, rr.Body.Bytes()), nil, JSONRPC_INVALID_BATCH) + require.Zero(t, calls.Load(), "an oversized batch must be rejected before dispatch") +} + +func TestJSONRPCMalformedBatchReturnsParseErrorObject(t *testing.T) { + s := newBatchTestService() + rr := serveRPC(t, s, []byte(`[{"jsonrpc":"2.0","method":"rpc.discover","id":1},`)) + + require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, "application/json", rr.Header().Get("Content-Type")) + requireRPCError(t, decodeRPCResponse(t, rr.Body.Bytes()), nil, JSONRPC_PARSE_ERROR) +} + +func TestJSONRPCBatchMalformedElementDoesNotPoisonValidSiblings(t *testing.T) { + s := newBatchTestService() + body := []byte(`[ + {"jsonrpc":"2.0","method":"cartesi_getNodeVersion","id":1}, + 17, + {"jsonrpc":"2.0","method":"cartesi_getNodeVersion","id":3} + ]`) + rr := serveRPC(t, s, body) + + require.Equal(t, http.StatusOK, rr.Code) + responses := decodeRPCBatch(t, rr.Body.Bytes()) + require.Len(t, responses, 3) + require.Nil(t, responses[0].Error) + require.EqualValues(t, 1, responses[0].ID) + requireRPCError(t, responses[1], nil, JSONRPC_INVALID_REQUEST) + require.Nil(t, responses[2].Error) + require.EqualValues(t, 3, responses[2].ID) +} + +func TestJSONRPCBatchStructurallyInvalidElementsDoNotPoisonValidSiblings(t *testing.T) { + tests := map[string]struct { + request string + id any + }{ + "null": {request: `null`}, + "empty object": {request: `{}`}, + "missing method": {request: `{"jsonrpc":"2.0","id":2}`, id: float64(2)}, + "invalid version": {request: `{"jsonrpc":"1.0","method":"cartesi_getNodeVersion","id":2}`, id: float64(2)}, + "invalid id": {request: `{"jsonrpc":"2.0","method":"cartesi_getNodeVersion","id":true}`}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + s := newBatchTestService() + body := []byte(fmt.Sprintf(`[ + {"jsonrpc":"2.0","method":"cartesi_getNodeVersion","id":1}, + %s, + {"jsonrpc":"2.0","method":"cartesi_getNodeVersion","id":3} + ]`, test.request)) + rr := serveRPC(t, s, body) + + require.Equal(t, http.StatusOK, rr.Code) + responses := decodeRPCBatch(t, rr.Body.Bytes()) + require.Len(t, responses, 3) + + require.Nil(t, responses[0].Error) + require.EqualValues(t, 1, responses[0].ID) + + requireRPCError(t, responses[1], test.id, JSONRPC_INVALID_REQUEST) + + require.Nil(t, responses[2].Error) + require.EqualValues(t, 3, responses[2].ID) + }) + } +} + +func TestJSONRPCValidationErrorsEchoValidID(t *testing.T) { + s := newBatchTestService() + tests := map[string]string{ + "missing method": `{"jsonrpc":"2.0","id":"request-id"}`, + "invalid version": `{"jsonrpc":"1.0","method":"cartesi_getNodeVersion","id":42}`, + } + + for name, body := range tests { + t.Run(name, func(t *testing.T) { + response := decodeRPCResponse(t, serveRPC(t, s, []byte(body)).Body.Bytes()) + expectedID := any("request-id") + if name == "invalid version" { + expectedID = float64(42) + } + requireRPCError(t, response, expectedID, JSONRPC_INVALID_REQUEST) + }) + } +} + +func TestJSONRPCRejectsInvalidIDTypesWithNullID(t *testing.T) { + s := newBatchTestService() + for name, id := range map[string]string{ + "boolean": `true`, + "array": `[]`, + "object": `{}`, + } { + t.Run(name, func(t *testing.T) { + body := []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":"cartesi_getNodeVersion","id":%s}`, id)) + response := decodeRPCResponse(t, serveRPC(t, s, body).Body.Bytes()) + requireRPCError(t, response, nil, JSONRPC_INVALID_REQUEST) + }) + } +} + +func TestJSONRPCBatchNotificationsReceiveNullIDResponses(t *testing.T) { + s := newBatchTestService() + rr := serveRPC(t, s, []byte(`[ + {"jsonrpc":"2.0","method":"cartesi_getNodeVersion"}, + {"jsonrpc":"2.0","method":"does_not_exist"} + ]`)) + + require.Equal(t, http.StatusOK, rr.Code) + responses := decodeRPCBatch(t, rr.Body.Bytes()) + require.Len(t, responses, 2, "notifications are deliberately answered by this server") + require.Nil(t, responses[0].ID) + require.Nil(t, responses[0].Error) + requireRPCError(t, responses[1], nil, JSONRPC_METHOD_NOT_FOUND) +} + +func TestJSONRPCBatchAlwaysReturnsHTTP200ForJSONErrors(t *testing.T) { + s := newBatchTestService() + tests := map[string][]byte{ + "parse error": []byte(`[nope`), + "invalid request": []byte(`[]`), + "error entries": []byte(`[false,{"jsonrpc":"2.0","method":"does_not_exist","id":2}]`), + } + for name, body := range tests { + t.Run(name, func(t *testing.T) { + rr := serveRPC(t, s, body) + require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, "application/json", rr.Header().Get("Content-Type")) + require.True(t, json.Valid(rr.Body.Bytes())) + }) + } +} + +func TestJSONRPCBatchReplacesResponsesAtCumulativeResponseBudget(t *testing.T) { + s := newBatchTestService() + var calls atomic.Int32 + const method = "test_large_batch_result" + largeResult := strings.Repeat("x", testLargeResultSize) + withTestRPCHandler(t, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + calls.Add(1) + return largeResult, nil + }) + + requests := make([]json.RawMessage, testBatchSize) + for i := range requests { + requests[i] = json.RawMessage(fmt.Sprintf( + `{"jsonrpc":"2.0","method":%q,"params":{"limit":10000},"id":%d}`, method, i)) + } + body, err := json.Marshal(requests) + require.NoError(t, err) + require.Less(t, len(body), 10<<10, "the request cap must not be mistaken for a response cap") + rr := serveRPC(t, s, body) + + require.Equal(t, http.StatusOK, rr.Code) + responses := decodeRPCBatch(t, rr.Body.Bytes()) + require.Len(t, responses, testBatchSize) + require.Equal(t, int32(testBatchSuccessCount+1), calls.Load()) + require.LessOrEqual(t, rr.Body.Len(), (10<<20)+testResponseBudgetSlack) + for i := range responses[:testBatchSuccessCount] { + require.Equal(t, "2.0", responses[i].JSONRPC) + require.Equal(t, float64(i), responses[i].ID) + require.Nil(t, responses[i].Error) + require.Equal(t, responses[i].Result, largeResult) + } + for i := testBatchSuccessCount; i < len(responses); i++ { + requireRPCError(t, responses[i], float64(i), JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED) + require.Equal(t, "Response size limit exceeded", responses[i].Error.Message) + } +} + +func TestJSONRPCBatchStopsBetweenEntriesWhenContextIsCanceled(t *testing.T) { + s := newBatchTestService() + var calls atomic.Int32 + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx, cancel := context.WithCancel(context.Background()) + const method = "test_cancel_batch" + withTestRPCHandler(t, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + calls.Add(1) + cancel() + return true, nil + }) + + body := []byte(fmt.Sprintf(`[ + {"jsonrpc":"2.0","method":%q,"id":1}, + {"jsonrpc":"2.0","method":%q,"id":2}, + {"jsonrpc":"2.0","method":%q,"id":3} + ]`, method, method, method)) + req := httptest.NewRequest(http.MethodPost, "/rpc", bytes.NewReader(body)).WithContext(ctx) + rr := httptest.NewRecorder() + s.handleRPC(rr, req) + + require.Equal(t, int32(1), calls.Load(), + "a canceled request must not run the remaining batch handlers") + for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") { + if line == "" { + continue + } + var record map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &record)) + require.False(t, + record["level"] == "ERROR" && strings.Contains(strings.ToLower(line), "context canceled"), + "context.Canceled is a graceful stop and must not be ERROR logged") + } +} + +func TestJSONRPCBatchReturnsErrorsForIDDRequestsAfterDeadline(t *testing.T) { + s := newBatchTestService() + var calls atomic.Int32 + const method = "test_deadline_batch" + withTestRPCHandler(t, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + calls.Add(1) + return true, nil + }) + + body := []byte(fmt.Sprintf(`[ + {"jsonrpc":"2.0","method":%q,"id":1}, + {"jsonrpc":"2.0","method":%q}, + {"jsonrpc":"2.0","method":%q,"id":"three"}, + false, + {"jsonrpc":"2.0","method":%q,"id":null} + ]`, method, method, method, method)) + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + req := httptest.NewRequest(http.MethodPost, "/rpc", bytes.NewReader(body)).WithContext(ctx) + rr := httptest.NewRecorder() + s.handleRPC(rr, req) + + require.Zero(t, calls.Load(), "expired batch entries must not be dispatched") + responses := decodeRPCBatch(t, rr.Body.Bytes()) + require.Len(t, responses, 5, "not all entries receive deadline errors") + requireRPCError(t, responses[0], float64(1), JSONRPC_TIMEOUT_ERROR) + requireRPCError(t, responses[1], nil, JSONRPC_TIMEOUT_ERROR) + requireRPCError(t, responses[2], "three", JSONRPC_TIMEOUT_ERROR) + requireRPCError(t, responses[3], nil, JSONRPC_INVALID_REQUEST) + requireRPCError(t, responses[4], nil, JSONRPC_TIMEOUT_ERROR) + for i, response := range responses { + if i == 3 { + require.Equal(t, "invalid request", response.Error.Message) + } else { + require.Equal(t, "Request timed out", response.Error.Message) + } + } +} + +func TestJSONRPCBatchUsesOneAdmissionPermit(t *testing.T) { + s := newBatchTestService() + s.admission = service.NewSemaphoreAdmission(1) + s.server = &http.Server{ + Handler: rebuildHandlerWithAdmission(s), + ReadHeaderTimeout: 2 * time.Second, + } + var nestedAcquisitions atomic.Int32 + const method = "test_batch_admission" + withTestRPCHandler(t, method, func(s *Service, _ *http.Request, _ RPCRequest) (any, error) { + if s.admission.TryAcquire() { + nestedAcquisitions.Add(1) + s.admission.Release() + } + return true, nil + }) + + body := []byte(fmt.Sprintf(`[ + {"jsonrpc":"2.0","method":%q,"id":1}, + {"jsonrpc":"2.0","method":%q,"id":2} + ]`, method, method)) + req := httptest.NewRequest(http.MethodPost, "/rpc", bytes.NewReader(body)) + rr := httptest.NewRecorder() + s.server.Handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + require.Zero(t, nestedAcquisitions.Load(), + "the HTTP request's one permit must remain held for the whole batch") + require.Len(t, decodeRPCBatch(t, rr.Body.Bytes()), 2) +} + +func TestJSONRPCBatchLoggingHasOneInfoAndDebugMethods(t *testing.T) { + s := newBatchTestService() + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + + const entries = 3 + body := []byte(`[ + {"jsonrpc":"2.0","method":"attacker_method_0","id":0}, + {"jsonrpc":"2.0","method":"attacker_method_1","id":1}, + {"jsonrpc":"2.0","method":"attacker_method_2","id":2} + ]`) + serveRPC(t, s, body) + + var batchInfo int + debugMethods := map[string]bool{} + for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") { + if line == "" { + continue + } + var record map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &record)) + level, _ := record["level"].(string) + encoded := string(line) + if level == "INFO" && strings.Contains(strings.ToLower(encoded), "batch") { + batchInfo++ + require.Contains(t, encoded, fmt.Sprint(entries)) + } + for i := range entries { + method := fmt.Sprintf("attacker_method_%d", i) + if strings.Contains(encoded, method) { + require.Equal(t, "DEBUG", level, "per-entry method names must never be Info logged") + debugMethods[method] = true + } + } + } + require.Equal(t, 1, batchInfo) + require.Len(t, debugMethods, entries) +} + +func withTestRPCHandler(t *testing.T, method string, handler rpcHandler) { + t.Helper() + previous, existed := jsonrpcHandlers[method] + jsonrpcHandlers[method] = handler + t.Cleanup(func() { + if existed { + jsonrpcHandlers[method] = previous + } else { + delete(jsonrpcHandlers, method) + } + }) +} diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index ea2b43c12..46c148dfc 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -3,7 +3,7 @@ "info": { "title": "Cartesi Rollups Node API", "version": "2.0.0", - "description": "A JSON-RPC API for reading rollups data. It provides information about applications, epochs, inputs, outputs, and reports in a read-only fashion.\n\nError handling: every method documents its possible errors under `errors`, and clients can dispatch on the error code. `-32002` (application not found) means the application identifier itself is unknown to this node; for application-scoped methods, this is a configuration error that will not resolve by retrying. `-32001` (resource not found) means the requested resource does not exist in the method's scope. For application-scoped methods, `-32001` means the application is known but the nested entity is missing; for node-scoped methods, it can also report missing node resources such as EVM reader configuration. For forward-looking application resources (e.g. the next epoch, input, or output index), `-32001` is the documented \"not created yet\" signal and is safe to poll. The error message names the missing resource. `-32603` (internal error) is never used for missing resources - clients should treat it as a node-side failure and alarm or back off, not poll. The transport-level codes `-32700` (parse error), `-32600` (invalid request), and `-32601` (method not found) follow the JSON-RPC 2.0 specification." + "description": "A JSON-RPC API for reading rollups data. It provides information about applications, epochs, inputs, outputs, and reports in a read-only fashion.\n\nBatch requests: JSON-RPC non-empty batch arrays are supported with a maximum of 100 entries per batch; batches outside that size range receive a single response with error code `-32040`. Entries execute sequentially and responses are returned in the same order as their requests. The 1 MB request-body limit applies to the whole batch array. A cumulative 10 MB response-size budget also applies; once the budget is exceeded, the remaining requests receive `-31003` error entries (consider resending the requests individually or in a smaller batch). Every batch entry receives a response. Notification suppression is not supported: entries without an ID are answered with `id: null`. This is a documented deviation from JSON-RPC 2.0, under which notifications normally produce no response. A batch response uses HTTP status 200 even when some or all of its entries are errors. Because execution is sequential and subject to the server time limit, heavy list calls should be kept outside large batches.\n\nError handling: every method documents its possible errors under `errors`, and clients can dispatch on the error code. `-31002` (application not found) means the application identifier itself is unknown to this node; for application-scoped methods, this is a configuration error that will not resolve by retrying. `-31001` (resource not found) means the requested resource does not exist in the method's scope. For application-scoped methods, `-31001` means the application is known but the nested entity is missing; for node-scoped methods, it can also report missing node resources such as EVM reader configuration. For forward-looking application resources (e.g. the next epoch, input, or output index), `-31001` is the documented \"not created yet\" signal and is safe to poll. The error message names the missing resource. `-32603` (internal error) is never used for missing resources - clients should treat it as a node-side failure and alarm or back off, not poll. `-32070` (timeout error) indicates the request was not able to be processed in the time limit available. The standard codes `-32700` (parse error), `-32600` (invalid request), and `-32601` (method not found) follow the JSON-RPC 2.0 specification." }, "methods": [ { @@ -53,6 +53,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -85,6 +91,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -155,6 +167,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -198,6 +216,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -233,6 +257,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -318,6 +348,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -361,6 +397,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -393,6 +435,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -486,6 +534,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -529,6 +583,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -604,6 +664,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -647,6 +713,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -716,6 +788,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -759,6 +837,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -852,6 +936,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -895,6 +985,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -972,6 +1068,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -1031,6 +1133,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -1108,6 +1216,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -1167,6 +1281,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -1252,6 +1372,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -1319,6 +1445,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -1339,6 +1471,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -1352,7 +1490,18 @@ "schema": { "$ref": "#/components/schemas/NodeVersionResult" } - } + }, + "errors": [ + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + } + ] } ], "components": { @@ -2521,48 +2670,56 @@ "code": -32603, "message": "Internal server error" }, + "TimeoutError": { + "code": -32070, + "message": "Request timed out" + }, + "ResponseSizeLimitExceeded": { + "code": -31003, + "message": "Response size limit exceeded" + }, "ApplicationNotFound": { - "code": -32002, + "code": -31002, "message": "Application not found" }, "EpochNotFound": { - "code": -32001, + "code": -31001, "message": "Epoch not found" }, "InputNotFound": { - "code": -32001, + "code": -31001, "message": "Input not found" }, "OutputNotFound": { - "code": -32001, + "code": -31001, "message": "Output not found" }, "ReportNotFound": { - "code": -32001, + "code": -31001, "message": "Report not found" }, "WithdrawalNotFound": { - "code": -32001, + "code": -31001, "message": "Withdrawal not found" }, "TournamentNotFound": { - "code": -32001, + "code": -31001, "message": "Tournament not found" }, "CommitmentNotFound": { - "code": -32001, + "code": -31001, "message": "Commitment not found" }, "MatchNotFound": { - "code": -32001, + "code": -31001, "message": "Match not found" }, "MatchAdvancedNotFound": { - "code": -32001, + "code": -31001, "message": "Match advanced not found" }, "NodeConfigNotFound": { - "code": -32001, + "code": -31001, "message": "EVM Reader config not found" } } diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index 71ffc07d9..88a4113da 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -4,6 +4,8 @@ package jsonrpc import ( + "bytes" + "context" "embed" "encoding/json" "errors" @@ -25,6 +27,10 @@ var discoverSpec embed.FS const ( // Maximum allowed body size (1 MB). MAX_BODY_SIZE = 1 << 20 //nolint: revive + // Maximum cumulative response size (10 MB). + MAX_RESPONSE_SIZE = 10 << 20 //nolint: revive + // Maximum amount of request in a batch (100) + MAX_BATCH_SIZE = 100 //nolint: revive // Maximum amount of items to list (10,000). LIST_ITEM_LIMIT = 10000 //nolint: revive // Default amount of item on a list (50) @@ -32,23 +38,30 @@ const ( ) const ( + // JSON-RPC Standard Error Codes (https://json-rpc.dev/docs/reference/error-codes) + JSONRPC_PARSE_ERROR int = -32700 //nolint: revive + JSONRPC_INVALID_REQUEST int = -32600 //nolint: revive + JSONRPC_METHOD_NOT_FOUND int = -32601 //nolint: revive + JSONRPC_INVALID_PARAMS int = -32602 //nolint: revive + JSONRPC_INTERNAL_ERROR int = -32603 //nolint: revive + JSONRPC_INVALID_BATCH int = -32040 //nolint: revive + JSONRPC_TIMEOUT_ERROR int = -32070 //nolint: revive + // Resource not found: the requested resource does not exist in the method's // scope. For application-scoped methods, this means the application exists // but the requested entity does not; unknown applications use // JSONRPC_APPLICATION_NOT_FOUND. For forward-looking keys, this can be the // "not created yet" signal and may be safe to poll depending on the method. - JSONRPC_RESOURCE_NOT_FOUND int = -32001 //nolint: revive + JSONRPC_RESOURCE_NOT_FOUND int = -31001 //nolint: revive // Application not found: the application identifier itself is unknown to // this node. A configuration error that will not resolve by retrying. - JSONRPC_APPLICATION_NOT_FOUND int = -32002 //nolint: revive - JSONRPC_PARSE_ERROR int = -32700 //nolint: revive - JSONRPC_INVALID_REQUEST int = -32600 //nolint: revive - JSONRPC_METHOD_NOT_FOUND int = -32601 //nolint: revive - JSONRPC_INVALID_PARAMS int = -32602 //nolint: revive - JSONRPC_INTERNAL_ERROR int = -32603 //nolint: revive + JSONRPC_APPLICATION_NOT_FOUND int = -31002 //nolint: revive + // Response size limit exceeded: cumulative buffered-response budget was + // not enough for all responses in the batch. + JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED int = -31003 //nolint: revive ) -type rpcHandler = func(*Service, http.ResponseWriter, *http.Request, RPCRequest) +type rpcHandler = func(*Service, *http.Request, RPCRequest) (any, error) type dispatchTable = map[string]rpcHandler var jsonrpcHandlers = dispatchTable{ @@ -83,6 +96,56 @@ var jsonrpcHandlers = dispatchTable{ // Dispatching JSON‑RPC methods // ----------------------------------------------------------------------------- +func (s *Service) handleWriteResponse(err error) bool { + if err == nil { + return true + } + s.Logger.Warn("failed writing response", "error", err) + return false +} + +func (s *Service) writeByte(w http.ResponseWriter, c byte) bool { + _, err := w.Write([]byte{c}) + return s.handleWriteResponse(err) +} + +// writeRPCError sends a generic error response for internal errors. +func (s *Service) writeRPCError(w http.ResponseWriter, id any, code int, message string) bool { + err := writeRPCError(w, id, code, message, nil) + return s.handleWriteResponse(err) +} + +func (s *Service) dispatchOneRequest(w io.Writer, r *http.Request, req RPCRequest) error { + switch req.ID.(type) { + case nil, string, float64: + default: + return writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request", nil) + } + if req.JSONRPC != "2.0" || req.Method == "" { + return writeRPCError(w, req.ID, JSONRPC_INVALID_REQUEST, "invalid request", nil) + } + fn, ok := jsonrpcHandlers[req.Method] + if !ok { + s.Logger.Debug("RPC method not found", "method", req.Method) + return writeRPCError(w, req.ID, JSONRPC_METHOD_NOT_FOUND, "Method not found", nil) + } + + result, err := fn(s, r, req) + if err == nil { + return writeRPCResult(w, req.ID, result) + } + + var rpcErr *RPCError + if errors.As(err, &rpcErr) { + // RPC errors describe expected client-facing failures. Do not log them at + // error level; unexpected failures are logged below before being hidden. + return writeRPCError(w, req.ID, rpcErr.Code, rpcErr.Message, rpcErr.Data) + } + + s.Logger.Error("RPC method failed", "method", req.Method, "error", err) + return writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) +} + func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { // Limit request body size and ensure it is closed. r.Body = http.MaxBytesReader(w, r.Body, MAX_BODY_SIZE) @@ -97,17 +160,101 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { http.Error(w, "Failed to read request body", http.StatusBadRequest) return } - var req RPCRequest - if err := json.Unmarshal(body, &req); err != nil { - http.Error(w, "Invalid JSON", http.StatusBadRequest) + + body = bytes.TrimSpace(body) + if len(body) == 0 { + http.Error(w, "Empty request body", http.StatusBadRequest) return } - s.Logger.Info(fmt.Sprintf("Received RPC request: %s", req.Method)) - if fn, ok := jsonrpcHandlers[req.Method]; ok { - fn(s, w, r, req) - } else { - s.Logger.Info(fmt.Sprintf("RPC method not found: %s", req.Method)) - writeRPCError(w, req.ID, JSONRPC_METHOD_NOT_FOUND, "Method not found", nil) + + switch body[0] { + case '{': + var req RPCRequest + if err := json.Unmarshal(body, &req); err != nil { + s.writeRPCError(w, nil, JSONRPC_PARSE_ERROR, "invalid request") + return + } + w.Header().Set("Content-Type", "application/json") + s.Logger.Info("Dispatching RPC request", "method", req.Method) + err := s.dispatchOneRequest(w, r, req) + s.handleWriteResponse(err) + + case '[': + w.Header().Set("Content-Type", "application/json") + // Keep each batch element raw so malformed requests fail independently and + // the list-item limit can be checked before dispatching any request. + var reqSeq []json.RawMessage + if err := json.Unmarshal(body, &reqSeq); err != nil { + s.writeRPCError(w, nil, JSONRPC_PARSE_ERROR, "invalid request batch") + return + } + if len(reqSeq) == 0 || len(reqSeq) > MAX_BATCH_SIZE { + s.writeRPCError(w, nil, JSONRPC_INVALID_BATCH, fmt.Sprintf("invalid request batch size (expected [1..%v])", MAX_BATCH_SIZE)) + return + } + + s.Logger.Info("Received RPC request batch", "items", len(reqSeq)) + if !s.writeByte(w, '[') { + return + } + + budgetResp := newBudgetWriter(w, MAX_RESPONSE_SIZE) + for i, rawReq := range reqSeq { + + if i > 0 && !s.writeByte(w, ',') { + return + } + + var responded bool + var req RPCRequest + + switch r.Context().Err() { + case context.Canceled: + return + case context.DeadlineExceeded: + s.Logger.Warn("RPC method dispatch timeout") + if err := json.Unmarshal(rawReq, &req); err != nil { + responded = s.writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request") + } else { + responded = s.writeRPCError(w, req.ID, JSONRPC_TIMEOUT_ERROR, "Request timed out") + } + default: + if err := json.Unmarshal(rawReq, &req); err != nil { + responded = s.writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request") + } else { + s.Logger.Debug("Dispatching RPC request", "method", req.Method) + buffer := budgetResp.NewLimitedWriter() + if buffer == nil { + responded = s.writeRPCError(w, req.ID, JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED, "Response size limit exceeded") + } else { + err := s.dispatchOneRequest(buffer, r, req) + switch { + case err == nil: + responded = s.handleWriteResponse(buffer.Flush()) + case errors.Is(err, io.ErrShortBuffer): + responded = s.writeRPCError(w, req.ID, JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED, "Response size limit exceeded") + default: + s.Logger.Error("RPC method response encode failed", "method", req.Method, "error", err) + responded = s.writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error") + } + } + } + } + + if !responded { + return + } + } + s.writeByte(w, ']') + + default: + w.Header().Set("Content-Type", "application/json") + if json.Valid(body) { + s.writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request") + } else { + s.writeRPCError(w, nil, JSONRPC_PARSE_ERROR, "Parse error") + } + } } @@ -116,28 +263,25 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { // ----------------------------------------------------------------------------- // Discovery: return the embedded specification. -func handleDiscover(s *Service, w http.ResponseWriter, _ *http.Request, req RPCRequest) { +func handleDiscover(s *Service, _ *http.Request, _ RPCRequest) (any, error) { data, err := discoverSpec.ReadFile("jsonrpc-discover.json") if err != nil { s.Logger.Error("Unable to read jsonrpc-discover content", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } var spec any if err := json.Unmarshal(data, &spec); err != nil { s.Logger.Error("Unable to unmarshal discovery spec JSON", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } - writeRPCResult(w, req.ID, spec) + return spec, nil } -func handleListApplications(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleListApplications(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListApplicationsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Use default values if not provided if params.Limit <= 0 { @@ -154,57 +298,51 @@ func handleListApplications(s *Service, w http.ResponseWriter, r *http.Request, }, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve applications from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } if apps == nil { apps = []*model.Application{} } - writeRPCResult(w, req.ID, api.ListResponse[*model.Application]{ + return api.ListResponse[*model.Application]{ Data: apps, Pagination: api.Pagination{ TotalCount: total, Limit: params.Limit, Offset: params.Offset, }, - }) + }, nil } -func handleGetApplication(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleGetApplication(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetApplicationParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } app, err := s.repository.GetApplication(r.Context(), params.Application) if err != nil { s.Logger.Error("Unable to retrieve application from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } if app == nil { - writeRPCError(w, req.ID, JSONRPC_APPLICATION_NOT_FOUND, "Application not found", nil) - return + return nil, newRPCError(JSONRPC_APPLICATION_NOT_FOUND, "Application not found", nil) } - writeRPCResult(w, req.ID, api.SingleResponse[*model.Application]{Data: app}) + return api.SingleResponse[*model.Application]{Data: app}, nil } -func handleListEpochs(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleListEpochs(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListEpochsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Use default values if not provided @@ -218,16 +356,14 @@ func handleListEpochs(s *Service, w http.ResponseWriter, r *http.Request, req RP // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } var epochFilter repository.EpochFilter if params.Status != nil { var status model.EpochStatus if err := status.Scan(*params.Status); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch status: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch status: %v", err), nil) } epochFilter.Status = []model.EpochStatus{status} } @@ -238,101 +374,92 @@ func handleListEpochs(s *Service, w http.ResponseWriter, r *http.Request, req RP }, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve epochs from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } - if len(epochs) == 0 && s.applicationAbsentOrError(w, r, req, params.Application) { - return + if len(epochs) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } } if epochs == nil { epochs = []*model.Epoch{} } - writeRPCResult(w, req.ID, api.ListResponse[*model.Epoch]{ + return api.ListResponse[*model.Epoch]{ Data: epochs, Pagination: api.Pagination{ TotalCount: total, Limit: params.Limit, Offset: params.Offset, }, - }) + }, nil } -func handleGetEpoch(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleGetEpoch(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetEpochParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } index, err := config.ToIndexFromString(params.EpochIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) } epoch, err := s.repository.GetEpoch(r.Context(), params.Application, index) if err != nil { s.Logger.Error("Unable to retrieve epoch from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } if epoch == nil { - if s.applicationAbsentOrError(w, r, req, params.Application) { - return + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err } - writeRPCError(w, req.ID, JSONRPC_RESOURCE_NOT_FOUND, "Epoch not found", nil) - return + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Epoch not found", nil) } - writeRPCResult(w, req.ID, api.SingleResponse[*model.Epoch]{Data: epoch}) + return api.SingleResponse[*model.Epoch]{Data: epoch}, nil } -func handleGetLastAcceptedEpochIndex(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleGetLastAcceptedEpochIndex(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetLastAcceptedEpochIndexParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } index, err := s.repository.GetLastAcceptedEpochIndex(r.Context(), params.Application) if errors.Is(err, repository.ErrNotFound) { - if s.applicationAbsentOrError(w, r, req, params.Application) { - return + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err } - writeRPCError(w, req.ID, JSONRPC_RESOURCE_NOT_FOUND, "Epoch not found", nil) - return + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Epoch not found", nil) } if err != nil { s.Logger.Error("Unable to retrieve epoch from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } - writeRPCResult(w, req.ID, api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", index)}) + return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", index)}, nil } -func handleListInputs(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleListInputs(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListInputsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Use default values if not provided @@ -346,8 +473,7 @@ func handleListInputs(s *Service, w http.ResponseWriter, r *http.Request, req RP // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } // Create input filter based on params @@ -355,8 +481,7 @@ func handleListInputs(s *Service, w http.ResponseWriter, r *http.Request, req RP if params.EpochIndex != nil { epochIndex, err := config.ToIndexFromString(*params.EpochIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) } inputFilter.EpochIndex = &epochIndex } @@ -365,16 +490,14 @@ func handleListInputs(s *Service, w http.ResponseWriter, r *http.Request, req RP if params.Sender != nil { sender, err := config.ToAddressFromString(*params.Sender) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input sender address: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input sender address: %v", err), nil) } inputFilter.Sender = &sender } if params.TransactionHash != nil { transactionHash, err := config.ToHashFromString(*params.TransactionHash) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid transaction hash: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid transaction hash: %v", err), nil) } inputFilter.TransactionHash = &transactionHash } @@ -385,11 +508,12 @@ func handleListInputs(s *Service, w http.ResponseWriter, r *http.Request, req RP }, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve inputs from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } - if len(inputs) == 0 && s.applicationAbsentOrError(w, r, req, params.Application) { - return + if len(inputs) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } } resultInputs := make([]*api.DecodedInput, 0, len(inputs)) @@ -401,48 +525,43 @@ func handleListInputs(s *Service, w http.ResponseWriter, r *http.Request, req RP resultInputs = append(resultInputs, decoded) } - writeRPCResult(w, req.ID, api.ListResponse[*api.DecodedInput]{ + return api.ListResponse[*api.DecodedInput]{ Data: resultInputs, Pagination: api.Pagination{ TotalCount: total, Limit: params.Limit, Offset: params.Offset, }, - }) + }, nil } -func handleGetInput(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleGetInput(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetInputParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } index, err := config.ToIndexFromString(params.InputIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input index: %v", err), nil) } input, err := s.repository.GetInput(r.Context(), params.Application, index) if err != nil { s.Logger.Error("Unable to retrieve input from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } if input == nil { - if s.applicationAbsentOrError(w, r, req, params.Application) { - return + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err } - writeRPCError(w, req.ID, JSONRPC_RESOURCE_NOT_FOUND, "Input not found", nil) - return + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Input not found", nil) } decoded, err := api.DecodeInput(input, s.inputABI) @@ -450,43 +569,38 @@ func handleGetInput(s *Service, w http.ResponseWriter, r *http.Request, req RPCR s.Logger.Error("Unable to decode Input", "app", params.Application, "index", input.Index, "err", err) } - writeRPCResult(w, req.ID, api.SingleResponse[*api.DecodedInput]{Data: decoded}) + return api.SingleResponse[*api.DecodedInput]{Data: decoded}, nil } -func handleGetProcessedInputCount(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleGetProcessedInputCount(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetApplicationParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } processedInputs, err := s.repository.GetProcessedInputCount(r.Context(), params.Application) if errors.Is(err, repository.ErrNotFound) { - writeRPCError(w, req.ID, JSONRPC_APPLICATION_NOT_FOUND, "Application not found", nil) - return + return nil, newRPCError(JSONRPC_APPLICATION_NOT_FOUND, "Application not found", nil) } if err != nil { s.Logger.Error("Unable to retrieve application from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } - writeRPCResult(w, req.ID, api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", processedInputs)}) + return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", processedInputs)}, nil } -func handleListOutputs(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListOutputsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Use default values if not provided @@ -500,8 +614,7 @@ func handleListOutputs(s *Service, w http.ResponseWriter, r *http.Request, req R // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } // Create output filter based on params @@ -509,8 +622,7 @@ func handleListOutputs(s *Service, w http.ResponseWriter, r *http.Request, req R if params.EpochIndex != nil { epochIndex, err := config.ToIndexFromString(*params.EpochIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) } outputFilter.EpochIndex = &epochIndex } @@ -518,8 +630,7 @@ func handleListOutputs(s *Service, w http.ResponseWriter, r *http.Request, req R if params.InputIndex != nil { inputIndex, err := config.ToIndexFromString(*params.InputIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input index: %v", err), nil) } outputFilter.InputIndex = &inputIndex } @@ -528,8 +639,7 @@ func handleListOutputs(s *Service, w http.ResponseWriter, r *http.Request, req R if params.OutputType != nil { outputType, err := api.ParseOutputType(*params.OutputType) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid output type: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid output type: %v", err), nil) } outputFilter.OutputType = &outputType } @@ -538,8 +648,7 @@ func handleListOutputs(s *Service, w http.ResponseWriter, r *http.Request, req R if params.VoucherAddress != nil { voucherAddress, err := config.ToAddressFromString(*params.VoucherAddress) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid voucher address: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid voucher address: %v", err), nil) } outputFilter.VoucherAddress = &voucherAddress } @@ -550,8 +659,7 @@ func handleListOutputs(s *Service, w http.ResponseWriter, r *http.Request, req R }, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve outputs from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } resultOutputs := make([]*api.DecodedOutput, 0, len(outputs)) @@ -563,52 +671,49 @@ func handleListOutputs(s *Service, w http.ResponseWriter, r *http.Request, req R resultOutputs = append(resultOutputs, decoded) } - if len(resultOutputs) == 0 && s.applicationAbsentOrError(w, r, req, params.Application) { - return + if len(resultOutputs) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } } - writeRPCResult(w, req.ID, api.ListResponse[*api.DecodedOutput]{ + return api.ListResponse[*api.DecodedOutput]{ Data: resultOutputs, Pagination: api.Pagination{ TotalCount: total, Limit: params.Limit, Offset: params.Offset, }, - }) + }, nil } -func handleGetOutput(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleGetOutput(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetOutputParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } index, err := config.ToIndexFromString(params.OutputIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid output index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid output index: %v", err), nil) } output, err := s.repository.GetOutput(r.Context(), params.Application, index) if err != nil { s.Logger.Error("Unable to retrieve output from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } if output == nil { - if s.applicationAbsentOrError(w, r, req, params.Application) { - return + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err } - writeRPCError(w, req.ID, JSONRPC_RESOURCE_NOT_FOUND, "Output not found", nil) - return + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Output not found", nil) } decoded, err := api.DecodeOutput(output, s.outputABI) @@ -616,15 +721,14 @@ func handleGetOutput(s *Service, w http.ResponseWriter, r *http.Request, req RPC s.Logger.Error("Unable to decode Output", "app", params.Application, "index", output.Index, "err", err) } - writeRPCResult(w, req.ID, api.SingleResponse[*api.DecodedOutput]{Data: decoded}) + return api.SingleResponse[*api.DecodedOutput]{Data: decoded}, nil } -func handleListReports(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleListReports(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListReportsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Use default values if not provided @@ -638,8 +742,7 @@ func handleListReports(s *Service, w http.ResponseWriter, r *http.Request, req R // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } // Create report filter based on params @@ -647,8 +750,7 @@ func handleListReports(s *Service, w http.ResponseWriter, r *http.Request, req R if params.EpochIndex != nil { epochIndex, err := config.ToIndexFromString(*params.EpochIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) } reportFilter.EpochIndex = &epochIndex } @@ -656,8 +758,7 @@ func handleListReports(s *Service, w http.ResponseWriter, r *http.Request, req R if params.InputIndex != nil { inputIndex, err := config.ToIndexFromString(*params.InputIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input index: %v", err), nil) } reportFilter.InputIndex = &inputIndex } @@ -668,70 +769,65 @@ func handleListReports(s *Service, w http.ResponseWriter, r *http.Request, req R }, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve reports from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } - if len(reports) == 0 && s.applicationAbsentOrError(w, r, req, params.Application) { - return + if len(reports) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } } if reports == nil { reports = []*model.Report{} } - writeRPCResult(w, req.ID, api.ListResponse[*model.Report]{ + return api.ListResponse[*model.Report]{ Data: reports, Pagination: api.Pagination{ TotalCount: total, Limit: params.Limit, Offset: params.Offset, }, - }) + }, nil } -func handleGetReport(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleGetReport(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetReportParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } index, err := config.ToIndexFromString(params.ReportIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid report index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid report index: %v", err), nil) } report, err := s.repository.GetReport(r.Context(), params.Application, index) if err != nil { s.Logger.Error("Unable to retrieve report from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } if report == nil { - if s.applicationAbsentOrError(w, r, req, params.Application) { - return + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err } - writeRPCError(w, req.ID, JSONRPC_RESOURCE_NOT_FOUND, "Report not found", nil) - return + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Report not found", nil) } - writeRPCResult(w, req.ID, api.SingleResponse[*model.Report]{Data: report}) + return api.SingleResponse[*model.Report]{Data: report}, nil } -func handleListWithdrawals(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleListWithdrawals(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListWithdrawalsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } if params.Limit <= 0 { @@ -742,16 +838,14 @@ func handleListWithdrawals(s *Service, w http.ResponseWriter, r *http.Request, r } if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } withdrawalFilter := repository.WithdrawalFilter{} if params.AccountIndex != nil { accountIndex, err := config.ToIndexFromString(*params.AccountIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid account index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid account index: %v", err), nil) } withdrawalFilter.AccountIndex = &accountIndex } @@ -763,69 +857,64 @@ func handleListWithdrawals(s *Service, w http.ResponseWriter, r *http.Request, r ) if err != nil { s.Logger.Error("Unable to retrieve withdrawals from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } - if len(withdrawals) == 0 && s.applicationAbsentOrError(w, r, req, params.Application) { - return + if len(withdrawals) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } } if withdrawals == nil { withdrawals = []*model.Withdrawal{} } - writeRPCResult(w, req.ID, api.ListResponse[*model.Withdrawal]{ + return api.ListResponse[*model.Withdrawal]{ Data: withdrawals, Pagination: api.Pagination{ TotalCount: total, Limit: params.Limit, Offset: params.Offset, }, - }) + }, nil } -func handleGetWithdrawal(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleGetWithdrawal(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetWithdrawalParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } accountIndex, err := config.ToIndexFromString(params.AccountIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid account index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid account index: %v", err), nil) } withdrawal, err := s.repository.GetWithdrawal(r.Context(), params.Application, accountIndex) if err != nil { s.Logger.Error("Unable to retrieve withdrawal from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } if withdrawal == nil { - if s.applicationAbsentOrError(w, r, req, params.Application) { - return + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err } - writeRPCError(w, req.ID, JSONRPC_RESOURCE_NOT_FOUND, "Withdrawal not found", nil) - return + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Withdrawal not found", nil) } - writeRPCResult(w, req.ID, api.SingleResponse[*model.Withdrawal]{Data: withdrawal}) + return api.SingleResponse[*model.Withdrawal]{Data: withdrawal}, nil } -func handleListTournaments(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleListTournaments(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListTournamentsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Use default values if not provided @@ -839,8 +928,7 @@ func handleListTournaments(s *Service, w http.ResponseWriter, r *http.Request, r // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } // Create tournament filter based on params @@ -848,8 +936,7 @@ func handleListTournaments(s *Service, w http.ResponseWriter, r *http.Request, r if params.EpochIndex != nil { epochIndex, err := config.ToIndexFromString(*params.EpochIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) } tournamentFilter.EpochIndex = &epochIndex } @@ -857,8 +944,7 @@ func handleListTournaments(s *Service, w http.ResponseWriter, r *http.Request, r if params.Level != nil { level, err := config.ToIndexFromString(*params.Level) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid level: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid level: %v", err), nil) } tournamentFilter.Level = &level } @@ -866,8 +952,7 @@ func handleListTournaments(s *Service, w http.ResponseWriter, r *http.Request, r if params.ParentTournamentAddress != nil { parentAddress, err := config.ToAddressFromString(*params.ParentTournamentAddress) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent tournament address: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent tournament address: %v", err), nil) } tournamentFilter.ParentTournamentAddress = &parentAddress } @@ -875,8 +960,7 @@ func handleListTournaments(s *Service, w http.ResponseWriter, r *http.Request, r if params.ParentMatchIDHash != nil { parentMatchIDHash, err := config.ToHashFromString(*params.ParentMatchIDHash) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent match ID hash: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent match ID hash: %v", err), nil) } tournamentFilter.ParentMatchIDHash = &parentMatchIDHash } @@ -887,69 +971,64 @@ func handleListTournaments(s *Service, w http.ResponseWriter, r *http.Request, r }, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve tournaments from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } - if len(tournaments) == 0 && s.applicationAbsentOrError(w, r, req, params.Application) { - return + if len(tournaments) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } } if tournaments == nil { tournaments = []*model.Tournament{} } - writeRPCResult(w, req.ID, api.ListResponse[*model.Tournament]{ + return api.ListResponse[*model.Tournament]{ Data: tournaments, Pagination: api.Pagination{ TotalCount: total, Limit: params.Limit, Offset: params.Offset, }, - }) + }, nil } -func handleGetTournament(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleGetTournament(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetTournamentParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } // Validate tournament address if _, err := config.ToAddressFromString(params.Address); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) } tournament, err := s.repository.GetTournament(r.Context(), params.Application, params.Address) if err != nil { s.Logger.Error("Unable to retrieve tournament from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } if tournament == nil { - if s.applicationAbsentOrError(w, r, req, params.Application) { - return + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err } - writeRPCError(w, req.ID, JSONRPC_RESOURCE_NOT_FOUND, "Tournament not found", nil) - return + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Tournament not found", nil) } - writeRPCResult(w, req.ID, api.SingleResponse[*model.Tournament]{Data: tournament}) + return api.SingleResponse[*model.Tournament]{Data: tournament}, nil } -func handleListCommitments(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleListCommitments(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListCommitmentsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Use default values if not provided @@ -963,8 +1042,7 @@ func handleListCommitments(s *Service, w http.ResponseWriter, r *http.Request, r // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } // Create commitment filter based on params @@ -972,16 +1050,14 @@ func handleListCommitments(s *Service, w http.ResponseWriter, r *http.Request, r if params.EpochIndex != nil { epochIndex, err := config.ToIndexFromString(*params.EpochIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) } commitmentFilter.EpochIndex = &epochIndex } if params.TournamentAddress != nil { if _, err := config.ToAddressFromString(*params.TournamentAddress); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) } commitmentFilter.TournamentAddress = params.TournamentAddress } @@ -992,83 +1068,75 @@ func handleListCommitments(s *Service, w http.ResponseWriter, r *http.Request, r }, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve commitments from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } - if len(commitments) == 0 && s.applicationAbsentOrError(w, r, req, params.Application) { - return + if len(commitments) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } } if commitments == nil { commitments = []*model.Commitment{} } - writeRPCResult(w, req.ID, api.ListResponse[*model.Commitment]{ + return api.ListResponse[*model.Commitment]{ Data: commitments, Pagination: api.Pagination{ TotalCount: total, Limit: params.Limit, Offset: params.Offset, }, - }) + }, nil } -func handleGetCommitment(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleGetCommitment(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetCommitmentParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } epochIndex, err := config.ToIndexFromString(params.EpochIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) } if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) } if len(params.Commitment) == 0 { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid commitment hex: Empty string", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid commitment hex: Empty string", nil) } if _, err := config.ToHashFromString(params.Commitment); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid commitment hex: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid commitment hex: %v", err), nil) } commitment, err := s.repository.GetCommitment(r.Context(), params.Application, epochIndex, params.TournamentAddress, params.Commitment) if err != nil { s.Logger.Error("Unable to retrieve commitment from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } if commitment == nil { - if s.applicationAbsentOrError(w, r, req, params.Application) { - return + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err } - writeRPCError(w, req.ID, JSONRPC_RESOURCE_NOT_FOUND, "Commitment not found", nil) - return + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Commitment not found", nil) } - writeRPCResult(w, req.ID, api.SingleResponse[*model.Commitment]{Data: commitment}) + return api.SingleResponse[*model.Commitment]{Data: commitment}, nil } -func handleListMatches(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleListMatches(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListMatchesParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Use default values if not provided @@ -1082,8 +1150,7 @@ func handleListMatches(s *Service, w http.ResponseWriter, r *http.Request, req R // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } // Create match filter based on params @@ -1091,16 +1158,14 @@ func handleListMatches(s *Service, w http.ResponseWriter, r *http.Request, req R if params.EpochIndex != nil { epochIndex, err := config.ToIndexFromString(*params.EpochIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) } matchFilter.EpochIndex = &epochIndex } if params.TournamentAddress != nil { if _, err := config.ToAddressFromString(*params.TournamentAddress); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) } matchFilter.TournamentAddress = params.TournamentAddress } @@ -1111,79 +1176,72 @@ func handleListMatches(s *Service, w http.ResponseWriter, r *http.Request, req R }, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve matches from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } - if len(matches) == 0 && s.applicationAbsentOrError(w, r, req, params.Application) { - return + if len(matches) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } } if matches == nil { matches = []*model.Match{} } - writeRPCResult(w, req.ID, api.ListResponse[*model.Match]{ + return api.ListResponse[*model.Match]{ Data: matches, Pagination: api.Pagination{ TotalCount: total, Limit: params.Limit, Offset: params.Offset, }, - }) + }, nil } -func handleGetMatch(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleGetMatch(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetMatchParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } epochIndex, err := config.ToIndexFromString(params.EpochIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) } if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) } if _, err := config.ToHashFromString(params.IDHash); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err), nil) } match, err := s.repository.GetMatch(r.Context(), params.Application, epochIndex, params.TournamentAddress, params.IDHash) if err != nil { s.Logger.Error("Unable to retrieve match from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } if match == nil { - if s.applicationAbsentOrError(w, r, req, params.Application) { - return + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err } - writeRPCError(w, req.ID, JSONRPC_RESOURCE_NOT_FOUND, "Match not found", nil) - return + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Match not found", nil) } - writeRPCResult(w, req.ID, api.SingleResponse[*model.Match]{Data: match}) + return api.SingleResponse[*model.Match]{Data: match}, nil } -func handleListMatchAdvances(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleListMatchAdvances(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListMatchAdvancesParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Use default values if not provided @@ -1197,25 +1255,21 @@ func handleListMatchAdvances(s *Service, w http.ResponseWriter, r *http.Request, // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } // Create match advance filter based on params epochIndex, err := config.ToIndexFromString(params.EpochIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) } if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) } if _, err := config.ToHashFromString(params.IDHash); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err), nil) } pagination := repository.Pagination{ @@ -1226,112 +1280,99 @@ func handleListMatchAdvances(s *Service, w http.ResponseWriter, r *http.Request, params.TournamentAddress, params.IDHash, pagination, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve match advances from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } - if len(matchAdvances) == 0 && s.applicationAbsentOrError(w, r, req, params.Application) { - return + if len(matchAdvances) == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } } if matchAdvances == nil { matchAdvances = []*model.MatchAdvanced{} } - writeRPCResult(w, req.ID, api.ListResponse[*model.MatchAdvanced]{ + return api.ListResponse[*model.MatchAdvanced]{ Data: matchAdvances, Pagination: api.Pagination{ TotalCount: total, Limit: params.Limit, Offset: params.Offset, }, - }) + }, nil } -func handleGetMatchAdvanced(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleGetMatchAdvanced(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetMatchAdvancedParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) } epochIndex, err := config.ToIndexFromString(params.EpochIndex) if err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) } if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) } if _, err := config.ToHashFromString(params.IDHash); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err), nil) } if _, err := config.ToHashFromString(params.Parent); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent hash: %v", err), nil) - return + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent hash: %v", err), nil) } matchAdvanced, err := s.repository.GetMatchAdvanced(r.Context(), params.Application, epochIndex, params.TournamentAddress, params.IDHash, params.Parent[2:]) // TODO: use parsed value if err != nil { s.Logger.Error("Unable to retrieve match advanced from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } if matchAdvanced == nil { - if s.applicationAbsentOrError(w, r, req, params.Application) { - return + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err } - writeRPCError(w, req.ID, JSONRPC_RESOURCE_NOT_FOUND, "Match advanced not found", nil) - return + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Match advanced not found", nil) } - writeRPCResult(w, req.ID, api.SingleResponse[*model.MatchAdvanced]{Data: matchAdvanced}) + return api.SingleResponse[*model.MatchAdvanced]{Data: matchAdvanced}, nil } -func handleGetChainID(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleGetChainID(s *Service, r *http.Request, _ RPCRequest) (any, error) { config, err := repository.LoadNodeConfig[evmreader.PersistentConfig](r.Context(), s.repository, evmreader.EvmReaderConfigKey) if errors.Is(err, repository.ErrNotFound) { - writeRPCError(w, req.ID, JSONRPC_RESOURCE_NOT_FOUND, "EVM Reader config not found", nil) - return + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "EVM Reader config not found", nil) } if err != nil { s.Logger.Error("Unable to retrieve evmreader config from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } - writeRPCResult(w, req.ID, api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", config.Value.ChainID)}) + return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", config.Value.ChainID)}, nil } -func handleGetNodeVersion(_ *Service, w http.ResponseWriter, _ *http.Request, req RPCRequest) { - writeRPCResult(w, req.ID, api.SingleResponse[string]{Data: version.BuildVersion}) +func handleGetNodeVersion(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + return api.SingleResponse[string]{Data: version.BuildVersion}, nil } func (s *Service) applicationAbsentOrError( - w http.ResponseWriter, r *http.Request, - req RPCRequest, validatedNameOrAddress string, -) bool { +) error { app, err := s.repository.GetApplication(r.Context(), validatedNameOrAddress) if err != nil { s.Logger.Error("Unable to retrieve application from repository", "err", err) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return true + return newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } else if app == nil { - writeRPCError(w, req.ID, JSONRPC_APPLICATION_NOT_FOUND, "Application not found", nil) - return true + return newRPCError(JSONRPC_APPLICATION_NOT_FOUND, "Application not found", nil) } - return false + return nil } diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index d4fd906f3..f82285174 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -54,7 +54,10 @@ func TestInvalidJSON(t *testing.T) { "id": 0, }`)) - assert.Equal(t, "Invalid JSON\n", string(body)) + var resp RPCResponse + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_PARSE_ERROR, resp.Error.Code) + assert.Equal(t, "invalid request", resp.Error.Message) } // failure: invalid method diff --git a/internal/jsonrpc/limitedwriter.go b/internal/jsonrpc/limitedwriter.go new file mode 100644 index 000000000..2ab55396f --- /dev/null +++ b/internal/jsonrpc/limitedwriter.go @@ -0,0 +1,58 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "bytes" + "io" +) + +type budgetWriter struct { + writer io.Writer + budget int + closed bool +} + +func newBudgetWriter(writer io.Writer, limit int) *budgetWriter { + return &budgetWriter{ + writer: writer, + budget: limit, + } +} + +func (w *budgetWriter) Write(data []byte) (int, error) { + written, err := w.writer.Write(data) + if err == nil { + w.budget -= written + } + return written, err +} + +func (w *budgetWriter) NewLimitedWriter() *limitedWriter { + if w.closed { + return nil + } + return &limitedWriter{writer: w} +} + +type limitedWriter struct { + writer *budgetWriter + buffer bytes.Buffer +} + +func (w *limitedWriter) Write(data []byte) (int, error) { + if w.buffer.Len()+len(data) > w.writer.budget { + w.writer.closed = true + return 0, io.ErrShortBuffer + } + return w.buffer.Write(data) +} + +func (w *limitedWriter) Flush() error { + if w.writer.closed { + return nil + } + _, err := w.writer.Write(w.buffer.Bytes()) + return err +} diff --git a/internal/jsonrpc/limitedwriter_test.go b/internal/jsonrpc/limitedwriter_test.go new file mode 100644 index 000000000..3bf654c5c --- /dev/null +++ b/internal/jsonrpc/limitedwriter_test.go @@ -0,0 +1,121 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "bytes" + "errors" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLimitedWriterFlushesWithinBudget(t *testing.T) { + var output bytes.Buffer + budget := newBudgetWriter(&output, 5) + writer := budget.NewLimitedWriter() + require.NotNil(t, writer) + + written, err := writer.Write([]byte("he")) + require.NoError(t, err) + assert.Equal(t, 2, written) + + written, err = writer.Write([]byte("llo")) + require.NoError(t, err) + assert.Equal(t, 3, written) + assert.Empty(t, output.String(), "writes should remain buffered until Flush") + assert.Equal(t, 5, budget.budget, "buffered writes should not consume the budget") + + require.NoError(t, writer.Flush()) + assert.Equal(t, "hello", output.String()) + assert.Zero(t, budget.budget) +} + +func TestLimitedWriterRejectsWriteBeyondBudget(t *testing.T) { + var output bytes.Buffer + budget := newBudgetWriter(&output, 4) + writer := budget.NewLimitedWriter() + require.NotNil(t, writer) + + written, err := writer.Write([]byte("abc")) + require.NoError(t, err) + assert.Equal(t, 3, written) + + written, err = writer.Write([]byte("de")) + assert.ErrorIs(t, err, io.ErrShortBuffer) + assert.Zero(t, written) + assert.Nil(t, budget.NewLimitedWriter(), "exceeding the budget should close the budget writer") + + require.NoError(t, writer.Flush()) + assert.Empty(t, output.String(), "a response that exceeded the budget should be discarded") + assert.Equal(t, 4, budget.budget) +} + +func TestLimitedWritersShareBudget(t *testing.T) { + var output bytes.Buffer + budget := newBudgetWriter(&output, 6) + + first := budget.NewLimitedWriter() + require.NotNil(t, first) + _, err := first.Write([]byte("one")) + require.NoError(t, err) + require.NoError(t, first.Flush()) + + second := budget.NewLimitedWriter() + require.NotNil(t, second) + _, err = second.Write([]byte("two")) + require.NoError(t, err) + require.NoError(t, second.Flush()) + + assert.Equal(t, "onetwo", output.String()) + assert.Zero(t, budget.budget) + + third := budget.NewLimitedWriter() + require.NotNil(t, third) + written, err := third.Write([]byte("x")) + assert.ErrorIs(t, err, io.ErrShortBuffer) + assert.Zero(t, written) + assert.Nil(t, budget.NewLimitedWriter()) +} + +func TestLimitedWriterAllowsEmptyWriteAtExhaustedBudget(t *testing.T) { + budget := newBudgetWriter(io.Discard, 0) + writer := budget.NewLimitedWriter() + require.NotNil(t, writer) + + written, err := writer.Write(nil) + require.NoError(t, err) + assert.Zero(t, written) + assert.False(t, budget.closed) + require.NoError(t, writer.Flush()) +} + +func TestLimitedWriterFlushPropagatesWriterError(t *testing.T) { + expectedErr := errors.New("write failed") + underlying := &stubWriter{written: 2, err: expectedErr} + budget := newBudgetWriter(underlying, 4) + writer := budget.NewLimitedWriter() + require.NotNil(t, writer) + + _, err := writer.Write([]byte("data")) + require.NoError(t, err) + + err = writer.Flush() + assert.ErrorIs(t, err, expectedErr) + assert.Equal(t, []byte("data"), underlying.data) + assert.Equal(t, 4, budget.budget, "a failed underlying write should not consume budget") +} + +type stubWriter struct { + written int + err error + data []byte +} + +func (w *stubWriter) Write(data []byte) (int, error) { + w.data = append(w.data, data...) + return w.written, w.err +} diff --git a/internal/jsonrpc/types.go b/internal/jsonrpc/types.go index 46607995e..9598bc03d 100644 --- a/internal/jsonrpc/types.go +++ b/internal/jsonrpc/types.go @@ -7,7 +7,7 @@ import ( "bytes" "encoding/json" "fmt" - "net/http" + "io" "reflect" "regexp" @@ -38,8 +38,16 @@ type RPCError struct { Data any `json:"data,omitempty"` } +func (e *RPCError) Error() string { + return e.Message +} + +func newRPCError(code int, message string, data any) error { + return &RPCError{Code: code, Message: message, Data: data} +} + // writeRPCError sends a generic error response for internal errors. -func writeRPCError(w http.ResponseWriter, id any, code int, message string, data any) { +func writeRPCError(w io.Writer, id any, code int, message string, data any) error { // Hide detailed error info for internal errors. if code == JSONRPC_INTERNAL_ERROR { message = "Internal server error" @@ -54,18 +62,16 @@ func writeRPCError(w http.ResponseWriter, id any, code int, message string, data }, ID: id, } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + return json.NewEncoder(w).Encode(resp) } -func writeRPCResult(w http.ResponseWriter, id any, result any) { +func writeRPCResult(w io.Writer, id any, result any) error { resp := RPCResponse{ JSONRPC: "2.0", Result: result, ID: id, } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + return json.NewEncoder(w).Encode(resp) } // UnmarshalParams supports both by-name (object) and by-position (array) parameter structures. From e954e1a98a1edf83799c62c0b9bc4cbe5e1a73d1 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:00:07 -0300 Subject: [PATCH 02/43] feat(jsonrpc): impose the same response size budget of batch requests to single requests --- internal/jsonrpc/jsonrpc.go | 41 +++++++++++++++++--------------- internal/jsonrpc/jsonrpc_test.go | 23 ++++++++++++++++++ 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index 88a4113da..f86782e3f 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -115,7 +115,7 @@ func (s *Service) writeRPCError(w http.ResponseWriter, id any, code int, message return s.handleWriteResponse(err) } -func (s *Service) dispatchOneRequest(w io.Writer, r *http.Request, req RPCRequest) error { +func (s *Service) handleRequest(w io.Writer, r *http.Request, req RPCRequest) error { switch req.ID.(type) { case nil, string, float64: default: @@ -146,6 +146,23 @@ func (s *Service) dispatchOneRequest(w io.Writer, r *http.Request, req RPCReques return writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) } +func (s *Service) dispatchOneRequest(w http.ResponseWriter, r *http.Request, req RPCRequest, budgetResp *budgetWriter) bool { + buffer := budgetResp.NewLimitedWriter() + if buffer == nil { + return s.writeRPCError(w, req.ID, JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED, "Response size limit exceeded") + } + err := s.handleRequest(buffer, r, req) + switch { + case err == nil: + return s.handleWriteResponse(buffer.Flush()) + case errors.Is(err, io.ErrShortBuffer): + return s.writeRPCError(w, req.ID, JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED, "Response size limit exceeded") + default: + s.Logger.Error("RPC method response encode failed", "method", req.Method, "error", err) + return s.writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error") + } +} + func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { // Limit request body size and ensure it is closed. r.Body = http.MaxBytesReader(w, r.Body, MAX_BODY_SIZE) @@ -167,6 +184,8 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { return } + budgetResp := newBudgetWriter(w, MAX_RESPONSE_SIZE) + switch body[0] { case '{': var req RPCRequest @@ -176,8 +195,7 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") s.Logger.Info("Dispatching RPC request", "method", req.Method) - err := s.dispatchOneRequest(w, r, req) - s.handleWriteResponse(err) + s.dispatchOneRequest(w, r, req, budgetResp) case '[': w.Header().Set("Content-Type", "application/json") @@ -198,7 +216,6 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { return } - budgetResp := newBudgetWriter(w, MAX_RESPONSE_SIZE) for i, rawReq := range reqSeq { if i > 0 && !s.writeByte(w, ',') { @@ -223,21 +240,7 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { responded = s.writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request") } else { s.Logger.Debug("Dispatching RPC request", "method", req.Method) - buffer := budgetResp.NewLimitedWriter() - if buffer == nil { - responded = s.writeRPCError(w, req.ID, JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED, "Response size limit exceeded") - } else { - err := s.dispatchOneRequest(buffer, r, req) - switch { - case err == nil: - responded = s.handleWriteResponse(buffer.Flush()) - case errors.Is(err, io.ErrShortBuffer): - responded = s.writeRPCError(w, req.ID, JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED, "Response size limit exceeded") - default: - s.Logger.Error("RPC method response encode failed", "method", req.Method, "error", err) - responded = s.writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error") - } - } + responded = s.dispatchOneRequest(w, r, req, budgetResp) } } diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index f82285174..edbf0bab4 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -19,6 +19,7 @@ import ( "context" "encoding/json" "fmt" + "net/http" "os" "time" @@ -77,6 +78,28 @@ func TestInvalidMethod(t *testing.T) { assert.Equal(t, "Method not found", resp.Error.Message) } +func TestJSONRPCSingleRequestReplacesResponseAtResponseBudget(t *testing.T) { + s := newBatchTestService() + const method = "test_large_single_result" + largeResult := strings.Repeat("x", MAX_RESPONSE_SIZE) + var called bool + withTestRPCHandler(t, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + called = true + return largeResult, nil + }) + + body := []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":%q,"params":{"limit":10000},"id":1}`, method)) + require.Less(t, len(body), 1<<10, "the request cap must not be mistaken for the response cap") + rr := serveRPC(t, s, body) + + require.True(t, called, "the request handler must run before its oversized response is replaced") + require.Equal(t, http.StatusOK, rr.Code) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED) + require.Equal(t, "Response size limit exceeded", response.Error.Message) +} + // tests for jsonrpc methods grouped by method name. // At the end we check if all methods ran at least once func TestMethod(t *testing.T) { From af297384381a243e87053f8fc637373177fa85f6 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:56:09 -0300 Subject: [PATCH 03/43] refactor(jsonrpc): remove unused field 'data' from error responses --- internal/jsonrpc/jsonrpc.go | 256 ++++++++++++++++++------------------ internal/jsonrpc/types.go | 9 +- 2 files changed, 131 insertions(+), 134 deletions(-) diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index f86782e3f..a02b6e1fc 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -111,7 +111,7 @@ func (s *Service) writeByte(w http.ResponseWriter, c byte) bool { // writeRPCError sends a generic error response for internal errors. func (s *Service) writeRPCError(w http.ResponseWriter, id any, code int, message string) bool { - err := writeRPCError(w, id, code, message, nil) + err := writeRPCError(w, id, code, message) return s.handleWriteResponse(err) } @@ -119,15 +119,15 @@ func (s *Service) handleRequest(w io.Writer, r *http.Request, req RPCRequest) er switch req.ID.(type) { case nil, string, float64: default: - return writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request", nil) + return writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request") } if req.JSONRPC != "2.0" || req.Method == "" { - return writeRPCError(w, req.ID, JSONRPC_INVALID_REQUEST, "invalid request", nil) + return writeRPCError(w, req.ID, JSONRPC_INVALID_REQUEST, "invalid request") } fn, ok := jsonrpcHandlers[req.Method] if !ok { s.Logger.Debug("RPC method not found", "method", req.Method) - return writeRPCError(w, req.ID, JSONRPC_METHOD_NOT_FOUND, "Method not found", nil) + return writeRPCError(w, req.ID, JSONRPC_METHOD_NOT_FOUND, "Method not found") } result, err := fn(s, r, req) @@ -139,11 +139,11 @@ func (s *Service) handleRequest(w io.Writer, r *http.Request, req RPCRequest) er if errors.As(err, &rpcErr) { // RPC errors describe expected client-facing failures. Do not log them at // error level; unexpected failures are logged below before being hidden. - return writeRPCError(w, req.ID, rpcErr.Code, rpcErr.Message, rpcErr.Data) + return writeRPCError(w, req.ID, rpcErr.Code, rpcErr.Message) } s.Logger.Error("RPC method failed", "method", req.Method, "error", err) - return writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error") } func (s *Service) dispatchOneRequest(w http.ResponseWriter, r *http.Request, req RPCRequest, budgetResp *budgetWriter) bool { @@ -270,12 +270,12 @@ func handleDiscover(s *Service, _ *http.Request, _ RPCRequest) (any, error) { data, err := discoverSpec.ReadFile("jsonrpc-discover.json") if err != nil { s.Logger.Error("Unable to read jsonrpc-discover content", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } var spec any if err := json.Unmarshal(data, &spec); err != nil { s.Logger.Error("Unable to unmarshal discovery spec JSON", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } return spec, nil } @@ -284,7 +284,7 @@ func handleListApplications(s *Service, r *http.Request, req RPCRequest) (any, e var params api.ListApplicationsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Use default values if not provided if params.Limit <= 0 { @@ -301,7 +301,7 @@ func handleListApplications(s *Service, r *http.Request, req RPCRequest) (any, e }, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve applications from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if apps == nil { apps = []*model.Application{} @@ -321,21 +321,21 @@ func handleGetApplication(s *Service, r *http.Request, req RPCRequest) (any, err var params api.GetApplicationParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } app, err := s.repository.GetApplication(r.Context(), params.Application) if err != nil { s.Logger.Error("Unable to retrieve application from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if app == nil { - return nil, newRPCError(JSONRPC_APPLICATION_NOT_FOUND, "Application not found", nil) + return nil, newRPCError(JSONRPC_APPLICATION_NOT_FOUND, "Application not found") } return api.SingleResponse[*model.Application]{Data: app}, nil @@ -345,7 +345,7 @@ func handleListEpochs(s *Service, r *http.Request, req RPCRequest) (any, error) var params api.ListEpochsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Use default values if not provided @@ -359,14 +359,14 @@ func handleListEpochs(s *Service, r *http.Request, req RPCRequest) (any, error) // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } var epochFilter repository.EpochFilter if params.Status != nil { var status model.EpochStatus if err := status.Scan(*params.Status); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch status: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch status: %v", err)) } epochFilter.Status = []model.EpochStatus{status} } @@ -377,7 +377,7 @@ func handleListEpochs(s *Service, r *http.Request, req RPCRequest) (any, error) }, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve epochs from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if len(epochs) == 0 { @@ -403,29 +403,29 @@ func handleGetEpoch(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetEpochParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } index, err := config.ToIndexFromString(params.EpochIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) } epoch, err := s.repository.GetEpoch(r.Context(), params.Application, index) if err != nil { s.Logger.Error("Unable to retrieve epoch from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if epoch == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { return nil, err } - return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Epoch not found", nil) + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Epoch not found") } return api.SingleResponse[*model.Epoch]{Data: epoch}, nil @@ -435,12 +435,12 @@ func handleGetLastAcceptedEpochIndex(s *Service, r *http.Request, req RPCRequest var params api.GetLastAcceptedEpochIndexParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } index, err := s.repository.GetLastAcceptedEpochIndex(r.Context(), params.Application) @@ -448,11 +448,11 @@ func handleGetLastAcceptedEpochIndex(s *Service, r *http.Request, req RPCRequest if err := s.applicationAbsentOrError(r, params.Application); err != nil { return nil, err } - return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Epoch not found", nil) + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Epoch not found") } if err != nil { s.Logger.Error("Unable to retrieve epoch from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", index)}, nil @@ -462,7 +462,7 @@ func handleListInputs(s *Service, r *http.Request, req RPCRequest) (any, error) var params api.ListInputsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Use default values if not provided @@ -476,7 +476,7 @@ func handleListInputs(s *Service, r *http.Request, req RPCRequest) (any, error) // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } // Create input filter based on params @@ -484,7 +484,7 @@ func handleListInputs(s *Service, r *http.Request, req RPCRequest) (any, error) if params.EpochIndex != nil { epochIndex, err := config.ToIndexFromString(*params.EpochIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) } inputFilter.EpochIndex = &epochIndex } @@ -493,14 +493,14 @@ func handleListInputs(s *Service, r *http.Request, req RPCRequest) (any, error) if params.Sender != nil { sender, err := config.ToAddressFromString(*params.Sender) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input sender address: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input sender address: %v", err)) } inputFilter.Sender = &sender } if params.TransactionHash != nil { transactionHash, err := config.ToHashFromString(*params.TransactionHash) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid transaction hash: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid transaction hash: %v", err)) } inputFilter.TransactionHash = &transactionHash } @@ -511,7 +511,7 @@ func handleListInputs(s *Service, r *http.Request, req RPCRequest) (any, error) }, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve inputs from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if len(inputs) == 0 { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -542,29 +542,29 @@ func handleGetInput(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetInputParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } index, err := config.ToIndexFromString(params.InputIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input index: %v", err)) } input, err := s.repository.GetInput(r.Context(), params.Application, index) if err != nil { s.Logger.Error("Unable to retrieve input from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if input == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { return nil, err } - return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Input not found", nil) + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Input not found") } decoded, err := api.DecodeInput(input, s.inputABI) @@ -579,21 +579,21 @@ func handleGetProcessedInputCount(s *Service, r *http.Request, req RPCRequest) ( var params api.GetApplicationParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } processedInputs, err := s.repository.GetProcessedInputCount(r.Context(), params.Application) if errors.Is(err, repository.ErrNotFound) { - return nil, newRPCError(JSONRPC_APPLICATION_NOT_FOUND, "Application not found", nil) + return nil, newRPCError(JSONRPC_APPLICATION_NOT_FOUND, "Application not found") } if err != nil { s.Logger.Error("Unable to retrieve application from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", processedInputs)}, nil @@ -603,7 +603,7 @@ func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) var params api.ListOutputsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Use default values if not provided @@ -617,7 +617,7 @@ func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } // Create output filter based on params @@ -625,7 +625,7 @@ func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) if params.EpochIndex != nil { epochIndex, err := config.ToIndexFromString(*params.EpochIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) } outputFilter.EpochIndex = &epochIndex } @@ -633,7 +633,7 @@ func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) if params.InputIndex != nil { inputIndex, err := config.ToIndexFromString(*params.InputIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input index: %v", err)) } outputFilter.InputIndex = &inputIndex } @@ -642,7 +642,7 @@ func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) if params.OutputType != nil { outputType, err := api.ParseOutputType(*params.OutputType) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid output type: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid output type: %v", err)) } outputFilter.OutputType = &outputType } @@ -651,7 +651,7 @@ func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) if params.VoucherAddress != nil { voucherAddress, err := config.ToAddressFromString(*params.VoucherAddress) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid voucher address: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid voucher address: %v", err)) } outputFilter.VoucherAddress = &voucherAddress } @@ -662,7 +662,7 @@ func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) }, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve outputs from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } resultOutputs := make([]*api.DecodedOutput, 0, len(outputs)) @@ -694,29 +694,29 @@ func handleGetOutput(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetOutputParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } index, err := config.ToIndexFromString(params.OutputIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid output index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid output index: %v", err)) } output, err := s.repository.GetOutput(r.Context(), params.Application, index) if err != nil { s.Logger.Error("Unable to retrieve output from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if output == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { return nil, err } - return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Output not found", nil) + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Output not found") } decoded, err := api.DecodeOutput(output, s.outputABI) @@ -731,7 +731,7 @@ func handleListReports(s *Service, r *http.Request, req RPCRequest) (any, error) var params api.ListReportsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Use default values if not provided @@ -745,7 +745,7 @@ func handleListReports(s *Service, r *http.Request, req RPCRequest) (any, error) // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } // Create report filter based on params @@ -753,7 +753,7 @@ func handleListReports(s *Service, r *http.Request, req RPCRequest) (any, error) if params.EpochIndex != nil { epochIndex, err := config.ToIndexFromString(*params.EpochIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) } reportFilter.EpochIndex = &epochIndex } @@ -761,7 +761,7 @@ func handleListReports(s *Service, r *http.Request, req RPCRequest) (any, error) if params.InputIndex != nil { inputIndex, err := config.ToIndexFromString(*params.InputIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid input index: %v", err)) } reportFilter.InputIndex = &inputIndex } @@ -772,7 +772,7 @@ func handleListReports(s *Service, r *http.Request, req RPCRequest) (any, error) }, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve reports from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if len(reports) == 0 { @@ -798,29 +798,29 @@ func handleGetReport(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetReportParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } index, err := config.ToIndexFromString(params.ReportIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid report index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid report index: %v", err)) } report, err := s.repository.GetReport(r.Context(), params.Application, index) if err != nil { s.Logger.Error("Unable to retrieve report from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if report == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { return nil, err } - return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Report not found", nil) + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Report not found") } return api.SingleResponse[*model.Report]{Data: report}, nil @@ -830,7 +830,7 @@ func handleListWithdrawals(s *Service, r *http.Request, req RPCRequest) (any, er var params api.ListWithdrawalsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } if params.Limit <= 0 { @@ -841,14 +841,14 @@ func handleListWithdrawals(s *Service, r *http.Request, req RPCRequest) (any, er } if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } withdrawalFilter := repository.WithdrawalFilter{} if params.AccountIndex != nil { accountIndex, err := config.ToIndexFromString(*params.AccountIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid account index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid account index: %v", err)) } withdrawalFilter.AccountIndex = &accountIndex } @@ -860,7 +860,7 @@ func handleListWithdrawals(s *Service, r *http.Request, req RPCRequest) (any, er ) if err != nil { s.Logger.Error("Unable to retrieve withdrawals from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if len(withdrawals) == 0 { @@ -886,28 +886,28 @@ func handleGetWithdrawal(s *Service, r *http.Request, req RPCRequest) (any, erro var params api.GetWithdrawalParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } accountIndex, err := config.ToIndexFromString(params.AccountIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid account index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid account index: %v", err)) } withdrawal, err := s.repository.GetWithdrawal(r.Context(), params.Application, accountIndex) if err != nil { s.Logger.Error("Unable to retrieve withdrawal from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if withdrawal == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { return nil, err } - return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Withdrawal not found", nil) + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Withdrawal not found") } return api.SingleResponse[*model.Withdrawal]{Data: withdrawal}, nil @@ -917,7 +917,7 @@ func handleListTournaments(s *Service, r *http.Request, req RPCRequest) (any, er var params api.ListTournamentsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Use default values if not provided @@ -931,7 +931,7 @@ func handleListTournaments(s *Service, r *http.Request, req RPCRequest) (any, er // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } // Create tournament filter based on params @@ -939,7 +939,7 @@ func handleListTournaments(s *Service, r *http.Request, req RPCRequest) (any, er if params.EpochIndex != nil { epochIndex, err := config.ToIndexFromString(*params.EpochIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) } tournamentFilter.EpochIndex = &epochIndex } @@ -947,7 +947,7 @@ func handleListTournaments(s *Service, r *http.Request, req RPCRequest) (any, er if params.Level != nil { level, err := config.ToIndexFromString(*params.Level) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid level: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid level: %v", err)) } tournamentFilter.Level = &level } @@ -955,7 +955,7 @@ func handleListTournaments(s *Service, r *http.Request, req RPCRequest) (any, er if params.ParentTournamentAddress != nil { parentAddress, err := config.ToAddressFromString(*params.ParentTournamentAddress) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent tournament address: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent tournament address: %v", err)) } tournamentFilter.ParentTournamentAddress = &parentAddress } @@ -963,7 +963,7 @@ func handleListTournaments(s *Service, r *http.Request, req RPCRequest) (any, er if params.ParentMatchIDHash != nil { parentMatchIDHash, err := config.ToHashFromString(*params.ParentMatchIDHash) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent match ID hash: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent match ID hash: %v", err)) } tournamentFilter.ParentMatchIDHash = &parentMatchIDHash } @@ -974,7 +974,7 @@ func handleListTournaments(s *Service, r *http.Request, req RPCRequest) (any, er }, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve tournaments from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if len(tournaments) == 0 { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -999,29 +999,29 @@ func handleGetTournament(s *Service, r *http.Request, req RPCRequest) (any, erro var params api.GetTournamentParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } // Validate tournament address if _, err := config.ToAddressFromString(params.Address); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) } tournament, err := s.repository.GetTournament(r.Context(), params.Application, params.Address) if err != nil { s.Logger.Error("Unable to retrieve tournament from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if tournament == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { return nil, err } - return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Tournament not found", nil) + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Tournament not found") } return api.SingleResponse[*model.Tournament]{Data: tournament}, nil @@ -1031,7 +1031,7 @@ func handleListCommitments(s *Service, r *http.Request, req RPCRequest) (any, er var params api.ListCommitmentsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Use default values if not provided @@ -1045,7 +1045,7 @@ func handleListCommitments(s *Service, r *http.Request, req RPCRequest) (any, er // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } // Create commitment filter based on params @@ -1053,14 +1053,14 @@ func handleListCommitments(s *Service, r *http.Request, req RPCRequest) (any, er if params.EpochIndex != nil { epochIndex, err := config.ToIndexFromString(*params.EpochIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) } commitmentFilter.EpochIndex = &epochIndex } if params.TournamentAddress != nil { if _, err := config.ToAddressFromString(*params.TournamentAddress); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) } commitmentFilter.TournamentAddress = params.TournamentAddress } @@ -1071,7 +1071,7 @@ func handleListCommitments(s *Service, r *http.Request, req RPCRequest) (any, er }, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve commitments from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if len(commitments) == 0 { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -1096,40 +1096,40 @@ func handleGetCommitment(s *Service, r *http.Request, req RPCRequest) (any, erro var params api.GetCommitmentParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } epochIndex, err := config.ToIndexFromString(params.EpochIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) } if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) } if len(params.Commitment) == 0 { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid commitment hex: Empty string", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid commitment hex: Empty string") } if _, err := config.ToHashFromString(params.Commitment); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid commitment hex: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid commitment hex: %v", err)) } commitment, err := s.repository.GetCommitment(r.Context(), params.Application, epochIndex, params.TournamentAddress, params.Commitment) if err != nil { s.Logger.Error("Unable to retrieve commitment from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if commitment == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { return nil, err } - return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Commitment not found", nil) + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Commitment not found") } return api.SingleResponse[*model.Commitment]{Data: commitment}, nil @@ -1139,7 +1139,7 @@ func handleListMatches(s *Service, r *http.Request, req RPCRequest) (any, error) var params api.ListMatchesParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Use default values if not provided @@ -1153,7 +1153,7 @@ func handleListMatches(s *Service, r *http.Request, req RPCRequest) (any, error) // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } // Create match filter based on params @@ -1161,14 +1161,14 @@ func handleListMatches(s *Service, r *http.Request, req RPCRequest) (any, error) if params.EpochIndex != nil { epochIndex, err := config.ToIndexFromString(*params.EpochIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) } matchFilter.EpochIndex = &epochIndex } if params.TournamentAddress != nil { if _, err := config.ToAddressFromString(*params.TournamentAddress); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) } matchFilter.TournamentAddress = params.TournamentAddress } @@ -1179,7 +1179,7 @@ func handleListMatches(s *Service, r *http.Request, req RPCRequest) (any, error) }, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve matches from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if len(matches) == 0 { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -1204,37 +1204,37 @@ func handleGetMatch(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetMatchParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } epochIndex, err := config.ToIndexFromString(params.EpochIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) } if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) } if _, err := config.ToHashFromString(params.IDHash); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err)) } match, err := s.repository.GetMatch(r.Context(), params.Application, epochIndex, params.TournamentAddress, params.IDHash) if err != nil { s.Logger.Error("Unable to retrieve match from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if match == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { return nil, err } - return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Match not found", nil) + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Match not found") } return api.SingleResponse[*model.Match]{Data: match}, nil @@ -1244,7 +1244,7 @@ func handleListMatchAdvances(s *Service, r *http.Request, req RPCRequest) (any, var params api.ListMatchAdvancesParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Use default values if not provided @@ -1258,21 +1258,21 @@ func handleListMatchAdvances(s *Service, r *http.Request, req RPCRequest) (any, // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } // Create match advance filter based on params epochIndex, err := config.ToIndexFromString(params.EpochIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) } if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) } if _, err := config.ToHashFromString(params.IDHash); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err)) } pagination := repository.Pagination{ @@ -1283,7 +1283,7 @@ func handleListMatchAdvances(s *Service, r *http.Request, req RPCRequest) (any, params.TournamentAddress, params.IDHash, pagination, params.Descending) if err != nil { s.Logger.Error("Unable to retrieve match advances from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if len(matchAdvances) == 0 { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -1308,42 +1308,42 @@ func handleGetMatchAdvanced(s *Service, r *http.Request, req RPCRequest) (any, e var params api.GetMatchAdvancedParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) - return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters", nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } // Validate application parameter if err := validateNameOrAddress(params.Application); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) } epochIndex, err := config.ToIndexFromString(params.EpochIndex) if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch index: %v", err)) } if _, err := config.ToAddressFromString(params.TournamentAddress); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid tournament address: %v", err)) } if _, err := config.ToHashFromString(params.IDHash); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err)) } if _, err := config.ToHashFromString(params.Parent); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent hash: %v", err), nil) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent hash: %v", err)) } matchAdvanced, err := s.repository.GetMatchAdvanced(r.Context(), params.Application, epochIndex, params.TournamentAddress, params.IDHash, params.Parent[2:]) // TODO: use parsed value if err != nil { s.Logger.Error("Unable to retrieve match advanced from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } if matchAdvanced == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { return nil, err } - return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Match advanced not found", nil) + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Match advanced not found") } return api.SingleResponse[*model.MatchAdvanced]{Data: matchAdvanced}, nil @@ -1352,11 +1352,11 @@ func handleGetMatchAdvanced(s *Service, r *http.Request, req RPCRequest) (any, e func handleGetChainID(s *Service, r *http.Request, _ RPCRequest) (any, error) { config, err := repository.LoadNodeConfig[evmreader.PersistentConfig](r.Context(), s.repository, evmreader.EvmReaderConfigKey) if errors.Is(err, repository.ErrNotFound) { - return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "EVM Reader config not found", nil) + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "EVM Reader config not found") } if err != nil { s.Logger.Error("Unable to retrieve evmreader config from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", config.Value.ChainID)}, nil @@ -1373,9 +1373,9 @@ func (s *Service) applicationAbsentOrError( app, err := s.repository.GetApplication(r.Context(), validatedNameOrAddress) if err != nil { s.Logger.Error("Unable to retrieve application from repository", "err", err) - return newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error", nil) + return newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } else if app == nil { - return newRPCError(JSONRPC_APPLICATION_NOT_FOUND, "Application not found", nil) + return newRPCError(JSONRPC_APPLICATION_NOT_FOUND, "Application not found") } return nil } diff --git a/internal/jsonrpc/types.go b/internal/jsonrpc/types.go index 9598bc03d..00aa12f3d 100644 --- a/internal/jsonrpc/types.go +++ b/internal/jsonrpc/types.go @@ -35,30 +35,27 @@ type RPCResponse struct { type RPCError struct { Code int `json:"code"` Message string `json:"message"` - Data any `json:"data,omitempty"` } func (e *RPCError) Error() string { return e.Message } -func newRPCError(code int, message string, data any) error { - return &RPCError{Code: code, Message: message, Data: data} +func newRPCError(code int, message string) error { + return &RPCError{Code: code, Message: message} } // writeRPCError sends a generic error response for internal errors. -func writeRPCError(w io.Writer, id any, code int, message string, data any) error { +func writeRPCError(w io.Writer, id any, code int, message string) error { // Hide detailed error info for internal errors. if code == JSONRPC_INTERNAL_ERROR { message = "Internal server error" - data = nil } resp := RPCResponse{ JSONRPC: "2.0", Error: &RPCError{ Code: code, Message: message, - Data: data, }, ID: id, } From da49cc46ba468511a04eca4a6186862b81817901 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:38:29 -0300 Subject: [PATCH 04/43] feat(jsonrpc): add operation to get epoch by a virtual contiguous index --- internal/jsonrpc/api/params.go | 6 ++ internal/jsonrpc/jsonrpc-discover.json | 43 ++++++++++ internal/jsonrpc/jsonrpc.go | 33 +++++++ internal/jsonrpc/jsonrpc_test.go | 114 +++++++++++++++++++++++++ internal/jsonrpc/util_test.go | 1 + 5 files changed, 197 insertions(+) diff --git a/internal/jsonrpc/api/params.go b/internal/jsonrpc/api/params.go index ef21488c2..92f31e82d 100644 --- a/internal/jsonrpc/api/params.go +++ b/internal/jsonrpc/api/params.go @@ -30,6 +30,12 @@ type GetEpochParams struct { EpochIndex string `json:"epoch_index"` } +// GetEpochByVirtualIndexParams aligns with the OpenRPC specification +type GetEpochByVirtualIndexParams struct { + Application string `json:"application"` + VirtualIndex string `json:"virtual_index"` +} + // GetLastAcceptedEpochIndexParams with the OpenRPC specification type GetLastAcceptedEpochIndexParams struct { Application string `json:"application"` diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index 46c148dfc..5234bff25 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -225,6 +225,49 @@ } ] }, + { + "name": "cartesi_getEpochByVirtualIndex", + "summary": "Get a specific epoch by its virtual index", + "description": "Fetches a single epoch by application and its virtual index, which is the epoch's dense insertion rank — 0, 1, 2, … with no gaps by construction.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + }, + { + "name": "virtual_index", + "description": "The virtual index of the epoch to be retrieved (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/EpochGetResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/EpochNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + } + ] + }, { "name": "cartesi_getLastAcceptedEpochIndex", "summary": "Get the last accepted epoch index", diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index a02b6e1fc..b76f6ac53 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -70,6 +70,7 @@ var jsonrpcHandlers = dispatchTable{ "cartesi_getApplication": handleGetApplication, "cartesi_listEpochs": handleListEpochs, "cartesi_getEpoch": handleGetEpoch, + "cartesi_getEpochByVirtualIndex": handleGetEpochByVirtualIndex, "cartesi_getLastAcceptedEpochIndex": handleGetLastAcceptedEpochIndex, "cartesi_listInputs": handleListInputs, "cartesi_getInput": handleGetInput, @@ -431,6 +432,38 @@ func handleGetEpoch(s *Service, r *http.Request, req RPCRequest) (any, error) { return api.SingleResponse[*model.Epoch]{Data: epoch}, nil } +func handleGetEpochByVirtualIndex(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetEpochByVirtualIndexParams + if err := UnmarshalParams(req.Params, ¶ms); err != nil { + s.Logger.Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + // Validate application parameter + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + index, err := config.ToIndexFromString(params.VirtualIndex) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid virtual index: %v", err)) + } + + epoch, err := s.repository.GetEpochByVirtualIndex(r.Context(), params.Application, index) + if err != nil { + s.Logger.Error("Unable to retrieve epoch from repository", "err", err) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + } + if epoch == nil { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Epoch not found") + } + + return api.SingleResponse[*model.Epoch]{Data: epoch}, nil +} + func handleGetLastAcceptedEpochIndex(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetLastAcceptedEpochIndexParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index edbf0bab4..72604c66e 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -354,6 +354,120 @@ func TestMethod(t *testing.T) { }) }) + //////////////////////////////////////////////////////////////////////// + // getEpochByVirtualIndex + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getEpochByVirtualIndex", func(t *testing.T) { + method := getName(t.Name()) + + // failure: virtual_index not hex encoded -> invalid param + t.Run("malformedVirtualIndex", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getEpochByVirtualIndex", + "params": { + "application": "%v", + "virtual_index": 0 + }, + "id": 0 + }`, numberToName(1))) + + resp := testRPCResponse[any]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.NotNil(t, resp.Error) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Equal(t, "Invalid parameters", resp.Error.Message) + }) + + // failure: virtual index not in the database -> resource not found + t.Run("absent", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(5). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getEpochByVirtualIndex", + "params": { + "application": "%v", + "virtual_index": "%v" + }, + "id": 0 + }`, numberToName(app), hexutil.EncodeUint64(1))) + + resp := testRPCResponse[any]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.NotNil(t, resp.Error) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Epoch not found", resp.Error.Message) + }) + + // failure: application not in the database -> application not found + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getEpochByVirtualIndex", + "params": { + "application": "%v", + "virtual_index": "0x0" + }, + "id": 0 + }`, numberToName(0xdeadbeef))) + + resp := testRPCResponse[any]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.NotNil(t, resp.Error) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + // success: lookup uses the dense virtual index, not the physical epoch index + t.Run("presentWithDivergentPhysicalIndex", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + appID := s.newTestApplication(ctx, t, app) + s.createTestEpoch(ctx, t, numberToName(app), + repotest.NewEpochBuilder(appID). + WithIndex(5). + WithStatus(model.EpochStatus_ClaimAccepted). + Build()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getEpochByVirtualIndex", + "params": { + "application": "%v", + "virtual_index": "0x0" + }, + "id": 0 + }`, numberToName(app))) + + resp := testRPCResponse[*model.Epoch]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.Nil(t, resp.Error) + require.NotNil(t, resp.Result.Data) + assert.Equal(t, uint64(5), resp.Result.Data.Index) + assert.Equal(t, uint64(0), resp.Result.Data.VirtualIndex) + }) + }) + //////////////////////////////////////////////////////////////////////// // getInput //////////////////////////////////////////////////////////////////////// diff --git a/internal/jsonrpc/util_test.go b/internal/jsonrpc/util_test.go index e68660282..aadfbe8f5 100644 --- a/internal/jsonrpc/util_test.go +++ b/internal/jsonrpc/util_test.go @@ -96,6 +96,7 @@ func newTestServiceFull(t *testing.T, name string, maxInflight uint64, corsOrigi repo, err := factory.NewRepositoryFromConnectionString(ctx, dbTestEndpoint) require.NoError(t, err) + t.Cleanup(repo.Close) logLevel, err := config.GetLogLevel() require.NoError(t, err) From d9bd6989c1aa870f9815944fbf48ca0fe2cae12c Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:31:15 -0300 Subject: [PATCH 05/43] feat(jsonrpc): add operation to get Node info like its chain ID, version, and default block --- internal/jsonrpc/api/response.go | 6 +++ internal/jsonrpc/jsonrpc-discover.json | 57 +++++++++++++++++++++++- internal/jsonrpc/jsonrpc.go | 18 ++++++++ internal/jsonrpc/jsonrpc_test.go | 60 ++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 2 deletions(-) diff --git a/internal/jsonrpc/api/response.go b/internal/jsonrpc/api/response.go index 8f974a8ad..69caa614c 100644 --- a/internal/jsonrpc/api/response.go +++ b/internal/jsonrpc/api/response.go @@ -20,3 +20,9 @@ type ListResponse[T any] struct { type SingleResponse[T any] struct { Data T `json:"data"` } + +type NodeInfo struct { + ChainID string `json:"chain_id"` + Version string `json:"version"` + DefaultBlock string `json:"default_block"` // FINALIZED | SAFE | LATEST | PENDING +} diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index 5234bff25..68fd329b2 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -1497,10 +1497,31 @@ } ] }, + { + "name": "cartesi_getNodeInfo", + "summary": "Get node information", + "description": "Fetches the chain ID, semantic node version, and default blockchain block tag used by the node. `default_block` is the node's finality contract for blockchain-derived data: it identifies the block tag (`FINALIZED`, `SAFE`, `LATEST`, or `PENDING`) up to which the node reads and acts on chain state. Clients should therefore interpret data exposed by this node with the stability guarantees of that tag.", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/NodeInfoResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/NodeConfigNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + } + ] + }, { "name": "cartesi_getChainId", "summary": "Get node's chain ID", - "description": "Fetches the chain ID that node is operating on.", + "description": "Fetches the chain ID that the node is operating on. Deprecated: use `cartesi_getNodeInfo`, which returns the chain ID together with the node version and default blockchain block tag.", + "deprecated": true, "params": [], "result": { "name": "result", @@ -1526,7 +1547,8 @@ { "name": "cartesi_getNodeVersion", "summary": "Get node version", - "description": "Fetches the semantic version of the Cartesi rollups node.", + "description": "Fetches the semantic version of the Cartesi rollups node. Deprecated: use `cartesi_getNodeInfo`, which returns the node version together with the chain ID and default blockchain block tag.", + "deprecated": true, "params": [], "result": { "name": "result", @@ -2313,6 +2335,37 @@ } } }, + "NodeInfo": { + "type": "object", + "properties": { + "chain_id": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "version": { + "type": "string", + "format": "semver", + "pattern": "^[a-zA-Z0-9_-\\.]+$" + }, + "default_block": { + "type": "string", + "description": "The block tag that defines the finality/stability level of blockchain-derived data read and acted on by this node.", + "enum": [ + "FINALIZED", + "SAFE", + "LATEST", + "PENDING" + ] + } + } + }, + "NodeInfoResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/NodeInfo" + } + } + }, "NodeVersionResult": { "type": "object", "properties": { diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index b76f6ac53..33be09463 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -89,6 +89,7 @@ var jsonrpcHandlers = dispatchTable{ "cartesi_getMatch": handleGetMatch, "cartesi_listMatchAdvances": handleListMatchAdvances, "cartesi_getMatchAdvanced": handleGetMatchAdvanced, + "cartesi_getNodeInfo": handleGetNodeInfo, "cartesi_getChainId": handleGetChainID, "cartesi_getNodeVersion": handleGetNodeVersion, } @@ -1382,6 +1383,23 @@ func handleGetMatchAdvanced(s *Service, r *http.Request, req RPCRequest) (any, e return api.SingleResponse[*model.MatchAdvanced]{Data: matchAdvanced}, nil } +func handleGetNodeInfo(s *Service, r *http.Request, _ RPCRequest) (any, error) { + cfg, err := repository.LoadNodeConfig[evmreader.PersistentConfig](r.Context(), s.repository, evmreader.EvmReaderConfigKey) + if errors.Is(err, repository.ErrNotFound) { + return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "EVM Reader config not found") + } + if err != nil { + s.Logger.Error("Unable to retrieve evmreader config from repository", "err", err) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + } + + return api.SingleResponse[api.NodeInfo]{Data: api.NodeInfo{ + ChainID: fmt.Sprintf("0x%x", cfg.Value.ChainID), + Version: version.BuildVersion, + DefaultBlock: string(cfg.Value.DefaultBlock), // FINALIZED | SAFE | LATEST | PENDING + }}, nil +} + func handleGetChainID(s *Service, r *http.Request, _ RPCRequest) (any, error) { config, err := repository.LoadNodeConfig[evmreader.PersistentConfig](r.Context(), s.repository, evmreader.EvmReaderConfigKey) if errors.Is(err, repository.ErrNotFound) { diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index 72604c66e..a637b5372 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -185,6 +185,66 @@ func TestMethod(t *testing.T) { }) }) + //////////////////////////////////////////////////////////////////////// + // getNodeInfo + //////////////////////////////////////////////////////////////////////// + t.Run("cartesi_getNodeInfo", func(t *testing.T) { + method := getName(t.Name()) + + // failure: evm reader not configured -> resource not found + t.Run("absent", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + body := s.doRequest(t, 0, []byte(`{ + "jsonrpc": "2.0", + "method": "cartesi_getNodeInfo", + "params": {}, + "id": 0 + }`)) + + resp := testRPCResponse[any]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.NotNil(t, resp.Error) + assert.Equal(t, JSONRPC_RESOURCE_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "EVM Reader config not found", resp.Error.Message) + }) + + // success: combine persisted node configuration with the build version + t.Run("present", func(t *testing.T) { + testHistogram.inc(method) + ctx := context.Background() + s := newTestService(t, t.Name()) + + chainID := uint64(0xdeadbeef) + defaultBlock := model.DefaultBlock_Safe + err := repository.SaveNodeConfig(ctx, s.repository, + &model.NodeConfig[evmreader.PersistentConfig]{ + Key: evmreader.EvmReaderConfigKey, + Value: evmreader.PersistentConfig{ + ChainID: chainID, + DefaultBlock: defaultBlock, + }, + }, + ) + require.NoError(t, err) + + body := s.doRequest(t, 0, []byte(`{ + "jsonrpc": "2.0", + "method": "cartesi_getNodeInfo", + "params": {}, + "id": 0 + }`)) + + resp := testRPCResponse[api.NodeInfo]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.Nil(t, resp.Error) + assert.Equal(t, hexutil.EncodeUint64(chainID), resp.Result.Data.ChainID) + assert.Equal(t, version.BuildVersion, resp.Result.Data.Version) + assert.Equal(t, string(defaultBlock), resp.Result.Data.DefaultBlock) + }) + }) + //////////////////////////////////////////////////////////////////////// // getChainId //////////////////////////////////////////////////////////////////////// From dca72551166410b9e1a8a0833148e627a297a6e9 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:05:06 -0300 Subject: [PATCH 06/43] feat(jsonrpc): add inclusive index ranges to list epochs, inputs, outputs, and reports Changes include: - Added optional from and to JSON-RPC parameters. - Added IndexRange *Range to all four repository filters. - Added index-backed >= from and <= to PostgreSQL predicates shared by COUNT(*) and list queries. - Added shared hex-bound parsing and from <= to validation returning -32602 (invalid params). - Preserved positional-parameter compatibility by appending new fields after existing parameters. - Updated the OpenRPC specification. - Added repository tests confirming range composition with total count, descending order, offset, and limit. - Added no-database tests for reversed and malformed bounds. --- internal/jsonrpc/api/params.go | 8 +++ internal/jsonrpc/jsonrpc-discover.json | 64 +++++++++++++++++++ internal/jsonrpc/jsonrpc.go | 47 ++++++++++++++ internal/jsonrpc/jsonrpc_test.go | 50 +++++++++++++++ internal/repository/postgres/epoch.go | 6 ++ internal/repository/postgres/input.go | 6 ++ internal/repository/postgres/output.go | 6 ++ internal/repository/postgres/report.go | 6 ++ internal/repository/repository.go | 4 ++ .../repository/repotest/epoch_test_cases.go | 25 ++++++++ .../repository/repotest/input_test_cases.go | 24 +++++++ .../repository/repotest/output_test_cases.go | 16 +++++ .../repository/repotest/report_test_cases.go | 16 +++++ 13 files changed, 278 insertions(+) diff --git a/internal/jsonrpc/api/params.go b/internal/jsonrpc/api/params.go index 92f31e82d..031e315e2 100644 --- a/internal/jsonrpc/api/params.go +++ b/internal/jsonrpc/api/params.go @@ -22,6 +22,8 @@ type ListEpochsParams struct { Limit uint64 `json:"limit"` Offset uint64 `json:"offset"` Descending bool `json:"descending,omitempty"` + From *string `json:"from,omitempty"` // inclusive lower bound on the epoch index (hex) + To *string `json:"to,omitempty"` // inclusive upper bound on the epoch index (hex) } // GetEpochParams aligns with the OpenRPC specification @@ -50,6 +52,8 @@ type ListInputsParams struct { Limit uint64 `json:"limit"` Offset uint64 `json:"offset"` Descending bool `json:"descending,omitempty"` + From *string `json:"from,omitempty"` // inclusive lower bound on the input index (hex) + To *string `json:"to,omitempty"` // inclusive upper bound on the input index (hex) } // GetInputParams aligns with the OpenRPC specification @@ -73,6 +77,8 @@ type ListOutputsParams struct { Limit uint64 `json:"limit"` Offset uint64 `json:"offset"` Descending bool `json:"descending,omitempty"` + From *string `json:"from,omitempty"` // inclusive lower bound on the output index (hex) + To *string `json:"to,omitempty"` // inclusive upper bound on the output index (hex) } // GetOutputParams aligns with the OpenRPC specification @@ -89,6 +95,8 @@ type ListReportsParams struct { Limit uint64 `json:"limit"` Offset uint64 `json:"offset"` Descending bool `json:"descending,omitempty"` + From *string `json:"from,omitempty"` // inclusive lower bound on the report index (hex) + To *string `json:"to,omitempty"` // inclusive upper bound on the report index (hex) } // GetReportParams aligns with the OpenRPC specification diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index 68fd329b2..8ffb3c9c7 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -149,6 +149,22 @@ "default": false }, "required": false + }, + { + "name": "from", + "description": "Inclusive lower bound on the epoch index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "to", + "description": "Inclusive upper bound on the epoch index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false } ], "result": { @@ -374,6 +390,22 @@ "default": false }, "required": false + }, + { + "name": "from", + "description": "Inclusive lower bound on the input index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "to", + "description": "Inclusive upper bound on the input index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false } ], "result": { @@ -560,6 +592,22 @@ "default": false }, "required": false + }, + { + "name": "from", + "description": "Inclusive lower bound on the output index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "to", + "description": "Inclusive upper bound on the output index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false } ], "result": { @@ -690,6 +738,22 @@ "default": false }, "required": false + }, + { + "name": "from", + "description": "Inclusive lower bound on the report index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false + }, + { + "name": "to", + "description": "Inclusive upper bound on the report index (hex encoded).", + "schema": { + "$ref": "#/components/schemas/UnsignedInteger" + }, + "required": false } ], "result": { diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index 33be09463..e080bfcdb 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "math" "net/http" "github.com/cartesi/rollups-node/internal/config" @@ -365,6 +366,11 @@ func handleListEpochs(s *Service, r *http.Request, req RPCRequest) (any, error) } var epochFilter repository.EpochFilter + indexRange, err := parseIndexRange(params.From, params.To) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, err.Error()) + } + epochFilter.IndexRange = indexRange if params.Status != nil { var status model.EpochStatus if err := status.Scan(*params.Status); err != nil { @@ -515,6 +521,11 @@ func handleListInputs(s *Service, r *http.Request, req RPCRequest) (any, error) // Create input filter based on params inputFilter := repository.InputFilter{} + indexRange, err := parseIndexRange(params.From, params.To) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, err.Error()) + } + inputFilter.IndexRange = indexRange if params.EpochIndex != nil { epochIndex, err := config.ToIndexFromString(*params.EpochIndex) if err != nil { @@ -656,6 +667,11 @@ func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) // Create output filter based on params outputFilter := repository.OutputFilter{} + indexRange, err := parseIndexRange(params.From, params.To) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, err.Error()) + } + outputFilter.IndexRange = indexRange if params.EpochIndex != nil { epochIndex, err := config.ToIndexFromString(*params.EpochIndex) if err != nil { @@ -784,6 +800,11 @@ func handleListReports(s *Service, r *http.Request, req RPCRequest) (any, error) // Create report filter based on params reportFilter := repository.ReportFilter{} + indexRange, err := parseIndexRange(params.From, params.To) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, err.Error()) + } + reportFilter.IndexRange = indexRange if params.EpochIndex != nil { epochIndex, err := config.ToIndexFromString(*params.EpochIndex) if err != nil { @@ -1417,6 +1438,32 @@ func handleGetNodeVersion(_ *Service, _ *http.Request, _ RPCRequest) (any, error return api.SingleResponse[string]{Data: version.BuildVersion}, nil } +func parseIndexRange(from, to *string) (*repository.Range, error) { + if from == nil && to == nil { + return nil, nil + } + + indexRange := repository.Range{End: math.MaxUint64} + if from != nil { + value, err := config.ToIndexFromString(*from) + if err != nil { + return nil, fmt.Errorf("invalid from index: %w", err) + } + indexRange.Start = value + } + if to != nil { + value, err := config.ToIndexFromString(*to) + if err != nil { + return nil, fmt.Errorf("invalid to index: %w", err) + } + indexRange.End = value + } + if indexRange.Start > indexRange.End { + return nil, fmt.Errorf("invalid index range: from must be less than or equal to to") + } + return &indexRange, nil +} + func (s *Service) applicationAbsentOrError( r *http.Request, validatedNameOrAddress string, diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index a637b5372..d757fc55e 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -19,6 +19,7 @@ import ( "context" "encoding/json" "fmt" + "math" "net/http" "os" "time" @@ -3677,3 +3678,52 @@ func TestMethod(t *testing.T) { t.Errorf("Method coverage issues:\n%s", strings.Join(errors, "\n")) } } + +func TestListIndexRangeValidation(t *testing.T) { + for _, method := range []string{ + "cartesi_listEpochs", + "cartesi_listInputs", + "cartesi_listOutputs", + "cartesi_listReports", + } { + t.Run(method, func(t *testing.T) { + s := newBatchTestService() + body := []byte(fmt.Sprintf(`{ + "jsonrpc":"2.0", + "method":%q, + "params":{"application":"app","from":"0x2","to":"0x1"}, + "id":1 + }`, method)) + rr := serveRPC(t, s, body) + + require.Equal(t, http.StatusOK, rr.Code) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_INVALID_PARAMS) + require.Equal(t, "invalid index range: from must be less than or equal to to", response.Error.Message) + }) + } +} + +func TestParseIndexRange(t *testing.T) { + from := "0x2" + to := "0x4" + indexRange, err := parseIndexRange(&from, &to) + require.NoError(t, err) + require.Equal(t, repository.Range{Start: 2, End: 4}, *indexRange) + + indexRange, err = parseIndexRange(&from, nil) + require.NoError(t, err) + require.Equal(t, uint64(2), indexRange.Start) + require.Equal(t, uint64(math.MaxUint64), indexRange.End) + + indexRange, err = parseIndexRange(nil, &to) + require.NoError(t, err) + require.Equal(t, uint64(0), indexRange.Start) + require.Equal(t, uint64(4), indexRange.End) + + invalid := "2" + _, err = parseIndexRange(&invalid, nil) + require.EqualError(t, err, "invalid from index: expected hex encoded value") + _, err = parseIndexRange(nil, &invalid) + require.EqualError(t, err, "invalid to index: expected hex encoded value") +} diff --git a/internal/repository/postgres/epoch.go b/internal/repository/postgres/epoch.go index 8930eecc0..5a9b8986e 100644 --- a/internal/repository/postgres/epoch.go +++ b/internal/repository/postgres/epoch.go @@ -842,6 +842,12 @@ func (r *PostgresRepository) ListEpochs( ) conditions := []postgres.BoolExpression{whereClause} + if f.IndexRange != nil { + conditions = append(conditions, + table.Epoch.Index.GT_EQ(uint64Expr(f.IndexRange.Start)), + table.Epoch.Index.LT_EQ(uint64Expr(f.IndexRange.End)), + ) + } if len(f.Status) > 0 { statuses := make([]postgres.Expression, 0, len(f.Status)) for _, status := range f.Status { diff --git a/internal/repository/postgres/input.go b/internal/repository/postgres/input.go index c826387a1..67049469e 100644 --- a/internal/repository/postgres/input.go +++ b/internal/repository/postgres/input.go @@ -229,6 +229,12 @@ func (r *PostgresRepository) ListInputs( ) conditions := []postgres.BoolExpression{whereClause} + if f.IndexRange != nil { + conditions = append(conditions, + table.Input.Index.GT_EQ(uint64Expr(f.IndexRange.Start)), + table.Input.Index.LT_EQ(uint64Expr(f.IndexRange.End)), + ) + } if f.EpochIndex != nil { conditions = append(conditions, table.Input.EpochIndex.EQ(uint64Expr(*f.EpochIndex))) } diff --git a/internal/repository/postgres/output.go b/internal/repository/postgres/output.go index b18f54b28..6bcf3123e 100644 --- a/internal/repository/postgres/output.go +++ b/internal/repository/postgres/output.go @@ -169,6 +169,12 @@ func (r *PostgresRepository) ListOutputs( ) conditions := []postgres.BoolExpression{whereClause} + if f.IndexRange != nil { + conditions = append(conditions, + table.Output.Index.GT_EQ(uint64Expr(f.IndexRange.Start)), + table.Output.Index.LT_EQ(uint64Expr(f.IndexRange.End)), + ) + } if f.BlockRange != nil { conditions = append(conditions, table.Input.BlockNumber.BETWEEN( uint64Expr(f.BlockRange.Start), diff --git a/internal/repository/postgres/report.go b/internal/repository/postgres/report.go index d653ad9a1..b011e57ff 100644 --- a/internal/repository/postgres/report.go +++ b/internal/repository/postgres/report.go @@ -90,6 +90,12 @@ func (r *PostgresRepository) ListReports( ) conditions := []postgres.BoolExpression{whereClause} + if f.IndexRange != nil { + conditions = append(conditions, + table.Report.Index.GT_EQ(uint64Expr(f.IndexRange.Start)), + table.Report.Index.LT_EQ(uint64Expr(f.IndexRange.End)), + ) + } if f.InputIndex != nil { conditions = append(conditions, table.Report.InputIndex.EQ(uint64Expr(*f.InputIndex))) } diff --git a/internal/repository/repository.go b/internal/repository/repository.go index f17d621fe..af7493b27 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -62,6 +62,7 @@ func ExecutableApplicationsFilter() ApplicationFilter { type EpochFilter struct { Status []EpochStatus BeforeBlock *uint64 + IndexRange *Range } type InputFilter struct { @@ -70,6 +71,7 @@ type InputFilter struct { NotStatus *InputCompletionStatus Sender *common.Address TransactionHash *common.Hash + IndexRange *Range } type Range struct { @@ -81,6 +83,7 @@ type OutputFilter struct { EpochIndex *uint64 InputIndex *uint64 BlockRange *Range + IndexRange *Range OutputType *[]byte VoucherAddress *common.Address } @@ -88,6 +91,7 @@ type OutputFilter struct { type ReportFilter struct { EpochIndex *uint64 InputIndex *uint64 + IndexRange *Range } type StateHashFilter struct { diff --git a/internal/repository/repotest/epoch_test_cases.go b/internal/repository/repotest/epoch_test_cases.go index bc91275de..7d22efdbb 100644 --- a/internal/repository/repotest/epoch_test_cases.go +++ b/internal/repository/repotest/epoch_test_cases.go @@ -371,6 +371,31 @@ func (s *EpochSuite) TestListEpochs() { s.Equal(uint64(5), total) }) + s.Run("IndexRangeComposesWithPaginationAndDescending", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epochInputMap := make(map[*Epoch][]*Input) + for i := range uint64(5) { + epoch := NewEpochBuilder(app.ID). + WithIndex(i).WithStatus(EpochStatus_Closed). + WithBlocks(i*10, i*10+9).WithInputBounds(i, i).Build() + input := NewInputBuilder().WithIndex(i).WithEpochIndex(i).WithBlockNumber(i*10 + 5).Build() + epochInputMap[epoch] = []*Input{input} + } + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), epochInputMap, 50) + s.Require().NoError(err) + + indexRange := repository.Range{Start: 1, End: 3} + epochs, total, err := s.Repo.ListEpochs( + s.Ctx, app.IApplicationAddress.String(), + repository.EpochFilter{IndexRange: &indexRange}, + repository.Pagination{Limit: 1, Offset: 1}, true) + s.Require().NoError(err) + s.Require().Len(epochs, 1) + s.Equal(uint64(3), total) + s.Equal(uint64(2), epochs[0].Index) + }) + s.Run("Descending", func() { app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) diff --git a/internal/repository/repotest/input_test_cases.go b/internal/repository/repotest/input_test_cases.go index ddd55fd0a..82254ced2 100644 --- a/internal/repository/repotest/input_test_cases.go +++ b/internal/repository/repotest/input_test_cases.go @@ -262,6 +262,30 @@ func (s *InputSuite) TestListInputs() { s.Equal(uint64(3), total) }) + s.Run("IndexRangeComposesWithPaginationAndDescending", func() { + app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) + epoch := NewEpochBuilder(app.ID). + WithIndex(0).WithStatus(EpochStatus_Closed). + WithBlocks(0, 49).WithInputBounds(0, 4).Build() + inputs := make([]*Input, 5) + for i := range uint64(5) { + inputs[i] = NewInputBuilder().WithIndex(i).WithBlockNumber(i*10 + 5).Build() + } + err := s.Repo.CreateEpochsAndInputs( + s.Ctx, app.IApplicationAddress.String(), map[*Epoch][]*Input{epoch: inputs}, 50) + s.Require().NoError(err) + + indexRange := repository.Range{Start: 1, End: 3} + got, total, err := s.Repo.ListInputs( + s.Ctx, app.IApplicationAddress.String(), + repository.InputFilter{IndexRange: &indexRange}, + repository.Pagination{Limit: 1, Offset: 1}, true) + s.Require().NoError(err) + s.Require().Len(got, 1) + s.Equal(uint64(3), total) + s.Equal(uint64(2), got[0].Index) + }) + s.Run("FilterByEpochIndex", func() { app := NewApplicationBuilder().Create(s.Ctx, s.T(), s.Repo) diff --git a/internal/repository/repotest/output_test_cases.go b/internal/repository/repotest/output_test_cases.go index 49eae9f38..2e0ebdc6d 100644 --- a/internal/repository/repotest/output_test_cases.go +++ b/internal/repository/repotest/output_test_cases.go @@ -62,6 +62,22 @@ func (s *OutputSuite) TestListOutputs() { s.Equal(uint64(3), total) }) + s.Run("IndexRangeComposesWithPaginationAndDescending", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{[]byte("o0"), []byte("o1"), []byte("o2"), []byte("o3"), []byte("o4")}, nil) + + indexRange := repository.Range{Start: 1, End: 3} + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{IndexRange: &indexRange}, + repository.Pagination{Limit: 1, Offset: 1}, true) + s.Require().NoError(err) + s.Require().Len(outputs, 1) + s.Equal(uint64(3), total) + s.Equal(uint64(2), outputs[0].Index) + }) + s.Run("FilterByEpochIndex", func() { seed := Seed(s.Ctx, s.T(), s.Repo) diff --git a/internal/repository/repotest/report_test_cases.go b/internal/repository/repotest/report_test_cases.go index 8d5c74571..2791bef91 100644 --- a/internal/repository/repotest/report_test_cases.go +++ b/internal/repository/repotest/report_test_cases.go @@ -62,6 +62,22 @@ func (s *ReportSuite) TestListReports() { s.Equal(uint64(3), total) }) + s.Run("IndexRangeComposesWithPaginationAndDescending", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + s.storeAdvanceResult(seed.App.ID, 0, 0, nil, + [][]byte{[]byte("r0"), []byte("r1"), []byte("r2"), []byte("r3"), []byte("r4")}) + + indexRange := repository.Range{Start: 1, End: 3} + reports, total, err := s.Repo.ListReports( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.ReportFilter{IndexRange: &indexRange}, + repository.Pagination{Limit: 1, Offset: 1}, true) + s.Require().NoError(err) + s.Require().Len(reports, 1) + s.Equal(uint64(3), total) + s.Equal(uint64(2), reports[0].Index) + }) + s.Run("FilterByEpochIndex", func() { seed := Seed(s.Ctx, s.T(), s.Repo) From 28ecb4256eb184ac1f03c392e9c8f1998503d1cc Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:08:56 -0300 Subject: [PATCH 07/43] feat(jsonrpc): allows to filter output by execution and multiple selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Added optional executed *bool to ListOutputsParams. - output_type now accepts either one selector string or a non-empty selector array. - Empty arrays return -32602 (invalid paramters). - Changed OutputFilter.OutputType to a selector slice and added Executed. - PostgreSQL now uses IN for selector OR semantics. - Extracted shared execution and selector predicates reused by GetNumberOfPendingExecutableOutputs. - Updated OpenRPC with string-or-array schema and executed. - Added tests for: - Single-selector compatibility - Selector arrays - Empty-array rejection - Executed tri-state decoding - Combined selector/execution filtering - Unchanged nil-filter behavior required by validator claim generation Targeted tests, package compilation, OpenRPC validation, and git diff --check pass. Validator compilation remains blocked by the environment’s missing Cartesi machine C header. --- .../root/read/outputs/outputs.go | 2 +- .../root/read/service/jsonrpc.go | 6 +- .../root/read/service/repository.go | 10 ++- internal/jsonrpc/api/params.go | 48 ++++++++++++--- internal/jsonrpc/api/params_test.go | 56 +++++++++++++++++ internal/jsonrpc/batchcalls_test.go | 15 +++++ internal/jsonrpc/jsonrpc-discover.json | 23 ++++++- internal/jsonrpc/jsonrpc.go | 16 +++-- internal/repository/postgres/output.go | 47 ++++++++++---- internal/repository/repository.go | 3 +- .../repository/repotest/output_test_cases.go | 61 ++++++++++++++++++- 11 files changed, 251 insertions(+), 36 deletions(-) create mode 100644 internal/jsonrpc/api/params_test.go diff --git a/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go b/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go index d584689ea..cdb5722df 100644 --- a/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go +++ b/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go @@ -128,7 +128,7 @@ func run(cmd *cobra.Command, args []string) { // Add output type filter if provided if cmd.Flags().Changed("output-type") { - params.OutputType = &outputType + params.OutputType = &api.OutputTypeSelectors{outputType} } // Add voucher address filter if provided diff --git a/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go b/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go index b6bbb9efb..3d7c41914 100644 --- a/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go +++ b/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go @@ -130,8 +130,10 @@ func (s *JsonrpcReadService) ListOutputs(ctx context.Context, params api.ListOut } // Add output type filter if provided if params.OutputType != nil { - if _, err := api.ParseOutputType(*params.OutputType); err != nil { - return nil, fmt.Errorf("invalid output type: %w", err) + for i, selector := range *params.OutputType { + if _, err := api.ParseOutputType(selector); err != nil { + return nil, fmt.Errorf("invalid output type #%d: %w", i+1, err) + } } } // Add voucher address filter if provided diff --git a/cmd/cartesi-rollups-cli/root/read/service/repository.go b/cmd/cartesi-rollups-cli/root/read/service/repository.go index 89cb1399b..a1f16f353 100644 --- a/cmd/cartesi-rollups-cli/root/read/service/repository.go +++ b/cmd/cartesi-rollups-cli/root/read/service/repository.go @@ -289,9 +289,13 @@ func (s *RepositoryReadService) ListOutputs(ctx context.Context, params api.List } // Add output type filter if provided if params.OutputType != nil { - outputTypeVal, err := api.ParseOutputType(*params.OutputType) - if err != nil { - return nil, fmt.Errorf("invalid output type: %w", err) + outputTypeVal := make([][]byte, len(*params.OutputType)) + for i, selector := range *params.OutputType { + parsed, err := api.ParseOutputType(selector) + if err != nil { + return nil, fmt.Errorf("invalid output type #%d: %w", i+1, err) + } + outputTypeVal[i] = parsed } filter.OutputType = &outputTypeVal } diff --git a/internal/jsonrpc/api/params.go b/internal/jsonrpc/api/params.go index 031e315e2..fafbda9a1 100644 --- a/internal/jsonrpc/api/params.go +++ b/internal/jsonrpc/api/params.go @@ -3,6 +3,33 @@ package api +import ( + "bytes" + "encoding/json" + "fmt" +) + +type OutputTypeSelectors []string + +func (s *OutputTypeSelectors) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) > 0 && data[0] == '"' { + var value string + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *s = []string{value} + return nil + } + + var values []string + if err := json.Unmarshal(data, &values); err != nil { + return fmt.Errorf("expected a string or an array of strings: %w", err) + } + *s = values + return nil +} + // ListApplicationsParams aligns with the OpenRPC specification type ListApplicationsParams struct { Limit uint64 `json:"limit"` @@ -69,16 +96,17 @@ type GetProcessedInputCountParams struct { // ListOutputsParams aligns with the OpenRPC specification type ListOutputsParams struct { - Application string `json:"application"` - EpochIndex *string `json:"epoch_index,omitempty"` - InputIndex *string `json:"input_index,omitempty"` - OutputType *string `json:"output_type,omitempty"` - VoucherAddress *string `json:"voucher_address,omitempty"` - Limit uint64 `json:"limit"` - Offset uint64 `json:"offset"` - Descending bool `json:"descending,omitempty"` - From *string `json:"from,omitempty"` // inclusive lower bound on the output index (hex) - To *string `json:"to,omitempty"` // inclusive upper bound on the output index (hex) + Application string `json:"application"` + EpochIndex *string `json:"epoch_index,omitempty"` + InputIndex *string `json:"input_index,omitempty"` + OutputType *OutputTypeSelectors `json:"output_type,omitempty"` + VoucherAddress *string `json:"voucher_address,omitempty"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` + From *string `json:"from,omitempty"` // inclusive lower bound on the output index (hex) + To *string `json:"to,omitempty"` // inclusive upper bound on the output index (hex) + Executed *bool `json:"executed,omitempty"` } // GetOutputParams aligns with the OpenRPC specification diff --git a/internal/jsonrpc/api/params_test.go b/internal/jsonrpc/api/params_test.go new file mode 100644 index 000000000..9c4fbdca7 --- /dev/null +++ b/internal/jsonrpc/api/params_test.go @@ -0,0 +1,56 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package api + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListOutputsParamsOutputTypeSelectors(t *testing.T) { + tests := map[string]struct { + input string + expected OutputTypeSelectors + }{ + "single selector": { + input: `{"output_type":"0x237a816f"}`, + expected: OutputTypeSelectors{"0x237a816f"}, + }, + "selector list": { + input: `{"output_type":["0x237a816f","0x10321e8b"]}`, + expected: OutputTypeSelectors{"0x237a816f", "0x10321e8b"}, + }, + "empty list": { + input: `{"output_type":[]}`, + expected: OutputTypeSelectors{}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var params ListOutputsParams + require.NoError(t, json.Unmarshal([]byte(test.input), ¶ms)) + require.NotNil(t, params.OutputType) + require.Equal(t, test.expected, *params.OutputType) + }) + } +} + +func TestListOutputsParamsExecutedIsOptional(t *testing.T) { + var omitted ListOutputsParams + require.NoError(t, json.Unmarshal([]byte(`{}`), &omitted)) + require.Nil(t, omitted.Executed) + + var executed ListOutputsParams + require.NoError(t, json.Unmarshal([]byte(`{"executed":true}`), &executed)) + require.NotNil(t, executed.Executed) + require.True(t, *executed.Executed) + + var pending ListOutputsParams + require.NoError(t, json.Unmarshal([]byte(`{"executed":false}`), &pending)) + require.NotNil(t, pending.Executed) + require.False(t, *pending.Executed) +} diff --git a/internal/jsonrpc/batchcalls_test.go b/internal/jsonrpc/batchcalls_test.go index 9fe8a5824..71a71c0d4 100644 --- a/internal/jsonrpc/batchcalls_test.go +++ b/internal/jsonrpc/batchcalls_test.go @@ -66,6 +66,21 @@ func requireRPCError(t *testing.T, response RPCResponse, id any, code int) { require.Equal(t, code, response.Error.Code) } +func TestListOutputsRejectsEmptyOutputTypeList(t *testing.T) { + s := newBatchTestService() + rr := serveRPC(t, s, []byte(`{ + "jsonrpc":"2.0", + "method":"cartesi_listOutputs", + "params":{"application":"app","output_type":[]}, + "id":1 + }`)) + + require.Equal(t, http.StatusOK, rr.Code) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_INVALID_PARAMS) + require.Equal(t, "Invalid output type: expected at least one selector", response.Error.Message) +} + func TestJSONRPCBatchRejectsEmptyBatchWithSingleObject(t *testing.T) { s := newBatchTestService() rr := serveRPC(t, s, []byte(`[]`)) diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index 8ffb3c9c7..9332292c4 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -550,9 +550,20 @@ }, { "name": "output_type", - "description": "Filter outputs by output type (first 4 bytes of raw data hex encoded).", + "description": "Filter outputs by one or more output type selectors (the first 4 bytes of raw data, hex encoded). A single selector string is accepted for compatibility; arrays use OR semantics and must not be empty.", "schema": { - "$ref": "#/components/schemas/FunctionSelector" + "oneOf": [ + { + "$ref": "#/components/schemas/FunctionSelector" + }, + { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/FunctionSelector" + } + } + ] }, "required": false }, @@ -608,6 +619,14 @@ "$ref": "#/components/schemas/UnsignedInteger" }, "required": false + }, + { + "name": "executed", + "description": "Filter by execution status: true selects outputs with an execution transaction hash; false selects outputs without one. Executions happen out of index order: an old voucher can be executed long after newer ones, appearing at a low index. Therefore, do not build a resume cursor keyed on output index or on an executed-count offset over this filter as it will silently skip such late executions.", + "schema": { + "type": "boolean" + }, + "required": false } ], "result": { diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index e080bfcdb..b66fbaee0 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -690,12 +690,20 @@ func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) // Add output type filter if provided if params.OutputType != nil { - outputType, err := api.ParseOutputType(*params.OutputType) - if err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid output type: %v", err)) + if len(*params.OutputType) == 0 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid output type: expected at least one selector") + } + outputTypes := make([][]byte, 0, len(*params.OutputType)) + for _, selector := range *params.OutputType { + outputType, err := api.ParseOutputType(selector) + if err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid output type: %v", err)) + } + outputTypes = append(outputTypes, outputType) } - outputFilter.OutputType = &outputType + outputFilter.OutputType = &outputTypes } + outputFilter.Executed = params.Executed // Add sender filter if provided if params.VoucherAddress != nil { diff --git a/internal/repository/postgres/output.go b/internal/repository/postgres/output.go index 6bcf3123e..5a8209ea6 100644 --- a/internal/repository/postgres/output.go +++ b/internal/repository/postgres/output.go @@ -21,6 +21,33 @@ var ( voucherSelector = []byte{0x23, 0x7a, 0x81, 0x6f} ) +func outputExecutionCondition(executed bool) postgres.BoolExpression { + if executed { + return table.Output.ExecutionTransactionHash.IS_NOT_NULL() + } + return table.Output.ExecutionTransactionHash.IS_NULL() +} + +func outputTypesCondition(selectors [][]byte) postgres.BoolExpression { + values := make([]postgres.Expression, 0, len(selectors)) + for _, selector := range selectors { + values = append(values, ByteaLiteral(selector)) + } + return SubstrBytea(table.Output.RawData, 1, 4).IN(values...) +} + +// outputVoucherTypesCondition uses literals so PostgreSQL can prove +// that the condition implies output_pending_voucher_idx's predicate even when +// pgx executes the query with a generic prepared plan. +func outputVoucherTypesCondition() postgres.BoolExpression { + return outputTypesCondition( + [][]byte{ + voucherSelector, + delegateCallVoucherSelector, + }, + ) +} + func (r *PostgresRepository) GetOutput( ctx context.Context, nameOrAddress string, @@ -193,9 +220,11 @@ func (r *PostgresRepository) ListOutputs( } if f.OutputType != nil { - conditions = append(conditions, - SubstrBytea(table.Output.RawData, 1, 4).EQ(postgres.Bytea(*f.OutputType)), - ) + conditions = append(conditions, outputTypesCondition(*f.OutputType)) + } + + if f.Executed != nil { + conditions = append(conditions, outputExecutionCondition(*f.Executed)) } if f.VoucherAddress != nil { @@ -205,10 +234,7 @@ func (r *PostgresRepository) ListOutputs( // inline literals, is also what lets the planner prove the partial // predicate of output_raw_data_address_idx. conditions = append(conditions, - SubstrBytea(table.Output.RawData, 1, 4).IN( - ByteaLiteral(voucherSelector), - ByteaLiteral(delegateCallVoucherSelector), - ), + outputVoucherTypesCondition(), SubstrBytea(table.Output.RawData, 17, 20).EQ(postgres.Bytea(f.VoucherAddress.Bytes())), ) } @@ -325,8 +351,6 @@ func (r *PostgresRepository) GetNumberOfPendingExecutableOutputs( ) (uint64, error) { whereClause := getWhereClauseFromNameOrAddress(nameOrAddress) - outputType := SubstrBytea(table.Output.RawData, 1, 4) - sel := table.Output. SELECT(postgres.COUNT(postgres.STAR)). FROM( @@ -337,9 +361,8 @@ func (r *PostgresRepository) GetNumberOfPendingExecutableOutputs( ). WHERE( whereClause. - AND(table.Output.ExecutionTransactionHash.IS_NULL()). - AND(outputType.EQ(postgres.Bytea(delegateCallVoucherSelector)). - OR(outputType.EQ(postgres.Bytea(voucherSelector)))), + AND(outputExecutionCondition(false)). + AND(outputVoucherTypesCondition()), ) sqlStr, args := sel.Sql() diff --git a/internal/repository/repository.go b/internal/repository/repository.go index af7493b27..c6ae3c6ff 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -84,7 +84,8 @@ type OutputFilter struct { InputIndex *uint64 BlockRange *Range IndexRange *Range - OutputType *[]byte + OutputType *[][]byte + Executed *bool VoucherAddress *common.Address } diff --git a/internal/repository/repotest/output_test_cases.go b/internal/repository/repotest/output_test_cases.go index 2e0ebdc6d..2d79edb4f 100644 --- a/internal/repository/repotest/output_test_cases.go +++ b/internal/repository/repotest/output_test_cases.go @@ -247,9 +247,10 @@ func (s *OutputSuite) TestListOutputs() { s.storeAdvanceResult(seed.App.ID, 0, 0, [][]byte{rawWithType, rawWithOther}, nil) + targetTypes := [][]byte{targetType} outputs, total, err := s.Repo.ListOutputs( s.Ctx, seed.App.IApplicationAddress.String(), - repository.OutputFilter{OutputType: &targetType}, + repository.OutputFilter{OutputType: &targetTypes}, repository.Pagination{Limit: 10}, false) s.Require().NoError(err) s.Len(outputs, 1) @@ -257,6 +258,64 @@ func (s *OutputSuite) TestListOutputs() { s.Equal(rawWithType, outputs[0].RawData) }) + s.Run("FilterByOutputTypesAndExecutionStatus", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + voucherSelector := []byte{0x23, 0x7a, 0x81, 0x6f} + delegateCallVoucherSelector := []byte{0x10, 0x32, 0x1e, 0x8b} + voucher := append([]byte{}, voucherSelector...) + delegateCallVoucher := append([]byte{}, delegateCallVoucherSelector...) + notice := []byte{0xc2, 0x58, 0xd6, 0xe5} + executedVoucher := append([]byte{}, voucherSelector...) + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{voucher, delegateCallVoucher, notice, executedVoucher}, nil) + + txHash := UniqueHash() + err := s.Repo.UpdateOutputsExecution( + s.Ctx, + seed.App.IApplicationAddress.String(), + []*Output{{ + InputEpochApplicationID: seed.App.ID, + Index: 3, + ExecutionTransactionHash: &txHash, + }}, + 200, + ) + s.Require().NoError(err) + + outputTypes := [][]byte{voucherSelector, delegateCallVoucherSelector} + executed := false + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{OutputType: &outputTypes, Executed: &executed}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Require().Len(outputs, 2) + s.Equal(uint64(2), total) + s.Equal(uint64(0), outputs[0].Index) + s.Equal(uint64(1), outputs[1].Index) + + executed = true + outputs, total, err = s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{OutputType: &outputTypes, Executed: &executed}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Require().Len(outputs, 1) + s.Equal(uint64(1), total) + s.Equal(uint64(3), outputs[0].Index) + + // The validator uses the nil-filter path to reproduce epoch claims; + // it must continue to include every output type and execution state. + outputs, total, err = s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{}, + repository.Pagination{}, false) + s.Require().NoError(err) + s.Len(outputs, 4) + s.Equal(uint64(4), total) + }) + s.Run("FilterByVoucherAddress", func() { seed := Seed(s.Ctx, s.T(), s.Repo) From 203750077117a5592a30ed24a99098e140402b5b Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:15:14 -0300 Subject: [PATCH 08/43] perf(repository): add DB index to improve filter output by execution --- .../migrations/000001_create_initial_schema.down.sql | 1 + .../schema/migrations/000001_create_initial_schema.up.sql | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql index dc333f594..6c051c030 100644 --- a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql +++ b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql @@ -39,6 +39,7 @@ DROP TABLE IF EXISTS "withdrawal"; DROP TRIGGER IF EXISTS "output_set_updated_at" ON "output"; DROP INDEX IF EXISTS "output_input_index_idx"; +DROP INDEX IF EXISTS "output_pending_voucher_idx"; DROP INDEX IF EXISTS "output_raw_data_address_idx"; DROP INDEX IF EXISTS "output_raw_data_type_idx"; DROP TABLE IF EXISTS "output"; diff --git a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql index 2d2da98fe..7516698d1 100644 --- a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql +++ b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql @@ -477,6 +477,14 @@ WHERE SUBSTRING("raw_data" FROM 1 FOR 4) IN ( E'\\x237a816f' -- Voucher ); +-- Serves GetNumberOfPendingExecutableOutputs and pending-voucher list queries +-- without scanning the application's full output history. +CREATE INDEX "output_pending_voucher_idx" ON "output" ("input_epoch_application_id") +WHERE "execution_transaction_hash" IS NULL AND SUBSTRING("raw_data" FROM 1 FOR 4) IN ( + E'\\x10321e8b', -- DelegateCallVoucher + E'\\x237a816f' -- Voucher +); + CREATE TRIGGER "output_set_updated_at" BEFORE UPDATE ON "output" FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); From 8f63de246d3b9c2b4e0d56eec58ed383a30b0515 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:15:44 -0300 Subject: [PATCH 09/43] feat(cli): allows to filter output by execution and multiple selectors - Added flag '--executed' to `read outputs` - '--executed' filters for executed outputs. - '--executed=false' filters for unexecuted outputs. - Omitting the flag leaves execution status unfiltered. - Updated the CLI example. - Flag '--output-type' now can be repeated multiple times. - All selectors are forwarded in request order. - The help example and flag description were updated. --- .../root/read/outputs/outputs.go | 21 +++++++++++++------ .../root/read/service/repository.go | 1 + 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go b/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go index cdb5722df..98feffa04 100644 --- a/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go +++ b/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go @@ -39,8 +39,8 @@ cartesi-rollups-cli read outputs echo-dapp 10 # Read all outputs: cartesi-rollups-cli read outputs echo-dapp -# Read all outputs with filter: -cartesi-rollups-cli read outputs echo-dapp --epoch-index 10 --input-index 10 --output-type 0x237a816f --voucher-address 0x95eac57f9d67c5e0f255d5a19eb5d3fd00cafa73 +# Read all outputs with filters: +cartesi-rollups-cli read outputs echo-dapp --epoch-index 10 --input-index 10 --output-type 0x237a816f --output-type 0x10321e8b --executed --voucher-address 0x95eac57f9d67c5e0f255d5a19eb5d3fd00cafa73 # Read all outputs with pagination: cartesi-rollups-cli read outputs echo-dapp --limit 10 --offset 10 --descending @@ -49,7 +49,8 @@ cartesi-rollups-cli read outputs echo-dapp --limit 10 --offset 10 --descending var ( epochIndex string inputIndex string - outputType string + outputTypes []string + executed bool voucherAddress string limit uint64 offset uint64 @@ -61,8 +62,10 @@ func init() { "Filter outputs by epoch index (decimal or hex encoded)") Cmd.Flags().StringVar(&inputIndex, "input-index", "", "Filter outputs by input index (decimal or hex encoded)") - Cmd.Flags().StringVar(&outputType, "output-type", "", - "Filter outputs by output type (first 4 bytes of raw data hex encoded)") + Cmd.Flags().StringArrayVar(&outputTypes, "output-type", nil, + "Filter outputs by output type (first 4 bytes of raw data hex encoded); may be specified multiple times") + Cmd.Flags().BoolVar(&executed, "executed", false, + "Filter outputs by execution status") Cmd.Flags().StringVar(&voucherAddress, "voucher-address", "", "Filter outputs by voucher address (hex encoded)") Cmd.Flags().Uint64Var(&limit, "limit", 50, //nolint: mnd @@ -128,7 +131,13 @@ func run(cmd *cobra.Command, args []string) { // Add output type filter if provided if cmd.Flags().Changed("output-type") { - params.OutputType = &api.OutputTypeSelectors{outputType} + selectors := api.OutputTypeSelectors(outputTypes) + params.OutputType = &selectors + } + + // Add execution status filter if provided + if cmd.Flags().Changed("executed") { + params.Executed = &executed } // Add voucher address filter if provided diff --git a/cmd/cartesi-rollups-cli/root/read/service/repository.go b/cmd/cartesi-rollups-cli/root/read/service/repository.go index a1f16f353..234e999e6 100644 --- a/cmd/cartesi-rollups-cli/root/read/service/repository.go +++ b/cmd/cartesi-rollups-cli/root/read/service/repository.go @@ -307,6 +307,7 @@ func (s *RepositoryReadService) ListOutputs(ctx context.Context, params api.List } filter.VoucherAddress = &voucherAddressVal } + filter.Executed = params.Executed pagination.Limit = params.Limit pagination.Offset = params.Offset From 77448eaf61a9ff0df367f610be53215f66a06072 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:29:18 -0300 Subject: [PATCH 10/43] feat(jsonrpc): add methods to get the count of executed and pending outputs Methods added: - cartesi_getExecutedOutputCount - cartesi_getPendingExecutableOutputCount Each method: - Accepts an application parameter. - Returns {"data":"0x..."}. - Explicitly checks application existence before querying the aggregate. - Returns -32002 (application not found) for unknown applications. - Is registered in the dispatch table. Also updated the OpenRPC specification with the requested descriptions and added tests distinguishing unknown applications from existing applications with zero outputs. --- internal/jsonrpc/jsonrpc-discover.json | 82 +++++++++++++++- internal/jsonrpc/jsonrpc.go | 106 +++++++++++++++----- internal/jsonrpc/jsonrpc_test.go | 131 ++++++++++++++++++++++++- 3 files changed, 290 insertions(+), 29 deletions(-) diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index 9332292c4..9b251fbbb 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -519,6 +519,70 @@ } ] }, + { + "name": "cartesi_getExecutedOutputCount", + "summary": "Retrieve the number of executed outputs for the application", + "description": "Returns a monotone change signal: an unchanged value means no new executions; not a resume cursor.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/ExecutedOutputCountResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + } + ] + }, + { + "name": "cartesi_getPendingExecutableOutputCount", + "summary": "Retrieve the number of pending executable outputs for the application", + "description": "Returns a non-monotone gauge (grows with new vouchers, shrinks with executions): do not use for change detection — poll the executed count instead.", + "params": [ + { + "name": "application", + "description": "The application's name or hex encoded address.", + "schema": { + "$ref": "#/components/schemas/NameOrAddress" + }, + "required": true + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/PendingExecutableOutputCountResult" + } + }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + } + ] + }, { "name": "cartesi_listOutputs", "summary": "Retrieve a List of Outputs", @@ -622,7 +686,7 @@ }, { "name": "executed", - "description": "Filter by execution status: true selects outputs with an execution transaction hash; false selects outputs without one. Executions happen out of index order: an old voucher can be executed long after newer ones, appearing at a low index. Therefore, do not build a resume cursor keyed on output index or on an executed-count offset over this filter as it will silently skip such late executions.", + "description": "Filter by execution status: true selects outputs with an execution transaction hash; false selects outputs without one. Executions happen out of index order: an old voucher can be executed long after newer ones, appearing at a low index. Therefore, do not build a resume cursor keyed on output index or on an executed-count offset over this filter as it will silently skip such late executions. Instead poll cartesi_getExecutedOutputCount, and on change query the bounded working set with executed=false, output_type=[...].", "schema": { "type": "boolean" }, @@ -2099,6 +2163,22 @@ } } }, + "ExecutedOutputCountResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/UnsignedInteger" + } + } + }, + "PendingExecutableOutputCountResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/UnsignedInteger" + } + } + }, "Output": { "type": "object", "properties": { diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index b66fbaee0..31527c0b2 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -66,33 +66,35 @@ type rpcHandler = func(*Service, *http.Request, RPCRequest) (any, error) type dispatchTable = map[string]rpcHandler var jsonrpcHandlers = dispatchTable{ - "rpc.discover": handleDiscover, - "cartesi_listApplications": handleListApplications, - "cartesi_getApplication": handleGetApplication, - "cartesi_listEpochs": handleListEpochs, - "cartesi_getEpoch": handleGetEpoch, - "cartesi_getEpochByVirtualIndex": handleGetEpochByVirtualIndex, - "cartesi_getLastAcceptedEpochIndex": handleGetLastAcceptedEpochIndex, - "cartesi_listInputs": handleListInputs, - "cartesi_getInput": handleGetInput, - "cartesi_getProcessedInputCount": handleGetProcessedInputCount, - "cartesi_listOutputs": handleListOutputs, - "cartesi_getOutput": handleGetOutput, - "cartesi_listReports": handleListReports, - "cartesi_getReport": handleGetReport, - "cartesi_listWithdrawals": handleListWithdrawals, - "cartesi_getWithdrawal": handleGetWithdrawal, - "cartesi_listTournaments": handleListTournaments, - "cartesi_getTournament": handleGetTournament, - "cartesi_listCommitments": handleListCommitments, - "cartesi_getCommitment": handleGetCommitment, - "cartesi_listMatches": handleListMatches, - "cartesi_getMatch": handleGetMatch, - "cartesi_listMatchAdvances": handleListMatchAdvances, - "cartesi_getMatchAdvanced": handleGetMatchAdvanced, - "cartesi_getNodeInfo": handleGetNodeInfo, - "cartesi_getChainId": handleGetChainID, - "cartesi_getNodeVersion": handleGetNodeVersion, + "rpc.discover": handleDiscover, + "cartesi_listApplications": handleListApplications, + "cartesi_getApplication": handleGetApplication, + "cartesi_listEpochs": handleListEpochs, + "cartesi_getEpoch": handleGetEpoch, + "cartesi_getEpochByVirtualIndex": handleGetEpochByVirtualIndex, + "cartesi_getLastAcceptedEpochIndex": handleGetLastAcceptedEpochIndex, + "cartesi_listInputs": handleListInputs, + "cartesi_getInput": handleGetInput, + "cartesi_getProcessedInputCount": handleGetProcessedInputCount, + "cartesi_getExecutedOutputCount": handleGetExecutedOutputCount, + "cartesi_getPendingExecutableOutputCount": handleGetPendingExecutableOutputCount, + "cartesi_listOutputs": handleListOutputs, + "cartesi_getOutput": handleGetOutput, + "cartesi_listReports": handleListReports, + "cartesi_getReport": handleGetReport, + "cartesi_listWithdrawals": handleListWithdrawals, + "cartesi_getWithdrawal": handleGetWithdrawal, + "cartesi_listTournaments": handleListTournaments, + "cartesi_getTournament": handleGetTournament, + "cartesi_listCommitments": handleListCommitments, + "cartesi_getCommitment": handleGetCommitment, + "cartesi_listMatches": handleListMatches, + "cartesi_getMatch": handleGetMatch, + "cartesi_listMatchAdvances": handleListMatchAdvances, + "cartesi_getMatchAdvanced": handleGetMatchAdvanced, + "cartesi_getNodeInfo": handleGetNodeInfo, + "cartesi_getChainId": handleGetChainID, + "cartesi_getNodeVersion": handleGetNodeVersion, } // ----------------------------------------------------------------------------- @@ -644,6 +646,56 @@ func handleGetProcessedInputCount(s *Service, r *http.Request, req RPCRequest) ( return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", processedInputs)}, nil } +func handleGetExecutedOutputCount(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetApplicationParams + if err := UnmarshalParams(req.Params, ¶ms); err != nil { + s.Logger.Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + count, err := s.repository.GetNumberOfExecutedOutputs(r.Context(), params.Application) + if err != nil { + s.Logger.Error("Unable to retrieve executed output count from repository", "err", err) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + } + if count == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + } + + return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", count)}, nil +} + +func handleGetPendingExecutableOutputCount(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetApplicationParams + if err := UnmarshalParams(req.Params, ¶ms); err != nil { + s.Logger.Debug("Invalid parameters", "err", err) + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") + } + + if err := validateNameOrAddress(params.Application); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid application identifier: %v", err)) + } + + count, err := s.repository.GetNumberOfPendingExecutableOutputs(r.Context(), params.Application) + if err != nil { + s.Logger.Error("Unable to retrieve pending executable output count from repository", "err", err) + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + } + if count == 0 { + if err := s.applicationAbsentOrError(r, params.Application); err != nil { + return nil, err + } + } + + return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", count)}, nil +} + func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListOutputsParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index d757fc55e..bc12c7a9f 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -936,9 +936,138 @@ func TestMethod(t *testing.T) { assert.Equal(t, uint64(0), uint64(resp.Result.Data)) }) - // TODO: test with inputs (use createTestEpochWithInput) + t.Run("processedInputs", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + appID := s.newTestApplication(ctx, t, app) + epoch := repotest.NewEpochBuilder(appID). + WithIndex(0). + WithStatus(model.EpochStatus_ClaimAccepted). + Build() + inputs := []*model.Input{ + repotest.NewInputBuilder().WithIndex(0).WithRawData(emptyInput()).Build(), + repotest.NewInputBuilder().WithIndex(1).WithRawData(emptyInput()).Build(), + } + err := s.repository.CreateEpochsAndInputs( + ctx, + numberToName(app), + map[*model.Epoch][]*model.Input{epoch: inputs}, + 10, + ) + require.NoError(t, err) + s.advanceInput(ctx, t, appID, 0, 0, nil, nil) + s.advanceInput(ctx, t, appID, 0, 1, nil, nil) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_getProcessedInputCount", + "params": { "application": "%s" }, + "id": 0 + }`, numberToName(app))) + + resp := testRPCResponse[hex64]{} + require.NoError(t, json.Unmarshal(body, &resp)) + assert.Nil(t, resp.Error) + assert.Equal(t, uint64(2), uint64(resp.Result.Data)) + }) }) + for _, methodName := range []string{ + "cartesi_getExecutedOutputCount", + "cartesi_getPendingExecutableOutputCount", + } { + t.Run(methodName, func(t *testing.T) { + method := getName(t.Name()) + + t.Run("absentApplication", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "%s", + "params": { "application": "%s" }, + "id": 0 + }`, method, numberToName(1))) + + resp := testRPCResponse[hex64]{} + require.NoError(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_APPLICATION_NOT_FOUND, resp.Error.Code) + assert.Equal(t, "Application not found", resp.Error.Message) + }) + + t.Run("existingApplicationWithNoOutputs", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + app := uint64(1) + s.newTestApplication(context.Background(), t, app) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "%s", + "params": { "application": "%s" }, + "id": 0 + }`, method, numberToName(app))) + + resp := testRPCResponse[hex64]{} + require.NoError(t, json.Unmarshal(body, &resp)) + assert.Nil(t, resp.Error) + assert.Equal(t, uint64(0), uint64(resp.Result.Data)) + }) + + t.Run("outputsPresent", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + app := uint64(1) + appID := s.newTestApplication(ctx, t, app) + epoch := repotest.NewEpochBuilder(appID). + WithIndex(0). + WithStatus(model.EpochStatus_ClaimAccepted). + Build() + input := repotest.NewInputBuilder(). + WithIndex(0). + WithRawData(emptyInput()). + Build() + s.createTestEpochWithInput(ctx, t, numberToName(app), epoch, input) + s.advanceInput(ctx, t, appID, 0, 0, [][]byte{ + emptyVoucher(), + {0x10, 0x32, 0x1e, 0x8b}, + {0xc2, 0x58, 0xd6, 0xe5}, + }, nil) + + txHash := common.HexToHash("0x1") + err := s.repository.UpdateOutputsExecution( + ctx, + numberToName(app), + []*model.Output{{ + InputEpochApplicationID: appID, + Index: 0, + ExecutionTransactionHash: &txHash, + }}, + 10, + ) + require.NoError(t, err) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "%s", + "params": { "application": "%s" }, + "id": 0 + }`, method, numberToName(app))) + + resp := testRPCResponse[hex64]{} + require.NoError(t, json.Unmarshal(body, &resp)) + assert.Nil(t, resp.Error) + assert.Equal(t, uint64(1), uint64(resp.Result.Data)) + }) + }) + } + //////////////////////////////////////////////////////////////////////// // getReport //////////////////////////////////////////////////////////////////////// From f294cdc90417460ef2d33653b4033e636c52bdda Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:49:58 -0300 Subject: [PATCH 11/43] feat(jsonrpc): support listing epochs with multiple statuses - 'status' now accepts either a string or string array. - Every status is validated through 'EpochStatus.Scan'. - Invalid values return -32602 (invalid params) and identify the bad value. - An explicit empty array returns -32602 with Invalid epoch status: expected at least one status. - Added coverage for scalar/list decoding, multiple-status filtering, invalid list elements, and empty arrays. - Documents 'status' as 'oneOf': a single EpochStatus or a non-empty array of EpochStatus. - Documents omission as no filter and rejects empty arrays. - Documents the non-terminal watch set. - Clarifies terminal statuses never regress, preventing settled epochs from being re-read. --- .../root/read/epochs/epochs.go | 2 +- .../root/read/outputs/outputs.go | 2 +- .../root/read/service/jsonrpc.go | 10 ++- .../root/read/service/repository.go | 11 ++- internal/jsonrpc/api/params.go | 40 +++++----- internal/jsonrpc/api/params_test.go | 39 +++++++-- internal/jsonrpc/jsonrpc-discover.json | 17 +++- internal/jsonrpc/jsonrpc.go | 15 +++- internal/jsonrpc/jsonrpc_test.go | 80 +++++++++++++++++++ 9 files changed, 174 insertions(+), 42 deletions(-) diff --git a/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go b/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go index 4f1aea19e..434816acd 100644 --- a/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go +++ b/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go @@ -106,7 +106,7 @@ func run(cmd *cobra.Command, args []string) { // Add status filter if provided if cmd.Flags().Changed("status") { - params.Status = &status + params.Status = &api.StringOrList{status} } params.Limit = limit params.Offset = offset diff --git a/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go b/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go index 98feffa04..0b8512704 100644 --- a/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go +++ b/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go @@ -131,7 +131,7 @@ func run(cmd *cobra.Command, args []string) { // Add output type filter if provided if cmd.Flags().Changed("output-type") { - selectors := api.OutputTypeSelectors(outputTypes) + selectors := api.StringOrList(outputTypes) params.OutputType = &selectors } diff --git a/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go b/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go index 3d7c41914..489920f46 100644 --- a/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go +++ b/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go @@ -46,11 +46,13 @@ func (s *JsonrpcReadService) ListEpochs(ctx context.Context, params api.ListEpoc if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { return nil, fmt.Errorf("invalid application: %w", err) } - // Add status filter if provided + // Validate status filter if provided if params.Status != nil { - var statusVal model.EpochStatus - if err := statusVal.Scan(*params.Status); err != nil { - return nil, fmt.Errorf("invalid status: %w", err) + for i, status := range *params.Status { + var statusVal model.EpochStatus + if err := statusVal.Scan(status); err != nil { + return nil, fmt.Errorf("invalid status #%d: %w", i+1, err) + } } } diff --git a/cmd/cartesi-rollups-cli/root/read/service/repository.go b/cmd/cartesi-rollups-cli/root/read/service/repository.go index 234e999e6..74da8a619 100644 --- a/cmd/cartesi-rollups-cli/root/read/service/repository.go +++ b/cmd/cartesi-rollups-cli/root/read/service/repository.go @@ -87,11 +87,14 @@ func (s *RepositoryReadService) ListEpochs(ctx context.Context, params api.ListE pagination := repository.Pagination{} // Add status filter if provided if params.Status != nil { - var statusVal model.EpochStatus - if err := statusVal.Scan(*params.Status); err != nil { - return nil, fmt.Errorf("invalid status: %w", err) + filter.Status = make([]model.EpochStatus, len(*params.Status)) + for i, status := range *params.Status { + var statusVal model.EpochStatus + if err := statusVal.Scan(status); err != nil { + return nil, fmt.Errorf("invalid status #%d: %w", i+1, err) + } + filter.Status[i] = statusVal } - filter.Status = []model.EpochStatus{statusVal} } pagination.Limit = params.Limit pagination.Offset = params.Offset diff --git a/internal/jsonrpc/api/params.go b/internal/jsonrpc/api/params.go index fafbda9a1..47f56e7fa 100644 --- a/internal/jsonrpc/api/params.go +++ b/internal/jsonrpc/api/params.go @@ -9,9 +9,9 @@ import ( "fmt" ) -type OutputTypeSelectors []string +type StringOrList []string -func (s *OutputTypeSelectors) UnmarshalJSON(data []byte) error { +func (s *StringOrList) UnmarshalJSON(data []byte) error { data = bytes.TrimSpace(data) if len(data) > 0 && data[0] == '"' { var value string @@ -44,13 +44,13 @@ type GetApplicationParams struct { // ListEpochsParams aligns with the OpenRPC specification type ListEpochsParams struct { - Application string `json:"application"` - Status *string `json:"status,omitempty"` - Limit uint64 `json:"limit"` - Offset uint64 `json:"offset"` - Descending bool `json:"descending,omitempty"` - From *string `json:"from,omitempty"` // inclusive lower bound on the epoch index (hex) - To *string `json:"to,omitempty"` // inclusive upper bound on the epoch index (hex) + Application string `json:"application"` + Status *StringOrList `json:"status,omitempty"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` + From *string `json:"from,omitempty"` // inclusive lower bound on the epoch index (hex) + To *string `json:"to,omitempty"` // inclusive upper bound on the epoch index (hex) } // GetEpochParams aligns with the OpenRPC specification @@ -96,17 +96,17 @@ type GetProcessedInputCountParams struct { // ListOutputsParams aligns with the OpenRPC specification type ListOutputsParams struct { - Application string `json:"application"` - EpochIndex *string `json:"epoch_index,omitempty"` - InputIndex *string `json:"input_index,omitempty"` - OutputType *OutputTypeSelectors `json:"output_type,omitempty"` - VoucherAddress *string `json:"voucher_address,omitempty"` - Limit uint64 `json:"limit"` - Offset uint64 `json:"offset"` - Descending bool `json:"descending,omitempty"` - From *string `json:"from,omitempty"` // inclusive lower bound on the output index (hex) - To *string `json:"to,omitempty"` // inclusive upper bound on the output index (hex) - Executed *bool `json:"executed,omitempty"` + Application string `json:"application"` + EpochIndex *string `json:"epoch_index,omitempty"` + InputIndex *string `json:"input_index,omitempty"` + OutputType *StringOrList `json:"output_type,omitempty"` + VoucherAddress *string `json:"voucher_address,omitempty"` + Limit uint64 `json:"limit"` + Offset uint64 `json:"offset"` + Descending bool `json:"descending,omitempty"` + From *string `json:"from,omitempty"` // inclusive lower bound on the output index (hex) + To *string `json:"to,omitempty"` // inclusive upper bound on the output index (hex) + Executed *bool `json:"executed,omitempty"` } // GetOutputParams aligns with the OpenRPC specification diff --git a/internal/jsonrpc/api/params_test.go b/internal/jsonrpc/api/params_test.go index 9c4fbdca7..d47aa81cd 100644 --- a/internal/jsonrpc/api/params_test.go +++ b/internal/jsonrpc/api/params_test.go @@ -10,22 +10,22 @@ import ( "github.com/stretchr/testify/require" ) -func TestListOutputsParamsOutputTypeSelectors(t *testing.T) { +func TestListOutputsParamsStringOrList(t *testing.T) { tests := map[string]struct { input string - expected OutputTypeSelectors + expected StringOrList }{ "single selector": { input: `{"output_type":"0x237a816f"}`, - expected: OutputTypeSelectors{"0x237a816f"}, + expected: StringOrList{"0x237a816f"}, }, "selector list": { input: `{"output_type":["0x237a816f","0x10321e8b"]}`, - expected: OutputTypeSelectors{"0x237a816f", "0x10321e8b"}, + expected: StringOrList{"0x237a816f", "0x10321e8b"}, }, "empty list": { input: `{"output_type":[]}`, - expected: OutputTypeSelectors{}, + expected: StringOrList{}, }, } @@ -39,6 +39,35 @@ func TestListOutputsParamsOutputTypeSelectors(t *testing.T) { } } +func TestListEpochsParamsStringOrList(t *testing.T) { + tests := map[string]struct { + input string + expected StringOrList + }{ + "single status": { + input: `{"status":"OPEN"}`, + expected: StringOrList{"OPEN"}, + }, + "status list": { + input: `{"status":["OPEN","CLOSED"]}`, + expected: StringOrList{"OPEN", "CLOSED"}, + }, + "empty list": { + input: `{"status":[]}`, + expected: StringOrList{}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var params ListEpochsParams + require.NoError(t, json.Unmarshal([]byte(test.input), ¶ms)) + require.NotNil(t, params.Status) + require.Equal(t, test.expected, *params.Status) + }) + } +} + func TestListOutputsParamsExecutedIsOptional(t *testing.T) { var omitted ListOutputsParams require.NoError(t, json.Unmarshal([]byte(`{}`), &omitted)) diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index 9b251fbbb..a8f94bd32 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -103,7 +103,7 @@ { "name": "cartesi_listEpochs", "summary": "List epochs", - "description": "Returns a paginated list of epochs for the specified application. Can filter by epoch status.", + "description": "Returns a paginated list of epochs for the specified application. Can filter by one or more epoch statuses.\n\nTo synchronize epochs, request `from` equal to the next unseen epoch index and advance it as new epochs appear. Separately, repeatedly filter previously seen epochs by the non-terminal statuses `OPEN`, `CLOSED`, `INPUTS_PROCESSED`, `CLAIM_COMPUTED`, `CLAIM_SUBMITTED`, and `CLAIM_STAGED`. Terminal statuses (`CLAIM_ACCEPTED`, `CLAIM_REJECTED`, and `CLAIM_FORECLOSED`) never regress, so settled epochs can be removed from the status-refresh set while `from` continues discovering new epochs.", "params": [ { "name": "application", @@ -115,9 +115,20 @@ }, { "name": "status", - "description": "Filter epochs by status.", + "description": "Filter epochs by one status or a non-empty list of statuses. Omit this parameter to disable status filtering; an empty list is invalid.", "schema": { - "$ref": "#/components/schemas/EpochStatus" + "oneOf": [ + { + "$ref": "#/components/schemas/EpochStatus" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/EpochStatus" + }, + "minItems": 1 + } + ] }, "required": false }, diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index 31527c0b2..812f10130 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -374,11 +374,18 @@ func handleListEpochs(s *Service, r *http.Request, req RPCRequest) (any, error) } epochFilter.IndexRange = indexRange if params.Status != nil { - var status model.EpochStatus - if err := status.Scan(*params.Status); err != nil { - return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch status: %v", err)) + if len(*params.Status) == 0 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid epoch status: expected at least one status") } - epochFilter.Status = []model.EpochStatus{status} + statuses := make([]model.EpochStatus, 0, len(*params.Status)) + for _, value := range *params.Status { + var status model.EpochStatus + if err := status.Scan(value); err != nil { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch status: %v", err)) + } + statuses = append(statuses, status) + } + epochFilter.Status = statuses } epochs, total, err := s.repository.ListEpochs(r.Context(), params.Application, epochFilter, repository.Pagination{ diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index bc12c7a9f..a85e11908 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -1440,6 +1440,86 @@ func TestMethod(t *testing.T) { assert.Equal(t, "Invalid epoch status: invalid value 'INVALID' for EpochStatus enum", resp.Error.Message) }) + // failure: any invalid status in a list -> invalid params + t.Run("invalidInList", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + body := s.doRequest(t, 0, []byte(`{ + "jsonrpc": "2.0", + "method": "cartesi_listEpochs", + "params": { + "application": "app", + "status": ["OPEN", "INVALID"] + }, + "id": 0 + }`)) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Equal(t, "Invalid epoch status: invalid value 'INVALID' for EpochStatus enum", resp.Error.Message) + }) + + // failure: an explicitly empty status list -> invalid params + t.Run("emptyStatusList", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + + body := s.doRequest(t, 0, []byte(`{ + "jsonrpc": "2.0", + "method": "cartesi_listEpochs", + "params": { + "application": "app", + "status": [] + }, + "id": 0 + }`)) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, JSONRPC_INVALID_PARAMS, resp.Error.Code) + assert.Equal(t, "Invalid epoch status: expected at least one status", resp.Error.Message) + }) + + // success: status may contain multiple values + t.Run("multipleStatuses", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + appID := s.newTestApplication(ctx, t, nr) + for i, status := range []model.EpochStatus{ + model.EpochStatus_Open, + model.EpochStatus_Closed, + model.EpochStatus_ClaimAccepted, + } { + s.createTestEpoch(ctx, t, numberToName(nr), + repotest.NewEpochBuilder(appID). + WithIndex(uint64(i)). + WithStatus(status). + Build()) + } + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listEpochs", + "params": { + "application": "%v", + "status": ["OPEN", "CLOSED"] + }, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Epoch]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Nil(t, resp.Error) + assert.Len(t, resp.Result.Data, 2) + assert.Equal(t, model.EpochStatus_Open, resp.Result.Data[0].Status) + assert.Equal(t, model.EpochStatus_Closed, resp.Result.Data[1].Status) + }) + // success: many epochs is in the database -> limit t.Run("many", func(t *testing.T) { testHistogram.inc(method) From c12dd7b609eba490aa748db193d7b49e08efb2b4 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:59:24 -0300 Subject: [PATCH 12/43] feat(cli): support listing epochs with multiple statuses - '--status' now uses a repeatable string-array flag. - Multiple values are sent as StringOrList. - Updated help text and example. --- cmd/cartesi-rollups-cli/root/read/epochs/epochs.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go b/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go index 434816acd..22c0aca23 100644 --- a/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go +++ b/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go @@ -40,23 +40,23 @@ cartesi-rollups-cli read epochs echo-dapp 10 cartesi-rollups-cli read epochs echo-dapp # Read all epochs with filter: -cartesi-rollups-cli read epochs echo-dapp --status OPEN +cartesi-rollups-cli read epochs echo-dapp --status OPEN --status CLOSED # Read all epochs with pagination: cartesi-rollups-cli read epochs echo-dapp --limit 10 --offset 10 --descending ` var ( - status string + statuses []string limit uint64 offset uint64 descending bool ) func init() { - Cmd.Flags().StringVar(&status, "status", "", + Cmd.Flags().StringArrayVar(&statuses, "status", nil, "Filter epochs by status (OPEN, CLOSED, INPUTS_PROCESSED, CLAIM_COMPUTED, CLAIM_SUBMITTED, "+ - "CLAIM_STAGED, CLAIM_ACCEPTED, CLAIM_REJECTED, CLAIM_FORECLOSED)") + "CLAIM_STAGED, CLAIM_ACCEPTED, CLAIM_REJECTED, CLAIM_FORECLOSED); may be specified multiple times") Cmd.Flags().Uint64Var(&limit, "limit", 50, //nolint: mnd "Maximum number of epochs to return") Cmd.Flags().Uint64Var(&offset, "offset", 0, @@ -106,7 +106,8 @@ func run(cmd *cobra.Command, args []string) { // Add status filter if provided if cmd.Flags().Changed("status") { - params.Status = &api.StringOrList{status} + epochStatuses := api.StringOrList(statuses) + params.Status = &epochStatuses } params.Limit = limit params.Offset = offset From 94d954beb3de8ef894e5b6ea42439ef30b8769af Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:25:29 -0300 Subject: [PATCH 13/43] fix(jsonrpc): report 256-bit integer fields in OpenRPC specification Added 'UnsignedInteger256' schema for uint256 reference it in field 'Voucher.value'. Updated uint256 field 'prev_randao' to reference 'UnsignedInteger256': Kept 'EvmAdvance.index' and other genuine uint64 indexes on 'UnsignedInteger'. --- internal/jsonrpc/jsonrpc-discover.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index a8f94bd32..665ff6449 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -2126,7 +2126,7 @@ "$ref": "#/components/schemas/UnsignedInteger" }, "prev_randao": { - "$ref": "#/components/schemas/ByteArray" + "$ref": "#/components/schemas/UnsignedInteger256" }, "index": { "$ref": "#/components/schemas/UnsignedInteger" @@ -2275,7 +2275,8 @@ "$ref": "#/components/schemas/EthereumAddress" }, "value": { - "type": "string" + "$ref": "#/components/schemas/UnsignedInteger256", + "description": "Amount of Wei transferred by the voucher's call" }, "payload": { "$ref": "#/components/schemas/ByteArray" @@ -2615,6 +2616,12 @@ "format": "hex-uint64", "pattern": "^0x[a-fA-F0-9]{1,16}$" }, + "UnsignedInteger256": { + "type": "string", + "format": "hex-uint256", + "pattern": "^0x[a-fA-F0-9]{1,64}$", + "description": "256-bit unsigned integer, hex encoded (the node emits minimal hex)" + }, "FunctionSelector": { "type": "string", "format": "hex-byte", From 4906bcd39dec5d3f1e9ad3d240cf6189cad1e81d Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:35:43 -0300 Subject: [PATCH 14/43] refactor(jsonrpc): rename cartesi_getMatchAdvanced as cartesi_getMatchAdvance - Renamed handler to handleGetMatchAdvance. - Renamed params struct to GetMatchAdvanceParams across node and CLI. - Updated OpenRPC discovery and JSON-RPC tests. - Normalized the parsed parent hash before repository lookup. - Added a mixed-case parent-hash regression test. --- .../root/read/matchadvances/matchadvances.go | 2 +- .../root/read/service/jsonrpc.go | 4 ++-- .../root/read/service/repository.go | 2 +- .../root/read/service/types.go | 2 +- internal/jsonrpc/api/params.go | 4 ++-- internal/jsonrpc/jsonrpc-discover.json | 2 +- internal/jsonrpc/jsonrpc.go | 11 ++++++----- internal/jsonrpc/jsonrpc_test.go | 19 ++++++++++--------- 8 files changed, 24 insertions(+), 22 deletions(-) diff --git a/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go b/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go index a0e3847e7..6253038ff 100644 --- a/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go +++ b/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go @@ -90,7 +90,7 @@ func run(cmd *cobra.Command, args []string) { var result json.RawMessage if len(args) >= 5 { - var params api.GetMatchAdvancedParams + var params api.GetMatchAdvanceParams params.Application = args[0] params.EpochIndex, err = config.AsHexString(args[1]) cobra.CheckErr(err) diff --git a/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go b/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go index 489920f46..1a96b2099 100644 --- a/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go +++ b/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go @@ -342,7 +342,7 @@ func (s *JsonrpcReadService) ListMatches(ctx context.Context, params api.ListMat return resp, err } -func (s *JsonrpcReadService) GetMatchAdvanced(ctx context.Context, params api.GetMatchAdvancedParams) (json.RawMessage, error) { +func (s *JsonrpcReadService) GetMatchAdvanced(ctx context.Context, params api.GetMatchAdvanceParams) (json.RawMessage, error) { if _, err := config.ToApplicationNameOrAddressFromString(params.Application); err != nil { return nil, fmt.Errorf("invalid application: %w", err) } @@ -360,7 +360,7 @@ func (s *JsonrpcReadService) GetMatchAdvanced(ctx context.Context, params api.Ge } var resp json.RawMessage - err := s.Client.Call(ctx, "cartesi_getMatchAdvanced", params, &resp) + err := s.Client.Call(ctx, "cartesi_getMatchAdvance", params, &resp) return resp, err } diff --git a/cmd/cartesi-rollups-cli/root/read/service/repository.go b/cmd/cartesi-rollups-cli/root/read/service/repository.go index 74da8a619..f12259b72 100644 --- a/cmd/cartesi-rollups-cli/root/read/service/repository.go +++ b/cmd/cartesi-rollups-cli/root/read/service/repository.go @@ -790,7 +790,7 @@ func (s *RepositoryReadService) ListMatches(ctx context.Context, params api.List return json.RawMessage(result), err } -func (s *RepositoryReadService) GetMatchAdvanced(ctx context.Context, params api.GetMatchAdvancedParams) (json.RawMessage, error) { +func (s *RepositoryReadService) GetMatchAdvanced(ctx context.Context, params api.GetMatchAdvanceParams) (json.RawMessage, error) { repo := s.Repository application, err := config.ToApplicationNameOrAddressFromString(params.Application) if err != nil { diff --git a/cmd/cartesi-rollups-cli/root/read/service/types.go b/cmd/cartesi-rollups-cli/root/read/service/types.go index fbdc8f3d4..f8ecae6e2 100644 --- a/cmd/cartesi-rollups-cli/root/read/service/types.go +++ b/cmd/cartesi-rollups-cli/root/read/service/types.go @@ -35,7 +35,7 @@ type ReadService interface { ListCommitments(ctx context.Context, params api.ListCommitmentsParams) (json.RawMessage, error) GetMatch(ctx context.Context, params api.GetMatchParams) (json.RawMessage, error) ListMatches(ctx context.Context, params api.ListMatchesParams) (json.RawMessage, error) - GetMatchAdvanced(ctx context.Context, params api.GetMatchAdvancedParams) (json.RawMessage, error) + GetMatchAdvanced(ctx context.Context, params api.GetMatchAdvanceParams) (json.RawMessage, error) ListMatchAdvances(ctx context.Context, params api.ListMatchAdvancesParams) (json.RawMessage, error) Close() } diff --git a/internal/jsonrpc/api/params.go b/internal/jsonrpc/api/params.go index 47f56e7fa..adffd98ef 100644 --- a/internal/jsonrpc/api/params.go +++ b/internal/jsonrpc/api/params.go @@ -198,8 +198,8 @@ type ListMatchAdvancesParams struct { Descending bool `json:"descending,omitempty"` } -// GetMatchAdvancedParams aligns with the OpenRPC specification -type GetMatchAdvancedParams struct { +// GetMatchAdvanceParams aligns with the OpenRPC specification +type GetMatchAdvanceParams struct { Application string `json:"application"` EpochIndex string `json:"epoch_index"` TournamentAddress string `json:"tournament_address"` diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index 665ff6449..44ca35021 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -1583,7 +1583,7 @@ ] }, { - "name": "cartesi_getMatchAdvanced", + "name": "cartesi_getMatchAdvance", "summary": "Get a specific match advance", "description": "Fetches a single match advance by application, epoch index, tournament address, ID hash and parent.", "params": [ diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index 812f10130..b4c0f3d03 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -91,7 +91,7 @@ var jsonrpcHandlers = dispatchTable{ "cartesi_listMatches": handleListMatches, "cartesi_getMatch": handleGetMatch, "cartesi_listMatchAdvances": handleListMatchAdvances, - "cartesi_getMatchAdvanced": handleGetMatchAdvanced, + "cartesi_getMatchAdvance": handleGetMatchAdvance, "cartesi_getNodeInfo": handleGetNodeInfo, "cartesi_getChainId": handleGetChainID, "cartesi_getNodeVersion": handleGetNodeVersion, @@ -1426,8 +1426,8 @@ func handleListMatchAdvances(s *Service, r *http.Request, req RPCRequest) (any, }, nil } -func handleGetMatchAdvanced(s *Service, r *http.Request, req RPCRequest) (any, error) { - var params api.GetMatchAdvancedParams +func handleGetMatchAdvance(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetMatchAdvanceParams if err := UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") @@ -1451,12 +1451,13 @@ func handleGetMatchAdvanced(s *Service, r *http.Request, req RPCRequest) (any, e return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid ID hash: %v", err)) } - if _, err := config.ToHashFromString(params.Parent); err != nil { + parent, err := config.ToHashFromString(params.Parent) + if err != nil { return nil, newRPCError(JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent hash: %v", err)) } matchAdvanced, err := s.repository.GetMatchAdvanced(r.Context(), params.Application, epochIndex, - params.TournamentAddress, params.IDHash, params.Parent[2:]) // TODO: use parsed value + params.TournamentAddress, params.IDHash, parent.Hex()[2:]) if err != nil { s.Logger.Error("Unable to retrieve match advanced from repository", "err", err) return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index a85e11908..34363ebcd 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -2768,9 +2768,9 @@ func TestMethod(t *testing.T) { }) //////////////////////////////////////////////////////////////////////// - // getMatchAdvanced + // getMatchAdvance //////////////////////////////////////////////////////////////////////// - t.Run("cartesi_getMatchAdvanced", func(t *testing.T) { + t.Run("cartesi_getMatchAdvance", func(t *testing.T) { method := getName(t.Name()) // failure: epoch_index not hex encoded -> invalid param @@ -2783,7 +2783,7 @@ func TestMethod(t *testing.T) { body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ "jsonrpc": "2.0", - "method": "cartesi_getMatchAdvanced", + "method": "cartesi_getMatchAdvance", "params": { "application": "%v", "epoch_index": "%v" @@ -2815,7 +2815,7 @@ func TestMethod(t *testing.T) { body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ "jsonrpc": "2.0", - "method": "cartesi_getMatchAdvanced", + "method": "cartesi_getMatchAdvance", "params": { "application": "%v", "epoch_index": "0x%020x", @@ -2840,7 +2840,7 @@ func TestMethod(t *testing.T) { nr := uint64(0xdeadbeef) body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ "jsonrpc": "2.0", - "method": "cartesi_getMatchAdvanced", + "method": "cartesi_getMatchAdvance", "params": { "application": "%v", "epoch_index": "0x%020x", @@ -2867,7 +2867,8 @@ func TestMethod(t *testing.T) { nr := uint64(2) address := common.HexToAddress("0x03") idHash := common.HexToHash("0x04") - parent := common.HexToHash("0x05") + parentHex := "0xAbCdEf0123456789aBcDeF0123456789AbCdEf0123456789aBcDeF0123456789" + parent := common.HexToHash(parentHex) appID := s.newTestApplication(ctx, t, app) s.createTestEpoch(ctx, t, numberToName(app), @@ -2913,16 +2914,16 @@ func TestMethod(t *testing.T) { body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ "jsonrpc": "2.0", - "method": "cartesi_getMatchAdvanced", + "method": "cartesi_getMatchAdvance", "params": { "application": "%v", "epoch_index": "0x%020x", "tournament_address": "0x%020x", "id_hash": "0x%064x", - "parent": "0x%064x" + "parent": "%s" }, "id": 0 - }`, numberToName(app), nr, address, idHash, parent)) + }`, numberToName(app), nr, address, idHash, parentHex)) resp := testRPCResponse[getMatchAdvancedResult]{} assert.Nil(t, json.Unmarshal(body, &resp)) From 31568e8e55069ef7dcc6d949f53a586f44198c11 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:17:46 -0300 Subject: [PATCH 15/43] test(jsonrpc): add tests for positional decoding of parameters Tests covers all structs declared in `params.go`, comparing positional decoding against equivalent named decoding. --- internal/jsonrpc/api/params.go | 52 ++++++++++ internal/jsonrpc/api/params_test.go | 142 ++++++++++++++++++++++++++++ internal/jsonrpc/jsonrpc.go | 50 +++++----- internal/jsonrpc/jsonrpc_test.go | 19 ++++ internal/jsonrpc/types.go | 48 ---------- 5 files changed, 238 insertions(+), 73 deletions(-) diff --git a/internal/jsonrpc/api/params.go b/internal/jsonrpc/api/params.go index adffd98ef..f988f9843 100644 --- a/internal/jsonrpc/api/params.go +++ b/internal/jsonrpc/api/params.go @@ -7,6 +7,7 @@ import ( "bytes" "encoding/json" "fmt" + "reflect" ) type StringOrList []string @@ -221,3 +222,54 @@ type GetWithdrawalParams struct { Application string `json:"application"` AccountIndex string `json:"account_index"` } + +// UnmarshalParams supports both by-name (object) and by-position (array) parameter structures. +// If params is an object, it simply does json.Unmarshal; if it's an array, it will attempt +// to unmarshal each positional parameter into the target struct field in declaration order. +func UnmarshalParams(data json.RawMessage, target any) error { + data = bytes.TrimSpace(data) + switch { + case len(data) == 0: + // Parameters field is absent + return nil + case data[0] == '[': + // Unmarshal positional parameters into a slice of json.RawMessage. + var rawParams []json.RawMessage + if err := json.Unmarshal(data, &rawParams); err != nil { + return err + } + // Use reflection to set values in the target struct in the order they appear. + val := reflect.ValueOf(target) + if val.Kind() != reflect.Pointer || val.IsNil() { + return fmt.Errorf("error unmarshalling positional parameters target must be a non-nil pointer to a struct") + } + val = val.Elem() + if val.Kind() != reflect.Struct { + return fmt.Errorf("error unmarshalling positional parameters target must point to a struct") + } + typ := val.Type() + if len(rawParams) > typ.NumField() { + return fmt.Errorf("error unmarshalling positional parameters, expected %d params, got %d", + typ.NumField(), len(rawParams)) + } + // For each field in the struct, if a positional parameter exists, unmarshal that parameter. + for i := 0; i < typ.NumField() && i < len(rawParams); i++ { + sf := typ.Field(i) + if sf.Tag.Get("json") == "-" { + continue + } + field := val.Field(i) + if !field.CanSet() { + return fmt.Errorf("error unmarshalling positional parameter field %q is not settable", typ.Field(i).Name) + } + // Unmarshal the corresponding raw parameter into the field. + if err := json.Unmarshal(rawParams[i], field.Addr().Interface()); err != nil { + return fmt.Errorf("error unmarshalling positional parameter %d for field %s: %w", i, typ.Field(i).Name, err) + } + } + return nil + default: + // Otherwise, assume by-name structure. + return json.Unmarshal(data, target) + } +} diff --git a/internal/jsonrpc/api/params_test.go b/internal/jsonrpc/api/params_test.go index d47aa81cd..88e80039d 100644 --- a/internal/jsonrpc/api/params_test.go +++ b/internal/jsonrpc/api/params_test.go @@ -83,3 +83,145 @@ func TestListOutputsParamsExecutedIsOptional(t *testing.T) { require.NotNil(t, pending.Executed) require.False(t, *pending.Executed) } + +func TestPositionalParamsDeclarationOrder(t *testing.T) { + tests := map[string]struct { + newTarget func() any + positional string + named string + }{ + "ListApplicationsParams": { + func() any { return &ListApplicationsParams{} }, + `[25,3,true]`, + `{"limit":25,"offset":3,"descending":true}`, + }, + "GetApplicationParams": { + func() any { return &GetApplicationParams{} }, + `["app"]`, + `{"application":"app"}`, + }, + "ListEpochsParams": { + func() any { return &ListEpochsParams{} }, + `["app",["OPEN","CLOSED"],25,3,true,"0x2","0x9"]`, + `{"application":"app","status":["OPEN","CLOSED"],"limit":25,"offset":3,"descending":true,"from":"0x2","to":"0x9"}`, + }, + "GetEpochParams": { + func() any { return &GetEpochParams{} }, + `["app","0x4"]`, + `{"application":"app","epoch_index":"0x4"}`, + }, + "GetEpochByVirtualIndexParams": { + func() any { return &GetEpochByVirtualIndexParams{} }, + `["app","0x7"]`, + `{"application":"app","virtual_index":"0x7"}`, + }, + "GetLastAcceptedEpochIndexParams": { + func() any { return &GetLastAcceptedEpochIndexParams{} }, + `["app"]`, + `{"application":"app"}`, + }, + "ListInputsParams": { + func() any { return &ListInputsParams{} }, + `["app","0x4","sender","transaction-hash",25,3,true,"0x2","0x9"]`, + `{"application":"app","epoch_index":"0x4","sender":"sender","transaction_hash":"transaction-hash",` + + `"limit":25,"offset":3,"descending":true,"from":"0x2","to":"0x9"}`, + }, + "GetInputParams": { + func() any { return &GetInputParams{} }, + `["app","0x5"]`, + `{"application":"app","input_index":"0x5"}`, + }, + "GetProcessedInputCountParams": { + func() any { return &GetProcessedInputCountParams{} }, + `["app"]`, + `{"application":"app"}`, + }, + "ListOutputsParams": { + func() any { return &ListOutputsParams{} }, + `["app","0x4","0x5",["0x237a816f","0x10321e8b"],"voucher",25,3,true,"0x2","0x9",true]`, + `{"application":"app","epoch_index":"0x4","input_index":"0x5",` + + `"output_type":["0x237a816f","0x10321e8b"],"voucher_address":"voucher",` + + `"limit":25,"offset":3,"descending":true,"from":"0x2","to":"0x9","executed":true}`, + }, + "GetOutputParams": { + func() any { return &GetOutputParams{} }, + `["app","0x6"]`, + `{"application":"app","output_index":"0x6"}`, + }, + "ListReportsParams": { + func() any { return &ListReportsParams{} }, + `["app","0x4","0x5",25,3,true,"0x2","0x9"]`, + `{"application":"app","epoch_index":"0x4","input_index":"0x5","limit":25,"offset":3,"descending":true,"from":"0x2","to":"0x9"}`, + }, + "GetReportParams": { + func() any { return &GetReportParams{} }, + `["app","0x7"]`, + `{"application":"app","report_index":"0x7"}`, + }, + "ListTournamentsParams": { + func() any { return &ListTournamentsParams{} }, + `["app","0x4","0x2","parent-tournament","parent-match",25,3,true]`, + `{"application":"app","epoch_index":"0x4","level":"0x2",` + + `"parent_tournament_address":"parent-tournament","parent_match_id_hash":"parent-match",` + + `"limit":25,"offset":3,"descending":true}`, + }, + "GetTournamentParams": { + func() any { return &GetTournamentParams{} }, + `["app","tournament"]`, + `{"application":"app","address":"tournament"}`, + }, + "ListCommitmentsParams": { + func() any { return &ListCommitmentsParams{} }, + `["app","0x4","tournament",25,3,true]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament","limit":25,"offset":3,"descending":true}`, + }, + "GetCommitmentParams": { + func() any { return &GetCommitmentParams{} }, + `["app","0x4","tournament","commitment"]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament","commitment":"commitment"}`, + }, + "ListMatchesParams": { + func() any { return &ListMatchesParams{} }, + `["app","0x4","tournament",25,3,true]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament","limit":25,"offset":3,"descending":true}`, + }, + "GetMatchParams": { + func() any { return &GetMatchParams{} }, + `["app","0x4","tournament","id-hash"]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament","id_hash":"id-hash"}`, + }, + "ListMatchAdvancesParams": { + func() any { return &ListMatchAdvancesParams{} }, + `["app","0x4","tournament","id-hash",25,3,true]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament",` + + `"id_hash":"id-hash","limit":25,"offset":3,"descending":true}`, + }, + "GetMatchAdvanceParams": { + func() any { return &GetMatchAdvanceParams{} }, + `["app","0x4","tournament","id-hash","parent"]`, + `{"application":"app","epoch_index":"0x4","tournament_address":"tournament","id_hash":"id-hash","parent":"parent"}`, + }, + "ListWithdrawalsParams": { + func() any { return &ListWithdrawalsParams{} }, + `["app","0x8",25,3,true]`, + `{"application":"app","account_index":"0x8","limit":25,"offset":3,"descending":true}`, + }, + "GetWithdrawalParams": { + func() any { return &GetWithdrawalParams{} }, + `["app","0x8"]`, + `{"application":"app","account_index":"0x8"}`, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + expected := test.newTarget() + require.NoError(t, json.Unmarshal([]byte(test.named), expected)) + + actual := test.newTarget() + require.NoError(t, UnmarshalParams(json.RawMessage(test.positional), actual)) + + require.Equal(t, expected, actual) + }) + } +} diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index b4c0f3d03..c902cb5da 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -287,7 +287,7 @@ func handleDiscover(s *Service, _ *http.Request, _ RPCRequest) (any, error) { func handleListApplications(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListApplicationsParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -324,7 +324,7 @@ func handleListApplications(s *Service, r *http.Request, req RPCRequest) (any, e func handleGetApplication(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetApplicationParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -348,7 +348,7 @@ func handleGetApplication(s *Service, r *http.Request, req RPCRequest) (any, err func handleListEpochs(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListEpochsParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -418,7 +418,7 @@ func handleListEpochs(s *Service, r *http.Request, req RPCRequest) (any, error) func handleGetEpoch(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetEpochParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -450,7 +450,7 @@ func handleGetEpoch(s *Service, r *http.Request, req RPCRequest) (any, error) { func handleGetEpochByVirtualIndex(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetEpochByVirtualIndexParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -482,7 +482,7 @@ func handleGetEpochByVirtualIndex(s *Service, r *http.Request, req RPCRequest) ( func handleGetLastAcceptedEpochIndex(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetLastAcceptedEpochIndexParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -509,7 +509,7 @@ func handleGetLastAcceptedEpochIndex(s *Service, r *http.Request, req RPCRequest func handleListInputs(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListInputsParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -594,7 +594,7 @@ func handleListInputs(s *Service, r *http.Request, req RPCRequest) (any, error) func handleGetInput(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetInputParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -631,7 +631,7 @@ func handleGetInput(s *Service, r *http.Request, req RPCRequest) (any, error) { func handleGetProcessedInputCount(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetApplicationParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -655,7 +655,7 @@ func handleGetProcessedInputCount(s *Service, r *http.Request, req RPCRequest) ( func handleGetExecutedOutputCount(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetApplicationParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -680,7 +680,7 @@ func handleGetExecutedOutputCount(s *Service, r *http.Request, req RPCRequest) ( func handleGetPendingExecutableOutputCount(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetApplicationParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -705,7 +705,7 @@ func handleGetPendingExecutableOutputCount(s *Service, r *http.Request, req RPCR func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListOutputsParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -809,7 +809,7 @@ func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) func handleGetOutput(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetOutputParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -846,7 +846,7 @@ func handleGetOutput(s *Service, r *http.Request, req RPCRequest) (any, error) { func handleListReports(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListReportsParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -918,7 +918,7 @@ func handleListReports(s *Service, r *http.Request, req RPCRequest) (any, error) func handleGetReport(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetReportParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -950,7 +950,7 @@ func handleGetReport(s *Service, r *http.Request, req RPCRequest) (any, error) { func handleListWithdrawals(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListWithdrawalsParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -1006,7 +1006,7 @@ func handleListWithdrawals(s *Service, r *http.Request, req RPCRequest) (any, er func handleGetWithdrawal(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetWithdrawalParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -1037,7 +1037,7 @@ func handleGetWithdrawal(s *Service, r *http.Request, req RPCRequest) (any, erro func handleListTournaments(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListTournamentsParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -1119,7 +1119,7 @@ func handleListTournaments(s *Service, r *http.Request, req RPCRequest) (any, er func handleGetTournament(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetTournamentParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -1151,7 +1151,7 @@ func handleGetTournament(s *Service, r *http.Request, req RPCRequest) (any, erro func handleListCommitments(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListCommitmentsParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -1216,7 +1216,7 @@ func handleListCommitments(s *Service, r *http.Request, req RPCRequest) (any, er func handleGetCommitment(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetCommitmentParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -1259,7 +1259,7 @@ func handleGetCommitment(s *Service, r *http.Request, req RPCRequest) (any, erro func handleListMatches(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListMatchesParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -1324,7 +1324,7 @@ func handleListMatches(s *Service, r *http.Request, req RPCRequest) (any, error) func handleGetMatch(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetMatchParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -1364,7 +1364,7 @@ func handleGetMatch(s *Service, r *http.Request, req RPCRequest) (any, error) { func handleListMatchAdvances(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListMatchAdvancesParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } @@ -1428,7 +1428,7 @@ func handleListMatchAdvances(s *Service, r *http.Request, req RPCRequest) (any, func handleGetMatchAdvance(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.GetMatchAdvanceParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { + if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index 34363ebcd..13f84dc64 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -1273,6 +1273,25 @@ func TestMethod(t *testing.T) { assert.Equal(t, numberToName(nr), resp.Result.Data[0].Name) }) + // success: 1 application is in the database (array params) -> 1 + t.Run("emptyArrayParams", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + + nr := uint64(1) + s.newTestApplication(ctx, t, nr) + body := s.doRequest(t, 0, []byte(`{ + "jsonrpc": "2.0", + "method": "cartesi_listApplications", + "id": 0 + }`)) + resp := testRPCResponse[[]model.Application]{} + assert.Nil(t, json.Unmarshal(body, &resp)) + assert.Equal(t, 1, len(resp.Result.Data)) + assert.Equal(t, numberToName(nr), resp.Result.Data[0].Name) + }) + // success: many applications is in the database -> limit (many - 1) t.Run("many", func(t *testing.T) { testHistogram.inc(method) diff --git a/internal/jsonrpc/types.go b/internal/jsonrpc/types.go index 00aa12f3d..55c3a3191 100644 --- a/internal/jsonrpc/types.go +++ b/internal/jsonrpc/types.go @@ -4,11 +4,9 @@ package jsonrpc import ( - "bytes" "encoding/json" "fmt" "io" - "reflect" "regexp" "github.com/cartesi/rollups-node/internal/config" @@ -71,52 +69,6 @@ func writeRPCResult(w io.Writer, id any, result any) error { return json.NewEncoder(w).Encode(resp) } -// UnmarshalParams supports both by-name (object) and by-position (array) parameter structures. -// If params is an object, it simply does json.Unmarshal; if it's an array, it will attempt -// to unmarshal each positional parameter into the target struct field in declaration order. -func UnmarshalParams(data json.RawMessage, target any) error { - data = bytes.TrimSpace(data) - if len(data) > 0 && data[0] == '[' { - // Unmarshal positional parameters into a slice of json.RawMessage. - var rawParams []json.RawMessage - if err := json.Unmarshal(data, &rawParams); err != nil { - return err - } - // Use reflection to set values in the target struct in the order they appear. - val := reflect.ValueOf(target) - if val.Kind() != reflect.Pointer || val.IsNil() { - return fmt.Errorf("error unmarshalling positional parameters target must be a non-nil pointer to a struct") - } - val = val.Elem() - if val.Kind() != reflect.Struct { - return fmt.Errorf("error unmarshalling positional parameters target must point to a struct") - } - typ := val.Type() - if len(rawParams) > typ.NumField() { - return fmt.Errorf("error unmarshalling positional parameters, expected %d params, got %d", - typ.NumField(), len(rawParams)) - } - // For each field in the struct, if a positional parameter exists, unmarshal that parameter. - for i := 0; i < typ.NumField() && i < len(rawParams); i++ { - sf := typ.Field(i) - if sf.Tag.Get("json") == "-" { - continue - } - field := val.Field(i) - if !field.CanSet() { - return fmt.Errorf("error unmarshalling positional parameter field %q is not settable", typ.Field(i).Name) - } - // Unmarshal the corresponding raw parameter into the field. - if err := json.Unmarshal(rawParams[i], field.Addr().Interface()); err != nil { - return fmt.Errorf("error unmarshalling positional parameter %d for field %s: %w", i, typ.Field(i).Name, err) - } - } - return nil - } - // Otherwise, assume by-name structure. - return json.Unmarshal(data, target) -} - // ----------------------------------------------------------------------------- // Validation helpers (server-only) // ----------------------------------------------------------------------------- From 3a769b3cbc11f4bc103b41253db2bfdcebddb92c Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:29:28 -0300 Subject: [PATCH 16/43] fix(repository): avoid invalid SQL when listing outputs with empty type list --- internal/repository/postgres/output.go | 2 +- .../repository/repotest/output_test_cases.go | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/internal/repository/postgres/output.go b/internal/repository/postgres/output.go index 5a8209ea6..b2c2dc6f1 100644 --- a/internal/repository/postgres/output.go +++ b/internal/repository/postgres/output.go @@ -219,7 +219,7 @@ func (r *PostgresRepository) ListOutputs( conditions = append(conditions, table.Output.InputIndex.EQ(uint64Expr(*f.InputIndex))) } - if f.OutputType != nil { + if f.OutputType != nil && len(*f.OutputType) > 0 { conditions = append(conditions, outputTypesCondition(*f.OutputType)) } diff --git a/internal/repository/repotest/output_test_cases.go b/internal/repository/repotest/output_test_cases.go index 2d79edb4f..4cf7ca506 100644 --- a/internal/repository/repotest/output_test_cases.go +++ b/internal/repository/repotest/output_test_cases.go @@ -258,6 +258,32 @@ func (s *OutputSuite) TestListOutputs() { s.Equal(rawWithType, outputs[0].RawData) }) + s.Run("FilterByEmptyOutputType", func() { + seed := Seed(s.Ctx, s.T(), s.Repo) + + // OutputType filter uses SUBSTR(raw_data, 1, 4) to match the first 4 bytes + targetType := []byte{0xef, 0x01, 0xab, 0xcd} + rawWithType := make([]byte, 32) + copy(rawWithType[0:4], targetType) + + otherType := []byte{0x00, 0x00, 0x00, 0x00} + rawWithOther := make([]byte, 32) + copy(rawWithOther[0:4], otherType) + + s.storeAdvanceResult(seed.App.ID, 0, 0, + [][]byte{rawWithType, rawWithOther}, nil) + + outputs, total, err := s.Repo.ListOutputs( + s.Ctx, seed.App.IApplicationAddress.String(), + repository.OutputFilter{OutputType: &[][]byte{}}, + repository.Pagination{Limit: 10}, false) + s.Require().NoError(err) + s.Len(outputs, 2) + s.Equal(uint64(2), total) + s.Equal(rawWithType, outputs[0].RawData) + s.Equal(rawWithOther, outputs[1].RawData) + }) + s.Run("FilterByOutputTypesAndExecutionStatus", func() { seed := Seed(s.Ctx, s.T(), s.Repo) From 9ba37eaf502226686ab99746d7dad24059482f1c Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:39:14 -0300 Subject: [PATCH 17/43] test(jsonrpc): add tests for some use cases of listing operations - Added 'executed' plus multiple 'output_type' selector coverage for 'cartesi_listOutputs'. - Added inclusive 'from'/'to' DB-fixture requests for epochs, inputs, outputs, and reports. - Updated count fixture to execute two vouchers, asserting executed 2 and pending 0. --- internal/jsonrpc/jsonrpc_test.go | 115 +++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 6 deletions(-) diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index 13f84dc64..fa26d4307 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -1044,11 +1044,10 @@ func TestMethod(t *testing.T) { err := s.repository.UpdateOutputsExecution( ctx, numberToName(app), - []*model.Output{{ - InputEpochApplicationID: appID, - Index: 0, - ExecutionTransactionHash: &txHash, - }}, + []*model.Output{ + {InputEpochApplicationID: appID, Index: 0, ExecutionTransactionHash: &txHash}, + {InputEpochApplicationID: appID, Index: 1, ExecutionTransactionHash: &txHash}, + }, 10, ) require.NoError(t, err) @@ -1063,7 +1062,11 @@ func TestMethod(t *testing.T) { resp := testRPCResponse[hex64]{} require.NoError(t, json.Unmarshal(body, &resp)) assert.Nil(t, resp.Error) - assert.Equal(t, uint64(1), uint64(resp.Result.Data)) + expected := uint64(2) + if method == "cartesi_getPendingExecutableOutputCount" { + expected = 0 + } + assert.Equal(t, expected, uint64(resp.Result.Data)) }) }) } @@ -1644,6 +1647,22 @@ func TestMethod(t *testing.T) { assert.Equal(t, nr, resp.Result.Data[i].Index) } } + + { // inclusive index range + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listEpochs", + "params": {"application": "%v", "from": "0x2", "to": "0x4"}, + "id": 0 + }`, numberToName(nr))) + + resp := testRPCResponse[[]model.Epoch]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.Len(t, resp.Result.Data, 3) + assert.Equal(t, []uint64{2, 3, 4}, []uint64{ + resp.Result.Data[0].Index, resp.Result.Data[1].Index, resp.Result.Data[2].Index, + }) + } }) }) @@ -1758,6 +1777,18 @@ func TestMethod(t *testing.T) { assert.JSONEq(t, fmt.Sprintf("%q", txHash.Hex()), string(input["transaction_hash"])) assert.NotContains(t, input, "transaction_reference") } + + body = s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listInputs", + "params": {"application": "%v", "from": "0x1", "to": "0x2"}, + "id": 0 + }`, numberToName(app))) + resp = testRPCResponse[[]map[string]json.RawMessage]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.Len(t, resp.Result.Data, 2) + assert.JSONEq(t, `"0x1"`, string(resp.Result.Data[0]["index"])) + assert.JSONEq(t, `"0x2"`, string(resp.Result.Data[1]["index"])) }) // failure: malformed transaction hash -> invalid params, not an @@ -1959,6 +1990,62 @@ func TestMethod(t *testing.T) { assert.Equal(t, nr, uint64(resp.Result.Data[i].Index)) } } + + { // inclusive index range + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listOutputs", + "params": {"application": "%v", "from": "0x2", "to": "0x4"}, + "id": 0 + }`, numberToName(app))) + + resp := testRPCResponse[[]Result]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.Len(t, resp.Result.Data, 3) + for i, expected := range []uint64{2, 3, 4} { + assert.Equal(t, expected, uint64(resp.Result.Data[i].Index)) + } + } + }) + + t.Run("executedWithOutputTypeList", func(t *testing.T) { + testHistogram.inc(method) + s := newTestService(t, t.Name()) + ctx := context.Background() + app := uint64(4) + appID := s.newTestApplication(ctx, t, app) + epoch := repotest.NewEpochBuilder(appID).WithStatus(model.EpochStatus_ClaimAccepted).Build() + input := repotest.NewInputBuilder().WithRawData(emptyInput()).Build() + s.createTestEpochWithInput(ctx, t, numberToName(app), epoch, input) + s.advanceInput(ctx, t, appID, 0, 0, [][]byte{ + emptyVoucher(), + {0x10, 0x32, 0x1e, 0x8b}, + {0xc2, 0x58, 0xd6, 0xe5}, + emptyVoucher(), + }, nil) + + txHash := common.HexToHash("0x1") + err := s.repository.UpdateOutputsExecution(ctx, numberToName(app), []*model.Output{{ + InputEpochApplicationID: appID, Index: 3, ExecutionTransactionHash: &txHash, + }}, 10) + require.NoError(t, err) + + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listOutputs", + "params": { + "application": "%v", + "executed": true, + "output_type": ["0x237a816f", "0x10321e8b"] + }, + "id": 0 + }`, numberToName(app))) + + resp := testRPCResponse[[]model.Output]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.Nil(t, resp.Error) + require.Len(t, resp.Result.Data, 1) + assert.Equal(t, uint64(3), resp.Result.Data[0].Index) }) }) @@ -2131,6 +2218,22 @@ func TestMethod(t *testing.T) { assert.Equal(t, nr, uint64(resp.Result.Data[i].Index)) } } + + { // inclusive index range + body := s.doRequest(t, 0, fmt.Appendf([]byte{}, `{ + "jsonrpc": "2.0", + "method": "cartesi_listReports", + "params": {"application": "%v", "from": "0x2", "to": "0x4"}, + "id": 0 + }`, numberToName(app))) + + resp := testRPCResponse[[]Result]{} + require.NoError(t, json.Unmarshal(body, &resp)) + require.Len(t, resp.Result.Data, 3) + for i, expected := range []uint64{2, 3, 4} { + assert.Equal(t, expected, uint64(resp.Result.Data[i].Index)) + } + } }) }) From bb9906e6e3de7f9fe17688616a7e6164bfba76e7 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:00:31 -0300 Subject: [PATCH 18/43] perf(jsonrpc): parse JSON-RPC API spec on service initialization --- internal/jsonrpc/jsonrpc.go | 12 +----------- internal/jsonrpc/service.go | 11 +++++++++++ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index c902cb5da..cdaf0cb0c 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -272,17 +272,7 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { // Discovery: return the embedded specification. func handleDiscover(s *Service, _ *http.Request, _ RPCRequest) (any, error) { - data, err := discoverSpec.ReadFile("jsonrpc-discover.json") - if err != nil { - s.Logger.Error("Unable to read jsonrpc-discover content", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") - } - var spec any - if err := json.Unmarshal(data, &spec); err != nil { - s.Logger.Error("Unable to unmarshal discovery spec JSON", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") - } - return spec, nil + return s.discoverSpec, nil } func handleListApplications(s *Service, r *http.Request, req RPCRequest) (any, error) { diff --git a/internal/jsonrpc/service.go b/internal/jsonrpc/service.go index f39ad2157..9faf990a8 100644 --- a/internal/jsonrpc/service.go +++ b/internal/jsonrpc/service.go @@ -5,6 +5,7 @@ package jsonrpc import ( "context" + "encoding/json" "errors" "fmt" "net" @@ -37,6 +38,8 @@ type Service struct { // listen opens the HTTP listener. It defaults to net.Listen and is // overridden in tests so Serve() can be exercised without real sockets. listen func(network, address string) (net.Listener, error) + // OpenAPI description for JSON-RPC API loaded from 'jsonrpc-discover.json' file + discoverSpec any } type CreateInfo struct { @@ -66,6 +69,14 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { return nil, fmt.Errorf("repository on validator service Create is nil") } + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + if err != nil { + return nil, fmt.Errorf("unable to read jsonrpc-discover content: %w", err) + } + if err := json.Unmarshal(data, &s.discoverSpec); err != nil { + return nil, fmt.Errorf("unable to unmarshal discovery spec JSON: %w", err) + } + s.inputABI, err = inputs.InputsMetaData.GetAbi() if err != nil { return nil, err From 2911c1b813247696025b6e363925f29e86d5b9fe Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:11:10 -0300 Subject: [PATCH 19/43] docs(repository): add comment to clarify expected behavior of module API --- internal/repository/repository.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/repository/repository.go b/internal/repository/repository.go index c6ae3c6ff..f98ec9a09 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -74,6 +74,7 @@ type InputFilter struct { IndexRange *Range } +// Range defines a closed interval: both Start and End are inclusive. type Range struct { Start uint64 End uint64 From 2451cedf23fd850d3a5e87d91248495c9078f2ac Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:11:02 -0300 Subject: [PATCH 20/43] docs(jsonrpc): add warnings and recommendations on how to use the API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Warns at both cartesi_listOutputs and its executed parameter that executions occur out of index order. - Explicitly rejects output indexes, offsets, and counts as resume cursors. - Documents the complete count → bounded pending-set query → diff workflow. - Repeats the workflow on both output-count methods. - Notes that a race-free cursor belongs to a future ingestion API. --- internal/jsonrpc/jsonrpc-discover.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index 44ca35021..57de79c74 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -533,7 +533,7 @@ { "name": "cartesi_getExecutedOutputCount", "summary": "Retrieve the number of executed outputs for the application", - "description": "Returns a monotone change signal: an unchanged value means no new executions; not a resume cursor.", + "description": "Returns a monotone change signal for output execution. An unchanged count means that no new executions have been observed. When the count changes, re-query the bounded executable-output working set with cartesi_listOutputs using executed=false and output_type=[voucher, delegatecall_voucher], then diff that pending set against the previous result to identify executions. Do not use this count, an output index, or a pagination offset as a resume cursor: executions are observed out of output-index order, so a late execution can occur behind such a cursor. A race-free execution cursor is expected in a future ingestion API.", "params": [ { "name": "application", @@ -565,7 +565,7 @@ { "name": "cartesi_getPendingExecutableOutputCount", "summary": "Retrieve the number of pending executable outputs for the application", - "description": "Returns a non-monotone gauge (grows with new vouchers, shrinks with executions): do not use for change detection — poll the executed count instead.", + "description": "Returns a non-monotone gauge that grows with new executable outputs and shrinks with executions. Do not use it as a change signal or resume cursor. Instead poll cartesi_getExecutedOutputCount; when that monotone count changes, re-query the bounded executable-output working set with cartesi_listOutputs using executed=false and output_type=[voucher, delegatecall_voucher], then diff the pending set against the previous result.", "params": [ { "name": "application", @@ -597,7 +597,7 @@ { "name": "cartesi_listOutputs", "summary": "Retrieve a List of Outputs", - "description": "Returns a paginated list of outputs, with options to filter by epoch index, input index, output type and voucher address.", + "description": "Returns a paginated list of outputs, with options to filter by epoch index, input index, output type, voucher address, and execution status. Executions are observed out of output-index order: an old voucher can execute after newer outputs and therefore change behind an index- or offset-based cursor. Do not use output indexes, pagination offsets, or the executed-output count as resume cursors over the executed filter, because doing so can silently skip late executions. To synchronize executions, poll cartesi_getExecutedOutputCount; when it changes, re-query the bounded executable-output working set with executed=false and output_type=[voucher, delegatecall_voucher], then diff the pending set against the previous result. A race-free execution cursor is expected in a future ingestion API.", "params": [ { "name": "application", @@ -697,7 +697,7 @@ }, { "name": "executed", - "description": "Filter by execution status: true selects outputs with an execution transaction hash; false selects outputs without one. Executions happen out of index order: an old voucher can be executed long after newer ones, appearing at a low index. Therefore, do not build a resume cursor keyed on output index or on an executed-count offset over this filter as it will silently skip such late executions. Instead poll cartesi_getExecutedOutputCount, and on change query the bounded working set with executed=false, output_type=[...].", + "description": "Filter by execution status: true selects outputs with an execution transaction hash; false selects outputs without one. Executions are observed out of output-index order, so do not build a resume cursor over this filter from an output index, pagination offset, or executed-output count; it can silently skip late executions. Instead poll cartesi_getExecutedOutputCount and, when it changes, re-query the bounded executable-output working set with executed=false and output_type=[voucher, delegatecall_voucher], then diff the pending set against the previous result.", "schema": { "type": "boolean" }, From 7531899d0d75897ae2b03044b318b7e8dedcfe3e Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:30:49 -0300 Subject: [PATCH 21/43] docs(jsonrpc): improve documentation on response size limit - Documented budgetWriter and limitedWriter behavior, including: - Shared batch budget. - Atomic response buffering. - Exact-budget boundary behavior. - Poison-on-overflow semantics. - No budget consumption by the overflowing response. - Updated constant documentation to cover both single and batch requests. - Updated the OpenRPC description to document the 10 MB limit, -31003, retry guidance, and poison semantics. --- internal/jsonrpc/jsonrpc-discover.json | 2 +- internal/jsonrpc/jsonrpc.go | 7 ++++--- internal/jsonrpc/limitedwriter.go | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index 57de79c74..c597922bf 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -3,7 +3,7 @@ "info": { "title": "Cartesi Rollups Node API", "version": "2.0.0", - "description": "A JSON-RPC API for reading rollups data. It provides information about applications, epochs, inputs, outputs, and reports in a read-only fashion.\n\nBatch requests: JSON-RPC non-empty batch arrays are supported with a maximum of 100 entries per batch; batches outside that size range receive a single response with error code `-32040`. Entries execute sequentially and responses are returned in the same order as their requests. The 1 MB request-body limit applies to the whole batch array. A cumulative 10 MB response-size budget also applies; once the budget is exceeded, the remaining requests receive `-31003` error entries (consider resending the requests individually or in a smaller batch). Every batch entry receives a response. Notification suppression is not supported: entries without an ID are answered with `id: null`. This is a documented deviation from JSON-RPC 2.0, under which notifications normally produce no response. A batch response uses HTTP status 200 even when some or all of its entries are errors. Because execution is sequential and subject to the server time limit, heavy list calls should be kept outside large batches.\n\nError handling: every method documents its possible errors under `errors`, and clients can dispatch on the error code. `-31002` (application not found) means the application identifier itself is unknown to this node; for application-scoped methods, this is a configuration error that will not resolve by retrying. `-31001` (resource not found) means the requested resource does not exist in the method's scope. For application-scoped methods, `-31001` means the application is known but the nested entity is missing; for node-scoped methods, it can also report missing node resources such as EVM reader configuration. For forward-looking application resources (e.g. the next epoch, input, or output index), `-31001` is the documented \"not created yet\" signal and is safe to poll. The error message names the missing resource. `-32603` (internal error) is never used for missing resources - clients should treat it as a node-side failure and alarm or back off, not poll. `-32070` (timeout error) indicates the request was not able to be processed in the time limit available. The standard codes `-32700` (parse error), `-32600` (invalid request), and `-32601` (method not found) follow the JSON-RPC 2.0 specification." + "description": "A JSON-RPC API for reading rollups data. It provides information about applications, epochs, inputs, outputs, and reports in a read-only fashion.\n\nResponse limits: every HTTP request has a 10 MB response-size budget. For a single JSON-RPC request, its response must fit within that budget. For a batch, the budget is cumulative across all entries. An entry that would exceed the remaining budget is discarded without consuming it and receives error `-31003`; the budget is then closed, so every remaining batch entry also receives `-31003`, even if its response would otherwise fit. Clients can retry an affected entry individually or in a smaller batch.\n\nBatch requests: JSON-RPC non-empty batch arrays are supported with a maximum of 100 entries per batch; batches outside that size range receive a single response with error code `-32040`. Entries execute sequentially and responses are returned in the same order as their requests. The 1 MB request-body limit applies to the whole batch array. Every batch entry receives a response. Notification suppression is not supported: entries without an ID are answered with `id: null`. This is a documented deviation from JSON-RPC 2.0, under which notifications normally produce no response. A batch response uses HTTP status 200 even when some or all of its entries are errors. Because execution is sequential and subject to the server time limit, heavy list calls should be kept outside large batches.\n\nError handling: every method documents its possible errors under `errors`, and clients can dispatch on the error code. `-31002` (application not found) means the application identifier itself is unknown to this node; for application-scoped methods, this is a configuration error that will not resolve by retrying. `-31001` (resource not found) means the requested resource does not exist in the method's scope. For application-scoped methods, `-31001` means the application is known but the nested entity is missing; for node-scoped methods, it can also report missing node resources such as EVM reader configuration. For forward-looking application resources (e.g. the next epoch, input, or output index), `-31001` is the documented \"not created yet\" signal and is safe to poll. The error message names the missing resource. `-32603` (internal error) is never used for missing resources - clients should treat it as a node-side failure and alarm or back off, not poll. `-32070` (timeout error) indicates the request was not able to be processed in the time limit available. The standard codes `-32700` (parse error), `-32600` (invalid request), and `-32601` (method not found) follow the JSON-RPC 2.0 specification." }, "methods": [ { diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index cdaf0cb0c..d69877300 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -28,7 +28,8 @@ var discoverSpec embed.FS const ( // Maximum allowed body size (1 MB). MAX_BODY_SIZE = 1 << 20 //nolint: revive - // Maximum cumulative response size (10 MB). + // Maximum response size for a single request or cumulative response size for + // all entries in a batch (10 MB). MAX_RESPONSE_SIZE = 10 << 20 //nolint: revive // Maximum amount of request in a batch (100) MAX_BATCH_SIZE = 100 //nolint: revive @@ -57,8 +58,8 @@ const ( // Application not found: the application identifier itself is unknown to // this node. A configuration error that will not resolve by retrying. JSONRPC_APPLICATION_NOT_FOUND int = -31002 //nolint: revive - // Response size limit exceeded: cumulative buffered-response budget was - // not enough for all responses in the batch. + // Response size limit exceeded: the buffered-response budget was not enough + // for a single response or all responses in a batch. JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED int = -31003 //nolint: revive ) diff --git a/internal/jsonrpc/limitedwriter.go b/internal/jsonrpc/limitedwriter.go index 2ab55396f..e5ae71569 100644 --- a/internal/jsonrpc/limitedwriter.go +++ b/internal/jsonrpc/limitedwriter.go @@ -8,12 +8,25 @@ import ( "io" ) +// budgetWriter tracks the response bytes available to one HTTP request. A +// batch shares this budget across all of its entries; a single request uses the +// same budget for its sole response. +// +// Each response is first encoded into a limitedWriter and only reaches writer +// when Flush succeeds. If any response exceeds the remaining budget, that +// response is discarded atomically and the budgetWriter is permanently +// closed. NewLimitedWriter then returns nil, causing every remaining batch +// entry to receive the response-size-limit error, even if that entry's response +// would fit in the unused budget. The response that causes closure does not +// consume any budget. type budgetWriter struct { writer io.Writer budget int closed bool } +// newBudgetWriter creates a response budget of exactly limit bytes. A response +// whose encoded size equals the remaining budget is allowed. func newBudgetWriter(writer io.Writer, limit int) *budgetWriter { return &budgetWriter{ writer: writer, @@ -21,6 +34,8 @@ func newBudgetWriter(writer io.Writer, limit int) *budgetWriter { } } +// Write commits an already-buffered response and deducts successfully written +// bytes from the shared budget. func (w *budgetWriter) Write(data []byte) (int, error) { written, err := w.writer.Write(data) if err == nil { @@ -29,6 +44,8 @@ func (w *budgetWriter) Write(data []byte) (int, error) { return written, err } +// NewLimitedWriter creates an atomic buffer for the next response. It returns +// nil after any response has exceeded the shared budget. func (w *budgetWriter) NewLimitedWriter() *limitedWriter { if w.closed { return nil @@ -36,11 +53,16 @@ func (w *budgetWriter) NewLimitedWriter() *limitedWriter { return &limitedWriter{writer: w} } +// limitedWriter buffers one complete JSON-RPC response before committing it to +// its shared budgetWriter. type limitedWriter struct { writer *budgetWriter buffer bytes.Buffer } +// Write appends data while the complete buffered response fits in the remaining +// budget. An overflowing write returns io.ErrShortBuffer, discards the response +// on Flush, and permanently closes the shared budget. func (w *limitedWriter) Write(data []byte) (int, error) { if w.buffer.Len()+len(data) > w.writer.budget { w.writer.closed = true @@ -49,6 +71,8 @@ func (w *limitedWriter) Write(data []byte) (int, error) { return w.buffer.Write(data) } +// Flush atomically commits the buffered response unless an overflow has closed +// the shared budget. A flush after closure is intentionally a no-op. func (w *limitedWriter) Flush() error { if w.writer.closed { return nil From 65f2d40431f087b328b569f05b5640ccec29dd33 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:26:28 -0300 Subject: [PATCH 22/43] fix(jsonrpc): skip ignored fields on positional decoding of parameters --- internal/jsonrpc/api/params.go | 27 +++++++++++++++++---------- internal/jsonrpc/api/params_test.go | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/internal/jsonrpc/api/params.go b/internal/jsonrpc/api/params.go index f988f9843..9736a06ba 100644 --- a/internal/jsonrpc/api/params.go +++ b/internal/jsonrpc/api/params.go @@ -248,23 +248,30 @@ func UnmarshalParams(data json.RawMessage, target any) error { return fmt.Errorf("error unmarshalling positional parameters target must point to a struct") } typ := val.Type() - if len(rawParams) > typ.NumField() { + fields := make([]int, 0, typ.NumField()) + for i := 0; i < typ.NumField(); i++ { + if typ.Field(i).Tag.Get("json") != "-" { + fields = append(fields, i) + } + } + if len(rawParams) > len(fields) { return fmt.Errorf("error unmarshalling positional parameters, expected %d params, got %d", - typ.NumField(), len(rawParams)) + len(fields), len(rawParams)) } - // For each field in the struct, if a positional parameter exists, unmarshal that parameter. - for i := 0; i < typ.NumField() && i < len(rawParams); i++ { - sf := typ.Field(i) - if sf.Tag.Get("json") == "-" { - continue + // Map positional parameters to JSON-visible fields in declaration order. + for i, fieldIndex := range fields { + if i >= len(rawParams) { + break } - field := val.Field(i) + field := val.Field(fieldIndex) if !field.CanSet() { - return fmt.Errorf("error unmarshalling positional parameter field %q is not settable", typ.Field(i).Name) + return fmt.Errorf("error unmarshalling positional parameter field %q is not settable", + typ.Field(fieldIndex).Name) } // Unmarshal the corresponding raw parameter into the field. if err := json.Unmarshal(rawParams[i], field.Addr().Interface()); err != nil { - return fmt.Errorf("error unmarshalling positional parameter %d for field %s: %w", i, typ.Field(i).Name, err) + return fmt.Errorf("error unmarshalling positional parameter %d for field %s: %w", + i, typ.Field(fieldIndex).Name, err) } } return nil diff --git a/internal/jsonrpc/api/params_test.go b/internal/jsonrpc/api/params_test.go index 88e80039d..c14571690 100644 --- a/internal/jsonrpc/api/params_test.go +++ b/internal/jsonrpc/api/params_test.go @@ -225,3 +225,22 @@ func TestPositionalParamsDeclarationOrder(t *testing.T) { }) } } + +func TestUnmarshalParamsPositionalOrderSkipsIgnoredJSONFields(t *testing.T) { + type paramsWithIgnoredField struct { + First string `json:"first"` + Ignored string `json:"-"` + Second string `json:"second"` + } + + params := paramsWithIgnoredField{Ignored: "unchanged"} + require.NoError(t, UnmarshalParams(json.RawMessage(`["one","two"]`), ¶ms)) + require.Equal(t, paramsWithIgnoredField{ + First: "one", + Ignored: "unchanged", + Second: "two", + }, params) + + err := UnmarshalParams(json.RawMessage(`["one","two","extra"]`), ¶ms) + require.EqualError(t, err, "error unmarshalling positional parameters, expected 2 params, got 3") +} From ebb3d958d2d3edffc8dd4f3c3b2c4ac414ddbd91 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:27:17 -0300 Subject: [PATCH 23/43] test(jsonrpc): add more tests on parsing parameters - Omitted params - Top-level params: null - Empty positional arrays - Positional over-arity - Struct fields marked json:"-" --- internal/jsonrpc/api/params_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/jsonrpc/api/params_test.go b/internal/jsonrpc/api/params_test.go index c14571690..5836a5ca5 100644 --- a/internal/jsonrpc/api/params_test.go +++ b/internal/jsonrpc/api/params_test.go @@ -226,6 +226,29 @@ func TestPositionalParamsDeclarationOrder(t *testing.T) { } } +func TestUnmarshalParamsEmptyRepresentationsLeaveTargetUnchanged(t *testing.T) { + for name, data := range map[string]json.RawMessage{ + "omitted": nil, + "null": json.RawMessage(`null`), + "empty array": json.RawMessage(`[]`), + } { + t.Run(name, func(t *testing.T) { + params := ListApplicationsParams{Limit: 7, Offset: 3, Descending: true} + expected := params + + require.NoError(t, UnmarshalParams(data, ¶ms)) + require.Equal(t, expected, params) + }) + } +} + +func TestUnmarshalParamsRejectsPositionalOverArity(t *testing.T) { + var params GetApplicationParams + err := UnmarshalParams(json.RawMessage(`["app","extra"]`), ¶ms) + + require.EqualError(t, err, "error unmarshalling positional parameters, expected 1 params, got 2") +} + func TestUnmarshalParamsPositionalOrderSkipsIgnoredJSONFields(t *testing.T) { type paramsWithIgnoredField struct { First string `json:"first"` From 9bcb40d60a77955b3b81438cf340e92e2efef1bd Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:34:08 -0300 Subject: [PATCH 24/43] style(cli): avoid line length violation --- cmd/cartesi-rollups-cli/root/read/service/repository.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/cartesi-rollups-cli/root/read/service/repository.go b/cmd/cartesi-rollups-cli/root/read/service/repository.go index f12259b72..01a59fc70 100644 --- a/cmd/cartesi-rollups-cli/root/read/service/repository.go +++ b/cmd/cartesi-rollups-cli/root/read/service/repository.go @@ -847,7 +847,9 @@ func (s *RepositoryReadService) ListMatchAdvances(ctx context.Context, params ap pagination.Limit = params.Limit pagination.Offset = params.Offset - data, total, err := repo.ListMatchAdvances(ctx, application, epochIndex, params.TournamentAddress, params.IDHash, pagination, params.Descending) + data, total, err := repo.ListMatchAdvances( + ctx, application, epochIndex, params.TournamentAddress, params.IDHash, pagination, params.Descending, + ) if err != nil { return nil, err } From d79b075896f3e6a6b258db5ccefffabb36a8b19d Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:34:50 -0300 Subject: [PATCH 25/43] test(jsonrpc): add test to enforce log of method call --- internal/jsonrpc/jsonrpc_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index fa26d4307..1234f1ff1 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -16,9 +16,11 @@ package jsonrpc import ( + "bytes" "context" "encoding/json" "fmt" + "log/slog" "math" "net/http" "os" @@ -4059,3 +4061,23 @@ func TestParseIndexRange(t *testing.T) { _, err = parseIndexRange(nil, &invalid) require.EqualError(t, err, "invalid to index: expected hex encoded value") } + +func TestRequestMethodIsInfoLogged(t *testing.T) { + s := newBatchTestService() + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo})) + + const method = "attacker_controlled_method" + serveRPC(t, s, []byte(`{"jsonrpc":"2.0","method":"attacker_controlled_method","id":1}`)) + + var methodLogs int + for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") { + var record map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &record)) + if record["method"] == method { + require.Equal(t, "INFO", record["level"]) + methodLogs++ + } + } + require.Positive(t, methodLogs) +} From 54231eeab6faa2124914ffcd4bf3a0dcc0032866 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:22:46 -0300 Subject: [PATCH 26/43] feat(jsonrpc): impose the same listed items limit for all requests in a batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JSON-RPC server now: - Calculates 'sum(limit)' for every list operation before dispatching a batch. - Normalizes limits consistently with handlers: - Omitted or zero → 50 - Above 10,000 → 10,000 - Allows a cumulative limit of exactly 10,000. - Rejects totals above 10,000 before any handler or database query runs. - Returns one batch-level -31003 error: Batch list item limit exceeded. - Supports both named and positional parameters across every list method. - Includes a registry coverage test so future list methods cannot silently bypass the budget. The client contract is documented in 'jsonrpc-discover.json', and operator guidance—including the residual unbounded 'COUNT(*)' cost—is documented in 'docs/http-posture.md'. --- docs/http-posture.md | 25 ++++- internal/jsonrpc/batchbudget_test.go | 139 +++++++++++++++++++++++++ internal/jsonrpc/jsonrpc-discover.json | 33 ++++++ internal/jsonrpc/jsonrpc.go | 65 ++++++++++++ 4 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 internal/jsonrpc/batchbudget_test.go diff --git a/docs/http-posture.md b/docs/http-posture.md index ae9e601f5..889738212 100644 --- a/docs/http-posture.md +++ b/docs/http-posture.md @@ -14,7 +14,7 @@ operator-side network policy. | Surface | Default address | Purpose | Per-request cost | | --- | --- | --- | --- | | **Telemetry** (`/livez`, `/readyz`) | `:10000` | Orchestrator health checks | Trivial — a boolean check and a short response | -| **JSON-RPC API** (`/rpc`) | `:10011` | Read-only query interface | Up to 1 MiB body, DB queries, list responses up to 10000 items | +| **JSON-RPC API** (`/rpc`) | `:10011` | Read-only query interface | Up to 1 MiB body; one list operation, or a batch with a cumulative list limit of 10000 items; DB queries | | **Inspect** (`/inspect/{dapp}`) | `:10012` | Machine state query without advancing | Up to 2 MiB body, Cartesi Machine fork + execution | Telemetry is cheap by design — orchestrators (Kubernetes, Docker, @@ -149,6 +149,29 @@ falls back to: fail-fast; deeper in the request path). - JSON-RPC: the PostgreSQL connection pool (blocking). +### JSON-RPC batch work budget + +Admission counts HTTP requests, while a JSON-RPC batch can contain up to 100 +operations. To keep one admitted batch from buying substantially more row-fetch +work than one maximal list request, the service applies a protocol-level budget +before dispatch: + +- The sum of the effective `limit` values across all list entries in a batch + must not exceed 10000. +- An omitted or zero `limit` counts as the default of 50. A value above the + per-list maximum is capped to 10000 before it is added. +- If the sum exceeds 10000, the whole batch is rejected before any handler or + database query runs. The response is one JSON-RPC error object with code + `-31004` and message `Batch list item limit exceeded`. +- Non-list entries do not consume this work budget. A single request retains + the existing per-list maximum of 10000. + +This restores the row-fetch bound that existed before batch support: one +admission slot can fetch at most as many rows as one maximal list call. It does +not bound `COUNT(*)` cost, which is independent of `limit`; selective filters, +the pending-output partial index, proxy rate limiting, and PostgreSQL capacity +planning remain important. + ### Rejection semantics When admission rejects a request: diff --git a/internal/jsonrpc/batchbudget_test.go b/internal/jsonrpc/batchbudget_test.go new file mode 100644 index 000000000..ffe896380 --- /dev/null +++ b/internal/jsonrpc/batchbudget_test.go @@ -0,0 +1,139 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMethodErrorListsExcludeBatchWideErrors(t *testing.T) { + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + var spec struct { + Methods []struct { + Name string `json:"name"` + Errors []struct { + Ref string `json:"$ref"` + } `json:"errors"` + } `json:"methods"` + } + require.NoError(t, json.Unmarshal(data, &spec)) + + for _, method := range spec.Methods { + refs := make(map[string]bool, len(method.Errors)) + for _, methodErr := range method.Errors { + name := methodErr.Ref[strings.LastIndex(methodErr.Ref, "/")+1:] + refs[name] = true + } + require.False(t, refs["BatchListItemLimitExceeded"], + "%s must not advertise a whole-batch error as a per-method error", method.Name) + require.True(t, refs["TimeoutError"], "%s must advertise timeout responses", method.Name) + require.True(t, refs["ResponseSizeLimitExceeded"], + "%s must advertise response-size errors", method.Name) + } +} + +func TestBatchListItemLimitSupportsNamedAndPositionalParams(t *testing.T) { + positionalAtLimit := map[string]string{ + "cartesi_listApplications": `[10000]`, + "cartesi_listEpochs": `["app",null,10000]`, + "cartesi_listInputs": `["app",null,null,null,10000]`, + "cartesi_listOutputs": `["app",null,null,null,null,10000]`, + "cartesi_listReports": `["app",null,null,10000]`, + "cartesi_listWithdrawals": `["app",null,10000]`, + "cartesi_listTournaments": `["app",null,null,null,null,10000]`, + "cartesi_listCommitments": `["app",null,null,10000]`, + "cartesi_listMatches": `["app",null,null,10000]`, + "cartesi_listMatchAdvances": `["app","0x0","tournament","id",10000]`, + } + + for method, positional := range positionalAtLimit { + t.Run(method, func(t *testing.T) { + requests := []json.RawMessage{ + json.RawMessage(fmt.Sprintf( + `{"jsonrpc":"2.0","method":%q,"params":%s,"id":1}`, method, positional)), + json.RawMessage( + `{"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":1},"id":2}`), + } + require.True(t, batchExceedsListItemLimit(requests)) + }) + } + + require.False(t, batchExceedsListItemLimit([]json.RawMessage{ + json.RawMessage( + `{"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":6000},"id":1}`), + json.RawMessage( + `{"jsonrpc":"2.0","method":"cartesi_listOutputs","params":{"limit":4000},"id":2}`), + })) +} + +func TestBatchListItemLimitRegistryCoversEveryListHandler(t *testing.T) { + for method := range jsonrpcHandlers { + if strings.HasPrefix(method, "cartesi_list") { + require.Contains(t, listParamsTypes, method) + } + } +} + +func TestBatchListItemLimitNormalizesLimitsLikeHandlers(t *testing.T) { + // A zero limit uses the default, while a value above the per-list maximum + // is capped at that maximum. + require.False(t, batchExceedsListItemLimit([]json.RawMessage{json.RawMessage( + `{"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":0},"id":1}`, + )})) + require.False(t, batchExceedsListItemLimit([]json.RawMessage{json.RawMessage( + `{"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":20000},"id":1}`, + )})) + require.True(t, batchExceedsListItemLimit([]json.RawMessage{ + json.RawMessage( + `{"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":20000},"id":1}`), + json.RawMessage( + `{"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":1},"id":2}`), + })) +} + +func TestJSONRPCBatchRejectsListWorkOverLimitBeforeDispatch(t *testing.T) { + s := newBatchTestService() + var calls atomic.Int32 + withTestRPCHandler(t, "cartesi_listApplications", func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + calls.Add(1) + return true, nil + }) + + rr := serveRPC(t, s, []byte(`[ + {"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":6000},"id":1}, + {"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":4001},"id":2} + ]`)) + + require.Equal(t, http.StatusOK, rr.Code) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, nil, JSONRPC_BATCH_LIST_ITEM_LIMIT_EXCEEDED) + require.Equal(t, "Batch list item limit exceeded", response.Error.Message) + require.Zero(t, calls.Load(), "an over-budget batch must be rejected before dispatch") +} + +func TestJSONRPCBatchAllowsListWorkAtLimit(t *testing.T) { + s := newBatchTestService() + var calls atomic.Int32 + withTestRPCHandler(t, "cartesi_listApplications", func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + calls.Add(1) + return true, nil + }) + + rr := serveRPC(t, s, []byte(`[ + {"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":6000},"id":1}, + {"jsonrpc":"2.0","method":"cartesi_listApplications","params":{"limit":4000},"id":2} + ]`)) + + require.Equal(t, http.StatusOK, rr.Code) + require.Len(t, decodeRPCBatch(t, rr.Body.Bytes()), 2) + require.Equal(t, int32(2), calls.Load()) +} diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index c597922bf..77eeafe74 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -3,6 +3,11 @@ "info": { "title": "Cartesi Rollups Node API", "version": "2.0.0", + "x-batch-list-work-budget": { + "maximum": 10000, + "unit": "sum of effective limit values across list entries", + "description": "Before dispatching any entry, the server sums the effective limit of every list operation in a batch. Omitted or zero limits count as 50, and limits above 10000 count as 10000. A total above 10000 rejects the entire batch with one error response using code -31004; no entry is dispatched. This bounds row-fetch work to that of one maximal list request. It does not bound the cost of COUNT queries, so clients should still avoid broad or unnecessary list filters." + }, "description": "A JSON-RPC API for reading rollups data. It provides information about applications, epochs, inputs, outputs, and reports in a read-only fashion.\n\nResponse limits: every HTTP request has a 10 MB response-size budget. For a single JSON-RPC request, its response must fit within that budget. For a batch, the budget is cumulative across all entries. An entry that would exceed the remaining budget is discarded without consuming it and receives error `-31003`; the budget is then closed, so every remaining batch entry also receives `-31003`, even if its response would otherwise fit. Clients can retry an affected entry individually or in a smaller batch.\n\nBatch requests: JSON-RPC non-empty batch arrays are supported with a maximum of 100 entries per batch; batches outside that size range receive a single response with error code `-32040`. Entries execute sequentially and responses are returned in the same order as their requests. The 1 MB request-body limit applies to the whole batch array. Every batch entry receives a response. Notification suppression is not supported: entries without an ID are answered with `id: null`. This is a documented deviation from JSON-RPC 2.0, under which notifications normally produce no response. A batch response uses HTTP status 200 even when some or all of its entries are errors. Because execution is sequential and subject to the server time limit, heavy list calls should be kept outside large batches.\n\nError handling: every method documents its possible errors under `errors`, and clients can dispatch on the error code. `-31002` (application not found) means the application identifier itself is unknown to this node; for application-scoped methods, this is a configuration error that will not resolve by retrying. `-31001` (resource not found) means the requested resource does not exist in the method's scope. For application-scoped methods, `-31001` means the application is known but the nested entity is missing; for node-scoped methods, it can also report missing node resources such as EVM reader configuration. For forward-looking application resources (e.g. the next epoch, input, or output index), `-31001` is the documented \"not created yet\" signal and is safe to poll. The error message names the missing resource. `-32603` (internal error) is never used for missing resources - clients should treat it as a node-side failure and alarm or back off, not poll. `-32070` (timeout error) indicates the request was not able to be processed in the time limit available. The standard codes `-32700` (parse error), `-32600` (invalid request), and `-32601` (method not found) follow the JSON-RPC 2.0 specification." }, "methods": [ @@ -292,6 +297,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -559,6 +570,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -591,6 +608,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -1672,6 +1695,12 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" } ] }, @@ -2955,6 +2984,10 @@ "code": -31003, "message": "Response size limit exceeded" }, + "BatchListItemLimitExceeded": { + "code": -31004, + "message": "Batch list item limit exceeded" + }, "ApplicationNotFound": { "code": -31002, "message": "Application not found" diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index d69877300..d7ea6f2ed 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -13,6 +13,7 @@ import ( "io" "math" "net/http" + "reflect" "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/evmreader" @@ -61,6 +62,9 @@ const ( // Response size limit exceeded: the buffered-response budget was not enough // for a single response or all responses in a batch. JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED int = -31003 //nolint: revive + // Batch list item limit exceeded: the cumulative effective list limits in a + // batch exceed the work budget allowed to one HTTP request. + JSONRPC_BATCH_LIST_ITEM_LIMIT_EXCEEDED int = -31004 //nolint: revive ) type rpcHandler = func(*Service, *http.Request, RPCRequest) (any, error) @@ -98,6 +102,63 @@ var jsonrpcHandlers = dispatchTable{ "cartesi_getNodeVersion": handleGetNodeVersion, } +var listParamsTypes = map[string]reflect.Type{ + "cartesi_listApplications": reflect.TypeOf(api.ListApplicationsParams{}), + "cartesi_listEpochs": reflect.TypeOf(api.ListEpochsParams{}), + "cartesi_listInputs": reflect.TypeOf(api.ListInputsParams{}), + "cartesi_listOutputs": reflect.TypeOf(api.ListOutputsParams{}), + "cartesi_listReports": reflect.TypeOf(api.ListReportsParams{}), + "cartesi_listWithdrawals": reflect.TypeOf(api.ListWithdrawalsParams{}), + "cartesi_listTournaments": reflect.TypeOf(api.ListTournamentsParams{}), + "cartesi_listCommitments": reflect.TypeOf(api.ListCommitmentsParams{}), + "cartesi_listMatches": reflect.TypeOf(api.ListMatchesParams{}), + "cartesi_listMatchAdvances": reflect.TypeOf(api.ListMatchAdvancesParams{}), +} + +// batchExceedsListItemLimit reports whether the sum of the effective limits of +// valid list entries exceeds the amount of row-fetch work allowed to one HTTP +// request. It performs no handler or repository work. +// +// Entries that cannot be decoded are left for normal dispatch, which returns +// their appropriate JSON-RPC error without accessing the repository. Each +// decodable limit is normalized exactly as it is by the list handlers: zero +// selects the default and values above the per-list maximum are capped. +func batchExceedsListItemLimit(requests []json.RawMessage) bool { + var total uint64 + for _, rawRequest := range requests { + var request RPCRequest + if err := json.Unmarshal(rawRequest, &request); err != nil || request.JSONRPC != "2.0" { + continue + } + + paramsType, ok := listParamsTypes[request.Method] + if !ok { + continue + } + params := reflect.New(paramsType) + if err := api.UnmarshalParams(request.Params, params.Interface()); err != nil { + continue + } + + limitField := params.Elem().FieldByName("Limit") + if !limitField.IsValid() || limitField.Kind() != reflect.Uint64 { + return true + } + limit := limitField.Uint() + switch { + case limit == 0: + limit = LIST_ITEM_DEFAULT + case limit > LIST_ITEM_LIMIT: + limit = LIST_ITEM_LIMIT + } + total += limit + if total > LIST_ITEM_LIMIT { + return true + } + } + return false +} + // ----------------------------------------------------------------------------- // Dispatching JSON‑RPC methods // ----------------------------------------------------------------------------- @@ -216,6 +277,10 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { s.writeRPCError(w, nil, JSONRPC_INVALID_BATCH, fmt.Sprintf("invalid request batch size (expected [1..%v])", MAX_BATCH_SIZE)) return } + if batchExceedsListItemLimit(reqSeq) { + s.writeRPCError(w, nil, JSONRPC_BATCH_LIST_ITEM_LIMIT_EXCEEDED, "Batch list item limit exceeded") + return + } s.Logger.Info("Received RPC request batch", "items", len(reqSeq)) if !s.writeByte(w, '[') { From ac78085eec8c158f8d8e9894fc347d5fa3d46512 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:37:58 -0300 Subject: [PATCH 27/43] feat(jsonrpc): add a dispatch timeout to prevent reaching the write timeout to avoid incomplete responses - Added a 25-second JSON-RPC dispatch timeout, leaving five seconds before the HTTP 30-second WriteTimeout. - Applied the deadline at the top of handleRPC, propagating it through request dispatch. - Added an integration-style regression test through the actual server handler, verifying remaining batch entries receive -32070 responses. - Focused JSON-RPC regression tests pass. --- internal/jsonrpc/jsonrpc.go | 6 ++++++ internal/jsonrpc/service.go | 12 ++++++++++-- internal/jsonrpc/service_test.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index d7ea6f2ed..85b804d08 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -231,6 +231,12 @@ func (s *Service) dispatchOneRequest(w http.ResponseWriter, r *http.Request, req } func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { + if s.dispatchTimeout > 0 { + ctx, cancel := context.WithTimeout(r.Context(), s.dispatchTimeout) + defer cancel() + r = r.WithContext(ctx) + } + // Limit request body size and ensure it is closed. r.Body = http.MaxBytesReader(w, r.Body, MAX_BODY_SIZE) defer r.Body.Close() diff --git a/internal/jsonrpc/service.go b/internal/jsonrpc/service.go index 9faf990a8..b20287358 100644 --- a/internal/jsonrpc/service.go +++ b/internal/jsonrpc/service.go @@ -21,7 +21,10 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" ) -const jsonrpcShutdownTimeout = 5 * time.Second +const ( + jsonrpcShutdownTimeout = 5 * time.Second + jsonrpcWriteHeadroom = 5 * time.Second +) // ----------------------------------------------------------------------------- // Service Implementation @@ -40,6 +43,9 @@ type Service struct { listen func(network, address string) (net.Listener, error) // OpenAPI description for JSON-RPC API loaded from 'jsonrpc-discover.json' file discoverSpec any + // dispatchTimeout expires requests early enough to serialize a complete + // timeout response before the HTTP server's write deadline. + dispatchTimeout time.Duration } type CreateInfo struct { @@ -101,7 +107,9 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { []string{"POST", "OPTIONS"}, []string{"Content-Type"}), }) - s.server = service.NewHTTPServer(c.Config.JsonrpcApiAddress, handler, service.DefaultJSONRPCOptions(), s.Logger) + serverOpts := service.DefaultJSONRPCOptions() + s.dispatchTimeout = serverOpts.WriteTimeout - jsonrpcWriteHeadroom + s.server = service.NewHTTPServer(c.Config.JsonrpcApiAddress, handler, serverOpts, s.Logger) service.StartupBindWarning(s.Logger, "jsonrpc", c.Config.JsonrpcApiAddress) if s.listen == nil { diff --git a/internal/jsonrpc/service_test.go b/internal/jsonrpc/service_test.go index e4ad62ba9..411229a68 100644 --- a/internal/jsonrpc/service_test.go +++ b/internal/jsonrpc/service_test.go @@ -5,10 +5,13 @@ package jsonrpc import ( "bytes" + "encoding/json" + "fmt" "net/http" "net/http/httptest" "strconv" "testing" + "time" "github.com/cartesi/rollups-node/pkg/service" @@ -48,6 +51,35 @@ func TestJSONRPC_HardenedServerOptions(t *testing.T) { require.Equal(t, opts.IdleTimeout, s.server.IdleTimeout) require.Equal(t, opts.MaxHeaderBytes, s.server.MaxHeaderBytes) require.NotNil(t, s.server.ErrorLog) + require.Equal(t, opts.WriteTimeout-jsonrpcWriteHeadroom, s.dispatchTimeout) +} + +func TestJSONRPC_ServerHandlerAppliesBatchDispatchTimeout(t *testing.T) { + s := newTestService(t, "jsonrpc-dispatch-timeout") + s.dispatchTimeout = 10 * time.Millisecond + + const method = "test_server_dispatch_timeout" + withTestRPCHandler(t, method, func(_ *Service, r *http.Request, _ RPCRequest) (any, error) { + <-r.Context().Done() + return true, nil + }) + + body := []byte(fmt.Sprintf(`[ + {"jsonrpc":"2.0","method":%q,"id":1}, + {"jsonrpc":"2.0","method":%q,"id":2}, + {"jsonrpc":"2.0","method":%q,"id":3} + ]`, method, method, method)) + req := httptest.NewRequest(http.MethodPost, "/rpc", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.server.Handler.ServeHTTP(rr, req) + + var responses []RPCResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &responses)) + require.Len(t, responses, 3) + require.Nil(t, responses[0].Error) + requireRPCError(t, responses[1], float64(2), JSONRPC_TIMEOUT_ERROR) + requireRPCError(t, responses[2], float64(3), JSONRPC_TIMEOUT_ERROR) } // TestJSONRPC_RequestIDPropagated verifies the middleware chain echoes a From f8c17233594b3fb27fdf0e0eca13c0bb55b6fe47 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:55:18 -0300 Subject: [PATCH 28/43] fix(jsonrpc): refuse offsets that are too large to be handled correctly - Added shared validation rejecting list offsets above math.MaxInt64 with JSON-RPC -32602 / "Invalid offset". - Validation occurs before repository access, preventing negative int64 conversion and erroneous operator alarms. - Covers all ten list methods plus named and positional parameters. - Updated every OpenRPC offset schema with maximum: 9223372036854775807. --- internal/jsonrpc/jsonrpc-discover.json | 10 ++++++ internal/jsonrpc/jsonrpc.go | 31 +++++++++++++++++- internal/jsonrpc/jsonrpc_test.go | 44 ++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index 77eeafe74..06f99c1bf 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -32,6 +32,7 @@ "schema": { "type": "integer", "minimum": 0, + "maximum": 9223372036854775807, "default": 0 }, "required": false @@ -153,6 +154,7 @@ "schema": { "type": "integer", "minimum": 0, + "maximum": 9223372036854775807, "default": 0 }, "required": false @@ -400,6 +402,7 @@ "schema": { "type": "integer", "minimum": 0, + "maximum": 9223372036854775807, "default": 0 }, "required": false @@ -689,6 +692,7 @@ "schema": { "type": "integer", "minimum": 0, + "maximum": 9223372036854775807, "default": 0 }, "required": false @@ -843,6 +847,7 @@ "schema": { "type": "integer", "minimum": 0, + "maximum": 9223372036854775807, "default": 0 }, "required": false @@ -983,6 +988,7 @@ "schema": { "type": "integer", "minimum": 0, + "maximum": 9223372036854775807, "default": 0 }, "required": false @@ -1131,6 +1137,7 @@ "schema": { "type": "integer", "minimum": 0, + "maximum": 9223372036854775807, "default": 0 }, "required": false @@ -1263,6 +1270,7 @@ "schema": { "type": "integer", "minimum": 0, + "maximum": 9223372036854775807, "default": 0 }, "required": false @@ -1411,6 +1419,7 @@ "schema": { "type": "integer", "minimum": 0, + "maximum": 9223372036854775807, "default": 0 }, "required": false @@ -1567,6 +1576,7 @@ "schema": { "type": "integer", "minimum": 0, + "maximum": 9223372036854775807, "default": 0 }, "required": false diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index 85b804d08..a5807af38 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -196,7 +196,6 @@ func (s *Service) handleRequest(w io.Writer, r *http.Request, req RPCRequest) er s.Logger.Debug("RPC method not found", "method", req.Method) return writeRPCError(w, req.ID, JSONRPC_METHOD_NOT_FOUND, "Method not found") } - result, err := fn(s, r, req) if err == nil { return writeRPCResult(w, req.ID, result) @@ -353,6 +352,9 @@ func handleListApplications(s *Service, r *http.Request, req RPCRequest) (any, e s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } // Use default values if not provided if params.Limit <= 0 { params.Limit = LIST_ITEM_DEFAULT @@ -414,6 +416,9 @@ func handleListEpochs(s *Service, r *http.Request, req RPCRequest) (any, error) s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } // Use default values if not provided if params.Limit <= 0 { @@ -575,6 +580,9 @@ func handleListInputs(s *Service, r *http.Request, req RPCRequest) (any, error) s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } // Use default values if not provided if params.Limit <= 0 { @@ -771,6 +779,9 @@ func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } // Use default values if not provided if params.Limit <= 0 { @@ -912,6 +923,9 @@ func handleListReports(s *Service, r *http.Request, req RPCRequest) (any, error) s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } // Use default values if not provided if params.Limit <= 0 { @@ -1016,6 +1030,9 @@ func handleListWithdrawals(s *Service, r *http.Request, req RPCRequest) (any, er s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } if params.Limit <= 0 { params.Limit = LIST_ITEM_DEFAULT @@ -1103,6 +1120,9 @@ func handleListTournaments(s *Service, r *http.Request, req RPCRequest) (any, er s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } // Use default values if not provided if params.Limit <= 0 { @@ -1217,6 +1237,9 @@ func handleListCommitments(s *Service, r *http.Request, req RPCRequest) (any, er s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } // Use default values if not provided if params.Limit <= 0 { @@ -1325,6 +1348,9 @@ func handleListMatches(s *Service, r *http.Request, req RPCRequest) (any, error) s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } // Use default values if not provided if params.Limit <= 0 { @@ -1430,6 +1456,9 @@ func handleListMatchAdvances(s *Service, r *http.Request, req RPCRequest) (any, s.Logger.Debug("Invalid parameters", "err", err) return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid parameters") } + if params.Offset > math.MaxInt64 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid offset") + } // Use default values if not provided if params.Limit <= 0 { diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index 1234f1ff1..da40952a9 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -4038,6 +4038,50 @@ func TestListIndexRangeValidation(t *testing.T) { } } +func TestListOffsetValidation(t *testing.T) { + for _, method := range []string{ + "cartesi_listApplications", + "cartesi_listEpochs", + "cartesi_listInputs", + "cartesi_listOutputs", + "cartesi_listReports", + "cartesi_listWithdrawals", + "cartesi_listTournaments", + "cartesi_listCommitments", + "cartesi_listMatches", + "cartesi_listMatchAdvances", + } { + t.Run(method, func(t *testing.T) { + s := newBatchTestService() + body := []byte(fmt.Sprintf(`{ + "jsonrpc":"2.0", + "method":%q, + "params":{"offset":9223372036854775808}, + "id":1 + }`, method)) + rr := serveRPC(t, s, body) + + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_INVALID_PARAMS) + require.Equal(t, "Invalid offset", response.Error.Message) + }) + } + + t.Run("positional parameters", func(t *testing.T) { + s := newBatchTestService() + rr := serveRPC(t, s, []byte(`{ + "jsonrpc":"2.0", + "method":"cartesi_listApplications", + "params":[50,9223372036854775808], + "id":1 + }`)) + + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_INVALID_PARAMS) + require.Equal(t, "Invalid offset", response.Error.Message) + }) +} + func TestParseIndexRange(t *testing.T) { from := "0x2" to := "0x4" From 96c0a1460938c656ba5ba1db57846d7be7604daa Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:14:50 -0300 Subject: [PATCH 29/43] fix(jsonrpc): avoid logging canceled context errors due to disconnected clients - Added a shared repository-error handler that passes wrapped context.Canceled errors through without Error logging. - Updated all JSON-RPC repository failure paths to use it. - Request dispatch now silently stops on cancellation without writing an internal-error response. - context.DeadlineExceeded retains existing logging and response behavior. - Added regression coverage for cancellation during an active repository operation. --- internal/jsonrpc/batchcalls_test.go | 73 +++++++++++++++++++ internal/jsonrpc/jsonrpc.go | 104 +++++++++++++--------------- internal/jsonrpc/service_test.go | 7 +- 3 files changed, 125 insertions(+), 59 deletions(-) diff --git a/internal/jsonrpc/batchcalls_test.go b/internal/jsonrpc/batchcalls_test.go index 71a71c0d4..e51d39f3a 100644 --- a/internal/jsonrpc/batchcalls_test.go +++ b/internal/jsonrpc/batchcalls_test.go @@ -322,6 +322,41 @@ func TestJSONRPCBatchStopsBetweenEntriesWhenContextIsCanceled(t *testing.T) { } } +func TestJSONRPCBatchStopsSilentlyWhenRepositoryCallIsCanceled(t *testing.T) { + s := newBatchTestService() + var calls atomic.Int32 + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx, cancel := context.WithCancel(context.Background()) + const method = "test_repository_cancel_batch" + withTestRPCHandler(t, method, func(s *Service, _ *http.Request, _ RPCRequest) (any, error) { + calls.Add(1) + cancel() + return nil, s.repositoryError(ctx, "Unable to retrieve test data from repository", + fmt.Errorf("repository query failed: %w", ctx.Err())) + }) + + body := []byte(fmt.Sprintf(`[ + {"jsonrpc":"2.0","method":%q,"id":1}, + {"jsonrpc":"2.0","method":%q,"id":2} + ]`, method, method)) + req := httptest.NewRequest(http.MethodPost, "/rpc", bytes.NewReader(body)).WithContext(ctx) + rr := httptest.NewRecorder() + s.handleRPC(rr, req) + + require.Equal(t, int32(1), calls.Load()) + require.NotContains(t, rr.Body.String(), "Internal server error") + for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") { + if line == "" { + continue + } + var record map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &record)) + require.NotEqual(t, "ERROR", record["level"], + "context.Canceled must not be logged as an operator error") + } +} + func TestJSONRPCBatchReturnsErrorsForIDDRequestsAfterDeadline(t *testing.T) { s := newBatchTestService() var calls atomic.Int32 @@ -361,6 +396,44 @@ func TestJSONRPCBatchReturnsErrorsForIDDRequestsAfterDeadline(t *testing.T) { } } +func TestJSONRPCSingleRequestReturnsTimeoutWhenItsContextExpires(t *testing.T) { + s := newBatchTestService() + s.dispatchTimeout = 5 * time.Millisecond + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + const method = "test_single_request_timeout" + withTestRPCHandler(t, method, func(s *Service, r *http.Request, _ RPCRequest) (any, error) { + <-r.Context().Done() + return nil, s.repositoryError(r.Context(), "Unable to retrieve test data from repository", + fmt.Errorf("repository query failed: %w", r.Context().Err())) + }) + + rr := serveRPC(t, s, []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":%q,"id":1}`, method))) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_TIMEOUT_ERROR) + require.Equal(t, "Request timed out", response.Error.Message) + require.Contains(t, logs.String(), "RPC method dispatch timeout") + require.NotContains(t, logs.String(), `"level":"ERROR"`) +} + +func TestJSONRPCUpstreamDeadlineRemainsInternalError(t *testing.T) { + s := newBatchTestService() + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + const method = "test_upstream_deadline" + withTestRPCHandler(t, method, func(s *Service, r *http.Request, _ RPCRequest) (any, error) { + return nil, s.repositoryError(r.Context(), "Unable to retrieve test data from repository", + fmt.Errorf("upstream deadline: %w", context.DeadlineExceeded)) + }) + + rr := serveRPC(t, s, []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":%q,"id":1}`, method))) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_INTERNAL_ERROR) + require.Contains(t, logs.String(), `"level":"ERROR"`) +} + func TestJSONRPCBatchUsesOneAdmissionPermit(t *testing.T) { s := newBatchTestService() s.admission = service.NewSemaphoreAdmission(1) diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index a5807af38..502356a51 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -182,6 +182,17 @@ func (s *Service) writeRPCError(w http.ResponseWriter, id any, code int, message return s.handleWriteResponse(err) } +func (s *Service) repositoryError(ctx context.Context, message string, err error) error { + if errors.Is(err, context.Canceled) { + return err + } + if errors.Is(err, context.DeadlineExceeded) && errors.Is(ctx.Err(), context.DeadlineExceeded) { + return err + } + s.Logger.Error(message, "err", err) + return newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") +} + func (s *Service) handleRequest(w io.Writer, r *http.Request, req RPCRequest) error { switch req.ID.(type) { case nil, string, float64: @@ -200,6 +211,13 @@ func (s *Service) handleRequest(w io.Writer, r *http.Request, req RPCRequest) er if err == nil { return writeRPCResult(w, req.ID, result) } + if errors.Is(err, context.Canceled) { + return err + } + if errors.Is(err, context.DeadlineExceeded) && errors.Is(r.Context().Err(), context.DeadlineExceeded) { + s.Logger.Warn("RPC method dispatch timeout", "method", req.Method) + return writeRPCError(w, req.ID, JSONRPC_TIMEOUT_ERROR, "Request timed out") + } var rpcErr *RPCError if errors.As(err, &rpcErr) { @@ -221,6 +239,8 @@ func (s *Service) dispatchOneRequest(w http.ResponseWriter, r *http.Request, req switch { case err == nil: return s.handleWriteResponse(buffer.Flush()) + case errors.Is(err, context.Canceled): + return false case errors.Is(err, io.ErrShortBuffer): return s.writeRPCError(w, req.ID, JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED, "Response size limit exceeded") default: @@ -369,8 +389,7 @@ func handleListApplications(s *Service, r *http.Request, req RPCRequest) (any, e Offset: params.Offset, }, params.Descending) if err != nil { - s.Logger.Error("Unable to retrieve applications from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve applications from repository", err) } if apps == nil { apps = []*model.Application{} @@ -400,8 +419,7 @@ func handleGetApplication(s *Service, r *http.Request, req RPCRequest) (any, err app, err := s.repository.GetApplication(r.Context(), params.Application) if err != nil { - s.Logger.Error("Unable to retrieve application from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve application from repository", err) } if app == nil { return nil, newRPCError(JSONRPC_APPLICATION_NOT_FOUND, "Application not found") @@ -460,8 +478,7 @@ func handleListEpochs(s *Service, r *http.Request, req RPCRequest) (any, error) Offset: params.Offset, }, params.Descending) if err != nil { - s.Logger.Error("Unable to retrieve epochs from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve epochs from repository", err) } if len(epochs) == 0 { @@ -502,8 +519,7 @@ func handleGetEpoch(s *Service, r *http.Request, req RPCRequest) (any, error) { epoch, err := s.repository.GetEpoch(r.Context(), params.Application, index) if err != nil { - s.Logger.Error("Unable to retrieve epoch from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve epoch from repository", err) } if epoch == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -534,8 +550,7 @@ func handleGetEpochByVirtualIndex(s *Service, r *http.Request, req RPCRequest) ( epoch, err := s.repository.GetEpochByVirtualIndex(r.Context(), params.Application, index) if err != nil { - s.Logger.Error("Unable to retrieve epoch from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve epoch from repository", err) } if epoch == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -567,8 +582,7 @@ func handleGetLastAcceptedEpochIndex(s *Service, r *http.Request, req RPCRequest return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Epoch not found") } if err != nil { - s.Logger.Error("Unable to retrieve epoch from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve epoch from repository", err) } return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", index)}, nil @@ -634,8 +648,7 @@ func handleListInputs(s *Service, r *http.Request, req RPCRequest) (any, error) Offset: params.Offset, }, params.Descending) if err != nil { - s.Logger.Error("Unable to retrieve inputs from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve inputs from repository", err) } if len(inputs) == 0 { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -681,8 +694,7 @@ func handleGetInput(s *Service, r *http.Request, req RPCRequest) (any, error) { input, err := s.repository.GetInput(r.Context(), params.Application, index) if err != nil { - s.Logger.Error("Unable to retrieve input from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve input from repository", err) } if input == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -716,8 +728,7 @@ func handleGetProcessedInputCount(s *Service, r *http.Request, req RPCRequest) ( return nil, newRPCError(JSONRPC_APPLICATION_NOT_FOUND, "Application not found") } if err != nil { - s.Logger.Error("Unable to retrieve application from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve application from repository", err) } return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", processedInputs)}, nil @@ -736,8 +747,7 @@ func handleGetExecutedOutputCount(s *Service, r *http.Request, req RPCRequest) ( count, err := s.repository.GetNumberOfExecutedOutputs(r.Context(), params.Application) if err != nil { - s.Logger.Error("Unable to retrieve executed output count from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve executed output count from repository", err) } if count == 0 { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -761,8 +771,7 @@ func handleGetPendingExecutableOutputCount(s *Service, r *http.Request, req RPCR count, err := s.repository.GetNumberOfPendingExecutableOutputs(r.Context(), params.Application) if err != nil { - s.Logger.Error("Unable to retrieve pending executable output count from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve pending executable output count from repository", err) } if count == 0 { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -851,8 +860,7 @@ func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) Offset: params.Offset, }, params.Descending) if err != nil { - s.Logger.Error("Unable to retrieve outputs from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve outputs from repository", err) } resultOutputs := make([]*api.DecodedOutput, 0, len(outputs)) @@ -899,8 +907,7 @@ func handleGetOutput(s *Service, r *http.Request, req RPCRequest) (any, error) { output, err := s.repository.GetOutput(r.Context(), params.Application, index) if err != nil { - s.Logger.Error("Unable to retrieve output from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve output from repository", err) } if output == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -969,8 +976,7 @@ func handleListReports(s *Service, r *http.Request, req RPCRequest) (any, error) Offset: params.Offset, }, params.Descending) if err != nil { - s.Logger.Error("Unable to retrieve reports from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve reports from repository", err) } if len(reports) == 0 { @@ -1011,8 +1017,7 @@ func handleGetReport(s *Service, r *http.Request, req RPCRequest) (any, error) { report, err := s.repository.GetReport(r.Context(), params.Application, index) if err != nil { - s.Logger.Error("Unable to retrieve report from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve report from repository", err) } if report == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -1060,8 +1065,7 @@ func handleListWithdrawals(s *Service, r *http.Request, req RPCRequest) (any, er params.Descending, ) if err != nil { - s.Logger.Error("Unable to retrieve withdrawals from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve withdrawals from repository", err) } if len(withdrawals) == 0 { @@ -1101,8 +1105,7 @@ func handleGetWithdrawal(s *Service, r *http.Request, req RPCRequest) (any, erro withdrawal, err := s.repository.GetWithdrawal(r.Context(), params.Application, accountIndex) if err != nil { - s.Logger.Error("Unable to retrieve withdrawal from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve withdrawal from repository", err) } if withdrawal == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -1177,8 +1180,7 @@ func handleListTournaments(s *Service, r *http.Request, req RPCRequest) (any, er Offset: params.Offset, }, params.Descending) if err != nil { - s.Logger.Error("Unable to retrieve tournaments from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve tournaments from repository", err) } if len(tournaments) == 0 { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -1218,8 +1220,7 @@ func handleGetTournament(s *Service, r *http.Request, req RPCRequest) (any, erro tournament, err := s.repository.GetTournament(r.Context(), params.Application, params.Address) if err != nil { - s.Logger.Error("Unable to retrieve tournament from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve tournament from repository", err) } if tournament == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -1277,8 +1278,7 @@ func handleListCommitments(s *Service, r *http.Request, req RPCRequest) (any, er Offset: params.Offset, }, params.Descending) if err != nil { - s.Logger.Error("Unable to retrieve commitments from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve commitments from repository", err) } if len(commitments) == 0 { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -1329,8 +1329,7 @@ func handleGetCommitment(s *Service, r *http.Request, req RPCRequest) (any, erro commitment, err := s.repository.GetCommitment(r.Context(), params.Application, epochIndex, params.TournamentAddress, params.Commitment) if err != nil { - s.Logger.Error("Unable to retrieve commitment from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve commitment from repository", err) } if commitment == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -1388,8 +1387,7 @@ func handleListMatches(s *Service, r *http.Request, req RPCRequest) (any, error) Offset: params.Offset, }, params.Descending) if err != nil { - s.Logger.Error("Unable to retrieve matches from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve matches from repository", err) } if len(matches) == 0 { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -1437,8 +1435,7 @@ func handleGetMatch(s *Service, r *http.Request, req RPCRequest) (any, error) { match, err := s.repository.GetMatch(r.Context(), params.Application, epochIndex, params.TournamentAddress, params.IDHash) if err != nil { - s.Logger.Error("Unable to retrieve match from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve match from repository", err) } if match == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -1495,8 +1492,7 @@ func handleListMatchAdvances(s *Service, r *http.Request, req RPCRequest) (any, matchAdvances, total, err := s.repository.ListMatchAdvances(r.Context(), params.Application, epochIndex, params.TournamentAddress, params.IDHash, pagination, params.Descending) if err != nil { - s.Logger.Error("Unable to retrieve match advances from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve match advances from repository", err) } if len(matchAdvances) == 0 { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -1550,8 +1546,7 @@ func handleGetMatchAdvance(s *Service, r *http.Request, req RPCRequest) (any, er matchAdvanced, err := s.repository.GetMatchAdvanced(r.Context(), params.Application, epochIndex, params.TournamentAddress, params.IDHash, parent.Hex()[2:]) if err != nil { - s.Logger.Error("Unable to retrieve match advanced from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve match advanced from repository", err) } if matchAdvanced == nil { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -1569,8 +1564,7 @@ func handleGetNodeInfo(s *Service, r *http.Request, _ RPCRequest) (any, error) { return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "EVM Reader config not found") } if err != nil { - s.Logger.Error("Unable to retrieve evmreader config from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve evmreader config from repository", err) } return api.SingleResponse[api.NodeInfo]{Data: api.NodeInfo{ @@ -1586,8 +1580,7 @@ func handleGetChainID(s *Service, r *http.Request, _ RPCRequest) (any, error) { return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "EVM Reader config not found") } if err != nil { - s.Logger.Error("Unable to retrieve evmreader config from repository", "err", err) - return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return nil, s.repositoryError(r.Context(), "Unable to retrieve evmreader config from repository", err) } return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", config.Value.ChainID)}, nil @@ -1629,8 +1622,7 @@ func (s *Service) applicationAbsentOrError( ) error { app, err := s.repository.GetApplication(r.Context(), validatedNameOrAddress) if err != nil { - s.Logger.Error("Unable to retrieve application from repository", "err", err) - return newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") + return s.repositoryError(r.Context(), "Unable to retrieve application from repository", err) } else if app == nil { return newRPCError(JSONRPC_APPLICATION_NOT_FOUND, "Application not found") } diff --git a/internal/jsonrpc/service_test.go b/internal/jsonrpc/service_test.go index 411229a68..ca615cbd9 100644 --- a/internal/jsonrpc/service_test.go +++ b/internal/jsonrpc/service_test.go @@ -59,9 +59,10 @@ func TestJSONRPC_ServerHandlerAppliesBatchDispatchTimeout(t *testing.T) { s.dispatchTimeout = 10 * time.Millisecond const method = "test_server_dispatch_timeout" - withTestRPCHandler(t, method, func(_ *Service, r *http.Request, _ RPCRequest) (any, error) { + withTestRPCHandler(t, method, func(s *Service, r *http.Request, _ RPCRequest) (any, error) { <-r.Context().Done() - return true, nil + return nil, s.repositoryError(r.Context(), "Unable to retrieve test data from repository", + fmt.Errorf("repository query failed: %w", r.Context().Err())) }) body := []byte(fmt.Sprintf(`[ @@ -77,7 +78,7 @@ func TestJSONRPC_ServerHandlerAppliesBatchDispatchTimeout(t *testing.T) { var responses []RPCResponse require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &responses)) require.Len(t, responses, 3) - require.Nil(t, responses[0].Error) + requireRPCError(t, responses[0], float64(1), JSONRPC_TIMEOUT_ERROR) requireRPCError(t, responses[1], float64(2), JSONRPC_TIMEOUT_ERROR) requireRPCError(t, responses[2], float64(3), JSONRPC_TIMEOUT_ERROR) } From 7c6012dce69b19b123714230cf9b20f17780aea6 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:53:10 -0300 Subject: [PATCH 30/43] feat(jsonrpc): truncate long method names to avoid flooding the log - RPC method names are now capped at 64 bytes in every log path. - Truncation preserves valid UTF-8. - Single-request method visibility remains at Info. - Batch-entry method logging remains at Debug. - Updated and added tests verifying long method names are truncated and never logged in full. --- internal/jsonrpc/batchcalls_test.go | 21 +++++++++++++++++++++ internal/jsonrpc/jsonrpc.go | 26 ++++++++++++++++++++------ internal/jsonrpc/jsonrpc_test.go | 8 +++++--- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/internal/jsonrpc/batchcalls_test.go b/internal/jsonrpc/batchcalls_test.go index e51d39f3a..e9198f086 100644 --- a/internal/jsonrpc/batchcalls_test.go +++ b/internal/jsonrpc/batchcalls_test.go @@ -504,6 +504,27 @@ func TestJSONRPCBatchLoggingHasOneInfoAndDebugMethods(t *testing.T) { require.Len(t, debugMethods, entries) } +func TestJSONRPCBatchMethodLoggingIsTruncated(t *testing.T) { + s := newBatchTestService() + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + method := strings.Repeat("b", MAX_LOGGED_METHOD_LEN+32) + body := []byte(fmt.Sprintf(`[{"jsonrpc":"2.0","method":%q,"id":1}]`, method)) + serveRPC(t, s, body) + + var found bool + for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") { + var record map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &record)) + if record["method"] == truncatedMethod(method) { + require.Equal(t, "DEBUG", record["level"]) + found = true + } + require.NotEqual(t, method, record["method"]) + } + require.True(t, found) +} + func withTestRPCHandler(t *testing.T, method string, handler rpcHandler) { t.Helper() previous, existed := jsonrpcHandlers[method] diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index 502356a51..0a04a5ef6 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -14,6 +14,7 @@ import ( "math" "net/http" "reflect" + "unicode/utf8" "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/evmreader" @@ -38,6 +39,8 @@ const ( LIST_ITEM_LIMIT = 10000 //nolint: revive // Default amount of item on a list (50) LIST_ITEM_DEFAULT = 50 //nolint: revive + // Maximum number of bytes from an RPC method included in a log record. + MAX_LOGGED_METHOD_LEN = 64 //nolint: revive ) const ( @@ -115,6 +118,17 @@ var listParamsTypes = map[string]reflect.Type{ "cartesi_listMatchAdvances": reflect.TypeOf(api.ListMatchAdvancesParams{}), } +func truncatedMethod(method string) string { + if len(method) <= MAX_LOGGED_METHOD_LEN { + return method + } + method = method[:MAX_LOGGED_METHOD_LEN] + for !utf8.ValidString(method) { + method = method[:len(method)-1] + } + return method + "…(truncated)" +} + // batchExceedsListItemLimit reports whether the sum of the effective limits of // valid list entries exceeds the amount of row-fetch work allowed to one HTTP // request. It performs no handler or repository work. @@ -204,7 +218,7 @@ func (s *Service) handleRequest(w io.Writer, r *http.Request, req RPCRequest) er } fn, ok := jsonrpcHandlers[req.Method] if !ok { - s.Logger.Debug("RPC method not found", "method", req.Method) + s.Logger.Debug("RPC method not found", "method", truncatedMethod(req.Method)) return writeRPCError(w, req.ID, JSONRPC_METHOD_NOT_FOUND, "Method not found") } result, err := fn(s, r, req) @@ -215,7 +229,7 @@ func (s *Service) handleRequest(w io.Writer, r *http.Request, req RPCRequest) er return err } if errors.Is(err, context.DeadlineExceeded) && errors.Is(r.Context().Err(), context.DeadlineExceeded) { - s.Logger.Warn("RPC method dispatch timeout", "method", req.Method) + s.Logger.Warn("RPC method dispatch timeout", "method", truncatedMethod(req.Method)) return writeRPCError(w, req.ID, JSONRPC_TIMEOUT_ERROR, "Request timed out") } @@ -226,7 +240,7 @@ func (s *Service) handleRequest(w io.Writer, r *http.Request, req RPCRequest) er return writeRPCError(w, req.ID, rpcErr.Code, rpcErr.Message) } - s.Logger.Error("RPC method failed", "method", req.Method, "error", err) + s.Logger.Error("RPC method failed", "method", truncatedMethod(req.Method), "error", err) return writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error") } @@ -244,7 +258,7 @@ func (s *Service) dispatchOneRequest(w http.ResponseWriter, r *http.Request, req case errors.Is(err, io.ErrShortBuffer): return s.writeRPCError(w, req.ID, JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED, "Response size limit exceeded") default: - s.Logger.Error("RPC method response encode failed", "method", req.Method, "error", err) + s.Logger.Error("RPC method response encode failed", "method", truncatedMethod(req.Method), "error", err) return s.writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error") } } @@ -286,7 +300,7 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "application/json") - s.Logger.Info("Dispatching RPC request", "method", req.Method) + s.Logger.Info("Dispatching RPC request", "method", truncatedMethod(req.Method)) s.dispatchOneRequest(w, r, req, budgetResp) case '[': @@ -335,7 +349,7 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { if err := json.Unmarshal(rawReq, &req); err != nil { responded = s.writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request") } else { - s.Logger.Debug("Dispatching RPC request", "method", req.Method) + s.Logger.Debug("Dispatching RPC request", "method", truncatedMethod(req.Method)) responded = s.dispatchOneRequest(w, r, req, budgetResp) } } diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index da40952a9..bdd6aa701 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -4111,17 +4111,19 @@ func TestRequestMethodIsInfoLogged(t *testing.T) { var logs bytes.Buffer s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo})) - const method = "attacker_controlled_method" - serveRPC(t, s, []byte(`{"jsonrpc":"2.0","method":"attacker_controlled_method","id":1}`)) + method := strings.Repeat("a", MAX_LOGGED_METHOD_LEN+32) + body := []byte(fmt.Sprintf(`{"jsonrpc":"2.0","method":%q,"id":1}`, method)) + serveRPC(t, s, body) var methodLogs int for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") { var record map[string]any require.NoError(t, json.Unmarshal([]byte(line), &record)) - if record["method"] == method { + if record["method"] == truncatedMethod(method) { require.Equal(t, "INFO", record["level"]) methodLogs++ } + require.NotEqual(t, method, record["method"]) } require.Positive(t, methodLogs) } From 01aeb98adb48fff38509308731848d305345b5af Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:29:25 -0300 Subject: [PATCH 31/43] feat(jsonrpc): report panic errors of individual methods in a request batch - Added per-entry panic recovery around dispatchOneRequest. - Panics are logged at Error with the method, panic value, and stack trace. - The affected entry receives JSON-RPC -32603. - Its private response buffer is discarded. - Remaining batch entries continue normally. - Added regression coverage proving the batch remains valid after a middle-entry panic. --- internal/jsonrpc/batchcalls_test.go | 46 +++++++++++++++++++++++++++++ internal/jsonrpc/jsonrpc.go | 22 +++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/internal/jsonrpc/batchcalls_test.go b/internal/jsonrpc/batchcalls_test.go index e9198f086..9ed86bf34 100644 --- a/internal/jsonrpc/batchcalls_test.go +++ b/internal/jsonrpc/batchcalls_test.go @@ -434,6 +434,52 @@ func TestJSONRPCUpstreamDeadlineRemainsInternalError(t *testing.T) { require.Contains(t, logs.String(), `"level":"ERROR"`) } +func TestJSONRPCBatchRecoversPanicPerEntry(t *testing.T) { + s := newBatchTestService() + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + panicMethod := strings.Repeat("p", MAX_LOGGED_METHOD_LEN+32) + const okMethod = "test_after_panic_batch" + withTestRPCHandler(t, panicMethod, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + panic("test panic") + }) + withTestRPCHandler(t, okMethod, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + return "ok", nil + }) + + body := []byte(fmt.Sprintf(`[ + {"jsonrpc":"2.0","method":%q,"id":1}, + {"jsonrpc":"2.0","method":%q,"id":2}, + {"jsonrpc":"2.0","method":%q,"id":3} + ]`, okMethod, panicMethod, okMethod)) + rr := serveRPC(t, s, body) + + responses := decodeRPCBatch(t, rr.Body.Bytes()) + require.Len(t, responses, 3) + require.Nil(t, responses[0].Error) + requireRPCError(t, responses[1], float64(2), JSONRPC_INTERNAL_ERROR) + require.Equal(t, "Internal server error", responses[1].Error.Message) + require.Nil(t, responses[2].Error, "entries after a panic must still be dispatched") + require.Contains(t, logs.String(), "RPC method panic") + require.Contains(t, logs.String(), "test panic") + require.Contains(t, logs.String(), "goroutine", "panic log must include a stack trace") + require.Contains(t, logs.String(), truncatedMethod(panicMethod)) + require.NotContains(t, logs.String(), panicMethod) +} + +func TestJSONRPCDoesNotRecoverAbortHandler(t *testing.T) { + s := newBatchTestService() + const method = "test_abort_handler" + withTestRPCHandler(t, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + panic(http.ErrAbortHandler) + }) + + require.PanicsWithValue(t, http.ErrAbortHandler, func() { + serveRPC(t, s, []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","method":%q,"id":1}`, method))) + }) +} + func TestJSONRPCBatchUsesOneAdmissionPermit(t *testing.T) { s := newBatchTestService() s.admission = service.NewSemaphoreAdmission(1) diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index 0a04a5ef6..5f60b0a0e 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -14,6 +14,7 @@ import ( "math" "net/http" "reflect" + "runtime/debug" "unicode/utf8" "github.com/cartesi/rollups-node/internal/config" @@ -244,7 +245,26 @@ func (s *Service) handleRequest(w io.Writer, r *http.Request, req RPCRequest) er return writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error") } -func (s *Service) dispatchOneRequest(w http.ResponseWriter, r *http.Request, req RPCRequest, budgetResp *budgetWriter) bool { +func (s *Service) dispatchOneRequest( + w http.ResponseWriter, + r *http.Request, + req RPCRequest, + budgetResp *budgetWriter, +) (responded bool) { + defer func() { + if recovered := recover(); recovered != nil { + if recovered == http.ErrAbortHandler { + panic(recovered) + } + s.Logger.Error("RPC method panic", + "method", truncatedMethod(req.Method), + "panic", recovered, + "stack", string(debug.Stack()), + ) + responded = s.writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error") + } + }() + buffer := budgetResp.NewLimitedWriter() if buffer == nil { return s.writeRPCError(w, req.ID, JSONRPC_RESPONSE_SIZE_LIMIT_EXCEEDED, "Response size limit exceeded") From 4e9f329df84c1e7ca959d60bc354c665c60c0611 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:19:00 -0300 Subject: [PATCH 32/43] feat(jsonrpc): avoid overlogging decoding errors of DApps inputs/outputs - List input/output decoding failures now log per-row details at Debug instead of Error. - Each list operation emits one aggregate Warn containing: - Application - Failure count - First failing index - Malformed rows remain in responses as partial decoded structures. - Added regression coverage verifying four malformed rows produce four Debug logs, two aggregate Warns, and no Error logs. --- internal/jsonrpc/decode_logging_test.go | 64 ++++++++++++++++++++ internal/jsonrpc/jsonrpc.go | 78 +++++++++++++++++-------- 2 files changed, 118 insertions(+), 24 deletions(-) create mode 100644 internal/jsonrpc/decode_logging_test.go diff --git a/internal/jsonrpc/decode_logging_test.go b/internal/jsonrpc/decode_logging_test.go new file mode 100644 index 000000000..92d2f9cc4 --- /dev/null +++ b/internal/jsonrpc/decode_logging_test.go @@ -0,0 +1,64 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "bytes" + "encoding/json" + "log/slog" + "testing" + + "github.com/cartesi/rollups-node/internal/model" + contractinputs "github.com/cartesi/rollups-node/pkg/contracts/inputs" + contractoutputs "github.com/cartesi/rollups-node/pkg/contracts/outputs" + + "github.com/stretchr/testify/require" +) + +func TestDecodeFailuresAreAggregatedInLogs(t *testing.T) { + inputABI, err := contractinputs.InputsMetaData.GetAbi() + require.NoError(t, err) + outputABI, err := contractoutputs.OutputsMetaData.GetAbi() + require.NoError(t, err) + + var logs bytes.Buffer + s := newBatchTestService() + s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) + s.inputABI = inputABI + s.outputABI = outputABI + + decodedInputs := s.decodeInputs("app", []*model.Input{ + {Index: 7, RawData: nil}, + {Index: 9, RawData: []byte{0x01}}, + }) + decodedOutputs := s.decodeOutputs("app", []*model.Output{ + {Index: 11, RawData: nil}, + {Index: 13, RawData: []byte{0x01}}, + }) + require.Len(t, decodedInputs, 2, "malformed rows must remain in the response") + require.Len(t, decodedOutputs, 2, "malformed rows must remain in the response") + + debugCount := 0 + warns := map[string]map[string]any{} + for _, line := range bytes.Split(bytes.TrimSpace(logs.Bytes()), []byte("\n")) { + var record map[string]any + require.NoError(t, json.Unmarshal(line, &record)) + switch record["level"] { + case "DEBUG": + debugCount++ + case "WARN": + message, _ := record["msg"].(string) + warns[message] = record + case "ERROR": + t.Fatalf("decode failure was logged at Error: %s", line) + } + } + + require.Equal(t, 4, debugCount, "each malformed row needs one diagnostic Debug log") + require.Len(t, warns, 2, "inputs and outputs each need one aggregate warning") + require.Equal(t, float64(2), warns["Unable to decode Inputs"]["count"]) + require.Equal(t, float64(7), warns["Unable to decode Inputs"]["first_index"]) + require.Equal(t, float64(2), warns["Unable to decode Outputs"]["count"]) + require.Equal(t, float64(11), warns["Unable to decode Outputs"]["first_index"]) +} diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index 5f60b0a0e..4ee5055d5 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -622,6 +622,31 @@ func handleGetLastAcceptedEpochIndex(s *Service, r *http.Request, req RPCRequest return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", index)}, nil } +func (s *Service) decodeInputs(application string, inputs []*model.Input) []*api.DecodedInput { + result := make([]*api.DecodedInput, 0, len(inputs)) + failureCount := 0 + var firstFailingIndex uint64 + for _, input := range inputs { + decoded, err := api.DecodeInput(input, s.inputABI) + if err != nil { + if failureCount == 0 { + firstFailingIndex = input.Index + } + failureCount++ + s.Logger.Debug("Unable to decode Input", "app", application, "index", input.Index, "err", err) + } + result = append(result, decoded) + } + if failureCount > 0 { + s.Logger.Warn("Unable to decode Inputs", + "app", application, + "count", failureCount, + "first_index", firstFailingIndex, + ) + } + return result +} + func handleListInputs(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListInputsParams if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { @@ -690,14 +715,7 @@ func handleListInputs(s *Service, r *http.Request, req RPCRequest) (any, error) } } - resultInputs := make([]*api.DecodedInput, 0, len(inputs)) - for _, in := range inputs { - decoded, err := api.DecodeInput(in, s.inputABI) - if err != nil { - s.Logger.Error("Unable to decode Input", "app", params.Application, "index", in.Index, "err", err) - } - resultInputs = append(resultInputs, decoded) - } + resultInputs := s.decodeInputs(params.Application, inputs) return api.ListResponse[*api.DecodedInput]{ Data: resultInputs, @@ -737,10 +755,7 @@ func handleGetInput(s *Service, r *http.Request, req RPCRequest) (any, error) { return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Input not found") } - decoded, err := api.DecodeInput(input, s.inputABI) - if err != nil { - s.Logger.Error("Unable to decode Input", "app", params.Application, "index", input.Index, "err", err) - } + decoded := s.decodeInputs(params.Application, []*model.Input{input})[0] return api.SingleResponse[*api.DecodedInput]{Data: decoded}, nil } @@ -816,6 +831,31 @@ func handleGetPendingExecutableOutputCount(s *Service, r *http.Request, req RPCR return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", count)}, nil } +func (s *Service) decodeOutputs(application string, outputs []*model.Output) []*api.DecodedOutput { + result := make([]*api.DecodedOutput, 0, len(outputs)) + failureCount := 0 + var firstFailingIndex uint64 + for _, output := range outputs { + decoded, err := api.DecodeOutput(output, s.outputABI) + if err != nil { + if failureCount == 0 { + firstFailingIndex = output.Index + } + failureCount++ + s.Logger.Debug("Unable to decode Output", "app", application, "index", output.Index, "err", err) + } + result = append(result, decoded) + } + if failureCount > 0 { + s.Logger.Warn("Unable to decode Outputs", + "app", application, + "count", failureCount, + "first_index", firstFailingIndex, + ) + } + return result +} + func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) { var params api.ListOutputsParams if err := api.UnmarshalParams(req.Params, ¶ms); err != nil { @@ -897,14 +937,7 @@ func handleListOutputs(s *Service, r *http.Request, req RPCRequest) (any, error) return nil, s.repositoryError(r.Context(), "Unable to retrieve outputs from repository", err) } - resultOutputs := make([]*api.DecodedOutput, 0, len(outputs)) - for _, out := range outputs { - decoded, err := api.DecodeOutput(out, s.outputABI) - if err != nil { - s.Logger.Error("Unable to decode Output", "app", params.Application, "index", out.Index, "err", err) - } - resultOutputs = append(resultOutputs, decoded) - } + resultOutputs := s.decodeOutputs(params.Application, outputs) if len(resultOutputs) == 0 { if err := s.applicationAbsentOrError(r, params.Application); err != nil { @@ -950,10 +983,7 @@ func handleGetOutput(s *Service, r *http.Request, req RPCRequest) (any, error) { return nil, newRPCError(JSONRPC_RESOURCE_NOT_FOUND, "Output not found") } - decoded, err := api.DecodeOutput(output, s.outputABI) - if err != nil { - s.Logger.Error("Unable to decode Output", "app", params.Application, "index", output.Index, "err", err) - } + decoded := s.decodeOutputs(params.Application, []*model.Output{output})[0] return api.SingleResponse[*api.DecodedOutput]{Data: decoded}, nil } From 17f15423f5f37627d9de21bd6ae714e385c86282 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:25:29 -0300 Subject: [PATCH 33/43] fix(jsonrpc): avoid response with different ID value due to decoding errors - Request and response IDs now use json.RawMessage. - Numeric and string IDs are echoed without decoding or precision loss. - Validation still accepts omitted, null, string, and numeric IDs. - Boolean, array, and object IDs remain invalid and receive a null response ID. - Updated existing batch assertions for raw IDs. - Added exact round-trip coverage for: - 9007199254740993 - Values beyond uint64 - String IDs - Both success and error responses --- internal/jsonrpc/batchcalls_test.go | 59 +++++++++++++++++++++++------ internal/jsonrpc/jsonrpc.go | 24 ++++++++++-- internal/jsonrpc/types.go | 14 +++---- 3 files changed, 75 insertions(+), 22 deletions(-) diff --git a/internal/jsonrpc/batchcalls_test.go b/internal/jsonrpc/batchcalls_test.go index 9ed86bf34..933615bc6 100644 --- a/internal/jsonrpc/batchcalls_test.go +++ b/internal/jsonrpc/batchcalls_test.go @@ -61,11 +61,18 @@ func decodeRPCBatch(t *testing.T, body []byte) []RPCResponse { func requireRPCError(t *testing.T, response RPCResponse, id any, code int) { t.Helper() require.Equal(t, "2.0", response.JSONRPC) - require.Equal(t, id, response.ID) + require.Equal(t, id, decodeRPCID(t, response.ID)) require.NotNil(t, response.Error) require.Equal(t, code, response.Error.Code) } +func decodeRPCID(t *testing.T, id json.RawMessage) any { + t.Helper() + var decoded any + require.NoError(t, json.Unmarshal(id, &decoded)) + return decoded +} + func TestListOutputsRejectsEmptyOutputTypeList(t *testing.T) { s := newBatchTestService() rr := serveRPC(t, s, []byte(`{ @@ -139,10 +146,10 @@ func TestJSONRPCBatchMalformedElementDoesNotPoisonValidSiblings(t *testing.T) { responses := decodeRPCBatch(t, rr.Body.Bytes()) require.Len(t, responses, 3) require.Nil(t, responses[0].Error) - require.EqualValues(t, 1, responses[0].ID) + require.EqualValues(t, 1, decodeRPCID(t, responses[0].ID)) requireRPCError(t, responses[1], nil, JSONRPC_INVALID_REQUEST) require.Nil(t, responses[2].Error) - require.EqualValues(t, 3, responses[2].ID) + require.EqualValues(t, 3, decodeRPCID(t, responses[2].ID)) } func TestJSONRPCBatchStructurallyInvalidElementsDoNotPoisonValidSiblings(t *testing.T) { @@ -172,12 +179,12 @@ func TestJSONRPCBatchStructurallyInvalidElementsDoNotPoisonValidSiblings(t *test require.Len(t, responses, 3) require.Nil(t, responses[0].Error) - require.EqualValues(t, 1, responses[0].ID) + require.EqualValues(t, 1, decodeRPCID(t, responses[0].ID)) requireRPCError(t, responses[1], test.id, JSONRPC_INVALID_REQUEST) require.Nil(t, responses[2].Error) - require.EqualValues(t, 3, responses[2].ID) + require.EqualValues(t, 3, decodeRPCID(t, responses[2].ID)) }) } } @@ -217,6 +224,34 @@ func TestJSONRPCRejectsInvalidIDTypesWithNullID(t *testing.T) { } } +func TestRPCIDRoundTripsWithoutNumericPrecisionLoss(t *testing.T) { + s := newBatchTestService() + single := serveRPC(t, s, []byte(`{ + "jsonrpc":"2.0", + "method":"cartesi_getNodeVersion", + "id":9007199254740993 + }`)) + singleResponse := decodeRPCResponse(t, single.Body.Bytes()) + require.Nil(t, singleResponse.Error) + require.Equal(t, `9007199254740993`, string(singleResponse.ID)) + + rr := serveRPC(t, s, []byte(`[ + {"jsonrpc":"2.0","method":"missing","id":9007199254740993}, + {"jsonrpc":"2.0","method":"missing","id":18446744073709551616}, + {"jsonrpc":"2.0","method":"missing","id":"request-3"} + ]`)) + + responses := decodeRPCBatch(t, rr.Body.Bytes()) + require.Len(t, responses, 3) + require.Equal(t, `9007199254740993`, string(responses[0].ID)) + require.Equal(t, `18446744073709551616`, string(responses[1].ID)) + require.Equal(t, `"request-3"`, string(responses[2].ID)) + for _, response := range responses { + require.NotNil(t, response.Error) + require.Equal(t, JSONRPC_METHOD_NOT_FOUND, response.Error.Code) + } +} + func TestJSONRPCBatchNotificationsReceiveNullIDResponses(t *testing.T) { s := newBatchTestService() rr := serveRPC(t, s, []byte(`[ @@ -227,7 +262,7 @@ func TestJSONRPCBatchNotificationsReceiveNullIDResponses(t *testing.T) { require.Equal(t, http.StatusOK, rr.Code) responses := decodeRPCBatch(t, rr.Body.Bytes()) require.Len(t, responses, 2, "notifications are deliberately answered by this server") - require.Nil(t, responses[0].ID) + require.Nil(t, decodeRPCID(t, responses[0].ID)) require.Nil(t, responses[0].Error) requireRPCError(t, responses[1], nil, JSONRPC_METHOD_NOT_FOUND) } @@ -276,7 +311,7 @@ func TestJSONRPCBatchReplacesResponsesAtCumulativeResponseBudget(t *testing.T) { require.LessOrEqual(t, rr.Body.Len(), (10<<20)+testResponseBudgetSlack) for i := range responses[:testBatchSuccessCount] { require.Equal(t, "2.0", responses[i].JSONRPC) - require.Equal(t, float64(i), responses[i].ID) + require.Equal(t, float64(i), decodeRPCID(t, responses[i].ID)) require.Nil(t, responses[i].Error) require.Equal(t, responses[i].Result, largeResult) } @@ -371,8 +406,9 @@ func TestJSONRPCBatchReturnsErrorsForIDDRequestsAfterDeadline(t *testing.T) { {"jsonrpc":"2.0","method":%q}, {"jsonrpc":"2.0","method":%q,"id":"three"}, false, + {"jsonrpc":"2.0","method":%q,"id":true}, {"jsonrpc":"2.0","method":%q,"id":null} - ]`, method, method, method, method)) + ]`, method, method, method, method, method)) ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) defer cancel() req := httptest.NewRequest(http.MethodPost, "/rpc", bytes.NewReader(body)).WithContext(ctx) @@ -381,14 +417,15 @@ func TestJSONRPCBatchReturnsErrorsForIDDRequestsAfterDeadline(t *testing.T) { require.Zero(t, calls.Load(), "expired batch entries must not be dispatched") responses := decodeRPCBatch(t, rr.Body.Bytes()) - require.Len(t, responses, 5, "not all entries receive deadline errors") + require.Len(t, responses, 6, "not all entries receive deadline errors") requireRPCError(t, responses[0], float64(1), JSONRPC_TIMEOUT_ERROR) requireRPCError(t, responses[1], nil, JSONRPC_TIMEOUT_ERROR) requireRPCError(t, responses[2], "three", JSONRPC_TIMEOUT_ERROR) requireRPCError(t, responses[3], nil, JSONRPC_INVALID_REQUEST) - requireRPCError(t, responses[4], nil, JSONRPC_TIMEOUT_ERROR) + requireRPCError(t, responses[4], nil, JSONRPC_INVALID_REQUEST) + requireRPCError(t, responses[5], nil, JSONRPC_TIMEOUT_ERROR) for i, response := range responses { - if i == 3 { + if i == 3 || i == 4 { require.Equal(t, "invalid request", response.Error.Message) } else { require.Equal(t, "Request timed out", response.Error.Message) diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index 4ee5055d5..45608fd11 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -192,7 +192,7 @@ func (s *Service) writeByte(w http.ResponseWriter, c byte) bool { } // writeRPCError sends a generic error response for internal errors. -func (s *Service) writeRPCError(w http.ResponseWriter, id any, code int, message string) bool { +func (s *Service) writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, message string) bool { err := writeRPCError(w, id, code, message) return s.handleWriteResponse(err) } @@ -209,9 +209,7 @@ func (s *Service) repositoryError(ctx context.Context, message string, err error } func (s *Service) handleRequest(w io.Writer, r *http.Request, req RPCRequest) error { - switch req.ID.(type) { - case nil, string, float64: - default: + if !validRPCID(req.ID) { return writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request") } if req.JSONRPC != "2.0" || req.Method == "" { @@ -245,6 +243,22 @@ func (s *Service) handleRequest(w io.Writer, r *http.Request, req RPCRequest) er return writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error") } +func validRPCID(id json.RawMessage) bool { + id = bytes.TrimSpace(id) + if len(id) == 0 || bytes.Equal(id, []byte("null")) { + return true + } + if id[0] == '"' { + var value string + return json.Unmarshal(id, &value) == nil + } + if (id[0] >= '0' && id[0] <= '9') || id[0] == '-' { + var number json.Number + return json.Unmarshal(id, &number) == nil + } + return false +} + func (s *Service) dispatchOneRequest( w http.ResponseWriter, r *http.Request, @@ -362,6 +376,8 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { s.Logger.Warn("RPC method dispatch timeout") if err := json.Unmarshal(rawReq, &req); err != nil { responded = s.writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request") + } else if !validRPCID(req.ID) { + responded = s.writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request") } else { responded = s.writeRPCError(w, req.ID, JSONRPC_TIMEOUT_ERROR, "Request timed out") } diff --git a/internal/jsonrpc/types.go b/internal/jsonrpc/types.go index 55c3a3191..d9051b9c6 100644 --- a/internal/jsonrpc/types.go +++ b/internal/jsonrpc/types.go @@ -20,14 +20,14 @@ type RPCRequest struct { JSONRPC string `json:"jsonrpc"` Method string `json:"method"` Params json.RawMessage `json:"params"` - ID any `json:"id"` + ID json.RawMessage `json:"id"` } type RPCResponse struct { - JSONRPC string `json:"jsonrpc"` - Result any `json:"result,omitempty"` - Error *RPCError `json:"error,omitempty"` - ID any `json:"id"` + JSONRPC string `json:"jsonrpc"` + Result any `json:"result,omitempty"` + Error *RPCError `json:"error,omitempty"` + ID json.RawMessage `json:"id"` } type RPCError struct { @@ -44,7 +44,7 @@ func newRPCError(code int, message string) error { } // writeRPCError sends a generic error response for internal errors. -func writeRPCError(w io.Writer, id any, code int, message string) error { +func writeRPCError(w io.Writer, id json.RawMessage, code int, message string) error { // Hide detailed error info for internal errors. if code == JSONRPC_INTERNAL_ERROR { message = "Internal server error" @@ -60,7 +60,7 @@ func writeRPCError(w io.Writer, id any, code int, message string) error { return json.NewEncoder(w).Encode(resp) } -func writeRPCResult(w io.Writer, id any, result any) error { +func writeRPCResult(w io.Writer, id json.RawMessage, result any) error { resp := RPCResponse{ JSONRPC: "2.0", Result: result, From 726d8444eb7b088af937a683276030b216966342 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:40:01 -0300 Subject: [PATCH 34/43] docs(jsonrpc): document the potential costs of DB transversal due to large offsets - Updated docs/http-posture.md to clarify that the batch budget does not meter offset traversal. - Updated the OpenRPC x-batch-list-work-budget description with the same caveat. - Documented that traversal cost is bounded by the filtered set size, not the numeric offset, while PostgreSQL may still scan and discard matching rows. --- docs/http-posture.md | 9 ++++++--- internal/jsonrpc/jsonrpc-discover.json | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/http-posture.md b/docs/http-posture.md index 889738212..e0012c144 100644 --- a/docs/http-posture.md +++ b/docs/http-posture.md @@ -168,9 +168,12 @@ before dispatch: This restores the row-fetch bound that existed before batch support: one admission slot can fetch at most as many rows as one maximal list call. It does -not bound `COUNT(*)` cost, which is independent of `limit`; selective filters, -the pending-output partial index, proxy rate limiting, and PostgreSQL capacity -planning remain important. +not bound `COUNT(*)` cost, which is independent of `limit`. It also does not +meter `offset` traversal cost; that cost is bounded by the size of the filtered +set rather than by the numeric `offset` value, but PostgreSQL may still have to +scan and discard the matching rows before the requested page. Selective +filters, the pending-output partial index, proxy rate limiting, and PostgreSQL +capacity planning remain important. ### Rejection semantics diff --git a/internal/jsonrpc/jsonrpc-discover.json b/internal/jsonrpc/jsonrpc-discover.json index 06f99c1bf..5d72106e2 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -6,7 +6,7 @@ "x-batch-list-work-budget": { "maximum": 10000, "unit": "sum of effective limit values across list entries", - "description": "Before dispatching any entry, the server sums the effective limit of every list operation in a batch. Omitted or zero limits count as 50, and limits above 10000 count as 10000. A total above 10000 rejects the entire batch with one error response using code -31004; no entry is dispatched. This bounds row-fetch work to that of one maximal list request. It does not bound the cost of COUNT queries, so clients should still avoid broad or unnecessary list filters." + "description": "Before dispatching any entry, the server sums the effective limit of every list operation in a batch. Omitted or zero limits count as 50, and limits above 10000 count as 10000. A total above 10000 rejects the entire batch with one error response using code -31004; no entry is dispatched. This bounds row-fetch work to that of one maximal list request. It does not bound the cost of COUNT queries. It also does not meter offset traversal cost: that cost is bounded by the size of the filtered set rather than by the numeric offset value, but PostgreSQL may still scan and discard matching rows before the requested page. Clients should still avoid broad or unnecessary list filters." }, "description": "A JSON-RPC API for reading rollups data. It provides information about applications, epochs, inputs, outputs, and reports in a read-only fashion.\n\nResponse limits: every HTTP request has a 10 MB response-size budget. For a single JSON-RPC request, its response must fit within that budget. For a batch, the budget is cumulative across all entries. An entry that would exceed the remaining budget is discarded without consuming it and receives error `-31003`; the budget is then closed, so every remaining batch entry also receives `-31003`, even if its response would otherwise fit. Clients can retry an affected entry individually or in a smaller batch.\n\nBatch requests: JSON-RPC non-empty batch arrays are supported with a maximum of 100 entries per batch; batches outside that size range receive a single response with error code `-32040`. Entries execute sequentially and responses are returned in the same order as their requests. The 1 MB request-body limit applies to the whole batch array. Every batch entry receives a response. Notification suppression is not supported: entries without an ID are answered with `id: null`. This is a documented deviation from JSON-RPC 2.0, under which notifications normally produce no response. A batch response uses HTTP status 200 even when some or all of its entries are errors. Because execution is sequential and subject to the server time limit, heavy list calls should be kept outside large batches.\n\nError handling: every method documents its possible errors under `errors`, and clients can dispatch on the error code. `-31002` (application not found) means the application identifier itself is unknown to this node; for application-scoped methods, this is a configuration error that will not resolve by retrying. `-31001` (resource not found) means the requested resource does not exist in the method's scope. For application-scoped methods, `-31001` means the application is known but the nested entity is missing; for node-scoped methods, it can also report missing node resources such as EVM reader configuration. For forward-looking application resources (e.g. the next epoch, input, or output index), `-31001` is the documented \"not created yet\" signal and is safe to poll. The error message names the missing resource. `-32603` (internal error) is never used for missing resources - clients should treat it as a node-side failure and alarm or back off, not poll. `-32070` (timeout error) indicates the request was not able to be processed in the time limit available. The standard codes `-32700` (parse error), `-32600` (invalid request), and `-32601` (method not found) follow the JSON-RPC 2.0 specification." }, From d4ea032eb821597cd87d38c7f802537faa66b067 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:45:59 -0300 Subject: [PATCH 35/43] test(jsonrpc): add endpoint-level tests for 'cartesi_listOutputs' --- internal/jsonrpc/batchcalls_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/jsonrpc/batchcalls_test.go b/internal/jsonrpc/batchcalls_test.go index 933615bc6..19f3a599f 100644 --- a/internal/jsonrpc/batchcalls_test.go +++ b/internal/jsonrpc/batchcalls_test.go @@ -88,6 +88,29 @@ func TestListOutputsRejectsEmptyOutputTypeList(t *testing.T) { require.Equal(t, "Invalid output type: expected at least one selector", response.Error.Message) } +func TestListOutputsRejectsMalformedOutputType(t *testing.T) { + for name, selector := range map[string]string{ + "wrong length": "0x1234", + "non hex": "0xzzzzzzzz", + } { + t.Run(name, func(t *testing.T) { + s := newBatchTestService() + body := []byte(fmt.Sprintf(`{ + "jsonrpc":"2.0", + "method":"cartesi_listOutputs", + "params":{"application":"app","output_type":%q}, + "id":1 + }`, selector)) + rr := serveRPC(t, s, body) + + require.Equal(t, http.StatusOK, rr.Code) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, float64(1), JSONRPC_INVALID_PARAMS) + require.Contains(t, response.Error.Message, "Invalid output type") + }) + } +} + func TestJSONRPCBatchRejectsEmptyBatchWithSingleObject(t *testing.T) { s := newBatchTestService() rr := serveRPC(t, s, []byte(`[]`)) From b79a9988ce6d9220735854ad8e4e8e78c4659123 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:58:15 -0300 Subject: [PATCH 36/43] perf(jsonrpc): add DB index to improve filter of executed outputs - Added partial index output_executed_idx on input_epoch_application_id. - The index contains only rows where execution_transaction_hash IS NOT NULL. - Added the corresponding down-migration statement. --- .../schema/migrations/000001_create_initial_schema.down.sql | 1 + .../schema/migrations/000001_create_initial_schema.up.sql | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql index 6c051c030..553bd3ecf 100644 --- a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql +++ b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.down.sql @@ -40,6 +40,7 @@ DROP TABLE IF EXISTS "withdrawal"; DROP TRIGGER IF EXISTS "output_set_updated_at" ON "output"; DROP INDEX IF EXISTS "output_input_index_idx"; DROP INDEX IF EXISTS "output_pending_voucher_idx"; +DROP INDEX IF EXISTS "output_executed_idx"; DROP INDEX IF EXISTS "output_raw_data_address_idx"; DROP INDEX IF EXISTS "output_raw_data_type_idx"; DROP TABLE IF EXISTS "output"; diff --git a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql index 7516698d1..d993f57ec 100644 --- a/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql +++ b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql @@ -485,6 +485,11 @@ WHERE "execution_transaction_hash" IS NULL AND SUBSTRING("raw_data" FROM 1 FOR 4 E'\\x237a816f' -- Voucher ); +-- Serves GetNumberOfExecutedOutputs without scanning the application's full +-- output history. Outputs enter this index only after execution is observed. +CREATE INDEX "output_executed_idx" ON "output" ("input_epoch_application_id") +WHERE "execution_transaction_hash" IS NOT NULL; + CREATE TRIGGER "output_set_updated_at" BEFORE UPDATE ON "output" FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); From 55a3eb0ff7c1bea64134405b23e6b64c6526a32b Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:04:59 -0300 Subject: [PATCH 37/43] docs(jsonrpc): document wort-case memory scenario for responses The memory model now documents: - 64 MiB of JSON-RPC request buffers. - Up to 640 MiB of response buffers. - Approximately 704 MiB combined. - Additional unbounded working memory from repository rows and decoded objects materialized before response-size enforcement. --- docs/http-posture.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/http-posture.md b/docs/http-posture.md index e0012c144..d38ee3011 100644 --- a/docs/http-posture.md +++ b/docs/http-posture.md @@ -243,13 +243,21 @@ pipeline additional requests on the same connection. This behavior depends on the internal `responseWriterTap.Unwrap()` cooperating with `http.MaxBytesReader`; see the hardening v3 plan for the design note. -**Worst-case body buffer memory under saturation.** +**Worst-case request and response memory under saturation.** Each admitted request pins its body buffer for the full request lifetime -(up to `InspectMaxDeadline + 30s` for inspect (typically ~210s with the default 180s deadline), 30s for JSON-RPC). At default concurrency this -means `CARTESI_INSPECT_MAX_INFLIGHT Ă— 2 MiB = 128 MiB` for inspect and -`CARTESI_JSONRPC_MAX_INFLIGHT Ă— 1 MiB = 64 MiB` for JSON-RPC. Operators -should size process RAM headroom accordingly, on top of machine state, -database connections, and other working memory. +(up to `InspectMaxDeadline + 30s` for inspect (typically ~210s with the default +180s deadline), 30s for JSON-RPC). At default concurrency this means +`CARTESI_INSPECT_MAX_INFLIGHT Ă— 2 MiB = 128 MiB` for inspect and +`CARTESI_JSONRPC_MAX_INFLIGHT Ă— 1 MiB = 64 MiB` for JSON-RPC request bodies. + +The dominant JSON-RPC term is response buffering: each of the 64 admitted +requests has a 10 MiB response budget, for up to `64 Ă— 10 MiB = 640 MiB` of +response buffers under saturation, or approximately 704 MiB including request +bodies. The budget is enforced while encoding the response, after repository +rows and decoded response objects have already been materialized; that working +set is additional and is not bounded by the 10 MiB serialized-response limit. +Operators should size process RAM headroom accordingly, on top of machine +state, database connections, and other working memory. ## PostgreSQL pool sizing From 2cdb69bd99f2ce7713be1aa68671b971fca7ab27 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:42:58 -0300 Subject: [PATCH 38/43] test(jsonrpc): avoid different test package setup to interfere with one another - Added a PostgreSQL advisory lock held for the full test-process lifetime. - Applied it to all three schema-resetting packages: - internal/jsonrpc - internal/repository/postgres - test/validator - JSON-RPC services now clone the handler dispatch table. - Test handlers modify only their service instance, not the package-global map. - Added a regression test proving handler overrides do not leak across services. --- internal/jsonrpc/batchbudget_test.go | 4 +- internal/jsonrpc/batchcalls_test.go | 49 +++++++++++++------ internal/jsonrpc/jsonrpc.go | 10 +++- internal/jsonrpc/jsonrpc_test.go | 2 +- internal/jsonrpc/main_test.go | 28 +++++++++++ internal/jsonrpc/service.go | 2 + internal/jsonrpc/service_test.go | 2 +- .../repository/postgres/postgres_repo_test.go | 16 ++++++ test/tooling/db/db.go | 19 +++++++ test/validator/validator_test.go | 17 +++++++ 10 files changed, 128 insertions(+), 21 deletions(-) create mode 100644 internal/jsonrpc/main_test.go diff --git a/internal/jsonrpc/batchbudget_test.go b/internal/jsonrpc/batchbudget_test.go index ffe896380..84c844b79 100644 --- a/internal/jsonrpc/batchbudget_test.go +++ b/internal/jsonrpc/batchbudget_test.go @@ -103,7 +103,7 @@ func TestBatchListItemLimitNormalizesLimitsLikeHandlers(t *testing.T) { func TestJSONRPCBatchRejectsListWorkOverLimitBeforeDispatch(t *testing.T) { s := newBatchTestService() var calls atomic.Int32 - withTestRPCHandler(t, "cartesi_listApplications", func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + withTestRPCHandler(t, s, "cartesi_listApplications", func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { calls.Add(1) return true, nil }) @@ -123,7 +123,7 @@ func TestJSONRPCBatchRejectsListWorkOverLimitBeforeDispatch(t *testing.T) { func TestJSONRPCBatchAllowsListWorkAtLimit(t *testing.T) { s := newBatchTestService() var calls atomic.Int32 - withTestRPCHandler(t, "cartesi_listApplications", func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + withTestRPCHandler(t, s, "cartesi_listApplications", func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { calls.Add(1) return true, nil }) diff --git a/internal/jsonrpc/batchcalls_test.go b/internal/jsonrpc/batchcalls_test.go index 19f3a599f..c10a17bce 100644 --- a/internal/jsonrpc/batchcalls_test.go +++ b/internal/jsonrpc/batchcalls_test.go @@ -41,6 +41,7 @@ func newBatchTestService() *Service { Service: service.Service{ Logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)), }, + handlers: cloneDispatchTable(jsonrpcHandlers), } } @@ -128,7 +129,7 @@ func TestJSONRPCBatchRejectsMoreThanMaximumBeforeDispatch(t *testing.T) { s := newBatchTestService() var calls atomic.Int32 const method = "test_batch_cap" - withTestRPCHandler(t, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + withTestRPCHandler(t, s, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { calls.Add(1) return true, nil }) @@ -312,7 +313,7 @@ func TestJSONRPCBatchReplacesResponsesAtCumulativeResponseBudget(t *testing.T) { var calls atomic.Int32 const method = "test_large_batch_result" largeResult := strings.Repeat("x", testLargeResultSize) - withTestRPCHandler(t, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + withTestRPCHandler(t, s, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { calls.Add(1) return largeResult, nil }) @@ -351,7 +352,7 @@ func TestJSONRPCBatchStopsBetweenEntriesWhenContextIsCanceled(t *testing.T) { s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) ctx, cancel := context.WithCancel(context.Background()) const method = "test_cancel_batch" - withTestRPCHandler(t, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + withTestRPCHandler(t, s, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { calls.Add(1) cancel() return true, nil @@ -387,7 +388,7 @@ func TestJSONRPCBatchStopsSilentlyWhenRepositoryCallIsCanceled(t *testing.T) { s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) ctx, cancel := context.WithCancel(context.Background()) const method = "test_repository_cancel_batch" - withTestRPCHandler(t, method, func(s *Service, _ *http.Request, _ RPCRequest) (any, error) { + withTestRPCHandler(t, s, method, func(s *Service, _ *http.Request, _ RPCRequest) (any, error) { calls.Add(1) cancel() return nil, s.repositoryError(ctx, "Unable to retrieve test data from repository", @@ -419,7 +420,7 @@ func TestJSONRPCBatchReturnsErrorsForIDDRequestsAfterDeadline(t *testing.T) { s := newBatchTestService() var calls atomic.Int32 const method = "test_deadline_batch" - withTestRPCHandler(t, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + withTestRPCHandler(t, s, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { calls.Add(1) return true, nil }) @@ -462,7 +463,7 @@ func TestJSONRPCSingleRequestReturnsTimeoutWhenItsContextExpires(t *testing.T) { var logs bytes.Buffer s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) const method = "test_single_request_timeout" - withTestRPCHandler(t, method, func(s *Service, r *http.Request, _ RPCRequest) (any, error) { + withTestRPCHandler(t, s, method, func(s *Service, r *http.Request, _ RPCRequest) (any, error) { <-r.Context().Done() return nil, s.repositoryError(r.Context(), "Unable to retrieve test data from repository", fmt.Errorf("repository query failed: %w", r.Context().Err())) @@ -482,7 +483,7 @@ func TestJSONRPCUpstreamDeadlineRemainsInternalError(t *testing.T) { var logs bytes.Buffer s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) const method = "test_upstream_deadline" - withTestRPCHandler(t, method, func(s *Service, r *http.Request, _ RPCRequest) (any, error) { + withTestRPCHandler(t, s, method, func(s *Service, r *http.Request, _ RPCRequest) (any, error) { return nil, s.repositoryError(r.Context(), "Unable to retrieve test data from repository", fmt.Errorf("upstream deadline: %w", context.DeadlineExceeded)) }) @@ -500,10 +501,10 @@ func TestJSONRPCBatchRecoversPanicPerEntry(t *testing.T) { s.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})) panicMethod := strings.Repeat("p", MAX_LOGGED_METHOD_LEN+32) const okMethod = "test_after_panic_batch" - withTestRPCHandler(t, panicMethod, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + withTestRPCHandler(t, s, panicMethod, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { panic("test panic") }) - withTestRPCHandler(t, okMethod, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + withTestRPCHandler(t, s, okMethod, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { return "ok", nil }) @@ -530,7 +531,7 @@ func TestJSONRPCBatchRecoversPanicPerEntry(t *testing.T) { func TestJSONRPCDoesNotRecoverAbortHandler(t *testing.T) { s := newBatchTestService() const method = "test_abort_handler" - withTestRPCHandler(t, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + withTestRPCHandler(t, s, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { panic(http.ErrAbortHandler) }) @@ -549,7 +550,7 @@ func TestJSONRPCBatchUsesOneAdmissionPermit(t *testing.T) { } var nestedAcquisitions atomic.Int32 const method = "test_batch_admission" - withTestRPCHandler(t, method, func(s *Service, _ *http.Request, _ RPCRequest) (any, error) { + withTestRPCHandler(t, s, method, func(s *Service, _ *http.Request, _ RPCRequest) (any, error) { if s.admission.TryAcquire() { nestedAcquisitions.Add(1) s.admission.Release() @@ -631,15 +632,31 @@ func TestJSONRPCBatchMethodLoggingIsTruncated(t *testing.T) { require.True(t, found) } -func withTestRPCHandler(t *testing.T, method string, handler rpcHandler) { +func withTestRPCHandler(t *testing.T, service *Service, method string, handler rpcHandler) { t.Helper() - previous, existed := jsonrpcHandlers[method] - jsonrpcHandlers[method] = handler + previous, existed := service.handlers[method] + service.handlers[method] = handler t.Cleanup(func() { if existed { - jsonrpcHandlers[method] = previous + service.handlers[method] = previous } else { - delete(jsonrpcHandlers, method) + delete(service.handlers, method) } }) } + +func TestRPCHandlerOverridesAreServiceLocal(t *testing.T) { + first := newBatchTestService() + second := newBatchTestService() + const method = "test_service_local_handler" + withTestRPCHandler(t, first, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + return true, nil + }) + + _, firstHasHandler := first.handlers[method] + _, secondHasHandler := second.handlers[method] + _, globalHasHandler := jsonrpcHandlers[method] + require.True(t, firstHasHandler) + require.False(t, secondHasHandler) + require.False(t, globalHasHandler) +} diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index 45608fd11..eb6f81180 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -74,6 +74,14 @@ const ( type rpcHandler = func(*Service, *http.Request, RPCRequest) (any, error) type dispatchTable = map[string]rpcHandler +func cloneDispatchTable(source dispatchTable) dispatchTable { + clone := make(dispatchTable, len(source)) + for method, handler := range source { + clone[method] = handler + } + return clone +} + var jsonrpcHandlers = dispatchTable{ "rpc.discover": handleDiscover, "cartesi_listApplications": handleListApplications, @@ -215,7 +223,7 @@ func (s *Service) handleRequest(w io.Writer, r *http.Request, req RPCRequest) er if req.JSONRPC != "2.0" || req.Method == "" { return writeRPCError(w, req.ID, JSONRPC_INVALID_REQUEST, "invalid request") } - fn, ok := jsonrpcHandlers[req.Method] + fn, ok := s.handlers[req.Method] if !ok { s.Logger.Debug("RPC method not found", "method", truncatedMethod(req.Method)) return writeRPCError(w, req.ID, JSONRPC_METHOD_NOT_FOUND, "Method not found") diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index bdd6aa701..e5704418b 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -86,7 +86,7 @@ func TestJSONRPCSingleRequestReplacesResponseAtResponseBudget(t *testing.T) { const method = "test_large_single_result" largeResult := strings.Repeat("x", MAX_RESPONSE_SIZE) var called bool - withTestRPCHandler(t, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { + withTestRPCHandler(t, s, method, func(_ *Service, _ *http.Request, _ RPCRequest) (any, error) { called = true return largeResult, nil }) diff --git a/internal/jsonrpc/main_test.go b/internal/jsonrpc/main_test.go new file mode 100644 index 000000000..16a32219d --- /dev/null +++ b/internal/jsonrpc/main_test.go @@ -0,0 +1,28 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/cartesi/rollups-node/test/tooling/db" +) + +func TestMain(m *testing.M) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + os.Exit(m.Run()) + } + release, err := db.LockTestPostgres(context.Background(), endpoint) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + code := m.Run() + release() + os.Exit(code) +} diff --git a/internal/jsonrpc/service.go b/internal/jsonrpc/service.go index b20287358..3e33d6fcd 100644 --- a/internal/jsonrpc/service.go +++ b/internal/jsonrpc/service.go @@ -43,6 +43,7 @@ type Service struct { listen func(network, address string) (net.Listener, error) // OpenAPI description for JSON-RPC API loaded from 'jsonrpc-discover.json' file discoverSpec any + handlers dispatchTable // dispatchTimeout expires requests early enough to serialize a complete // timeout response before the HTTP server's write deadline. dispatchTimeout time.Duration @@ -71,6 +72,7 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { } s.repository = c.Repository + s.handlers = cloneDispatchTable(jsonrpcHandlers) if s.repository == nil { return nil, fmt.Errorf("repository on validator service Create is nil") } diff --git a/internal/jsonrpc/service_test.go b/internal/jsonrpc/service_test.go index ca615cbd9..99fcadbbf 100644 --- a/internal/jsonrpc/service_test.go +++ b/internal/jsonrpc/service_test.go @@ -59,7 +59,7 @@ func TestJSONRPC_ServerHandlerAppliesBatchDispatchTimeout(t *testing.T) { s.dispatchTimeout = 10 * time.Millisecond const method = "test_server_dispatch_timeout" - withTestRPCHandler(t, method, func(s *Service, r *http.Request, _ RPCRequest) (any, error) { + withTestRPCHandler(t, s, method, func(s *Service, r *http.Request, _ RPCRequest) (any, error) { <-r.Context().Done() return nil, s.repositoryError(r.Context(), "Unable to retrieve test data from repository", fmt.Errorf("repository query failed: %w", r.Context().Err())) diff --git a/internal/repository/postgres/postgres_repo_test.go b/internal/repository/postgres/postgres_repo_test.go index 642bacb0c..0fd18784c 100644 --- a/internal/repository/postgres/postgres_repo_test.go +++ b/internal/repository/postgres/postgres_repo_test.go @@ -6,6 +6,7 @@ package postgres_test import ( "context" "fmt" + "os" "testing" "github.com/cartesi/rollups-node/internal/model" @@ -18,6 +19,21 @@ import ( "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + os.Exit(m.Run()) + } + release, err := db.LockTestPostgres(context.Background(), endpoint) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + code := m.Run() + release() + os.Exit(code) +} + func TestPostgresRepository(t *testing.T) { endpoint, err := db.GetTestDatabaseEndpoint() if err != nil { diff --git a/test/tooling/db/db.go b/test/tooling/db/db.go index 747f0234f..a5f626ebb 100644 --- a/test/tooling/db/db.go +++ b/test/tooling/db/db.go @@ -4,12 +4,16 @@ package db import ( + "context" "fmt" "os" "github.com/cartesi/rollups-node/internal/repository/postgres/schema" + "github.com/jackc/pgx/v5" ) +const testDatabaseLockID int64 = 0x4352545349544553 // "CRTSITES" + func GetTestDatabaseEndpoint() (string, error) { endpoint, ok := os.LookupEnv("CARTESI_TEST_DATABASE_CONNECTION") if !ok { @@ -18,6 +22,21 @@ func GetTestDatabaseEndpoint() (string, error) { return endpoint, nil } +// LockTestPostgres serializes package test processes that reset the shared test +// schema. The session-level advisory lock is held until the returned connection +// closer is called. +func LockTestPostgres(ctx context.Context, endpoint string) (func(), error) { + conn, err := pgx.Connect(ctx, endpoint) + if err != nil { + return nil, fmt.Errorf("failed to connect for test database lock: %w", err) + } + if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", testDatabaseLockID); err != nil { + _ = conn.Close(context.Background()) + return nil, fmt.Errorf("failed to lock test database: %w", err) + } + return func() { _ = conn.Close(context.Background()) }, nil +} + func SetupTestPostgres(endpoint string) error { schema, err := schema.New(endpoint) diff --git a/test/validator/validator_test.go b/test/validator/validator_test.go index 08ecee778..6fa78fc50 100644 --- a/test/validator/validator_test.go +++ b/test/validator/validator_test.go @@ -5,8 +5,10 @@ package validator import ( "context" + "fmt" "log/slog" "math/big" + "os" "testing" "time" @@ -29,6 +31,21 @@ const MAX_OUTPUT_TREE_HEIGHT = merkle.TREE_DEPTH //nolint: revive const testTimeout = 300 * time.Second +func TestMain(m *testing.M) { + endpoint, err := db.GetTestDatabaseEndpoint() + if err != nil { + os.Exit(m.Run()) + } + release, err := db.LockTestPostgres(context.Background(), endpoint) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + code := m.Run() + release() + os.Exit(code) +} + type ValidatorRepositoryIntegrationSuite struct { suite.Suite ctx context.Context From 72003b4a84a6b14b97d0f97b203ca3ea7043a9a9 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:06:44 -0300 Subject: [PATCH 39/43] style(jsonrpc): avoid lint errors `revive`'s `var-naming` rule reports the package-name violation only once per package, but the diagnostic location is not tied to one stable source file. When we added `//nolint:revive` to the reported package declaration, that file was excluded from the analysis and `revive` emitted the same package-level warning against another file's `package api` or `package jsonrpc` declaration. Meanwhile, `nolintlint` saw no diagnostic on the original line and reported the directive as unused. So a source directive caused this cycle: 1. Suppress the warning in one file. 2. revive reports it against another file in the same package. 3. nolintlint reports the first suppression as unused. The `.golangci.yml` exclusions instead match the diagnostic across the entire relevant package path and only for the two precise warning texts. Other `revive` checks remain enabled: - `internal/jsonrpc/api`: permits the established `api` name. - `internal/jsonrpc`: permits the established `jsonrpc` name despite its collision with a standard-library package name. Renaming the packages would also remove the warnings, but that would require a broad, unnecessary API and import change solely to satisfy a naming preference. --- .golangci.yml | 9 +++++++++ internal/jsonrpc/api/decode.go | 10 +++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index c80e60edd..7a94ed81d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -32,6 +32,15 @@ linters: - common-false-positives - legacy - std-error-handling + rules: + - path: internal/jsonrpc/api/.*\.go + text: avoid meaningless package names + linters: + - revive + - path: internal/jsonrpc/.*\.go + text: avoid package names that conflict with Go standard library package names + linters: + - revive paths: - third_party$ - builtin$ diff --git a/internal/jsonrpc/api/decode.go b/internal/jsonrpc/api/decode.go index 3e74266c9..7160ce28c 100644 --- a/internal/jsonrpc/api/decode.go +++ b/internal/jsonrpc/api/decode.go @@ -33,7 +33,7 @@ func ParseOutputType(s string) ([]byte, error) { // EvmAdvance represents decoded EvmAdvance input data. type EvmAdvance struct { - ChainId string `json:"chain_id"` + ChainID string `json:"chain_id"` AppContract string `json:"application_contract"` MsgSender string `json:"sender"` BlockNumber string `json:"block_number"` @@ -52,7 +52,7 @@ type DecodedInput struct { // DecodeInput ABI-decodes a raw input into a DecodedInput. func DecodeInput(input *model.Input, parsedAbi *abi.ABI) (*DecodedInput, error) { decoded := make(map[string]any) - if len(input.RawData) < 4 { + if len(input.RawData) < 4 { //nolint: mnd return &DecodedInput{Input: input}, fmt.Errorf("error: input needs at least 4 bytes") } @@ -71,7 +71,7 @@ func DecodeInput(input *model.Input, parsedAbi *abi.ABI) (*DecodedInput, error) return &DecodedInput{Input: input}, err } - chainId, ok1 := decoded["chainId"].(*big.Int) + chainID, ok1 := decoded["chainId"].(*big.Int) appContract, ok2 := decoded["appContract"].(common.Address) msgSender, ok3 := decoded["msgSender"].(common.Address) blockNumber, ok4 := decoded["blockNumber"].(*big.Int) @@ -84,7 +84,7 @@ func DecodeInput(input *model.Input, parsedAbi *abi.ABI) (*DecodedInput, error) } evmAdvance := EvmAdvance{ - ChainId: fmt.Sprintf("0x%x", chainId), + ChainID: fmt.Sprintf("0x%x", chainID), AppContract: appContract.Hex(), MsgSender: msgSender.Hex(), BlockNumber: fmt.Sprintf("0x%x", blockNumber), @@ -171,7 +171,7 @@ type DecodedOutput struct { // DecodeOutput ABI-decodes a raw output into a DecodedOutput. func DecodeOutput(output *model.Output, parsedAbi *abi.ABI) (*DecodedOutput, error) { decodedOutput := &DecodedOutput{Output: output} - if len(output.RawData) < 4 { + if len(output.RawData) < 4 { //nolint: mnd return decodedOutput, fmt.Errorf("raw data too short") } method, err := parsedAbi.MethodById(output.RawData[:4]) From a89cd03185c4eb2640cd560d4e5b975b74d22a12 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:47:10 -0300 Subject: [PATCH 40/43] style(cli): avoid lint errors --- .../execution_parameters.go | 21 +++++--- cmd/cartesi-rollups-cli/root/app/list/list.go | 4 +- .../root/app/register/register.go | 9 ++-- .../root/app/remove/remove.go | 4 +- .../root/app/status/status.go | 10 +++- .../root/contract/contract.go | 2 +- .../root/db/check/check.go | 6 +-- cmd/cartesi-rollups-cli/root/db/init/init.go | 6 +-- .../root/deploy/application.go | 50 ++++++++++--------- .../root/deploy/authority.go | 9 ++-- cmd/cartesi-rollups-cli/root/deploy/deploy.go | 12 ++--- cmd/cartesi-rollups-cli/root/deploy/quorum.go | 4 +- .../root/deposit/deposit.go | 2 +- .../root/execute/execute.go | 9 ++-- .../root/foreclose/foreclose.go | 4 +- .../root/provedriveroot/provedriveroot.go | 2 +- .../root/read/commitments/commitments.go | 10 ++-- .../root/read/epochs/epochs.go | 7 +-- .../root/read/inputs/inputs.go | 7 +-- .../root/read/matchadvances/matchadvances.go | 8 +-- .../root/read/matches/matches.go | 10 ++-- .../root/read/outputs/outputs.go | 8 +-- cmd/cartesi-rollups-cli/root/read/read.go | 2 +- .../root/read/reports/reports.go | 7 +-- .../root/read/service/types.go | 11 ++-- .../root/read/tournaments/tournaments.go | 8 +-- .../root/read/withdrawals/withdrawals.go | 7 +-- cmd/cartesi-rollups-cli/root/send/send.go | 7 +-- .../root/validate/validate.go | 6 ++- .../root/withdraw/withdraw.go | 2 +- cmd/cartesi-rollups-cli/util/util.go | 2 +- cmd/cartesi-rollups-cli/util/util_test.go | 2 +- 32 files changed, 146 insertions(+), 112 deletions(-) diff --git a/cmd/cartesi-rollups-cli/root/app/execution-parameters/execution_parameters.go b/cmd/cartesi-rollups-cli/root/app/execution-parameters/execution_parameters.go index 9f6330d34..526af11dc 100644 --- a/cmd/cartesi-rollups-cli/root/app/execution-parameters/execution_parameters.go +++ b/cmd/cartesi-rollups-cli/root/app/execution-parameters/execution_parameters.go @@ -72,7 +72,7 @@ func init() { } -func run(cmd *cobra.Command, args []string) { +func run(cmd *cobra.Command, _ []string) { // If no subcommand is provided, show help err := cmd.Help() cobra.CheckErr(err) @@ -82,7 +82,7 @@ func run(cmd *cobra.Command, args []string) { var getCmd = &cobra.Command{ Use: "get [application] [parameter]", Short: "Get a specific configuration parameter", - Args: cobra.ExactArgs(2), // nolint: mnd + Args: cobra.ExactArgs(2), //nolint:mnd Run: runGet, Long: ` Supported Environment Variables: @@ -93,7 +93,7 @@ Supported Environment Variables: var setCmd = &cobra.Command{ Use: "set [application] [parameter] [value]", Short: "Set a specific configuration parameter", - Args: cobra.ExactArgs(3), // nolint: mnd + Args: cobra.ExactArgs(3), //nolint:mnd Run: runSet, Long: ` Supported Environment Variables: @@ -157,7 +157,8 @@ func runGet(cmd *cobra.Command, args []string) { cobra.CheckErr(err) if app == nil { fmt.Fprintf(os.Stderr, "application %q not found\n", nameOrAddress) - os.Exit(1) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. } params, err := repo.GetExecutionParameters(ctx, app.ID) @@ -194,7 +195,8 @@ func runSet(cmd *cobra.Command, args []string) { cobra.CheckErr(err) if app == nil { fmt.Fprintf(os.Stderr, "application %q not found\n", nameOrAddress) - os.Exit(1) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. } params, err := repo.GetExecutionParameters(ctx, app.ID) @@ -230,7 +232,8 @@ func runList(cmd *cobra.Command, args []string) { cobra.CheckErr(err) if app == nil { fmt.Fprintf(os.Stderr, "application %q not found\n", nameOrAddress) - os.Exit(1) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. } params, err := repo.GetExecutionParameters(ctx, app.ID) @@ -256,7 +259,8 @@ func runDump(cmd *cobra.Command, args []string) { cobra.CheckErr(err) if app == nil { fmt.Fprintf(os.Stderr, "application %q not found\n", nameOrAddress) - os.Exit(1) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. } params, err := repo.GetExecutionParameters(ctx, app.ID) @@ -284,7 +288,8 @@ func runLoad(cmd *cobra.Command, args []string) { cobra.CheckErr(err) if app == nil { fmt.Fprintf(os.Stderr, "application %q not found\n", nameOrAddress) - os.Exit(1) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. } // Read JSON from stdin with size limit to prevent memory exhaustion diff --git a/cmd/cartesi-rollups-cli/root/app/list/list.go b/cmd/cartesi-rollups-cli/root/app/list/list.go index 7c535ca6d..b520248cb 100644 --- a/cmd/cartesi-rollups-cli/root/app/list/list.go +++ b/cmd/cartesi-rollups-cli/root/app/list/list.go @@ -1,7 +1,7 @@ // (c) Cartesi and individual authors (see AUTHORS) // SPDX-License-Identifier: Apache-2.0 (see LICENSE) -package list +package list //nolint:revive // The package implements the CLI's "list" subcommand. import ( "encoding/json" @@ -36,7 +36,7 @@ func init() { }) } -func run(cmd *cobra.Command, args []string) { +func run(cmd *cobra.Command, _ []string) { ctx := cmd.Context() dsn, err := config.GetDatabaseConnection() diff --git a/cmd/cartesi-rollups-cli/root/app/register/register.go b/cmd/cartesi-rollups-cli/root/app/register/register.go index a2122f60d..73db034ff 100644 --- a/cmd/cartesi-rollups-cli/root/app/register/register.go +++ b/cmd/cartesi-rollups-cli/root/app/register/register.go @@ -82,7 +82,7 @@ func init() { "Application template hash. (DO NOT USE IN PRODUCTION)\nThis value is retrieved from the application contract", ) - Cmd.Flags().Uint64VarP(&epochLength, "epoch-length", "e", 0, // nolint: mnd + Cmd.Flags().Uint64VarP(&epochLength, "epoch-length", "e", 0, "Consensus Epoch length. (DO NOT USE IN PRODUCTION)\nThis value is retrieved from the consensus contract", ) @@ -121,7 +121,7 @@ func init() { }) } -func run(cmd *cobra.Command, args []string) { +func run(cmd *cobra.Command, _ []string) { ctx := cmd.Context() validName, err := config.ToApplicationNameFromString(name) @@ -181,7 +181,8 @@ func run(cmd *cobra.Command, args []string) { if err != nil { fmt.Fprintf(os.Stderr, "Failed to get epoch length from consensus: %v\n", cli.DecorateRevert(err, iconsensus.IConsensusMetaData)) - os.Exit(1) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. } } @@ -266,7 +267,7 @@ func run(cmd *cobra.Command, args []string) { if executionParametersFileParam == "-" { filePath = os.Stdin.Name() } - contents, err := os.ReadFile(filePath) + contents, err := os.ReadFile(filePath) //nolint:gosec // The CLI user explicitly supplies this path. cobra.CheckErr(err) decoder := json.NewDecoder(strings.NewReader(string(contents))) diff --git a/cmd/cartesi-rollups-cli/root/app/remove/remove.go b/cmd/cartesi-rollups-cli/root/app/remove/remove.go index 79ebc7800..ef9ae2a49 100644 --- a/cmd/cartesi-rollups-cli/root/app/remove/remove.go +++ b/cmd/cartesi-rollups-cli/root/app/remove/remove.go @@ -62,11 +62,13 @@ func run(cmd *cobra.Command, args []string) { cobra.CheckErr(err) if app == nil { fmt.Fprintf(os.Stderr, "application %q not found\n", nameOrAddress) - os.Exit(1) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. } if app.Enabled { fmt.Fprintf(os.Stderr, "Error: Application %s has enabled=true. Must disable it first\n", app.Name) + repo.Close() os.Exit(1) } diff --git a/cmd/cartesi-rollups-cli/root/app/status/status.go b/cmd/cartesi-rollups-cli/root/app/status/status.go index 484f7e521..7ba3482d5 100644 --- a/cmd/cartesi-rollups-cli/root/app/status/status.go +++ b/cmd/cartesi-rollups-cli/root/app/status/status.go @@ -22,7 +22,7 @@ var Cmd = &cobra.Command{ Use: "status [app-name-or-address] [new-status]", Short: "Display application status or set the enabled flag", Example: examples, - Args: cobra.RangeArgs(1, 2), // nolint: mnd + Args: cobra.RangeArgs(1, 2), //nolint:mnd Run: run, Long: ` Supported Environment Variables: @@ -67,7 +67,8 @@ func run(cmd *cobra.Command, args []string) { cobra.CheckErr(err) if app == nil { fmt.Fprintf(os.Stderr, "application %q not found\n", nameOrAddress) - os.Exit(1) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. } // If no new status is provided, display the current status, operator @@ -93,6 +94,7 @@ func run(cmd *cobra.Command, args []string) { fmt.Printf("Accounts drive merkle root: %s\n", app.AccountsDriveMerkleRoot.Hex()) } } + repo.Close() os.Exit(0) } @@ -107,11 +109,13 @@ func run(cmd *cobra.Command, args []string) { targetEnabled = false default: fmt.Fprintf(os.Stderr, "Error: Invalid status %q. Valid values are 'enabled' or 'disabled'\n", newStatus) + repo.Close() os.Exit(1) } if app.Enabled == targetEnabled && (app.Status != model.ApplicationStatus_Failed || !targetEnabled) { fmt.Printf("Application %s enabled flag is already %t\n", app.Name, app.Enabled) + repo.Close() os.Exit(0) } @@ -126,10 +130,12 @@ func run(cmd *cobra.Command, args []string) { confirmed, err := cli.ConfirmPrompt("Proceed?") if err != nil { fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err) + repo.Close() os.Exit(1) } if !confirmed { fmt.Println("Aborted.") + repo.Close() os.Exit(0) } } diff --git a/cmd/cartesi-rollups-cli/root/contract/contract.go b/cmd/cartesi-rollups-cli/root/contract/contract.go index 3e7ceb540..1c0420e70 100644 --- a/cmd/cartesi-rollups-cli/root/contract/contract.go +++ b/cmd/cartesi-rollups-cli/root/contract/contract.go @@ -161,7 +161,7 @@ func computeIConsensusV3InterfaceID() [4]byte { if !ok { panic(fmt.Errorf("computeIConsensusV3InterfaceID: method %q not found in IConsensus ABI", name)) } - if len(m.ID) != 4 { + if len(m.ID) != 4 { //nolint:mnd // ABI method selectors are exactly four bytes. panic(fmt.Errorf("computeIConsensusV3InterfaceID: method %q selector is %d bytes, expected 4", name, len(m.ID))) } for i := range 4 { diff --git a/cmd/cartesi-rollups-cli/root/db/check/check.go b/cmd/cartesi-rollups-cli/root/db/check/check.go index 1855afdc5..69da6b658 100644 --- a/cmd/cartesi-rollups-cli/root/db/check/check.go +++ b/cmd/cartesi-rollups-cli/root/db/check/check.go @@ -30,7 +30,7 @@ func init() { }) } -func run(cmd *cobra.Command, args []string) { +func run(_ *cobra.Command, _ []string) { dsnURL, err := config.GetDatabaseConnection() cobra.CheckErr(err) @@ -40,13 +40,13 @@ func run(cmd *cobra.Command, args []string) { if err == nil { break } - if i == 4 { // nolint: mnd + if i == 4 { //nolint:mnd fmt.Fprintf(os.Stderr, "Failed to connect to database. (%s)\n", dsnURL) os.Exit(1) } fmt.Fprintf(os.Stderr, "Connection to database failed. Trying again... (%s)\n", dsnURL) // wait before retrying - time.Sleep(5 * time.Second) // nolint: mnd + time.Sleep(5 * time.Second) //nolint:mnd } defer s.Close() diff --git a/cmd/cartesi-rollups-cli/root/db/init/init.go b/cmd/cartesi-rollups-cli/root/db/init/init.go index 809790507..38399feaa 100644 --- a/cmd/cartesi-rollups-cli/root/db/init/init.go +++ b/cmd/cartesi-rollups-cli/root/db/init/init.go @@ -35,7 +35,7 @@ func init() { }) } -func run(cmd *cobra.Command, args []string) { +func run(_ *cobra.Command, _ []string) { var s *schema.Schema var err error @@ -47,13 +47,13 @@ func run(cmd *cobra.Command, args []string) { if err == nil { break } - if i == 4 { // nolint: mnd + if i == 4 { //nolint:mnd fmt.Fprintf(os.Stderr, "Failed to connect to database. (%s)\n", dsnURL) os.Exit(1) } fmt.Fprintf(os.Stderr, "Connection to database failed. Trying again... (%s)\n", dsnURL) // wait before retrying - time.Sleep(5 * time.Second) // nolint: mnd + time.Sleep(5 * time.Second) //nolint:mnd } defer s.Close() diff --git a/cmd/cartesi-rollups-cli/root/deploy/application.go b/cmd/cartesi-rollups-cli/root/deploy/application.go index 991a3b538..ec26b27f0 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/application.go +++ b/cmd/cartesi-rollups-cli/root/deploy/application.go @@ -47,7 +47,7 @@ var applicationCmd = &cobra.Command{ Short: "Deploy a new application and register it into the database", Args: func(cmd *cobra.Command, args []string) error { - if !(0 <= len(args) && len(args) <= 2) { + if len(args) > 2 { //nolint:mnd // The command accepts at most two positional arguments. return fmt.Errorf("error on argument count. Expected at most two positional arguments") } return cobra.OnlyValidArgs(cmd, args) @@ -64,6 +64,7 @@ Supported Environment Variables: CARTESI_CONTRACTS_DAVE_APP_FACTORY_ADDRESS Dave Application Factory address`, } +//nolint:lll // Long CLI examples are kept copy-pasteable. const applicationExamples = ` # deploy both application and authority contracts together via self hosted application contract, then register the application - cartesi-rollups-cli deploy application echo-dapp applications/echo-dapp/ @@ -142,17 +143,17 @@ func runDeployApplication(cmd *cobra.Command, args []string) { client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) cobra.CheckErr(err) - chainId, err := client.ChainID(ctx) + chainID, err := client.ChainID(ctx) cobra.CheckErr(err) - txOpts, err := cli.GetTransactOpts(ctx, chainId) + txOpts, err := cli.GetTransactOpts(ctx, chainID) cobra.CheckErr(err) // pre deployment checks if len(args) >= 1 { applicationName = args[0] } - if len(args) >= 2 { + if len(args) >= 2 { //nolint:mnd // The optional second argument is the template path. templateURI = args[1] } @@ -162,6 +163,7 @@ func runDeployApplication(cmd *cobra.Command, args []string) { cobra.CheckErr(err) dsn, err := config.GetDatabaseConnection() + cobra.CheckErr(err) repo, err := factory.NewRepositoryFromConnectionString(ctx, dsn.Raw()) cobra.CheckErr(err) defer repo.Close() @@ -170,7 +172,7 @@ func runDeployApplication(cmd *cobra.Command, args []string) { cobra.CheckErr(err) if applicationInUse != nil { - cobra.CheckErr(fmt.Errorf("application name is already in use: %v.", applicationInUse.Name)) + cobra.CheckErr(fmt.Errorf("application name is already in use: %v", applicationInUse.Name)) } } @@ -203,7 +205,7 @@ func runDeployApplication(cmd *cobra.Command, args []string) { if executionParametersFileParam == "-" { filePath = os.Stdin.Name() } - contents, err := os.ReadFile(filePath) + contents, err := os.ReadFile(filePath) //nolint:gosec // The CLI user explicitly supplies this path. cobra.CheckErr(err) decoder := json.NewDecoder(strings.NewReader(string(contents))) @@ -227,7 +229,7 @@ func runDeployApplication(cmd *cobra.Command, args []string) { cobra.CheckErr(err) if len(data) == 0 { - cobra.CheckErr(fmt.Errorf("No code at the factory address: %v", factoryAddress)) + cobra.CheckErr(fmt.Errorf("no code at the factory address: %v", factoryAddress)) } if verboseParam { fmt.Fprint(os.Stderr, "success\n") @@ -380,7 +382,7 @@ func buildSelfhostedApplicationDeployment( } if !cmd.Flags().Changed("template-hash") { - if len(args) >= 2 { // args[1] is mandatory if `template-hash` was absent + if len(args) >= 2 { //nolint:mnd // args[1] is mandatory if `template-hash` was absent request.TemplateHash, err = util.ReadRootHash(args[1]) } else { err = fmt.Errorf("missing argument. One of `template-path` or `template-hash` is required") @@ -392,26 +394,26 @@ func buildSelfhostedApplicationDeployment( return nil, fmt.Errorf("error on parameter template-hash: %w", err) } + var dataAvailabilityErr error if !cmd.Flags().Changed("data-availability") { - inputBoxAddress := common.Address{} - inputBoxAddress, err = config.GetContractsInputBoxAddress() + inputBoxAddress, err := config.GetContractsInputBoxAddress() if err != nil { return nil, fmt.Errorf("error on parameter data-availability: %w", err) } - request.InputBoxAddress, request.IInputBoxBlock, request.DataAvailability, err = + request.InputBoxAddress, request.IInputBoxBlock, request.DataAvailability, dataAvailabilityErr = ethutil.DefaultDA(client, inputBoxAddress) } else { - request.InputBoxAddress, request.IInputBoxBlock, request.DataAvailability, err = + request.InputBoxAddress, request.IInputBoxBlock, request.DataAvailability, dataAvailabilityErr = ethutil.CustomDA(client, applicationDataAvailabilityParam) } - if err != nil { - return nil, fmt.Errorf("error on parameter data-availability: %w", err) + if dataAvailabilityErr != nil { + return nil, fmt.Errorf("error on parameter data-availability: %w", dataAvailabilityErr) } // ensure there is a contract deployed at the input box address code, err := client.CodeAt(ctx, request.InputBoxAddress, nil) if err != nil { - return nil, fmt.Errorf("failed to probe input box address for contract: %v\n", err) + return nil, fmt.Errorf("failed to probe input box address for contract: %v", err) } if len(code) == 0 { return nil, fmt.Errorf("error input box address has no code: %v", request.InputBoxAddress) @@ -456,7 +458,7 @@ func buildApplicationOnlyDeployment( } if !cmd.Flags().Changed("template-hash") { - if len(args) >= 2 { // args[1] is mandatory if `template-hash` was absent + if len(args) >= 2 { //nolint:mnd // args[1] is mandatory if `template-hash` was absent request.TemplateHash, err = util.ReadRootHash(args[1]) } else { err = fmt.Errorf("missing argument. One of `template-path` or `template-hash` is required") @@ -477,26 +479,26 @@ func buildApplicationOnlyDeployment( return nil, fmt.Errorf("error on parameter application-owner: %w", err) } + var dataAvailabilityErr error if !cmd.Flags().Changed("data-availability") { - inputBoxAddress := common.Address{} - inputBoxAddress, err = config.GetContractsInputBoxAddress() + inputBoxAddress, err := config.GetContractsInputBoxAddress() if err != nil { return nil, fmt.Errorf("error on parameter data-availability: %w", err) } - request.InputBoxAddress, request.IInputBoxBlock, request.DataAvailability, err = + request.InputBoxAddress, request.IInputBoxBlock, request.DataAvailability, dataAvailabilityErr = ethutil.DefaultDA(client, inputBoxAddress) } else { - request.InputBoxAddress, request.IInputBoxBlock, request.DataAvailability, err = + request.InputBoxAddress, request.IInputBoxBlock, request.DataAvailability, dataAvailabilityErr = ethutil.CustomDA(client, applicationDataAvailabilityParam) } - if err != nil { - return nil, fmt.Errorf("error on parameter data-availability: %w", err) + if dataAvailabilityErr != nil { + return nil, fmt.Errorf("error on parameter data-availability: %w", dataAvailabilityErr) } // ensure there is a contract deployed at the input box address code, err := client.CodeAt(ctx, request.InputBoxAddress, nil) if err != nil { - return nil, fmt.Errorf("failed to probe input box address for contract: %v\n", err) + return nil, fmt.Errorf("failed to probe input box address for contract: %v", err) } if len(code) == 0 { return nil, fmt.Errorf("error input box address has no code: %v", request.InputBoxAddress) @@ -544,7 +546,7 @@ func buildPrtApplicationDeployment( } if !cmd.Flags().Changed("template-hash") { - if len(args) >= 2 { // args[1] is mandatory if `template-hash` was absent + if len(args) >= 2 { //nolint:mnd // args[1] is mandatory if `template-hash` was absent request.TemplateHash, err = util.ReadRootHash(args[1]) } else { err = fmt.Errorf("missing argument. One of `template-path` or `template-hash` is required") diff --git a/cmd/cartesi-rollups-cli/root/deploy/authority.go b/cmd/cartesi-rollups-cli/root/deploy/authority.go index 9955cb93b..8a00f22e6 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/authority.go +++ b/cmd/cartesi-rollups-cli/root/deploy/authority.go @@ -33,6 +33,7 @@ Supported Environment Variables: CARTESI_CONTRACTS_AUTHORITY_FACTORY_ADDRESS Authority Factory Address`, } +//nolint:lll // Long CLI examples are kept copy-pasteable. const authorityExamples = ` # deploy a new authority contract - cli deploy authority @@ -58,7 +59,7 @@ func init() { }) } -func runDeployAuthority(cmd *cobra.Command, args []string) { +func runDeployAuthority(cmd *cobra.Command, _ []string) { var err error ctx := cmd.Context() @@ -69,10 +70,10 @@ func runDeployAuthority(cmd *cobra.Command, args []string) { client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) cobra.CheckErr(err) - chainId, err := client.ChainID(ctx) + chainID, err := client.ChainID(ctx) cobra.CheckErr(err) - txOpts, err := cli.GetTransactOpts(ctx, chainId) + txOpts, err := cli.GetTransactOpts(ctx, chainID) cobra.CheckErr(err) deployment, err := buildAuthorityDeployment(cmd, txOpts) @@ -94,7 +95,7 @@ func runDeployAuthority(cmd *cobra.Command, args []string) { cobra.CheckErr(err) if len(data) == 0 { - cobra.CheckErr(fmt.Errorf("No code at the factory address: %v", factoryAddress)) + cobra.CheckErr(fmt.Errorf("no code at the factory address: %v", factoryAddress)) } if verboseParam { fmt.Fprint(os.Stderr, "success\n") diff --git a/cmd/cartesi-rollups-cli/root/deploy/deploy.go b/cmd/cartesi-rollups-cli/root/deploy/deploy.go index 1c77e1e4d..1d575ef3a 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/deploy.go +++ b/cmd/cartesi-rollups-cli/root/deploy/deploy.go @@ -27,9 +27,9 @@ var Cmd = &cobra.Command{ } func init() { - Cmd.PersistentFlags().Uint64VarP(&epochLengthParam, "epoch-length", "", 10, // nolint: mnd + Cmd.PersistentFlags().Uint64VarP(&epochLengthParam, "epoch-length", "", 10, //nolint:mnd "Epoch length") - Cmd.PersistentFlags().MarkHidden("epoch-length") + cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("epoch-length")) Cmd.PersistentFlags().Uint64Var(&claimStagingPeriodParam, "claim-staging-period", 0, "Number of blocks between a claim being submitted and accepted (Authority/Quorum only)") Cmd.PersistentFlags().StringVar(&withdrawalConfigParam, "withdrawal-config", "", @@ -39,20 +39,20 @@ func init() { "Path to a JSON file describing the WithdrawalConfig. Mutually exclusive with --withdrawal-config.") Cmd.PersistentFlags().StringVar(&saltParam, "salt", "0000000000000000000000000000000000000000000000000000000000000000", "Salt value for contract deployment") - Cmd.PersistentFlags().MarkHidden("salt") + cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("salt")) Cmd.PersistentFlags().BoolVarP(&asJSONParam, "json", "", false, "Print results as JSON") - Cmd.PersistentFlags().MarkHidden("json") + cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("json")) Cmd.PersistentFlags().BoolVarP(&verboseParam, "verbose", "", false, "Print extra information") - Cmd.PersistentFlags().MarkHidden("verbose") + cobra.CheckErr(Cmd.PersistentFlags().MarkHidden("verbose")) Cmd.AddCommand(applicationCmd) Cmd.AddCommand(authorityCmd) Cmd.AddCommand(quorumCmd) } -func run(cmd *cobra.Command, args []string) { +func run(cmd *cobra.Command, _ []string) { // If no subcommand is provided, show help err := cmd.Help() cobra.CheckErr(err) diff --git a/cmd/cartesi-rollups-cli/root/deploy/quorum.go b/cmd/cartesi-rollups-cli/root/deploy/quorum.go index 698125248..185cae245 100644 --- a/cmd/cartesi-rollups-cli/root/deploy/quorum.go +++ b/cmd/cartesi-rollups-cli/root/deploy/quorum.go @@ -58,7 +58,7 @@ func init() { }) } -func runDeployQuorum(cmd *cobra.Command, args []string) { +func runDeployQuorum(cmd *cobra.Command, _ []string) { var err error ctx := cmd.Context() @@ -92,7 +92,7 @@ func runDeployQuorum(cmd *cobra.Command, args []string) { cobra.CheckErr(err) if len(data) == 0 { - cobra.CheckErr(fmt.Errorf("No code at the factory address: %v", factoryAddress)) + cobra.CheckErr(fmt.Errorf("no code at the factory address: %v", factoryAddress)) } if verboseParam { fmt.Fprint(os.Stderr, "success\n") diff --git a/cmd/cartesi-rollups-cli/root/deposit/deposit.go b/cmd/cartesi-rollups-cli/root/deposit/deposit.go index cbaac1c31..536717419 100644 --- a/cmd/cartesi-rollups-cli/root/deposit/deposit.go +++ b/cmd/cartesi-rollups-cli/root/deposit/deposit.go @@ -217,7 +217,7 @@ func parseAmount(value string) (*big.Int, error) { } } else { var ok bool - amount, ok = new(big.Int).SetString(value, 10) + amount, ok = new(big.Int).SetString(value, 10) //nolint:mnd // User-facing amounts are decimal. if !ok { return nil, fmt.Errorf("invalid amount %q", value) } diff --git a/cmd/cartesi-rollups-cli/root/execute/execute.go b/cmd/cartesi-rollups-cli/root/execute/execute.go index 451abe678..f9891c60c 100644 --- a/cmd/cartesi-rollups-cli/root/execute/execute.go +++ b/cmd/cartesi-rollups-cli/root/execute/execute.go @@ -22,7 +22,7 @@ var Cmd = &cobra.Command{ Use: "execute [app-name-or-address] [output-index]", Short: "Executes a voucher", Example: examples, - Args: cobra.ExactArgs(2), // nolint: mnd + Args: cobra.ExactArgs(2), //nolint:mnd Run: run, Long: ` Supported Environment Variables: @@ -81,7 +81,8 @@ func run(cmd *cobra.Command, args []string) { if output == nil { fmt.Fprintf(os.Stderr, "The output with index %d was not found in the database\n", outputIndex) - os.Exit(1) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. } app, err := repo.GetApplication(ctx, nameOrAddress) @@ -95,10 +96,10 @@ func run(cmd *cobra.Command, args []string) { client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) cobra.CheckErr(err) - chainId, err := client.ChainID(ctx) + chainID, err := client.ChainID(ctx) cobra.CheckErr(err) - txOpts, err := cli.GetTransactOpts(ctx, chainId) + txOpts, err := cli.GetTransactOpts(ctx, chainID) cobra.CheckErr(err) if !skipConfirmation { diff --git a/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go b/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go index 2917e4e8b..cd63eb2fd 100644 --- a/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go +++ b/cmd/cartesi-rollups-cli/root/foreclose/foreclose.go @@ -86,10 +86,10 @@ func run(cmd *cobra.Command, args []string) { client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) cobra.CheckErr(err) - chainId, err := client.ChainID(ctx) + chainID, err := client.ChainID(ctx) cobra.CheckErr(err) - txOpts, err := cli.GetTransactOpts(ctx, chainId) + txOpts, err := cli.GetTransactOpts(ctx, chainID) cobra.CheckErr(err) appContract, err := iapplication.NewIApplication(appAddr, client) diff --git a/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go b/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go index 3a2714e5d..05a995b9a 100644 --- a/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go +++ b/cmd/cartesi-rollups-cli/root/provedriveroot/provedriveroot.go @@ -146,7 +146,7 @@ func run(cmd *cobra.Command, args []string) { } func loadProof(path string) ([32]byte, [][32]byte, error) { - raw, err := os.ReadFile(path) //nolint:gosec + raw, err := os.ReadFile(path) if err != nil { return [32]byte{}, nil, fmt.Errorf("read proof file %s: %w", path, err) } diff --git a/cmd/cartesi-rollups-cli/root/read/commitments/commitments.go b/cmd/cartesi-rollups-cli/root/read/commitments/commitments.go index 5f6a31939..c0417fd10 100644 --- a/cmd/cartesi-rollups-cli/root/read/commitments/commitments.go +++ b/cmd/cartesi-rollups-cli/root/read/commitments/commitments.go @@ -35,6 +35,7 @@ Supported Environment Variables: CARTESI_DATABASE_CONNECTION Database connection string`, } +//nolint:lll // Long CLI examples are kept copy-pasteable. const examples = `# Read specific commitment: cartesi-rollups-cli read commitments echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 @@ -75,8 +76,8 @@ func init() { origHelpFunc(command, strings) }) - Cmd.PreRunE = func(cmd *cobra.Command, args []string) error { - if len(args) > 1 && len(args) < 4 { //nolint: mnd + Cmd.PreRunE = func(_ *cobra.Command, args []string) error { + if len(args) > 1 && len(args) < 4 { return fmt.Errorf( "expected 1 argument (list) or 4 arguments (get), got %d", len(args)) } @@ -101,7 +102,7 @@ func run(cmd *cobra.Command, args []string) { defer readServ.Close() var result json.RawMessage - if len(args) >= 4 { + if len(args) >= 4 { //nolint:mnd // Four positional arguments select the get operation. var params api.GetCommitmentParams params.Application = args[0] params.EpochIndex, err = config.AsHexString(args[1]) @@ -138,5 +139,6 @@ func run(cmd *cobra.Command, args []string) { cobra.CheckErr(err) out.WriteString("\n") - out.WriteTo(os.Stdout) + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) } diff --git a/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go b/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go index 22c0aca23..751cedb3e 100644 --- a/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go +++ b/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go @@ -71,7 +71,7 @@ func init() { origHelpFunc(command, strings) }) - Cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + Cmd.PreRunE = func(_ *cobra.Command, _ []string) error { if limit > jsonrpc.LIST_ITEM_LIMIT { return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) } @@ -93,7 +93,7 @@ func run(cmd *cobra.Command, args []string) { defer readServ.Close() var result json.RawMessage - if len(args) >= 2 { + if len(args) >= 2 { //nolint:mnd // Two positional arguments select the get operation. var params api.GetEpochParams params.Application = args[0] params.EpochIndex, err = config.AsHexString(args[1]) @@ -122,5 +122,6 @@ func run(cmd *cobra.Command, args []string) { cobra.CheckErr(err) out.WriteString("\n") - out.WriteTo(os.Stdout) + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) } diff --git a/cmd/cartesi-rollups-cli/root/read/inputs/inputs.go b/cmd/cartesi-rollups-cli/root/read/inputs/inputs.go index 199e36692..8b0bf9eda 100644 --- a/cmd/cartesi-rollups-cli/root/read/inputs/inputs.go +++ b/cmd/cartesi-rollups-cli/root/read/inputs/inputs.go @@ -77,7 +77,7 @@ func init() { origHelpFunc(command, strings) }) - Cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + Cmd.PreRunE = func(_ *cobra.Command, _ []string) error { if limit > jsonrpc.LIST_ITEM_LIMIT { return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) } @@ -99,7 +99,7 @@ func run(cmd *cobra.Command, args []string) { defer readServ.Close() var result json.RawMessage - if len(args) >= 2 { + if len(args) >= 2 { //nolint:mnd // Two positional arguments select the get operation. var params api.GetInputParams params.Application = args[0] params.InputIndex, err = config.AsHexString(args[1]) @@ -137,5 +137,6 @@ func run(cmd *cobra.Command, args []string) { cobra.CheckErr(err) out.WriteString("\n") - out.WriteTo(os.Stdout) + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) } diff --git a/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go b/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go index 6253038ff..d1d2b712c 100644 --- a/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go +++ b/cmd/cartesi-rollups-cli/root/read/matchadvances/matchadvances.go @@ -36,6 +36,7 @@ Supported Environment Variables: CARTESI_DATABASE_CONNECTION Database connection string`, } +//nolint:lll // Long CLI examples are kept copy-pasteable. const examples = `# Read specific match advanced: cartesi-rollups-cli read match_advances echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 @@ -67,7 +68,7 @@ func init() { origHelpFunc(command, strings) }) - Cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + Cmd.PreRunE = func(_ *cobra.Command, _ []string) error { if limit > jsonrpc.LIST_ITEM_LIMIT { return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) } @@ -89,7 +90,7 @@ func run(cmd *cobra.Command, args []string) { defer readServ.Close() var result json.RawMessage - if len(args) >= 5 { + if len(args) >= 5 { //nolint:mnd // Five positional arguments select the get operation. var params api.GetMatchAdvanceParams params.Application = args[0] params.EpochIndex, err = config.AsHexString(args[1]) @@ -119,5 +120,6 @@ func run(cmd *cobra.Command, args []string) { cobra.CheckErr(err) out.WriteString("\n") - out.WriteTo(os.Stdout) + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) } diff --git a/cmd/cartesi-rollups-cli/root/read/matches/matches.go b/cmd/cartesi-rollups-cli/root/read/matches/matches.go index da27db00a..e24c6b933 100644 --- a/cmd/cartesi-rollups-cli/root/read/matches/matches.go +++ b/cmd/cartesi-rollups-cli/root/read/matches/matches.go @@ -35,6 +35,7 @@ Supported Environment Variables: CARTESI_DATABASE_CONNECTION Database connection string`, } +//nolint:lll // Long CLI examples are kept copy-pasteable. const examples = `# Read specific match: cartesi-rollups-cli read matches echo-dapp 10 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 0xdb99c9cdb2e2070a4e4e633c2e6874648dfe3971d14da843465b3d950df3dd19 @@ -75,8 +76,8 @@ func init() { origHelpFunc(command, strings) }) - Cmd.PreRunE = func(cmd *cobra.Command, args []string) error { - if len(args) > 1 && len(args) < 4 { //nolint: mnd + Cmd.PreRunE = func(_ *cobra.Command, args []string) error { + if len(args) > 1 && len(args) < 4 { return fmt.Errorf( "expected 1 argument (list) or 4 arguments (get), got %d", len(args)) } @@ -101,7 +102,7 @@ func run(cmd *cobra.Command, args []string) { defer readServ.Close() var result json.RawMessage - if len(args) >= 4 { + if len(args) >= 4 { //nolint:mnd // Four positional arguments select the get operation. var params api.GetMatchParams params.Application = args[0] params.EpochIndex, err = config.AsHexString(args[1]) @@ -138,5 +139,6 @@ func run(cmd *cobra.Command, args []string) { cobra.CheckErr(err) out.WriteString("\n") - out.WriteTo(os.Stdout) + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) } diff --git a/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go b/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go index 0b8512704..08429211f 100644 --- a/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go +++ b/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go @@ -33,6 +33,7 @@ Supported Environment Variables: CARTESI_DATABASE_CONNECTION Database connection string`, } +//nolint:lll // Long CLI examples are kept copy-pasteable. const examples = `# Read specific output: cartesi-rollups-cli read outputs echo-dapp 10 @@ -82,7 +83,7 @@ func init() { origHelpFunc(command, strings) }) - Cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + Cmd.PreRunE = func(_ *cobra.Command, _ []string) error { if limit > jsonrpc.LIST_ITEM_LIMIT { return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) } @@ -104,7 +105,7 @@ func run(cmd *cobra.Command, args []string) { defer readServ.Close() var result json.RawMessage - if len(args) >= 2 { + if len(args) >= 2 { //nolint:mnd // Two positional arguments select the get operation. var params api.GetOutputParams params.Application = args[0] params.OutputIndex, err = config.AsHexString(args[1]) @@ -157,5 +158,6 @@ func run(cmd *cobra.Command, args []string) { cobra.CheckErr(err) out.WriteString("\n") - out.WriteTo(os.Stdout) + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) } diff --git a/cmd/cartesi-rollups-cli/root/read/read.go b/cmd/cartesi-rollups-cli/root/read/read.go index 03568d9f2..b37d58420 100644 --- a/cmd/cartesi-rollups-cli/root/read/read.go +++ b/cmd/cartesi-rollups-cli/root/read/read.go @@ -22,7 +22,7 @@ import ( var Cmd = &cobra.Command{ Use: "read", Short: "Read the node state from the database", - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { if !cmd.Flags().Changed("jsonrpc") && cmd.Flags().Changed("jsonrpc-api-url") { if err := cmd.Flags().Set("jsonrpc", "true"); err != nil { return err diff --git a/cmd/cartesi-rollups-cli/root/read/reports/reports.go b/cmd/cartesi-rollups-cli/root/read/reports/reports.go index 13e86f3bd..d6fea0350 100644 --- a/cmd/cartesi-rollups-cli/root/read/reports/reports.go +++ b/cmd/cartesi-rollups-cli/root/read/reports/reports.go @@ -73,7 +73,7 @@ func init() { origHelpFunc(command, strings) }) - Cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + Cmd.PreRunE = func(_ *cobra.Command, _ []string) error { if limit > jsonrpc.LIST_ITEM_LIMIT { return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) } @@ -95,7 +95,7 @@ func run(cmd *cobra.Command, args []string) { defer readServ.Close() var result json.RawMessage - if len(args) >= 2 { + if len(args) >= 2 { //nolint:mnd // Two positional arguments select the get operation. var params api.GetReportParams params.Application = args[0] params.ReportIndex, err = config.AsHexString(args[1]) @@ -132,5 +132,6 @@ func run(cmd *cobra.Command, args []string) { cobra.CheckErr(err) out.WriteString("\n") - out.WriteTo(os.Stdout) + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) } diff --git a/cmd/cartesi-rollups-cli/root/read/service/types.go b/cmd/cartesi-rollups-cli/root/read/service/types.go index f8ecae6e2..4c647dbc7 100644 --- a/cmd/cartesi-rollups-cli/root/read/service/types.go +++ b/cmd/cartesi-rollups-cli/root/read/service/types.go @@ -47,11 +47,10 @@ func CreateReadService(ctx context.Context, useJsonrpc bool) (ReadService, error return nil, err } return NewJsonrpcReadService(ctx, url) - } else { - dsn, err := config.GetDatabaseConnection() - if err != nil { - return nil, err - } - return NewRepositoryReadService(ctx, dsn.Raw()) } + dsn, err := config.GetDatabaseConnection() + if err != nil { + return nil, err + } + return NewRepositoryReadService(ctx, dsn.Raw()) } diff --git a/cmd/cartesi-rollups-cli/root/read/tournaments/tournaments.go b/cmd/cartesi-rollups-cli/root/read/tournaments/tournaments.go index e140298dc..e4d89ca84 100644 --- a/cmd/cartesi-rollups-cli/root/read/tournaments/tournaments.go +++ b/cmd/cartesi-rollups-cli/root/read/tournaments/tournaments.go @@ -33,6 +33,7 @@ Supported Environment Variables: CARTESI_DATABASE_CONNECTION Database connection string`, } +//nolint:lll // Long CLI examples are kept copy-pasteable. const examples = `# Read specific tournament: cartesi-rollups-cli read tournaments echo-dapp 0x0073a8637d98649717bdc02ecb439c80aa8a10d0 @@ -79,7 +80,7 @@ func init() { origHelpFunc(command, strings) }) - Cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + Cmd.PreRunE = func(_ *cobra.Command, _ []string) error { if limit > jsonrpc.LIST_ITEM_LIMIT { return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) } @@ -101,7 +102,7 @@ func run(cmd *cobra.Command, args []string) { defer readServ.Close() var result json.RawMessage - if len(args) >= 2 { + if len(args) >= 2 { //nolint:mnd // Two positional arguments select the get operation. var params api.GetTournamentParams params.Application = args[0] params.Address = args[1] @@ -147,5 +148,6 @@ func run(cmd *cobra.Command, args []string) { cobra.CheckErr(err) out.WriteString("\n") - out.WriteTo(os.Stdout) + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) } diff --git a/cmd/cartesi-rollups-cli/root/read/withdrawals/withdrawals.go b/cmd/cartesi-rollups-cli/root/read/withdrawals/withdrawals.go index e8481ba09..49f0b8549 100644 --- a/cmd/cartesi-rollups-cli/root/read/withdrawals/withdrawals.go +++ b/cmd/cartesi-rollups-cli/root/read/withdrawals/withdrawals.go @@ -71,7 +71,7 @@ func init() { origHelpFunc(command, strings) }) - Cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + Cmd.PreRunE = func(_ *cobra.Command, _ []string) error { if limit > jsonrpc.LIST_ITEM_LIMIT { return fmt.Errorf("limit cannot exceed %d", jsonrpc.LIST_ITEM_LIMIT) } @@ -93,7 +93,7 @@ func run(cmd *cobra.Command, args []string) { defer readServ.Close() var result json.RawMessage - if len(args) >= 2 { + if len(args) >= 2 { //nolint:mnd // Two positional arguments select the get operation. var params api.GetWithdrawalParams params.Application = args[0] params.AccountIndex, err = config.AsHexString(args[1]) @@ -122,5 +122,6 @@ func run(cmd *cobra.Command, args []string) { cobra.CheckErr(err) out.WriteString("\n") - out.WriteTo(os.Stdout) + _, err = out.WriteTo(os.Stdout) + cobra.CheckErr(err) } diff --git a/cmd/cartesi-rollups-cli/root/send/send.go b/cmd/cartesi-rollups-cli/root/send/send.go index c96af0395..30308de40 100644 --- a/cmd/cartesi-rollups-cli/root/send/send.go +++ b/cmd/cartesi-rollups-cli/root/send/send.go @@ -125,7 +125,8 @@ func run(cmd *cobra.Command, args []string) { cobra.CheckErr(err) if app == nil { fmt.Fprintf(os.Stderr, "application %q not found\n", nameOrAddress) - os.Exit(1) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. } // Check if stdin is being used for payload and --yes flag is not set @@ -139,10 +140,10 @@ func run(cmd *cobra.Command, args []string) { client, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) cobra.CheckErr(err) - chainId, err := client.ChainID(ctx) + chainID, err := client.ChainID(ctx) cobra.CheckErr(err) - txOpts, err := cli.GetTransactOpts(ctx, chainId) + txOpts, err := cli.GetTransactOpts(ctx, chainID) cobra.CheckErr(err) txOptsFactory := ethutil.NewStaticTransactOptsFactory(txOpts) diff --git a/cmd/cartesi-rollups-cli/root/validate/validate.go b/cmd/cartesi-rollups-cli/root/validate/validate.go index b7e161a66..9ae180b41 100644 --- a/cmd/cartesi-rollups-cli/root/validate/validate.go +++ b/cmd/cartesi-rollups-cli/root/validate/validate.go @@ -22,7 +22,7 @@ var Cmd = &cobra.Command{ Use: "validate [app-name-or-address] [output-index]", Short: "Validates a notice", Example: examples, - Args: cobra.ExactArgs(2), // nolint: mnd + Args: cobra.ExactArgs(2), //nolint:mnd Run: run, Long: ` Supported Environment Variables: @@ -74,7 +74,8 @@ func run(cmd *cobra.Command, args []string) { if output == nil { fmt.Fprintf(os.Stderr, "The output with index %d was not found in the database\n", outputIndex) - os.Exit(1) + repo.Close() + os.Exit(1) //nolint:gocritic // The repository is closed explicitly before exiting. } app, err := repo.GetApplication(ctx, nameOrAddress) @@ -82,6 +83,7 @@ func run(cmd *cobra.Command, args []string) { if len(output.OutputHashesSiblings) == 0 { fmt.Fprintf(os.Stderr, "The output with index %d has no associated proof yet\n", outputIndex) + repo.Close() os.Exit(0) } diff --git a/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go b/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go index 4ce1b6085..66a968c41 100644 --- a/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go +++ b/cmd/cartesi-rollups-cli/root/withdraw/withdraw.go @@ -180,7 +180,7 @@ func run(cmd *cobra.Command, args []string) { func loadProof(path string) ([]byte, iapplication.AccountValidityProof, error) { zero := iapplication.AccountValidityProof{} - raw, err := os.ReadFile(path) //nolint:gosec + raw, err := os.ReadFile(path) if err != nil { return nil, zero, fmt.Errorf("read proof file %s: %w", path, err) } diff --git a/cmd/cartesi-rollups-cli/util/util.go b/cmd/cartesi-rollups-cli/util/util.go index e00f700b9..c878987b3 100644 --- a/cmd/cartesi-rollups-cli/util/util.go +++ b/cmd/cartesi-rollups-cli/util/util.go @@ -69,7 +69,7 @@ func ReadRootHash(machineDir string) (common.Hash, error) { // root hash is located at this offset (0x60). Double check its value // with the cartesi-machine-stored-hash tool. - _, err = f.Seek(0x60, io.SeekStart) + _, err = f.Seek(0x60, io.SeekStart) //nolint:mnd // Fixed root-hash offset in the machine image header. if err != nil { return zero, err } diff --git a/cmd/cartesi-rollups-cli/util/util_test.go b/cmd/cartesi-rollups-cli/util/util_test.go index bcd71a249..74d3ed9fa 100644 --- a/cmd/cartesi-rollups-cli/util/util_test.go +++ b/cmd/cartesi-rollups-cli/util/util_test.go @@ -1,7 +1,7 @@ // (c) Cartesi and individual authors (see AUTHORS) // SPDX-License-Identifier: Apache-2.0 (see LICENSE) -package util +package util //nolint:revive // Tests intentionally exercise unexported package helpers. import ( "context" From 4308df98cf0f99c4bc68e9cc69de3be0739d142c Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:22:02 -0300 Subject: [PATCH 41/43] fix(jsonrpc): report JSON contents even on incomplete/malformed responses - Sets Content-Type: application/json before switching/parsing the request body. - Removes redundant branch-specific assignments. - Adds a regression test for malformed single-object input. --- internal/jsonrpc/batchcalls_test.go | 9 +++++++++ internal/jsonrpc/jsonrpc.go | 4 +--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/internal/jsonrpc/batchcalls_test.go b/internal/jsonrpc/batchcalls_test.go index c10a17bce..9a043a2cc 100644 --- a/internal/jsonrpc/batchcalls_test.go +++ b/internal/jsonrpc/batchcalls_test.go @@ -157,6 +157,15 @@ func TestJSONRPCMalformedBatchReturnsParseErrorObject(t *testing.T) { requireRPCError(t, decodeRPCResponse(t, rr.Body.Bytes()), nil, JSONRPC_PARSE_ERROR) } +func TestJSONRPCMalformedObjectReturnsJSONContentType(t *testing.T) { + s := newBatchTestService() + rr := serveRPC(t, s, []byte(`{"jsonrpc":"2.0"`)) + + require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, "application/json", rr.Header().Get("Content-Type")) + requireRPCError(t, decodeRPCResponse(t, rr.Body.Bytes()), nil, JSONRPC_PARSE_ERROR) +} + func TestJSONRPCBatchMalformedElementDoesNotPoisonValidSiblings(t *testing.T) { s := newBatchTestService() body := []byte(`[ diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index eb6f81180..1bd85435a 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -333,6 +333,7 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { } budgetResp := newBudgetWriter(w, MAX_RESPONSE_SIZE) + w.Header().Set("Content-Type", "application/json") switch body[0] { case '{': @@ -341,12 +342,10 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { s.writeRPCError(w, nil, JSONRPC_PARSE_ERROR, "invalid request") return } - w.Header().Set("Content-Type", "application/json") s.Logger.Info("Dispatching RPC request", "method", truncatedMethod(req.Method)) s.dispatchOneRequest(w, r, req, budgetResp) case '[': - w.Header().Set("Content-Type", "application/json") // Keep each batch element raw so malformed requests fail independently and // the list-item limit can be checked before dispatching any request. var reqSeq []json.RawMessage @@ -405,7 +404,6 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { s.writeByte(w, ']') default: - w.Header().Set("Content-Type", "application/json") if json.Valid(body) { s.writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request") } else { From 57f4449130a3a5dac4b4e1619a7e6d8cb6eac22a Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:27:35 -0300 Subject: [PATCH 42/43] fix(jsonrpc): avoid corrupting the offset maximum reported by 'rpc.discover' - Changed Service.discoverSpec from any to json.RawMessage. - Retained json.Unmarshal, validating and copying the embedded JSON without converting numbers to float64. - Added a regression test asserting rpc.discover returns the exact literal 9223372036854775807. --- internal/jsonrpc/batchcalls_test.go | 12 ++++++++++++ internal/jsonrpc/service.go | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/jsonrpc/batchcalls_test.go b/internal/jsonrpc/batchcalls_test.go index 9a043a2cc..2d73cb41a 100644 --- a/internal/jsonrpc/batchcalls_test.go +++ b/internal/jsonrpc/batchcalls_test.go @@ -166,6 +166,18 @@ func TestJSONRPCMalformedObjectReturnsJSONContentType(t *testing.T) { requireRPCError(t, decodeRPCResponse(t, rr.Body.Bytes()), nil, JSONRPC_PARSE_ERROR) } +func TestJSONRPCDiscoverPreservesLargeIntegerLiterals(t *testing.T) { + s := newBatchTestService() + data, err := discoverSpec.ReadFile("jsonrpc-discover.json") + require.NoError(t, err) + require.NoError(t, json.Unmarshal(data, &s.discoverSpec)) + + rr := serveRPC(t, s, []byte(`{"jsonrpc":"2.0","method":"rpc.discover","id":1}`)) + + require.Equal(t, http.StatusOK, rr.Code) + require.Contains(t, rr.Body.String(), `"maximum":9223372036854775807`) +} + func TestJSONRPCBatchMalformedElementDoesNotPoisonValidSiblings(t *testing.T) { s := newBatchTestService() body := []byte(`[ diff --git a/internal/jsonrpc/service.go b/internal/jsonrpc/service.go index 3e33d6fcd..1a85c357a 100644 --- a/internal/jsonrpc/service.go +++ b/internal/jsonrpc/service.go @@ -42,7 +42,7 @@ type Service struct { // overridden in tests so Serve() can be exercised without real sockets. listen func(network, address string) (net.Listener, error) // OpenAPI description for JSON-RPC API loaded from 'jsonrpc-discover.json' file - discoverSpec any + discoverSpec json.RawMessage handlers dispatchTable // dispatchTimeout expires requests early enough to serialize a complete // timeout response before the HTTP server's write deadline. From 7558af64c87f3fa76fb96c07f2396f6cc5600156 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:14:43 -0300 Subject: [PATCH 43/43] fix(jsonrpc): improve error messages reported to the client - Added specific messages for invalid IDs and unsupported JSON-RPC versions. - Preserved empty-method validation as -32600. - Normalized standard messages to Parse error and Invalid Request. - Added regression assertions for the new messages. --- internal/jsonrpc/batchcalls_test.go | 40 +++++++++++++++++++---------- internal/jsonrpc/jsonrpc.go | 21 ++++++++------- internal/jsonrpc/jsonrpc_test.go | 2 +- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/internal/jsonrpc/batchcalls_test.go b/internal/jsonrpc/batchcalls_test.go index 2d73cb41a..538b7ee62 100644 --- a/internal/jsonrpc/batchcalls_test.go +++ b/internal/jsonrpc/batchcalls_test.go @@ -154,7 +154,9 @@ func TestJSONRPCMalformedBatchReturnsParseErrorObject(t *testing.T) { require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, "application/json", rr.Header().Get("Content-Type")) - requireRPCError(t, decodeRPCResponse(t, rr.Body.Bytes()), nil, JSONRPC_PARSE_ERROR) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, nil, JSONRPC_PARSE_ERROR) + require.Equal(t, "Parse error", response.Error.Message) } func TestJSONRPCMalformedObjectReturnsJSONContentType(t *testing.T) { @@ -163,7 +165,9 @@ func TestJSONRPCMalformedObjectReturnsJSONContentType(t *testing.T) { require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, "application/json", rr.Header().Get("Content-Type")) - requireRPCError(t, decodeRPCResponse(t, rr.Body.Bytes()), nil, JSONRPC_PARSE_ERROR) + response := decodeRPCResponse(t, rr.Body.Bytes()) + requireRPCError(t, response, nil, JSONRPC_PARSE_ERROR) + require.Equal(t, "Parse error", response.Error.Message) } func TestJSONRPCDiscoverPreservesLargeIntegerLiterals(t *testing.T) { @@ -236,19 +240,24 @@ func TestJSONRPCBatchStructurallyInvalidElementsDoNotPoisonValidSiblings(t *test func TestJSONRPCValidationErrorsEchoValidID(t *testing.T) { s := newBatchTestService() - tests := map[string]string{ - "missing method": `{"jsonrpc":"2.0","id":"request-id"}`, - "invalid version": `{"jsonrpc":"1.0","method":"cartesi_getNodeVersion","id":42}`, + tests := map[string]struct { + body string + id any + message string + }{ + "missing method": { + body: `{"jsonrpc":"2.0","id":"request-id"}`, id: "request-id", message: "Invalid Request", + }, + "invalid version": { + body: `{"jsonrpc":"1.0","method":"cartesi_getNodeVersion","id":42}`, id: float64(42), message: "Unsupported JSON-RPC version", + }, } - for name, body := range tests { + for name, test := range tests { t.Run(name, func(t *testing.T) { - response := decodeRPCResponse(t, serveRPC(t, s, []byte(body)).Body.Bytes()) - expectedID := any("request-id") - if name == "invalid version" { - expectedID = float64(42) - } - requireRPCError(t, response, expectedID, JSONRPC_INVALID_REQUEST) + response := decodeRPCResponse(t, serveRPC(t, s, []byte(test.body)).Body.Bytes()) + requireRPCError(t, response, test.id, JSONRPC_INVALID_REQUEST) + require.Equal(t, test.message, response.Error.Message) }) } } @@ -265,6 +274,7 @@ func TestJSONRPCRejectsInvalidIDTypesWithNullID(t *testing.T) { `{"jsonrpc":"2.0","method":"cartesi_getNodeVersion","id":%s}`, id)) response := decodeRPCResponse(t, serveRPC(t, s, body).Body.Bytes()) requireRPCError(t, response, nil, JSONRPC_INVALID_REQUEST) + require.Equal(t, "Invalid request ID", response.Error.Message) }) } } @@ -470,8 +480,10 @@ func TestJSONRPCBatchReturnsErrorsForIDDRequestsAfterDeadline(t *testing.T) { requireRPCError(t, responses[4], nil, JSONRPC_INVALID_REQUEST) requireRPCError(t, responses[5], nil, JSONRPC_TIMEOUT_ERROR) for i, response := range responses { - if i == 3 || i == 4 { - require.Equal(t, "invalid request", response.Error.Message) + if i == 3 { + require.Equal(t, "Invalid Request", response.Error.Message) + } else if i == 4 { + require.Equal(t, "Invalid request ID", response.Error.Message) } else { require.Equal(t, "Request timed out", response.Error.Message) } diff --git a/internal/jsonrpc/jsonrpc.go b/internal/jsonrpc/jsonrpc.go index 1bd85435a..93c7546bb 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -218,10 +218,13 @@ func (s *Service) repositoryError(ctx context.Context, message string, err error func (s *Service) handleRequest(w io.Writer, r *http.Request, req RPCRequest) error { if !validRPCID(req.ID) { - return writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request") + return writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "Invalid request ID") } - if req.JSONRPC != "2.0" || req.Method == "" { - return writeRPCError(w, req.ID, JSONRPC_INVALID_REQUEST, "invalid request") + if req.JSONRPC != "2.0" { + return writeRPCError(w, req.ID, JSONRPC_INVALID_REQUEST, "Unsupported JSON-RPC version") + } + if req.Method == "" { + return writeRPCError(w, req.ID, JSONRPC_INVALID_REQUEST, "Invalid Request") } fn, ok := s.handlers[req.Method] if !ok { @@ -339,7 +342,7 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { case '{': var req RPCRequest if err := json.Unmarshal(body, &req); err != nil { - s.writeRPCError(w, nil, JSONRPC_PARSE_ERROR, "invalid request") + s.writeRPCError(w, nil, JSONRPC_PARSE_ERROR, "Parse error") return } s.Logger.Info("Dispatching RPC request", "method", truncatedMethod(req.Method)) @@ -350,7 +353,7 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { // the list-item limit can be checked before dispatching any request. var reqSeq []json.RawMessage if err := json.Unmarshal(body, &reqSeq); err != nil { - s.writeRPCError(w, nil, JSONRPC_PARSE_ERROR, "invalid request batch") + s.writeRPCError(w, nil, JSONRPC_PARSE_ERROR, "Parse error") return } if len(reqSeq) == 0 || len(reqSeq) > MAX_BATCH_SIZE { @@ -382,15 +385,15 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { case context.DeadlineExceeded: s.Logger.Warn("RPC method dispatch timeout") if err := json.Unmarshal(rawReq, &req); err != nil { - responded = s.writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request") + responded = s.writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "Invalid Request") } else if !validRPCID(req.ID) { - responded = s.writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request") + responded = s.writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "Invalid request ID") } else { responded = s.writeRPCError(w, req.ID, JSONRPC_TIMEOUT_ERROR, "Request timed out") } default: if err := json.Unmarshal(rawReq, &req); err != nil { - responded = s.writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request") + responded = s.writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "Invalid Request") } else { s.Logger.Debug("Dispatching RPC request", "method", truncatedMethod(req.Method)) responded = s.dispatchOneRequest(w, r, req, budgetResp) @@ -405,7 +408,7 @@ func (s *Service) handleRPC(w http.ResponseWriter, r *http.Request) { default: if json.Valid(body) { - s.writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "invalid request") + s.writeRPCError(w, nil, JSONRPC_INVALID_REQUEST, "Invalid Request") } else { s.writeRPCError(w, nil, JSONRPC_PARSE_ERROR, "Parse error") } diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index e5704418b..0f65c1836 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -61,7 +61,7 @@ func TestInvalidJSON(t *testing.T) { var resp RPCResponse assert.Nil(t, json.Unmarshal(body, &resp)) assert.Equal(t, JSONRPC_PARSE_ERROR, resp.Error.Code) - assert.Equal(t, "invalid request", resp.Error.Message) + assert.Equal(t, "Parse error", resp.Error.Message) } // failure: invalid method