diff --git a/cmd/dora-explorer/main.go b/cmd/dora-explorer/main.go index d361082d..c36dabc3 100644 --- a/cmd/dora-explorer/main.go +++ b/cmd/dora-explorer/main.go @@ -321,12 +321,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 2941b16a..086709a2 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": { @@ -4449,6 +4751,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": { @@ -4995,6 +5339,9 @@ const docTemplate = `{ "public_key": { "type": "string" }, + "slashed": { + "type": "boolean" + }, "status": { "type": "string" }, @@ -5004,6 +5351,9 @@ const docTemplate = `{ "validator_liveness_max": { "type": "integer" }, + "withdrawable_epoch": { + "type": "integer" + }, "withdrawal_address": { "type": "string" }, @@ -5091,6 +5441,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": { @@ -5193,6 +5569,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 cd38e3ca..3adbaee9 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": { @@ -4446,6 +4748,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": { @@ -4992,6 +5336,9 @@ "public_key": { "type": "string" }, + "slashed": { + "type": "boolean" + }, "status": { "type": "string" }, @@ -5001,6 +5348,9 @@ "validator_liveness_max": { "type": "integer" }, + "withdrawable_epoch": { + "type": "integer" + }, "withdrawal_address": { "type": "string" }, @@ -5088,6 +5438,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": { @@ -5190,6 +5566,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 1403286c..a4ffbd99 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: @@ -1306,6 +1337,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: @@ -1665,12 +1723,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: @@ -1727,6 +1789,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: @@ -1794,6 +1873,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: @@ -2510,6 +2612,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 @@ -2840,6 +2983,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. @@ -3575,6 +3763,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 00000000..a0e5a564 --- /dev/null +++ b/handlers/api/epoch_duties_v1.go @@ -0,0 +1,136 @@ +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 + } + + firstSlot := chainState.EpochStartSlot(phase0.Epoch(epoch)) + + // 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) + + for slotIdx, committees := range epochCommittees { + slot := firstSlot + phase0.Slot(slotIdx) + + 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, + }) + } + + 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 00000000..96f09267 --- /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 00000000..84348731 --- /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 c4eafcfd..df0c9f98 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 diff --git a/services/chainservice_duties.go b/services/chainservice_duties.go index f4e5889f..fbe82888 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 {