diff --git a/api/server/structs/endpoints_beacon.go b/api/server/structs/endpoints_beacon.go index 2f82595607..15afc51efe 100644 --- a/api/server/structs/endpoints_beacon.go +++ b/api/server/structs/endpoints_beacon.go @@ -257,3 +257,10 @@ type GetPendingDepositsResponse struct { Finalized bool `json:"finalized"` Data []*PendingDeposit `json:"data"` } + +type GetPendingPartialWithdrawalsResponse struct { + Version string `json:"version"` + ExecutionOptimistic bool `json:"execution_optimistic"` + Finalized bool `json:"finalized"` + Data []*PendingPartialWithdrawal `json:"data"` +} diff --git a/beacon-chain/rpc/endpoints.go b/beacon-chain/rpc/endpoints.go index 3d00397f83..817086b882 100644 --- a/beacon-chain/rpc/endpoints.go +++ b/beacon-chain/rpc/endpoints.go @@ -893,6 +893,15 @@ func (s *Service) beaconEndpoints( handler: server.GetPendingDeposits, methods: []string{http.MethodGet}, }, + { + template: "/eth/v1/beacon/states/{state_id}/pending_partial_withdrawals", + name: namespace + ".GetPendingPartialWithdrawals", + middleware: []middleware.Middleware{ + middleware.AcceptHeaderHandler([]string{api.JsonMediaType}), + }, + handler: server.GetPendingPartialWithdrawals, + methods: []string{http.MethodGet}, + }, } } diff --git a/beacon-chain/rpc/endpoints_test.go b/beacon-chain/rpc/endpoints_test.go index 561f0de657..80bcc23259 100644 --- a/beacon-chain/rpc/endpoints_test.go +++ b/beacon-chain/rpc/endpoints_test.go @@ -17,39 +17,40 @@ func Test_endpoints(t *testing.T) { } beaconRoutes := map[string][]string{ - "/eth/v1/beacon/genesis": {http.MethodGet}, - "/eth/v1/beacon/states/{state_id}/root": {http.MethodGet}, - "/eth/v1/beacon/states/{state_id}/fork": {http.MethodGet}, - "/eth/v1/beacon/states/{state_id}/finality_checkpoints": {http.MethodGet}, - "/eth/v1/beacon/states/{state_id}/validators": {http.MethodGet, http.MethodPost}, - "/eth/v1/beacon/states/{state_id}/validators/{validator_id}": {http.MethodGet}, - "/eth/v1/beacon/states/{state_id}/validator_balances": {http.MethodGet, http.MethodPost}, - "/eth/v1/beacon/states/{state_id}/committees": {http.MethodGet}, - "/eth/v1/beacon/states/{state_id}/sync_committees": {http.MethodGet}, - "/eth/v1/beacon/states/{state_id}/randao": {http.MethodGet}, - "/eth/v1/beacon/states/{state_id}/pending_deposits": {http.MethodGet}, - "/eth/v1/beacon/headers": {http.MethodGet}, - "/eth/v1/beacon/headers/{block_id}": {http.MethodGet}, - "/eth/v1/beacon/blinded_blocks": {http.MethodPost}, - "/eth/v2/beacon/blinded_blocks": {http.MethodPost}, - "/eth/v1/beacon/blocks": {http.MethodPost}, - "/eth/v2/beacon/blocks": {http.MethodPost}, - "/eth/v2/beacon/blocks/{block_id}": {http.MethodGet}, - "/eth/v1/beacon/blocks/{block_id}/root": {http.MethodGet}, - "/eth/v1/beacon/blocks/{block_id}/attestations": {http.MethodGet}, - "/eth/v2/beacon/blocks/{block_id}/attestations": {http.MethodGet}, - "/eth/v1/beacon/blob_sidecars/{block_id}": {http.MethodGet}, - "/eth/v1/beacon/deposit_snapshot": {http.MethodGet}, - "/eth/v1/beacon/blinded_blocks/{block_id}": {http.MethodGet}, - "/eth/v1/beacon/pool/attestations": {http.MethodGet, http.MethodPost}, - "/eth/v2/beacon/pool/attestations": {http.MethodGet, http.MethodPost}, - "/eth/v1/beacon/pool/attester_slashings": {http.MethodGet, http.MethodPost}, - "/eth/v2/beacon/pool/attester_slashings": {http.MethodGet, http.MethodPost}, - "/eth/v1/beacon/pool/proposer_slashings": {http.MethodGet, http.MethodPost}, - "/eth/v1/beacon/pool/sync_committees": {http.MethodPost}, - "/eth/v1/beacon/pool/voluntary_exits": {http.MethodGet, http.MethodPost}, - "/eth/v1/beacon/pool/bls_to_execution_changes": {http.MethodGet, http.MethodPost}, - "/prysm/v1/beacon/individual_votes": {http.MethodPost}, + "/eth/v1/beacon/genesis": {http.MethodGet}, + "/eth/v1/beacon/states/{state_id}/root": {http.MethodGet}, + "/eth/v1/beacon/states/{state_id}/fork": {http.MethodGet}, + "/eth/v1/beacon/states/{state_id}/finality_checkpoints": {http.MethodGet}, + "/eth/v1/beacon/states/{state_id}/validators": {http.MethodGet, http.MethodPost}, + "/eth/v1/beacon/states/{state_id}/validators/{validator_id}": {http.MethodGet}, + "/eth/v1/beacon/states/{state_id}/validator_balances": {http.MethodGet, http.MethodPost}, + "/eth/v1/beacon/states/{state_id}/committees": {http.MethodGet}, + "/eth/v1/beacon/states/{state_id}/sync_committees": {http.MethodGet}, + "/eth/v1/beacon/states/{state_id}/randao": {http.MethodGet}, + "/eth/v1/beacon/states/{state_id}/pending_deposits": {http.MethodGet}, + "/eth/v1/beacon/states/{state_id}/pending_partial_withdrawals": {http.MethodGet}, + "/eth/v1/beacon/headers": {http.MethodGet}, + "/eth/v1/beacon/headers/{block_id}": {http.MethodGet}, + "/eth/v1/beacon/blinded_blocks": {http.MethodPost}, + "/eth/v2/beacon/blinded_blocks": {http.MethodPost}, + "/eth/v1/beacon/blocks": {http.MethodPost}, + "/eth/v2/beacon/blocks": {http.MethodPost}, + "/eth/v2/beacon/blocks/{block_id}": {http.MethodGet}, + "/eth/v1/beacon/blocks/{block_id}/root": {http.MethodGet}, + "/eth/v1/beacon/blocks/{block_id}/attestations": {http.MethodGet}, + "/eth/v2/beacon/blocks/{block_id}/attestations": {http.MethodGet}, + "/eth/v1/beacon/blob_sidecars/{block_id}": {http.MethodGet}, + "/eth/v1/beacon/deposit_snapshot": {http.MethodGet}, + "/eth/v1/beacon/blinded_blocks/{block_id}": {http.MethodGet}, + "/eth/v1/beacon/pool/attestations": {http.MethodGet, http.MethodPost}, + "/eth/v2/beacon/pool/attestations": {http.MethodGet, http.MethodPost}, + "/eth/v1/beacon/pool/attester_slashings": {http.MethodGet, http.MethodPost}, + "/eth/v2/beacon/pool/attester_slashings": {http.MethodGet, http.MethodPost}, + "/eth/v1/beacon/pool/proposer_slashings": {http.MethodGet, http.MethodPost}, + "/eth/v1/beacon/pool/sync_committees": {http.MethodPost}, + "/eth/v1/beacon/pool/voluntary_exits": {http.MethodGet, http.MethodPost}, + "/eth/v1/beacon/pool/bls_to_execution_changes": {http.MethodGet, http.MethodPost}, + "/prysm/v1/beacon/individual_votes": {http.MethodPost}, } lightClientRoutes := map[string][]string{ diff --git a/beacon-chain/rpc/eth/beacon/handlers.go b/beacon-chain/rpc/eth/beacon/handlers.go index 37d2fdaf8f..a71d1a5656 100644 --- a/beacon-chain/rpc/eth/beacon/handlers.go +++ b/beacon-chain/rpc/eth/beacon/handlers.go @@ -1637,7 +1637,7 @@ func (s *Server) GetPendingDeposits(w http.ResponseWriter, r *http.Request) { } w.Header().Set(api.VersionHeader, version.String(st.Version())) if httputil.RespondWithSsz(r) { - sszData, err := serializePendingDeposits(pd) + sszData, err := serializeItems(pd) if err != nil { httputil.HandleError(w, "Failed to serialize pending deposits: "+err.Error(), http.StatusInternalServerError) return @@ -1665,11 +1665,68 @@ func (s *Server) GetPendingDeposits(w http.ResponseWriter, r *http.Request) { } } -// serializePendingDeposits serializes a slice of PendingDeposit objects into a single byte array. -func serializePendingDeposits(pd []*eth.PendingDeposit) ([]byte, error) { +// GetPendingPartialWithdrawals returns pending partial withdrawals for state with given 'stateId'. +// Should return 400 if the state retrieved is prior to Electra. +// Supports both JSON and SSZ responses based on Accept header. +func (s *Server) GetPendingPartialWithdrawals(w http.ResponseWriter, r *http.Request) { + ctx, span := trace.StartSpan(r.Context(), "beacon.GetPendingPartialWithdrawals") + defer span.End() + + stateId := r.PathValue("state_id") + if stateId == "" { + httputil.HandleError(w, "state_id is required in URL params", http.StatusBadRequest) + return + } + st, err := s.Stater.State(ctx, []byte(stateId)) + if err != nil { + shared.WriteStateFetchError(w, err) + return + } + if st.Version() < version.Electra { + httputil.HandleError(w, "state_id is prior to electra", http.StatusBadRequest) + return + } + ppw, err := st.PendingPartialWithdrawals() + if err != nil { + httputil.HandleError(w, "Could not get pending partial withdrawals: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set(api.VersionHeader, version.String(st.Version())) + if httputil.RespondWithSsz(r) { + sszData, err := serializeItems(ppw) + if err != nil { + httputil.HandleError(w, "Failed to serialize pending partial withdrawals: "+err.Error(), http.StatusInternalServerError) + return + } + httputil.WriteSsz(w, sszData, "pending_partial_withdrawals.ssz") + } else { + isOptimistic, err := helpers.IsOptimistic(ctx, []byte(stateId), s.OptimisticModeFetcher, s.Stater, s.ChainInfoFetcher, s.BeaconDB) + if err != nil { + httputil.HandleError(w, "Could not check optimistic status: "+err.Error(), http.StatusInternalServerError) + return + } + blockRoot, err := st.LatestBlockHeader().HashTreeRoot() + if err != nil { + httputil.HandleError(w, "Could not calculate root of latest block header: "+err.Error(), http.StatusInternalServerError) + return + } + isFinalized := s.FinalizationFetcher.IsFinalized(ctx, blockRoot) + resp := structs.GetPendingPartialWithdrawalsResponse{ + Version: version.String(st.Version()), + ExecutionOptimistic: isOptimistic, + Finalized: isFinalized, + Data: structs.PendingPartialWithdrawalsFromConsensus(ppw), + } + httputil.WriteJson(w, resp) + } +} + +// SerializeItems serializes a slice of items, each of which implements the MarshalSSZ method, +// into a single byte array. +func serializeItems[T interface{ MarshalSSZ() ([]byte, error) }](items []T) ([]byte, error) { var result []byte - for _, d := range pd { - b, err := d.MarshalSSZ() + for _, item := range items { + b, err := item.MarshalSSZ() if err != nil { return nil, err } diff --git a/beacon-chain/rpc/eth/beacon/handlers_test.go b/beacon-chain/rpc/eth/beacon/handlers_test.go index 3718669bdf..c1bccc884c 100644 --- a/beacon-chain/rpc/eth/beacon/handlers_test.go +++ b/beacon-chain/rpc/eth/beacon/handlers_test.go @@ -4947,3 +4947,193 @@ func TestGetPendingDeposits(t *testing.T) { require.Equal(t, true, resp.Finalized) }) } + +func TestGetPendingPartialWithdrawals(t *testing.T) { + st, _ := util.DeterministicGenesisStateElectra(t, 10) + for i := 0; i < 10; i += 1 { + err := st.AppendPendingPartialWithdrawal( + ð.PendingPartialWithdrawal{ + Index: primitives.ValidatorIndex(i), + Amount: 100, + WithdrawableEpoch: primitives.Epoch(0), + }) + require.NoError(t, err) + } + withdrawals, err := st.PendingPartialWithdrawals() + require.NoError(t, err) + + chainService := &chainMock.ChainService{ + Optimistic: false, + FinalizedRoots: map[[32]byte]bool{}, + } + server := &Server{ + Stater: &testutil.MockStater{ + BeaconState: st, + }, + OptimisticModeFetcher: chainService, + FinalizationFetcher: chainService, + } + + t.Run("json response", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.com/eth/v1/beacon/states/{state_id}/pending_partial_withdrawals", nil) + req.SetPathValue("state_id", "head") + rec := httptest.NewRecorder() + rec.Body = new(bytes.Buffer) + + server.GetPendingPartialWithdrawals(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "electra", rec.Header().Get(api.VersionHeader)) + + var resp structs.GetPendingPartialWithdrawalsResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + + expectedVersion := version.String(st.Version()) + require.Equal(t, expectedVersion, resp.Version) + + require.Equal(t, false, resp.ExecutionOptimistic) + require.Equal(t, false, resp.Finalized) + + expectedWithdrawals := structs.PendingPartialWithdrawalsFromConsensus(withdrawals) + require.DeepEqual(t, expectedWithdrawals, resp.Data) + }) + + t.Run("ssz response", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.com/eth/v1/beacon/states/{state_id}/pending_partial_withdrawals", nil) + req.Header.Set("Accept", "application/octet-stream") + req.SetPathValue("state_id", "head") + rec := httptest.NewRecorder() + rec.Body = new(bytes.Buffer) + + server.GetPendingPartialWithdrawals(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "electra", rec.Header().Get(api.VersionHeader)) + + responseBytes := rec.Body.Bytes() + var recoveredWithdrawals []*eth.PendingPartialWithdrawal + + withdrawalSize := (ð.PendingPartialWithdrawal{}).SizeSSZ() + require.Equal(t, len(responseBytes), withdrawalSize*len(withdrawals)) + + for i := 0; i < len(withdrawals); i++ { + start := i * withdrawalSize + end := start + withdrawalSize + + var withdrawal eth.PendingPartialWithdrawal + require.NoError(t, withdrawal.UnmarshalSSZ(responseBytes[start:end])) + recoveredWithdrawals = append(recoveredWithdrawals, &withdrawal) + } + require.DeepEqual(t, withdrawals, recoveredWithdrawals) + }) + + t.Run("pre electra state", func(t *testing.T) { + preElectraSt, _ := util.DeterministicGenesisStateDeneb(t, 1) + preElectraServer := &Server{ + Stater: &testutil.MockStater{ + BeaconState: preElectraSt, + }, + OptimisticModeFetcher: chainService, + FinalizationFetcher: chainService, + } + + // Test JSON request + req := httptest.NewRequest(http.MethodGet, "http://example.com/eth/v1/beacon/states/{state_id}/pending_partial_withdrawals", nil) + req.SetPathValue("state_id", "head") + rec := httptest.NewRecorder() + rec.Body = new(bytes.Buffer) + + preElectraServer.GetPendingPartialWithdrawals(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code) + + var errResp struct { + Code int `json:"code"` + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &errResp)) + require.Equal(t, "state_id is prior to electra", errResp.Message) + + // Test SSZ request + sszReq := httptest.NewRequest(http.MethodGet, "http://example.com/eth/v1/beacon/states/{state_id}/pending_partial_withdrawals", nil) + sszReq.Header.Set("Accept", "application/octet-stream") + sszReq.SetPathValue("state_id", "head") + sszRec := httptest.NewRecorder() + sszRec.Body = new(bytes.Buffer) + + preElectraServer.GetPendingPartialWithdrawals(sszRec, sszReq) + require.Equal(t, http.StatusBadRequest, sszRec.Code) + + var sszErrResp struct { + Code int `json:"code"` + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal(sszRec.Body.Bytes(), &sszErrResp)) + require.Equal(t, "state_id is prior to electra", sszErrResp.Message) + }) + + t.Run("missing state_id parameter", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.com/eth/v1/beacon/states/{state_id}/pending_partial_withdrawals", nil) + // Intentionally not setting state_id + rec := httptest.NewRecorder() + rec.Body = new(bytes.Buffer) + + server.GetPendingPartialWithdrawals(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code) + + var errResp struct { + Code int `json:"code"` + Message string `json:"message"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &errResp)) + require.Equal(t, "state_id is required in URL params", errResp.Message) + }) + + t.Run("optimistic node", func(t *testing.T) { + optimisticChainService := &chainMock.ChainService{ + Optimistic: true, + FinalizedRoots: map[[32]byte]bool{}, + } + optimisticServer := &Server{ + Stater: server.Stater, + OptimisticModeFetcher: optimisticChainService, + FinalizationFetcher: optimisticChainService, + } + + req := httptest.NewRequest(http.MethodGet, "http://example.com/eth/v1/beacon/states/{state_id}/pending_partial_withdrawals", nil) + req.SetPathValue("state_id", "head") + rec := httptest.NewRecorder() + rec.Body = new(bytes.Buffer) + + optimisticServer.GetPendingPartialWithdrawals(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var resp structs.GetPendingPartialWithdrawalsResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, true, resp.ExecutionOptimistic) + }) + + t.Run("finalized node", func(t *testing.T) { + blockRoot, err := st.LatestBlockHeader().HashTreeRoot() + require.NoError(t, err) + + finalizedChainService := &chainMock.ChainService{ + Optimistic: false, + FinalizedRoots: map[[32]byte]bool{blockRoot: true}, + } + finalizedServer := &Server{ + Stater: server.Stater, + OptimisticModeFetcher: finalizedChainService, + FinalizationFetcher: finalizedChainService, + } + + req := httptest.NewRequest(http.MethodGet, "http://example.com/eth/v1/beacon/states/{state_id}/pending_partial_withdrawals", nil) + req.SetPathValue("state_id", "head") + rec := httptest.NewRecorder() + rec.Body = new(bytes.Buffer) + + finalizedServer.GetPendingPartialWithdrawals(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var resp structs.GetPendingPartialWithdrawalsResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, true, resp.Finalized) + }) +} diff --git a/changelog/saolyn_add-GetPendingPartialWithdrawals.md b/changelog/saolyn_add-GetPendingPartialWithdrawals.md new file mode 100644 index 0000000000..719049658f --- /dev/null +++ b/changelog/saolyn_add-GetPendingPartialWithdrawals.md @@ -0,0 +1,3 @@ +### Added + +- Add endpoint for getting pending partial withdrawals.