diff --git a/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go b/cmd/cartesi-rollups-cli/root/read/epochs/epochs.go index 4f1aea19e..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 = &status + epochStatuses := api.StringOrList(statuses) + params.Status = &epochStatuses } params.Limit = limit params.Offset = offset 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/outputs/outputs.go b/cmd/cartesi-rollups-cli/root/read/outputs/outputs.go index d584689ea..0b8512704 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 = &outputType + selectors := api.StringOrList(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/jsonrpc.go b/cmd/cartesi-rollups-cli/root/read/service/jsonrpc.go index b6bbb9efb..1a96b2099 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) + } } } @@ -130,8 +132,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 @@ -338,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) } @@ -356,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 89cb1399b..01a59fc70 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 @@ -289,9 +292,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 } @@ -303,6 +310,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 @@ -782,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 { @@ -839,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 } 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/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/api/params.go b/internal/jsonrpc/api/params.go index ef21488c2..9736a06ba 100644 --- a/internal/jsonrpc/api/params.go +++ b/internal/jsonrpc/api/params.go @@ -3,6 +3,34 @@ package api +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" +) + +type StringOrList []string + +func (s *StringOrList) 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"` @@ -17,11 +45,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"` + 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 @@ -30,6 +60,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"` @@ -44,6 +80,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 @@ -59,14 +97,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"` + 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 @@ -83,6 +124,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 @@ -156,8 +199,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"` @@ -179,3 +222,61 @@ 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() + 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", + len(fields), len(rawParams)) + } + // Map positional parameters to JSON-visible fields in declaration order. + for i, fieldIndex := range fields { + if i >= len(rawParams) { + break + } + field := val.Field(fieldIndex) + if !field.CanSet() { + 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(fieldIndex).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 new file mode 100644 index 000000000..5836a5ca5 --- /dev/null +++ b/internal/jsonrpc/api/params_test.go @@ -0,0 +1,269 @@ +// (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 TestListOutputsParamsStringOrList(t *testing.T) { + tests := map[string]struct { + input string + expected StringOrList + }{ + "single selector": { + input: `{"output_type":"0x237a816f"}`, + expected: StringOrList{"0x237a816f"}, + }, + "selector list": { + input: `{"output_type":["0x237a816f","0x10321e8b"]}`, + expected: StringOrList{"0x237a816f", "0x10321e8b"}, + }, + "empty list": { + input: `{"output_type":[]}`, + expected: StringOrList{}, + }, + } + + 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 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)) + 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) +} + +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) + }) + } +} + +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"` + 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") +} 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/batchbudget_test.go b/internal/jsonrpc/batchbudget_test.go new file mode 100644 index 000000000..fe31341bf --- /dev/null +++ b/internal/jsonrpc/batchbudget_test.go @@ -0,0 +1,112 @@ +// (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 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/batchcalls_test.go b/internal/jsonrpc/batchcalls_test.go new file mode 100644 index 000000000..71a71c0d4 --- /dev/null +++ b/internal/jsonrpc/batchcalls_test.go @@ -0,0 +1,445 @@ +// (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 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(`[]`)) + + 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 ea21d239e..75319a19b 100644 --- a/internal/jsonrpc/jsonrpc-discover.json +++ b/internal/jsonrpc/jsonrpc-discover.json @@ -3,7 +3,12 @@ "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." + "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": [ { @@ -53,6 +58,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -85,13 +99,22 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, { "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", @@ -103,9 +126,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 }, @@ -137,6 +171,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": { @@ -155,6 +205,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -186,6 +245,58 @@ "$ref": "#/components/schemas/EpochGetResult" } }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/EpochNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" + } + ] + }, + { + "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" @@ -233,6 +344,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -301,6 +421,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": { @@ -318,6 +454,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -361,6 +506,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -384,6 +538,79 @@ "$ref": "#/components/schemas/ProcessedInputCountResult" } }, + "errors": [ + { + "$ref": "#/components/errors/InvalidParams" + }, + { + "$ref": "#/components/errors/ApplicationNotFound" + }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" + } + ] + }, + { + "name": "cartesi_getExecutedOutputCount", + "summary": "Retrieve the number of executed outputs for the application", + "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", + "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 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", + "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" @@ -399,7 +626,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", @@ -427,9 +654,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 }, @@ -469,6 +707,30 @@ "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 + }, + { + "name": "executed", + "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" + }, + "required": false } ], "result": { @@ -486,6 +748,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -529,6 +800,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -587,6 +867,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": { @@ -604,6 +900,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -647,6 +952,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -716,6 +1030,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -759,6 +1082,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -852,6 +1184,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -895,6 +1236,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -972,6 +1322,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -1031,6 +1390,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -1108,6 +1476,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -1167,6 +1544,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -1252,11 +1638,20 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, { - "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": [ @@ -1317,6 +1712,35 @@ { "$ref": "#/components/errors/MatchAdvancedNotFound" }, + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" + } + ] + }, + { + "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" } @@ -1326,6 +1750,7 @@ "name": "cartesi_getChainId", "summary": "Get node's chain ID", "description": "Fetches the chain ID that node is operating on.", + "deprecated": true, "params": [], "result": { "name": "result", @@ -1339,6 +1764,15 @@ }, { "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" } ] }, @@ -1346,13 +1780,28 @@ "name": "cartesi_getNodeVersion", "summary": "Get node version", "description": "Fetches the semantic version of the Cartesi rollups node.", + "deprecated": true, "params": [], "result": { "name": "result", "schema": { "$ref": "#/components/schemas/NodeVersionResult" } - } + }, + "errors": [ + { + "$ref": "#/components/errors/InternalError" + }, + { + "$ref": "#/components/errors/TimeoutError" + }, + { + "$ref": "#/components/errors/ResponseSizeLimitExceeded" + }, + { + "$ref": "#/components/errors/BatchListItemLimitExceeded" + } + ] } ], "components": { @@ -1733,7 +2182,7 @@ "type": "object", "properties": { "chain_id": { - "$ref": "#/components/schemas/UnsignedInteger" + "$ref": "#/components/schemas/UnsignedInteger256" }, "application_contract": { "$ref": "#/components/schemas/EthereumAddress" @@ -1742,13 +2191,13 @@ "$ref": "#/components/schemas/EthereumAddress" }, "block_number": { - "$ref": "#/components/schemas/UnsignedInteger" + "$ref": "#/components/schemas/UnsignedInteger256" }, "block_timestamp": { - "$ref": "#/components/schemas/UnsignedInteger" + "$ref": "#/components/schemas/UnsignedInteger256" }, "prev_randao": { - "$ref": "#/components/schemas/ByteArray" + "$ref": "#/components/schemas/UnsignedInteger256" }, "index": { "$ref": "#/components/schemas/UnsignedInteger" @@ -1796,6 +2245,22 @@ } } }, + "ExecutedOutputCountResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/UnsignedInteger" + } + } + }, + "PendingExecutableOutputCountResult": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/UnsignedInteger" + } + } + }, "Output": { "type": "object", "properties": { @@ -1881,7 +2346,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" @@ -2113,6 +2579,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": { @@ -2188,6 +2685,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", @@ -2513,48 +3016,60 @@ "code": -32603, "message": "Internal server error" }, + "TimeoutError": { + "code": -32070, + "message": "Request timed out" + }, + "ResponseSizeLimitExceeded": { + "code": -31003, + "message": "Response size limit exceeded" + }, + "BatchListItemLimitExceeded": { + "code": -31004, + "message": "Batch list item 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..ac71105d0 100644 --- a/internal/jsonrpc/jsonrpc.go +++ b/internal/jsonrpc/jsonrpc.go @@ -4,12 +4,16 @@ package jsonrpc import ( + "bytes" + "context" "embed" "encoding/json" "errors" "fmt" "io" + "math" "net/http" + "reflect" "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/evmreader" @@ -25,6 +29,11 @@ var discoverSpec embed.FS const ( // Maximum allowed body size (1 MB). MAX_BODY_SIZE = 1 << 20 //nolint: revive + // 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 // Maximum amount of items to list (10,000). LIST_ITEM_LIMIT = 10000 //nolint: revive // Default amount of item on a list (50) @@ -32,57 +41,193 @@ 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: 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.ResponseWriter, *http.Request, RPCRequest) +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_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_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_getMatchAdvance": handleGetMatchAdvance, + "cartesi_getNodeInfo": handleGetNodeInfo, + "cartesi_getChainId": handleGetChainID, + "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 // ----------------------------------------------------------------------------- +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) + return s.handleWriteResponse(err) +} + +func (s *Service) handleRequest(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") + } + if req.JSONRPC != "2.0" || req.Method == "" { + 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") + } + + result, err := fn(s, r, req) + if err == nil { + return writeRPCResult(w, req.ID, result) + } + + var rpcErr *RPCError + if errors.As(err, &rpcErr) { + 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") +} + +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) @@ -97,17 +242,89 @@ 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) + + budgetResp := newBudgetWriter(w, MAX_RESPONSE_SIZE) + + 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) + s.dispatchOneRequest(w, r, req, budgetResp) + + case '[': + w.Header().Set("Content-Type", "application/json") + 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, "invalid request batch size (expected [1..100])") + 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, '[') { + return + } + + 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) + responded = s.dispatchOneRequest(w, r, req, budgetResp) + } + } + + 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 +333,15 @@ 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) { - 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 - } - 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 - } - writeRPCResult(w, req.ID, spec) +func handleDiscover(s *Service, _ *http.Request, _ RPCRequest) (any, error) { + return s.discoverSpec, 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 { + if err := api.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") } // Use default values if not provided if params.Limit <= 0 { @@ -154,57 +358,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") } 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 { + if err := api.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") } // 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)) } 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") } 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") } - 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 { + if err := api.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") } // Use default values if not provided @@ -218,18 +416,28 @@ 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)) } 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 { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid epoch status: %v", err), nil) - return + if len(*params.Status) == 0 { + return nil, newRPCError(JSONRPC_INVALID_PARAMS, "Invalid epoch status: expected at least one 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 = []model.EpochStatus{status} + epochFilter.Status = statuses } epochs, total, err := s.repository.ListEpochs(r.Context(), params.Application, epochFilter, repository.Pagination{ @@ -238,101 +446,124 @@ 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") } - 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 { + if err := api.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") } // 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)) } 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)) } 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") } 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") + } + + 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 := api.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)) } - writeRPCResult(w, req.ID, api.SingleResponse[*model.Epoch]{Data: epoch}) + 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, 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 { + if err := api.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") } // 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)) } 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") } 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") } - 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 { + if err := api.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") } // Use default values if not provided @@ -346,17 +577,20 @@ 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)) } // 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 { - 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)) } inputFilter.EpochIndex = &epochIndex } @@ -365,16 +599,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)) } 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)) } inputFilter.TransactionHash = &transactionHash } @@ -385,11 +617,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") } - 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 +634,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 { + if err := api.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") } // 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)) } 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)) } 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") } 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") } decoded, err := api.DecodeInput(input, s.inputABI) @@ -450,43 +678,88 @@ 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 { + if err := api.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") } // 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)) } 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") } 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") + } + + 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 := api.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 + } } - writeRPCResult(w, req.ID, api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", processedInputs)}) + return api.SingleResponse[string]{Data: fmt.Sprintf("0x%x", count)}, nil } -func handleListOutputs(s *Service, w http.ResponseWriter, r *http.Request, req RPCRequest) { +func handleGetPendingExecutableOutputCount(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetApplicationParams + if err := api.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 { + if err := api.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") } // Use default values if not provided @@ -500,17 +773,20 @@ 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)) } // 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 { - 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)) } outputFilter.EpochIndex = &epochIndex } @@ -518,28 +794,33 @@ 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)) } outputFilter.InputIndex = &inputIndex } // Add output type filter if provided 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 + 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 { 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)) } outputFilter.VoucherAddress = &voucherAddress } @@ -550,8 +831,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") } resultOutputs := make([]*api.DecodedOutput, 0, len(outputs)) @@ -563,52 +843,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 { + if err := api.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") } // 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)) } 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)) } 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") } 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") } decoded, err := api.DecodeOutput(output, s.outputABI) @@ -616,15 +893,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 { + if err := api.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") } // Use default values if not provided @@ -638,17 +914,20 @@ 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)) } // 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 { - 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)) } reportFilter.EpochIndex = &epochIndex } @@ -656,8 +935,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)) } reportFilter.InputIndex = &inputIndex } @@ -668,70 +946,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") } - 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 { + if err := api.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") } // 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)) } 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)) } 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") } 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") } - 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 { + if err := api.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") } if params.Limit <= 0 { @@ -742,16 +1015,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)) } 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)) } withdrawalFilter.AccountIndex = &accountIndex } @@ -763,69 +1034,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") } - 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 { + if err := api.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") } 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)) } 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)) } 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") } 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") } - 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 { + if err := api.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") } // Use default values if not provided @@ -839,8 +1105,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)) } // Create tournament filter based on params @@ -848,8 +1113,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)) } tournamentFilter.EpochIndex = &epochIndex } @@ -857,8 +1121,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)) } tournamentFilter.Level = &level } @@ -866,8 +1129,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)) } tournamentFilter.ParentTournamentAddress = &parentAddress } @@ -875,8 +1137,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)) } tournamentFilter.ParentMatchIDHash = &parentMatchIDHash } @@ -887,69 +1148,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") } - 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 { + if err := api.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") } // 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)) } // 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)) } 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") } 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") } - 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 { + if err := api.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") } // Use default values if not provided @@ -963,8 +1219,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)) } // Create commitment filter based on params @@ -972,16 +1227,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)) } 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)) } commitmentFilter.TournamentAddress = params.TournamentAddress } @@ -992,83 +1245,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") } - 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 { + if err := api.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") } // 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)) } 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)) } 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)) } 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") } 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)) } 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") } 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") } - 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 { + if err := api.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") } // Use default values if not provided @@ -1082,8 +1327,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)) } // Create match filter based on params @@ -1091,16 +1335,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)) } 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)) } matchFilter.TournamentAddress = params.TournamentAddress } @@ -1111,79 +1353,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") } - 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 { + if err := api.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") } // 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)) } 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)) } 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)) } 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)) } 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") } 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") } - 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 { + if err := api.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") } // Use default values if not provided @@ -1197,25 +1432,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)) } // 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)) } 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)) } 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)) } pagination := repository.Pagination{ @@ -1226,112 +1457,143 @@ 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") } - 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) { - var params api.GetMatchAdvancedParams - if err := UnmarshalParams(req.Params, ¶ms); err != nil { +func handleGetMatchAdvance(s *Service, r *http.Request, req RPCRequest) (any, error) { + var params api.GetMatchAdvanceParams + if err := api.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") } // 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)) } 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)) } 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)) } 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)) } - if _, err := config.ToHashFromString(params.Parent); err != nil { - writeRPCError(w, req.ID, JSONRPC_INVALID_PARAMS, fmt.Sprintf("Invalid parent hash: %v", err), nil) - return + 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) - writeRPCError(w, req.ID, JSONRPC_INTERNAL_ERROR, "Internal server error", nil) - return + return nil, newRPCError(JSONRPC_INTERNAL_ERROR, "Internal server error") } 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") } - 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 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) { - 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") } 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") } - 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 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( - 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") } 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") } - return false + return nil } diff --git a/internal/jsonrpc/jsonrpc_test.go b/internal/jsonrpc/jsonrpc_test.go index d4fd906f3..1234f1ff1 100644 --- a/internal/jsonrpc/jsonrpc_test.go +++ b/internal/jsonrpc/jsonrpc_test.go @@ -16,9 +16,13 @@ package jsonrpc import ( + "bytes" "context" "encoding/json" "fmt" + "log/slog" + "math" + "net/http" "os" "time" @@ -54,7 +58,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 @@ -74,6 +81,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) { @@ -159,6 +188,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 //////////////////////////////////////////////////////////////////////// @@ -328,6 +417,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 //////////////////////////////////////////////////////////////////////// @@ -735,9 +938,141 @@ 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}, + {InputEpochApplicationID: appID, Index: 1, 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) + expected := uint64(2) + if method == "cartesi_getPendingExecutableOutputCount" { + expected = 0 + } + assert.Equal(t, expected, uint64(resp.Result.Data)) + }) + }) + } + //////////////////////////////////////////////////////////////////////// // getReport //////////////////////////////////////////////////////////////////////// @@ -943,6 +1278,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) @@ -1110,6 +1464,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) @@ -1215,6 +1649,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, + }) + } }) }) @@ -1329,6 +1779,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 @@ -1530,6 +1992,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) }) }) @@ -1702,6 +2220,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)) + } + } }) }) @@ -2358,9 +2892,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 @@ -2373,7 +2907,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" @@ -2405,7 +2939,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", @@ -2430,7 +2964,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", @@ -2457,7 +2991,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), @@ -2503,16 +3038,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)) @@ -3477,3 +4012,72 @@ 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") +} + +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) +} diff --git a/internal/jsonrpc/limitedwriter.go b/internal/jsonrpc/limitedwriter.go new file mode 100644 index 000000000..e5ae71569 --- /dev/null +++ b/internal/jsonrpc/limitedwriter.go @@ -0,0 +1,82 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package jsonrpc + +import ( + "bytes" + "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, + budget: limit, + } +} + +// 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 { + w.budget -= written + } + 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 + } + 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 + return 0, io.ErrShortBuffer + } + 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 + } + _, 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/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 diff --git a/internal/jsonrpc/types.go b/internal/jsonrpc/types.go index 46607995e..55c3a3191 100644 --- a/internal/jsonrpc/types.go +++ b/internal/jsonrpc/types.go @@ -4,11 +4,9 @@ package jsonrpc import ( - "bytes" "encoding/json" "fmt" - "net/http" - "reflect" + "io" "regexp" "github.com/cartesi/rollups-node/internal/config" @@ -35,83 +33,40 @@ 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) error { + return &RPCError{Code: code, Message: message} } // 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) 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, } - 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) -} - -// 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) + return json.NewEncoder(w).Encode(resp) } // ----------------------------------------------------------------------------- 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) 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 5d944d4c2..4056f4b6d 100644 --- a/internal/repository/postgres/input.go +++ b/internal/repository/postgres/input.go @@ -223,6 +223,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..b2c2dc6f1 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, @@ -169,6 +196,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), @@ -186,10 +219,12 @@ func (r *PostgresRepository) ListOutputs( conditions = append(conditions, table.Output.InputIndex.EQ(uint64Expr(*f.InputIndex))) } - if f.OutputType != nil { - conditions = append(conditions, - SubstrBytea(table.Output.RawData, 1, 4).EQ(postgres.Bytea(*f.OutputType)), - ) + if f.OutputType != nil && len(*f.OutputType) > 0 { + conditions = append(conditions, outputTypesCondition(*f.OutputType)) + } + + if f.Executed != nil { + conditions = append(conditions, outputExecutionCondition(*f.Executed)) } if f.VoucherAddress != nil { @@ -199,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())), ) } @@ -319,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( @@ -331,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/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/postgres/schema/migrations/000001_create_initial_schema.up.sql b/internal/repository/postgres/schema/migrations/000001_create_initial_schema.up.sql index 4bc48857c..1f87a6d09 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 @@ -442,6 +442,12 @@ WHERE SUBSTRING("raw_data" FROM 1 FOR 4) IN ( E'\\x237a816f' -- Voucher ); +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(); diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 44f0d6115..48e8be999 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,8 +71,10 @@ type InputFilter struct { NotStatus *InputCompletionStatus Sender *common.Address TransactionHash *common.Hash + IndexRange *Range } +// Range defines a closed interval: both Start and End are inclusive. type Range struct { Start uint64 End uint64 @@ -81,13 +84,16 @@ type OutputFilter struct { EpochIndex *uint64 InputIndex *uint64 BlockRange *Range - OutputType *[]byte + IndexRange *Range + OutputType *[][]byte + Executed *bool VoucherAddress *common.Address } 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 3128fc4f4..b00d69fe8 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 5e725d68e..ea1114a51 100644 --- a/internal/repository/repotest/input_test_cases.go +++ b/internal/repository/repotest/input_test_cases.go @@ -251,6 +251,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..4cf7ca506 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) @@ -231,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) @@ -241,6 +258,90 @@ 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) + + 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) 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)