From 47bf1bdd5e493927ac6bbf0dde38e0c2becbdcc3 Mon Sep 17 00:00:00 2001 From: pk910 Date: Thu, 6 Aug 2026 16:01:02 +0200 Subject: [PATCH 1/2] feat(api): add attester duties, committees, and bulk validator status endpoints Add three general-purpose read APIs that expose data Dora already computes internally, so external tools can resolve attester committees and validator status without a beacon node: - GET /api/v1/epoch/{epoch}/duties - attester committees per slot for an epoch - GET /api/v1/slot/{slot}/committees - attester committees for a single slot - GET|POST /api/v1/validators/status - bulk validator status (up to 10k indices) Also expose `slashed` and `withdrawable_epoch` on GET /api/v1/validators. Committees are served from in-memory epoch stats with a blockdb fallback (no beacon-node call). Duties return 404 when unavailable for an epoch so callers can distinguish "no data" from "empty". Swagger regenerated. --- cmd/dora-explorer/main.go | 3 + docs/docs.go | 412 +++++++++++++++++++++++++++ docs/swagger.json | 412 +++++++++++++++++++++++++++ docs/swagger.yaml | 271 ++++++++++++++++++ handlers/api/epoch_duties_v1.go | 139 +++++++++ handlers/api/slot_committees_v1.go | 134 +++++++++ handlers/api/validators_status_v1.go | 153 ++++++++++ handlers/api/validators_v1.go | 16 +- 8 files changed, 1534 insertions(+), 6 deletions(-) create mode 100644 handlers/api/epoch_duties_v1.go create mode 100644 handlers/api/slot_committees_v1.go create mode 100644 handlers/api/validators_status_v1.go diff --git a/cmd/dora-explorer/main.go b/cmd/dora-explorer/main.go index 0068e261b..be8e7b3c8 100644 --- a/cmd/dora-explorer/main.go +++ b/cmd/dora-explorer/main.go @@ -320,12 +320,15 @@ func startApi(router *mux.Router) { {"/v1/validator/{indexOrPubkey}/deposits", api.ApiValidatorDepositsV1, []string{"GET", "OPTIONS"}, 1}, {"/v1/validators", api.APIValidatorsV1, []string{"GET", "OPTIONS"}, 1}, {"/v1/validators/activity", api.APIValidatorsActivityV1, []string{"GET", "OPTIONS"}, 2}, + {"/v1/validators/status", api.APIValidatorsStatusV1, []string{"GET", "POST", "OPTIONS"}, 2}, {"/v1/validator_names", api.APIValidatorNamesV1, []string{"GET", "POST", "OPTIONS"}, 1}, // Epoch and slot APIs {"/v1/epochs", api.APIEpochsV1, []string{"GET", "OPTIONS"}, 1}, {"/v1/epoch/{epoch}", api.ApiEpochV1, []string{"GET", "OPTIONS"}, 1}, {"/v1/epoch/{epoch}/health", api.ApiEpochHealthV1, []string{"GET", "OPTIONS"}, 1}, + {"/v1/epoch/{epoch}/duties", api.APIEpochDutiesV1, []string{"GET", "OPTIONS"}, 2}, + {"/v1/slot/{slot}/committees", api.APISlotCommitteesV1, []string{"GET", "OPTIONS"}, 1}, {"/v1/slot/{slotOrHash}", api.APISlotV1, []string{"GET", "OPTIONS"}, 1}, {"/v1/slot/{slotOrHash}/bids", api.APISlotBidsV1, []string{"GET", "OPTIONS"}, 1}, {"/v1/slot/{slotOrHash}/block_access_list", api.APISlotBlockAccessListV1, []string{"GET", "OPTIONS"}, 2}, diff --git a/docs/docs.go b/docs/docs.go index 7052eb081..d27f6a819 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -721,6 +721,68 @@ const docTemplate = `{ } } }, + "/v1/epoch/{epoch}/duties": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the attester committees for every slot in the specified epoch. Committee members are global validator indices in committee order.", + "produces": [ + "application/json" + ], + "tags": [ + "Epoch" + ], + "summary": "Get epoch attester duties", + "operationId": "getEpochDuties", + "parameters": [ + { + "type": "integer", + "description": "Epoch number", + "name": "epoch", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.APIEpochDutiesResponse" + } + }, + "400": { + "description": "Invalid parameters", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Duties not available", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/v1/epoch/{epoch}/health": { "get": { "description": "Returns the vote, proposal and payload participation rates for an epoch. The chain is only fully healthy when all three reach 100%. Post-ePBS (EIP-7732) payloads are revealed separately from beacon blocks and may be missing.", @@ -1539,6 +1601,74 @@ const docTemplate = `{ } } }, + "/v1/slot/{slot}/committees": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the attester committees for the specified slot. Committee members are global validator indices in committee order.", + "produces": [ + "application/json" + ], + "tags": [ + "Slot" + ], + "summary": "Get slot attester committees", + "operationId": "getSlotCommittees", + "parameters": [ + { + "type": "integer", + "description": "Slot number", + "name": "slot", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Comma-separated list of committee indices to filter by", + "name": "committee", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.APISlotCommitteesResponse" + } + }, + "400": { + "description": "Invalid parameters", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Committees not available", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/v1/slots": { "get": { "description": "Returns a list of slots with various filtering options, sorted by slot number descending", @@ -2285,6 +2415,130 @@ const docTemplate = `{ } } }, + "/v1/validators/status": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns status, slashed flag and lifecycle epochs for up to 10000 validators by index. Supports GET with query params or POST with JSON body for large lists. Unknown indices are omitted from the response.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "validators" + ], + "summary": "Get validator status in bulk", + "operationId": "getValidatorsStatus", + "parameters": [ + { + "type": "string", + "description": "Comma-separated list of validator indices (GET only)", + "name": "indices", + "in": "query" + }, + { + "description": "Request body for POST requests with indices array", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/api.APIValidatorsStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.APIValidatorsStatusResponse" + } + }, + "400": { + "description": "Invalid parameters", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns status, slashed flag and lifecycle epochs for up to 10000 validators by index. Supports GET with query params or POST with JSON body for large lists. Unknown indices are omitted from the response.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "validators" + ], + "summary": "Get validator status in bulk", + "operationId": "getValidatorsStatus", + "parameters": [ + { + "type": "string", + "description": "Comma-separated list of validator indices (GET only)", + "name": "indices", + "in": "query" + }, + { + "description": "Request body for POST requests with indices array", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/api.APIValidatorsStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.APIValidatorsStatusResponse" + } + }, + "400": { + "description": "Invalid parameters", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/v1/voluntary_exits": { "get": { "description": "Returns a list of voluntary exits with detailed information and filtering options", @@ -3304,6 +3558,54 @@ const docTemplate = `{ } } }, + "api.APIEpochDutiesData": { + "type": "object", + "properties": { + "committees_per_slot": { + "type": "integer" + }, + "dependent_root": { + "type": "string" + }, + "epoch": { + "type": "integer" + }, + "slots": { + "type": "array", + "items": { + "$ref": "#/definitions/api.APIEpochDutiesSlotInfo" + } + } + } + }, + "api.APIEpochDutiesResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/api.APIEpochDutiesData" + }, + "status": { + "type": "string" + } + } + }, + "api.APIEpochDutiesSlotInfo": { + "type": "object", + "properties": { + "committees": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "slot": { + "type": "integer" + } + } + }, "api.APIEpochHealthResponseV1": { "type": "object", "properties": { @@ -4442,6 +4744,48 @@ const docTemplate = `{ } } }, + "api.APISlotCommitteeInfo": { + "type": "object", + "properties": { + "index": { + "type": "integer" + }, + "validators": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "api.APISlotCommitteesData": { + "type": "object", + "properties": { + "committees": { + "type": "array", + "items": { + "$ref": "#/definitions/api.APISlotCommitteeInfo" + } + }, + "epoch": { + "type": "integer" + }, + "slot": { + "type": "integer" + } + } + }, + "api.APISlotCommitteesResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/api.APISlotCommitteesData" + }, + "status": { + "type": "string" + } + } + }, "api.APISlotData": { "type": "object", "properties": { @@ -4988,6 +5332,9 @@ const docTemplate = `{ "public_key": { "type": "string" }, + "slashed": { + "type": "boolean" + }, "status": { "type": "string" }, @@ -4997,6 +5344,9 @@ const docTemplate = `{ "validator_liveness_max": { "type": "integer" }, + "withdrawable_epoch": { + "type": "integer" + }, "withdrawal_address": { "type": "string" }, @@ -5084,6 +5434,32 @@ const docTemplate = `{ } } }, + "api.APIValidatorStatusInfo": { + "type": "object", + "properties": { + "activation_epoch": { + "type": "integer" + }, + "effective_balance": { + "type": "integer" + }, + "exit_epoch": { + "type": "integer" + }, + "index": { + "type": "integer" + }, + "slashed": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "withdrawable_epoch": { + "type": "integer" + } + } + }, "api.APIValidatorsActivityData": { "type": "object", "properties": { @@ -5186,6 +5562,42 @@ const docTemplate = `{ } } }, + "api.APIValidatorsStatusData": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "validators": { + "type": "array", + "items": { + "$ref": "#/definitions/api.APIValidatorStatusInfo" + } + } + } + }, + "api.APIValidatorsStatusRequest": { + "type": "object", + "properties": { + "indices": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "api.APIValidatorsStatusResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/api.APIValidatorsStatusData" + }, + "status": { + "type": "string" + } + } + }, "api.APIVoluntaryExitInfo": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 28c5e9d59..2f33a4e0f 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -718,6 +718,68 @@ } } }, + "/v1/epoch/{epoch}/duties": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the attester committees for every slot in the specified epoch. Committee members are global validator indices in committee order.", + "produces": [ + "application/json" + ], + "tags": [ + "Epoch" + ], + "summary": "Get epoch attester duties", + "operationId": "getEpochDuties", + "parameters": [ + { + "type": "integer", + "description": "Epoch number", + "name": "epoch", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.APIEpochDutiesResponse" + } + }, + "400": { + "description": "Invalid parameters", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Duties not available", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/v1/epoch/{epoch}/health": { "get": { "description": "Returns the vote, proposal and payload participation rates for an epoch. The chain is only fully healthy when all three reach 100%. Post-ePBS (EIP-7732) payloads are revealed separately from beacon blocks and may be missing.", @@ -1536,6 +1598,74 @@ } } }, + "/v1/slot/{slot}/committees": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the attester committees for the specified slot. Committee members are global validator indices in committee order.", + "produces": [ + "application/json" + ], + "tags": [ + "Slot" + ], + "summary": "Get slot attester committees", + "operationId": "getSlotCommittees", + "parameters": [ + { + "type": "integer", + "description": "Slot number", + "name": "slot", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Comma-separated list of committee indices to filter by", + "name": "committee", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.APISlotCommitteesResponse" + } + }, + "400": { + "description": "Invalid parameters", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Committees not available", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/v1/slots": { "get": { "description": "Returns a list of slots with various filtering options, sorted by slot number descending", @@ -2282,6 +2412,130 @@ } } }, + "/v1/validators/status": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns status, slashed flag and lifecycle epochs for up to 10000 validators by index. Supports GET with query params or POST with JSON body for large lists. Unknown indices are omitted from the response.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "validators" + ], + "summary": "Get validator status in bulk", + "operationId": "getValidatorsStatus", + "parameters": [ + { + "type": "string", + "description": "Comma-separated list of validator indices (GET only)", + "name": "indices", + "in": "query" + }, + { + "description": "Request body for POST requests with indices array", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/api.APIValidatorsStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.APIValidatorsStatusResponse" + } + }, + "400": { + "description": "Invalid parameters", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns status, slashed flag and lifecycle epochs for up to 10000 validators by index. Supports GET with query params or POST with JSON body for large lists. Unknown indices are omitted from the response.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "validators" + ], + "summary": "Get validator status in bulk", + "operationId": "getValidatorsStatus", + "parameters": [ + { + "type": "string", + "description": "Comma-separated list of validator indices (GET only)", + "name": "indices", + "in": "query" + }, + { + "description": "Request body for POST requests with indices array", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/api.APIValidatorsStatusRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.APIValidatorsStatusResponse" + } + }, + "400": { + "description": "Invalid parameters", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/v1/voluntary_exits": { "get": { "description": "Returns a list of voluntary exits with detailed information and filtering options", @@ -3301,6 +3555,54 @@ } } }, + "api.APIEpochDutiesData": { + "type": "object", + "properties": { + "committees_per_slot": { + "type": "integer" + }, + "dependent_root": { + "type": "string" + }, + "epoch": { + "type": "integer" + }, + "slots": { + "type": "array", + "items": { + "$ref": "#/definitions/api.APIEpochDutiesSlotInfo" + } + } + } + }, + "api.APIEpochDutiesResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/api.APIEpochDutiesData" + }, + "status": { + "type": "string" + } + } + }, + "api.APIEpochDutiesSlotInfo": { + "type": "object", + "properties": { + "committees": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "integer" + } + } + }, + "slot": { + "type": "integer" + } + } + }, "api.APIEpochHealthResponseV1": { "type": "object", "properties": { @@ -4439,6 +4741,48 @@ } } }, + "api.APISlotCommitteeInfo": { + "type": "object", + "properties": { + "index": { + "type": "integer" + }, + "validators": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "api.APISlotCommitteesData": { + "type": "object", + "properties": { + "committees": { + "type": "array", + "items": { + "$ref": "#/definitions/api.APISlotCommitteeInfo" + } + }, + "epoch": { + "type": "integer" + }, + "slot": { + "type": "integer" + } + } + }, + "api.APISlotCommitteesResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/api.APISlotCommitteesData" + }, + "status": { + "type": "string" + } + } + }, "api.APISlotData": { "type": "object", "properties": { @@ -4985,6 +5329,9 @@ "public_key": { "type": "string" }, + "slashed": { + "type": "boolean" + }, "status": { "type": "string" }, @@ -4994,6 +5341,9 @@ "validator_liveness_max": { "type": "integer" }, + "withdrawable_epoch": { + "type": "integer" + }, "withdrawal_address": { "type": "string" }, @@ -5081,6 +5431,32 @@ } } }, + "api.APIValidatorStatusInfo": { + "type": "object", + "properties": { + "activation_epoch": { + "type": "integer" + }, + "effective_balance": { + "type": "integer" + }, + "exit_epoch": { + "type": "integer" + }, + "index": { + "type": "integer" + }, + "slashed": { + "type": "boolean" + }, + "status": { + "type": "string" + }, + "withdrawable_epoch": { + "type": "integer" + } + } + }, "api.APIValidatorsActivityData": { "type": "object", "properties": { @@ -5183,6 +5559,42 @@ } } }, + "api.APIValidatorsStatusData": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "validators": { + "type": "array", + "items": { + "$ref": "#/definitions/api.APIValidatorStatusInfo" + } + } + } + }, + "api.APIValidatorsStatusRequest": { + "type": "object", + "properties": { + "indices": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "api.APIValidatorsStatusResponse": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/api.APIValidatorsStatusData" + }, + "status": { + "type": "string" + } + } + }, "api.APIVoluntaryExitInfo": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index f9f3bb67e..116d247d1 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -534,6 +534,37 @@ definitions: status: type: string type: object + api.APIEpochDutiesData: + properties: + committees_per_slot: + type: integer + dependent_root: + type: string + epoch: + type: integer + slots: + items: + $ref: '#/definitions/api.APIEpochDutiesSlotInfo' + type: array + type: object + api.APIEpochDutiesResponse: + properties: + data: + $ref: '#/definitions/api.APIEpochDutiesData' + status: + type: string + type: object + api.APIEpochDutiesSlotInfo: + properties: + committees: + items: + items: + type: integer + type: array + type: array + slot: + type: integer + type: object api.APIEpochHealthResponseV1: properties: eligible_ether: @@ -1298,6 +1329,33 @@ definitions: slot: type: string type: object + api.APISlotCommitteeInfo: + properties: + index: + type: integer + validators: + items: + type: integer + type: array + type: object + api.APISlotCommitteesData: + properties: + committees: + items: + $ref: '#/definitions/api.APISlotCommitteeInfo' + type: array + epoch: + type: integer + slot: + type: integer + type: object + api.APISlotCommitteesResponse: + properties: + data: + $ref: '#/definitions/api.APISlotCommitteesData' + status: + type: string + type: object api.APISlotData: properties: attestationscount: @@ -1657,12 +1715,16 @@ definitions: type: string public_key: type: string + slashed: + type: boolean status: type: string validator_liveness: type: integer validator_liveness_max: type: integer + withdrawable_epoch: + type: integer withdrawal_address: type: string withdrawal_credentials: @@ -1719,6 +1781,23 @@ definitions: total_eligible_ether: type: integer type: object + api.APIValidatorStatusInfo: + properties: + activation_epoch: + type: integer + effective_balance: + type: integer + exit_epoch: + type: integer + index: + type: integer + slashed: + type: boolean + status: + type: string + withdrawable_epoch: + type: integer + type: object api.APIValidatorsActivityData: properties: current_epoch: @@ -1786,6 +1865,29 @@ definitions: status: type: string type: object + api.APIValidatorsStatusData: + properties: + count: + type: integer + validators: + items: + $ref: '#/definitions/api.APIValidatorStatusInfo' + type: array + type: object + api.APIValidatorsStatusRequest: + properties: + indices: + items: + type: integer + type: array + type: object + api.APIValidatorsStatusResponse: + properties: + data: + $ref: '#/definitions/api.APIValidatorsStatusData' + status: + type: string + type: object api.APIVoluntaryExitInfo: properties: orphaned: @@ -2502,6 +2604,47 @@ paths: summary: Get epoch by number, latest, finalized tags: - Epoch + /v1/epoch/{epoch}/duties: + get: + description: Returns the attester committees for every slot in the specified + epoch. Committee members are global validator indices in committee order. + operationId: getEpochDuties + parameters: + - description: Epoch number + in: path + name: epoch + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.APIEpochDutiesResponse' + "400": + description: Invalid parameters + schema: + additionalProperties: + type: string + type: object + "404": + description: Duties not available + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get epoch attester duties + tags: + - Epoch /v1/epoch/{epoch}/health: get: description: Returns the vote, proposal and payload participation rates for @@ -2832,6 +2975,51 @@ paths: summary: Get slashings list tags: - slashings + /v1/slot/{slot}/committees: + get: + description: Returns the attester committees for the specified slot. Committee + members are global validator indices in committee order. + operationId: getSlotCommittees + parameters: + - description: Slot number + in: path + name: slot + required: true + type: integer + - description: Comma-separated list of committee indices to filter by + in: query + name: committee + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.APISlotCommitteesResponse' + "400": + description: Invalid parameters + schema: + additionalProperties: + type: string + type: object + "404": + description: Committees not available + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get slot attester committees + tags: + - Slot /v1/slot/{slotOrHash}: get: description: Returns detailed information about a specific slot from the database. @@ -3567,6 +3755,89 @@ paths: summary: Get validators activity statistics tags: - validators + /v1/validators/status: + get: + consumes: + - application/json + description: Returns status, slashed flag and lifecycle epochs for up to 10000 + validators by index. Supports GET with query params or POST with JSON body + for large lists. Unknown indices are omitted from the response. + operationId: getValidatorsStatus + parameters: + - description: Comma-separated list of validator indices (GET only) + in: query + name: indices + type: string + - description: Request body for POST requests with indices array + in: body + name: body + schema: + $ref: '#/definitions/api.APIValidatorsStatusRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.APIValidatorsStatusResponse' + "400": + description: Invalid parameters + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get validator status in bulk + tags: + - validators + post: + consumes: + - application/json + description: Returns status, slashed flag and lifecycle epochs for up to 10000 + validators by index. Supports GET with query params or POST with JSON body + for large lists. Unknown indices are omitted from the response. + operationId: getValidatorsStatus + parameters: + - description: Comma-separated list of validator indices (GET only) + in: query + name: indices + type: string + - description: Request body for POST requests with indices array + in: body + name: body + schema: + $ref: '#/definitions/api.APIValidatorsStatusRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.APIValidatorsStatusResponse' + "400": + description: Invalid parameters + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal server error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get validator status in bulk + tags: + - validators /v1/voluntary_exits: get: consumes: diff --git a/handlers/api/epoch_duties_v1.go b/handlers/api/epoch_duties_v1.go new file mode 100644 index 000000000..566279b53 --- /dev/null +++ b/handlers/api/epoch_duties_v1.go @@ -0,0 +1,139 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/ethpandaops/dora/services" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/gorilla/mux" + "github.com/sirupsen/logrus" +) + +// APIEpochDutiesResponse represents the response structure for epoch attester duties +type APIEpochDutiesResponse struct { + Status string `json:"status"` + Data *APIEpochDutiesData `json:"data"` +} + +// APIEpochDutiesData contains the attester committees for all slots of an epoch +type APIEpochDutiesData struct { + Epoch uint64 `json:"epoch"` + DependentRoot string `json:"dependent_root,omitempty"` + CommitteesPerSlot uint64 `json:"committees_per_slot"` + Slots []*APIEpochDutiesSlotInfo `json:"slots"` +} + +// APIEpochDutiesSlotInfo contains the attester committees of a single slot. +// The position in the committees array is the committee index, the values are +// global validator indices in committee order. +type APIEpochDutiesSlotInfo struct { + Slot uint64 `json:"slot"` + Committees [][]uint64 `json:"committees"` +} + +// APIEpochDutiesV1 returns the attester committees for every slot in an epoch +// @Summary Get epoch attester duties +// @Description Returns the attester committees for every slot in the specified epoch. Committee members are global validator indices in committee order. +// @Tags Epoch +// @Produce json +// @Param epoch path int true "Epoch number" +// @Success 200 {object} APIEpochDutiesResponse +// @Failure 400 {object} map[string]string "Invalid parameters" +// @Failure 404 {object} map[string]string "Duties not available" +// @Failure 500 {object} map[string]string "Internal server error" +// @Router /v1/epoch/{epoch}/duties [get] +// @ID getEpochDuties +// @Security BearerAuth +func APIEpochDutiesV1(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + vars := mux.Vars(r) + + epoch, err := strconv.ParseUint(vars["epoch"], 10, 64) + if err != nil { + http.Error(w, `{"status": "ERROR: invalid epoch parameter"}`, http.StatusBadRequest) + return + } + + chainState := services.GlobalBeaconService.GetChainState() + currentEpoch := uint64(chainState.CurrentEpoch()) + + // Attester duties are known one epoch in advance, everything beyond that is unknowable. + if epoch > currentEpoch+1 { + http.Error( + w, + fmt.Sprintf(`{"status": "ERROR: epoch is too far in the future. The latest epoch is %v"}`, currentEpoch), + http.StatusBadRequest, + ) + return + } + + specs := chainState.GetSpecs() + slotsPerEpoch := specs.SlotsPerEpoch + firstSlot := chainState.EpochStartSlot(phase0.Epoch(epoch)) + + slots := make([]*APIEpochDutiesSlotInfo, 0, slotsPerEpoch) + committeesPerSlot := uint64(0) + haveDuties := false + + for slotIdx := uint64(0); slotIdx < slotsPerEpoch; slotIdx++ { + slot := firstSlot + phase0.Slot(slotIdx) + committees := services.GlobalBeaconService.GetSlotCommittees(r.Context(), slot) + if committees != nil { + haveDuties = true + } + + slotCommittees := make([][]uint64, len(committees)) + for committeeIdx, committee := range committees { + members := make([]uint64, len(committee)) + for i, validatorIndex := range committee { + members[i] = uint64(validatorIndex) + } + slotCommittees[committeeIdx] = members + } + + if uint64(len(slotCommittees)) > committeesPerSlot { + committeesPerSlot = uint64(len(slotCommittees)) + } + + slots = append(slots, &APIEpochDutiesSlotInfo{ + Slot: uint64(slot), + Committees: slotCommittees, + }) + } + + if !haveDuties { + http.Error( + w, + fmt.Sprintf(`{"status": "ERROR: duties not available for epoch %v"}`, epoch), + http.StatusNotFound, + ) + return + } + + data := &APIEpochDutiesData{ + Epoch: epoch, + CommitteesPerSlot: committeesPerSlot, + Slots: slots, + } + + // The dependent root is only known while the epoch stats are held in memory. + if epochStats := services.GlobalBeaconService.GetBeaconIndexer().GetEpochStats(phase0.Epoch(epoch), nil); epochStats != nil { + dependentRoot := epochStats.GetDependentRoot() + data.DependentRoot = fmt.Sprintf("0x%x", dependentRoot[:]) + } + + response := APIEpochDutiesResponse{ + Status: "OK", + Data: data, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + logrus.WithError(err).Error("failed to encode epoch duties response") + http.Error(w, `{"status": "ERROR: failed to encode response"}`, http.StatusInternalServerError) + return + } +} diff --git a/handlers/api/slot_committees_v1.go b/handlers/api/slot_committees_v1.go new file mode 100644 index 000000000..96f09267e --- /dev/null +++ b/handlers/api/slot_committees_v1.go @@ -0,0 +1,134 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/ethpandaops/dora/services" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/gorilla/mux" + "github.com/sirupsen/logrus" +) + +// APISlotCommitteesResponse represents the response structure for slot committees +type APISlotCommitteesResponse struct { + Status string `json:"status"` + Data *APISlotCommitteesData `json:"data"` +} + +// APISlotCommitteesData contains the attester committees of a single slot +type APISlotCommitteesData struct { + Slot uint64 `json:"slot"` + Epoch uint64 `json:"epoch"` + Committees []*APISlotCommitteeInfo `json:"committees"` +} + +// APISlotCommitteeInfo represents a single attester committee of a slot. +// Validators are global validator indices in committee order. +type APISlotCommitteeInfo struct { + Index uint64 `json:"index"` + Validators []uint64 `json:"validators"` +} + +// APISlotCommitteesV1 returns the attester committees for a slot +// @Summary Get slot attester committees +// @Description Returns the attester committees for the specified slot. Committee members are global validator indices in committee order. +// @Tags Slot +// @Produce json +// @Param slot path int true "Slot number" +// @Param committee query string false "Comma-separated list of committee indices to filter by" +// @Success 200 {object} APISlotCommitteesResponse +// @Failure 400 {object} map[string]string "Invalid parameters" +// @Failure 404 {object} map[string]string "Committees not available" +// @Failure 500 {object} map[string]string "Internal server error" +// @Router /v1/slot/{slot}/committees [get] +// @ID getSlotCommittees +// @Security BearerAuth +func APISlotCommitteesV1(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + vars := mux.Vars(r) + + slot, err := strconv.ParseUint(vars["slot"], 10, 64) + if err != nil { + http.Error(w, `{"status": "ERROR: invalid slot parameter"}`, http.StatusBadRequest) + return + } + + chainState := services.GlobalBeaconService.GetChainState() + epoch := chainState.EpochOfSlot(phase0.Slot(slot)) + currentEpoch := chainState.CurrentEpoch() + + // Attester duties are known one epoch in advance, everything beyond that is unknowable. + if epoch > currentEpoch+1 { + http.Error( + w, + fmt.Sprintf(`{"status": "ERROR: slot is too far in the future. The current epoch is %v"}`, currentEpoch), + http.StatusBadRequest, + ) + return + } + + // Parse optional committee filter + var committeeFilter map[uint64]bool + if committeeParam := r.URL.Query().Get("committee"); committeeParam != "" { + committeeFilter = make(map[uint64]bool, 8) + for _, committeeStr := range strings.Split(committeeParam, ",") { + committeeStr = strings.TrimSpace(committeeStr) + if committeeStr == "" { + continue + } + committeeIdx, perr := strconv.ParseUint(committeeStr, 10, 64) + if perr != nil { + http.Error(w, `{"status": "ERROR: invalid committee parameter"}`, http.StatusBadRequest) + return + } + committeeFilter[committeeIdx] = true + } + } + + slotCommittees := services.GlobalBeaconService.GetSlotCommittees(r.Context(), phase0.Slot(slot)) + if slotCommittees == nil { + http.Error( + w, + fmt.Sprintf(`{"status": "ERROR: committees not available for slot %v"}`, slot), + http.StatusNotFound, + ) + return + } + + committees := make([]*APISlotCommitteeInfo, 0, len(slotCommittees)) + for committeeIdx, committee := range slotCommittees { + if committeeFilter != nil && !committeeFilter[uint64(committeeIdx)] { + continue + } + + members := make([]uint64, len(committee)) + for i, validatorIndex := range committee { + members[i] = uint64(validatorIndex) + } + + committees = append(committees, &APISlotCommitteeInfo{ + Index: uint64(committeeIdx), + Validators: members, + }) + } + + response := APISlotCommitteesResponse{ + Status: "OK", + Data: &APISlotCommitteesData{ + Slot: slot, + Epoch: uint64(epoch), + Committees: committees, + }, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + logrus.WithError(err).Error("failed to encode slot committees response") + http.Error(w, `{"status": "ERROR: failed to encode response"}`, http.StatusInternalServerError) + return + } +} diff --git a/handlers/api/validators_status_v1.go b/handlers/api/validators_status_v1.go new file mode 100644 index 000000000..843487317 --- /dev/null +++ b/handlers/api/validators_status_v1.go @@ -0,0 +1,153 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + + "github.com/ethpandaops/dora/services" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/sirupsen/logrus" +) + +// APIValidatorsStatusResponse represents the response structure for bulk validator status lookup +type APIValidatorsStatusResponse struct { + Status string `json:"status"` + Data *APIValidatorsStatusData `json:"data"` +} + +// APIValidatorsStatusData contains the validator status data +type APIValidatorsStatusData struct { + Validators []*APIValidatorStatusInfo `json:"validators"` + Count uint64 `json:"count"` +} + +// APIValidatorStatusInfo represents the status of a single validator. +// Unknown validator indices are omitted from the response. +type APIValidatorStatusInfo struct { + Index uint64 `json:"index"` + Status string `json:"status"` + Slashed bool `json:"slashed"` + ActivationEpoch uint64 `json:"activation_epoch"` + ExitEpoch uint64 `json:"exit_epoch"` + WithdrawableEpoch uint64 `json:"withdrawable_epoch"` + EffectiveBalance uint64 `json:"effective_balance"` +} + +// APIValidatorsStatusRequest represents the request body for POST requests +type APIValidatorsStatusRequest struct { + Indices []uint64 `json:"indices"` +} + +// APIValidatorsStatusV1 returns the status of validators in bulk +// Supports both GET (with query params) and POST (with JSON body) requests +// @Summary Get validator status in bulk +// @Description Returns status, slashed flag and lifecycle epochs for up to 10000 validators by index. Supports GET with query params or POST with JSON body for large lists. Unknown indices are omitted from the response. +// @Tags validators +// @Accept json +// @Produce json +// @Param indices query string false "Comma-separated list of validator indices (GET only)" +// @Param body body APIValidatorsStatusRequest false "Request body for POST requests with indices array" +// @Success 200 {object} APIValidatorsStatusResponse +// @Failure 400 {object} map[string]string "Invalid parameters" +// @Failure 500 {object} map[string]string "Internal server error" +// @Router /v1/validators/status [get] +// @Router /v1/validators/status [post] +// @ID getValidatorsStatus +// @Security BearerAuth +func APIValidatorsStatusV1(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + var indices []uint64 + + // Handle different request methods + switch r.Method { + case "GET": + // Parse query parameters + indicesStr := r.URL.Query().Get("indices") + if indicesStr == "" { + http.Error(w, `{"status": "ERROR: indices parameter must be provided"}`, http.StatusBadRequest) + return + } + + for _, indexStr := range strings.Split(indicesStr, ",") { + indexStr = strings.TrimSpace(indexStr) + if indexStr == "" { + continue + } + index, err := strconv.ParseUint(indexStr, 10, 64) + if err != nil { + http.Error(w, `{"status": "ERROR: invalid validator index: `+indexStr+`"}`, http.StatusBadRequest) + return + } + indices = append(indices, index) + } + + case "POST": + // Parse JSON body + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, `{"status": "ERROR: failed to read request body"}`, http.StatusBadRequest) + return + } + defer func() { + _ = r.Body.Close() + }() + + var req APIValidatorsStatusRequest + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, `{"status": "ERROR: invalid JSON body"}`, http.StatusBadRequest) + return + } + + indices = req.Indices + + default: + http.Error(w, `{"status": "ERROR: method not allowed, use GET or POST"}`, http.StatusMethodNotAllowed) + return + } + + if len(indices) == 0 { + http.Error(w, `{"status": "ERROR: no valid indices provided"}`, http.StatusBadRequest) + return + } + if len(indices) > 10000 { + http.Error(w, `{"status": "ERROR: maximum 10000 indices allowed"}`, http.StatusBadRequest) + return + } + + validators := make([]*APIValidatorStatusInfo, 0, len(indices)) + for _, index := range indices { + validator := services.GlobalBeaconService.GetValidatorByIndex(phase0.ValidatorIndex(index), false) + if validator == nil || validator.Validator == nil { + // Unknown validator index, omit from results + continue + } + + validators = append(validators, &APIValidatorStatusInfo{ + Index: uint64(validator.Index), + Status: validator.Status.String(), + Slashed: validator.Validator.Slashed, + ActivationEpoch: uint64(validator.Validator.ActivationEpoch), + ExitEpoch: uint64(validator.Validator.ExitEpoch), + WithdrawableEpoch: uint64(validator.Validator.WithdrawableEpoch), + EffectiveBalance: uint64(validator.Validator.EffectiveBalance), + }) + } + + response := APIValidatorsStatusResponse{ + Status: "OK", + Data: &APIValidatorsStatusData{ + Validators: validators, + Count: uint64(len(validators)), + }, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + logrus.WithError(err).Error("failed to encode validators status response") + http.Error(w, `{"status": "ERROR: failed to encode response"}`, http.StatusInternalServerError) + return + } +} diff --git a/handlers/api/validators_v1.go b/handlers/api/validators_v1.go index c4eafcfd2..df0c9f98c 100644 --- a/handlers/api/validators_v1.go +++ b/handlers/api/validators_v1.go @@ -46,6 +46,8 @@ type APIValidatorInfo struct { ActivationTime int64 `json:"activation_time,omitempty"` ExitEpoch uint64 `json:"exit_epoch,omitempty"` ExitTime int64 `json:"exit_time,omitempty"` + Slashed bool `json:"slashed"` + WithdrawableEpoch uint64 `json:"withdrawable_epoch"` WithdrawalAddress string `json:"withdrawal_address,omitempty"` WithdrawalCreds string `json:"withdrawal_credentials"` ValidatorLiveness uint8 `json:"validator_liveness,omitempty"` @@ -207,12 +209,14 @@ func APIValidatorsV1(w http.ResponseWriter, r *http.Request) { } validatorInfo := &APIValidatorInfo{ - Index: uint64(validator.Index), - Name: services.GlobalBeaconService.GetValidatorName(uint64(validator.Index)), - PublicKey: fmt.Sprintf("0x%x", validator.Validator.PublicKey[:]), - Balance: uint64(validator.Balance), - EffectiveBalance: uint64(validator.Validator.EffectiveBalance), - WithdrawalCreds: fmt.Sprintf("0x%x", validator.Validator.WithdrawalCredentials), + Index: uint64(validator.Index), + Name: services.GlobalBeaconService.GetValidatorName(uint64(validator.Index)), + PublicKey: fmt.Sprintf("0x%x", validator.Validator.PublicKey[:]), + Balance: uint64(validator.Balance), + EffectiveBalance: uint64(validator.Validator.EffectiveBalance), + WithdrawalCreds: fmt.Sprintf("0x%x", validator.Validator.WithdrawalCredentials), + Slashed: validator.Validator.Slashed, + WithdrawableEpoch: uint64(validator.Validator.WithdrawableEpoch), } // Set validator status and liveness From 443e22d92289027163a5f04f1b135a391ead0960 Mon Sep 17 00:00:00 2001 From: pk910 Date: Thu, 6 Aug 2026 16:10:57 +0200 Subject: [PATCH 2/2] perf(api): load epoch duties once instead of per-slot APIEpochDutiesV1 called GetSlotCommittees for every slot in the epoch. On the blockdb/S3 fallback path each of those calls loads the entire epoch duties object, so a full epoch reloaded the same object once per slot (32x on mainnet). Add ChainService.GetEpochCommittees(epoch), which resolves committees for all slots from a single source read (in-memory epoch stats, or one blockdb GetEpochDuties fetch), and switch the handler to it. --- handlers/api/epoch_duties_v1.go | 33 +++++++++----------- services/chainservice_duties.go | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 18 deletions(-) diff --git a/handlers/api/epoch_duties_v1.go b/handlers/api/epoch_duties_v1.go index 566279b53..a0e5a5644 100644 --- a/handlers/api/epoch_duties_v1.go +++ b/handlers/api/epoch_duties_v1.go @@ -71,20 +71,26 @@ func APIEpochDutiesV1(w http.ResponseWriter, r *http.Request) { return } - specs := chainState.GetSpecs() - slotsPerEpoch := specs.SlotsPerEpoch firstSlot := chainState.EpochStartSlot(phase0.Epoch(epoch)) - slots := make([]*APIEpochDutiesSlotInfo, 0, slotsPerEpoch) + // Load the whole epoch's committees in a single read (one blockdb/S3 fetch), + // rather than a per-slot lookup that would reload the same duties object for + // every slot in the epoch. + epochCommittees := services.GlobalBeaconService.GetEpochCommittees(r.Context(), phase0.Epoch(epoch)) + if epochCommittees == nil { + http.Error( + w, + fmt.Sprintf(`{"status": "ERROR: duties not available for epoch %v"}`, epoch), + http.StatusNotFound, + ) + return + } + + slots := make([]*APIEpochDutiesSlotInfo, 0, len(epochCommittees)) committeesPerSlot := uint64(0) - haveDuties := false - for slotIdx := uint64(0); slotIdx < slotsPerEpoch; slotIdx++ { + for slotIdx, committees := range epochCommittees { slot := firstSlot + phase0.Slot(slotIdx) - committees := services.GlobalBeaconService.GetSlotCommittees(r.Context(), slot) - if committees != nil { - haveDuties = true - } slotCommittees := make([][]uint64, len(committees)) for committeeIdx, committee := range committees { @@ -105,15 +111,6 @@ func APIEpochDutiesV1(w http.ResponseWriter, r *http.Request) { }) } - if !haveDuties { - http.Error( - w, - fmt.Sprintf(`{"status": "ERROR: duties not available for epoch %v"}`, epoch), - http.StatusNotFound, - ) - return - } - data := &APIEpochDutiesData{ Epoch: epoch, CommitteesPerSlot: committeesPerSlot, diff --git a/services/chainservice_duties.go b/services/chainservice_duties.go index f4e5889f4..fbe828885 100644 --- a/services/chainservice_duties.go +++ b/services/chainservice_duties.go @@ -59,6 +59,60 @@ func (bs *ChainService) GetSlotCommittees(ctx context.Context, slot phase0.Slot) return committees } +// GetEpochCommittees returns the attester committees for every slot of an epoch, +// indexed [slotIndex][committeeIndex] -> global validator indices in committee +// order. Unlike calling GetSlotCommittees per slot, it loads the epoch's duties +// exactly once (from the in-memory epoch cache, or a single blockdb read), +// avoiding a redundant blockdb/S3 fetch of the same duties object per slot. +// Returns nil if the duties are unavailable for the epoch. +func (bs *ChainService) GetEpochCommittees(ctx context.Context, epoch phase0.Epoch) [][][]phase0.ValidatorIndex { + chainState := bs.consensusPool.GetChainState() + + if epochStats := bs.beaconIndexer.GetEpochStats(epoch, nil); epochStats != nil { + if values := epochStats.GetOrLoadValues(ctx, bs.beaconIndexer, true, false); values != nil && values.AttesterDuties != nil { + out := make([][][]phase0.ValidatorIndex, len(values.AttesterDuties)) + for slotIndex, slotDuties := range values.AttesterDuties { + committees := make([][]phase0.ValidatorIndex, len(slotDuties)) + for committeeIndex, committee := range slotDuties { + members := make([]phase0.ValidatorIndex, len(committee)) + for k, activeIdx := range committee { + if int(activeIdx) < len(values.ActiveIndices) { + members[k] = values.ActiveIndices[activeIdx] + } + } + committees[committeeIndex] = members + } + out[slotIndex] = committees + } + return out + } + } + + if blockdb.GlobalBlockDb == nil || !blockdb.GlobalBlockDb.SupportsDuties() { + return nil + } + + firstSlot := uint64(chainState.EpochStartSlot(epoch)) + duties, err := blockdb.GlobalBlockDb.GetEpochDuties(ctx, firstSlot) + if err != nil { + bs.logger.Debugf("failed to load duties for epoch %d from blockdb: %v", epoch, err) + return nil + } + if duties == nil { + return nil + } + + out := make([][][]phase0.ValidatorIndex, len(duties.Committees)) + for slotIndex, slotCommittees := range duties.Committees { + committees := make([][]phase0.ValidatorIndex, len(slotCommittees)) + for committeeIndex, committee := range slotCommittees { + committees[committeeIndex] = toValidatorIndices(committee) + } + out[slotIndex] = committees + } + return out +} + // GetSlotPtc returns the PTC members for a slot (global validator indices), // from the in-memory epoch cache or the blockdb duties store. Returns nil if unavailable. func (bs *ChainService) GetSlotPtc(ctx context.Context, slot phase0.Slot) []phase0.ValidatorIndex {