diff --git a/api/server/structs/endpoints_beacon.go b/api/server/structs/endpoints_beacon.go index 61b96f7a582a..2f8259560750 100644 --- a/api/server/structs/endpoints_beacon.go +++ b/api/server/structs/endpoints_beacon.go @@ -250,3 +250,10 @@ type ChainHead struct { PreviousJustifiedBlockRoot string `json:"previous_justified_block_root"` OptimisticStatus bool `json:"optimistic_status"` } + +type GetPendingDepositsResponse struct { + Version string `json:"version"` + ExecutionOptimistic bool `json:"execution_optimistic"` + Finalized bool `json:"finalized"` + Data []*PendingDeposit `json:"data"` +} diff --git a/beacon-chain/rpc/endpoints.go b/beacon-chain/rpc/endpoints.go index 97682b995fee..3d00397f8346 100644 --- a/beacon-chain/rpc/endpoints.go +++ b/beacon-chain/rpc/endpoints.go @@ -884,6 +884,15 @@ func (s *Service) beaconEndpoints( handler: server.GetDepositSnapshot, methods: []string{http.MethodGet}, }, + { + template: "/eth/v1/beacon/states/{state_id}/pending_deposits", + name: namespace + ".GetPendingDeposits", + middleware: []middleware.Middleware{ + middleware.AcceptHeaderHandler([]string{api.JsonMediaType}), + }, + handler: server.GetPendingDeposits, + methods: []string{http.MethodGet}, + }, } } diff --git a/beacon-chain/rpc/endpoints_test.go b/beacon-chain/rpc/endpoints_test.go index 382dc427fa91..561f0de6572c 100644 --- a/beacon-chain/rpc/endpoints_test.go +++ b/beacon-chain/rpc/endpoints_test.go @@ -27,6 +27,7 @@ func Test_endpoints(t *testing.T) { "/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}, diff --git a/beacon-chain/rpc/eth/beacon/handlers.go b/beacon-chain/rpc/eth/beacon/handlers.go index ca13044ff1b9..37d2fdaf8f32 100644 --- a/beacon-chain/rpc/eth/beacon/handlers.go +++ b/beacon-chain/rpc/eth/beacon/handlers.go @@ -1608,3 +1608,72 @@ func (s *Server) broadcastSeenBlockSidecars( } return nil } + +// GetPendingDeposits returns pending deposits 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) GetPendingDeposits(w http.ResponseWriter, r *http.Request) { + ctx, span := trace.StartSpan(r.Context(), "beacon.GetPendingDeposits") + 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 + } + pd, err := st.PendingDeposits() + if err != nil { + httputil.HandleError(w, "Could not get pending deposits: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set(api.VersionHeader, version.String(st.Version())) + if httputil.RespondWithSsz(r) { + sszData, err := serializePendingDeposits(pd) + if err != nil { + httputil.HandleError(w, "Failed to serialize pending deposits: "+err.Error(), http.StatusInternalServerError) + return + } + httputil.WriteSsz(w, sszData, "pending_deposits.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.GetPendingDepositsResponse{ + Version: version.String(st.Version()), + ExecutionOptimistic: isOptimistic, + Finalized: isFinalized, + Data: structs.PendingDepositsFromConsensus(pd), + } + httputil.WriteJson(w, resp) + } +} + +// serializePendingDeposits serializes a slice of PendingDeposit objects into a single byte array. +func serializePendingDeposits(pd []*eth.PendingDeposit) ([]byte, error) { + var result []byte + for _, d := range pd { + b, err := d.MarshalSSZ() + if err != nil { + return nil, err + } + result = append(result, b...) + } + return result, nil +} diff --git a/beacon-chain/rpc/eth/beacon/handlers_test.go b/beacon-chain/rpc/eth/beacon/handlers_test.go index b72636a062a4..3718669bdfbc 100644 --- a/beacon-chain/rpc/eth/beacon/handlers_test.go +++ b/beacon-chain/rpc/eth/beacon/handlers_test.go @@ -4754,3 +4754,196 @@ func Test_validateBlobSidecars(t *testing.T) { require.NoError(t, err) require.ErrorContains(t, "could not verify blob proof: can't verify opening proof", s.validateBlobSidecars(b, [][]byte{blob[:]}, [][]byte{proof[:]})) } + +func TestGetPendingDeposits(t *testing.T) { + st, _ := util.DeterministicGenesisStateElectra(t, 10) + + validators := st.Validators() + dummySig := make([]byte, 96) + for j := 0; j < 96; j++ { + dummySig[j] = byte(j) + } + deps := make([]*eth.PendingDeposit, 10) + for i := 0; i < len(deps); i += 1 { + deps[i] = ð.PendingDeposit{ + PublicKey: validators[i].PublicKey, + WithdrawalCredentials: validators[i].WithdrawalCredentials, + Amount: 100, + Slot: 0, + Signature: dummySig, + } + } + require.NoError(t, st.SetPendingDeposits(deps)) + + 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_deposits", nil) + req.SetPathValue("state_id", "head") + rec := httptest.NewRecorder() + rec.Body = new(bytes.Buffer) + + server.GetPendingDeposits(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "electra", rec.Header().Get(api.VersionHeader)) + + var resp structs.GetPendingDepositsResponse + 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) + + expectedDeposits := structs.PendingDepositsFromConsensus(deps) + require.DeepEqual(t, expectedDeposits, 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_deposits", nil) + req.Header.Set("Accept", "application/octet-stream") + req.SetPathValue("state_id", "head") + rec := httptest.NewRecorder() + rec.Body = new(bytes.Buffer) + + server.GetPendingDeposits(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "electra", rec.Header().Get(api.VersionHeader)) + + responseBytes := rec.Body.Bytes() + var recoveredDeposits []*eth.PendingDeposit + + // Verify total size matches expected number of deposits + depositSize := (ð.PendingDeposit{}).SizeSSZ() + require.Equal(t, len(responseBytes), depositSize*len(deps)) + + for i := 0; i < len(deps); i++ { + start := i * depositSize + end := start + depositSize + + var deposit eth.PendingDeposit + require.NoError(t, deposit.UnmarshalSSZ(responseBytes[start:end])) + recoveredDeposits = append(recoveredDeposits, &deposit) + } + require.DeepEqual(t, deps, recoveredDeposits) + }) + 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_deposits", nil) + req.SetPathValue("state_id", "head") + rec := httptest.NewRecorder() + rec.Body = new(bytes.Buffer) + + preElectraServer.GetPendingDeposits(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_deposits", nil) + sszReq.Header.Set("Accept", "application/octet-stream") + sszReq.SetPathValue("state_id", "head") + sszRec := httptest.NewRecorder() + sszRec.Body = new(bytes.Buffer) + + preElectraServer.GetPendingDeposits(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_deposits", nil) + // Intentionally not setting state_id + rec := httptest.NewRecorder() + rec.Body = new(bytes.Buffer) + + server.GetPendingDeposits(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_deposits", nil) + req.SetPathValue("state_id", "head") + rec := httptest.NewRecorder() + rec.Body = new(bytes.Buffer) + + optimisticServer.GetPendingDeposits(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var resp structs.GetPendingDepositsResponse + 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_deposits", nil) + req.SetPathValue("state_id", "head") + rec := httptest.NewRecorder() + rec.Body = new(bytes.Buffer) + + finalizedServer.GetPendingDeposits(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + + var resp structs.GetPendingDepositsResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + require.Equal(t, true, resp.Finalized) + }) +} diff --git a/changelog/saolyn_add-GetPendingDeposits.md b/changelog/saolyn_add-GetPendingDeposits.md new file mode 100644 index 000000000000..c85338de390b --- /dev/null +++ b/changelog/saolyn_add-GetPendingDeposits.md @@ -0,0 +1,3 @@ +### Added + +- Add endpoint for getting pending deposits. \ No newline at end of file