Compare commits

...

3 Commits

Author SHA1 Message Date
satushh
f73073cfdf fix some edge cases 2026-02-04 23:37:31 +05:30
satushh
0076fc78bd Fix PTC duties endpoint state selection and dependent root handling 2026-02-04 20:46:48 +05:30
satushh
af2a6c29a6 Implement /eth/v1/validator/duties/ptc/{epoch} endpoint initial commit 2026-02-04 18:06:09 +05:30
6 changed files with 402 additions and 3 deletions

View File

@@ -74,6 +74,18 @@ type SyncCommitteeDuty struct {
ValidatorSyncCommitteeIndices []string `json:"validator_sync_committee_indices"`
}
type GetPTCDutiesResponse struct {
DependentRoot string `json:"dependent_root"`
ExecutionOptimistic bool `json:"execution_optimistic"`
Data []*PTCDuty `json:"data"`
}
type PTCDuty struct {
Pubkey string `json:"pubkey"`
ValidatorIndex string `json:"validator_index"`
Slot string `json:"slot"`
}
// ProduceBlockV3Response is a wrapper json object for the returned block from the ProduceBlockV3 endpoint
type ProduceBlockV3Response struct {
Version string `json:"version"`

View File

@@ -70,7 +70,7 @@ func ProcessPayloadAttestations(ctx context.Context, st state.BeaconState, body
// indexedPayloadAttestation converts a payload attestation into its indexed form.
func indexedPayloadAttestation(ctx context.Context, st state.ReadOnlyBeaconState, att *eth.PayloadAttestation) (*consensus_types.IndexedPayloadAttestation, error) {
committee, err := payloadCommittee(ctx, st, att.Data.Slot)
committee, err := PayloadCommittee(ctx, st, att.Data.Slot)
if err != nil {
return nil, err
}
@@ -89,7 +89,7 @@ func indexedPayloadAttestation(ctx context.Context, st state.ReadOnlyBeaconState
}, nil
}
// payloadCommittee returns the payload timeliness committee for a given slot for the state.
// PayloadCommittee returns the payload timeliness committee for a given slot for the state.
// Spec v1.7.0-alpha.0 (pseudocode):
// get_ptc(state: BeaconState, slot: Slot) -> Vector[ValidatorIndex, PTC_SIZE]:
//
@@ -101,7 +101,7 @@ func indexedPayloadAttestation(ctx context.Context, st state.ReadOnlyBeaconState
// committee = get_beacon_committee(state, slot, CommitteeIndex(i))
// indices.extend(committee)
// return compute_balance_weighted_selection(state, indices, seed, size=PTC_SIZE, shuffle_indices=False)
func payloadCommittee(ctx context.Context, st state.ReadOnlyBeaconState, slot primitives.Slot) ([]primitives.ValidatorIndex, error) {
func PayloadCommittee(ctx context.Context, st state.ReadOnlyBeaconState, slot primitives.Slot) ([]primitives.ValidatorIndex, error) {
epoch := slots.ToEpoch(slot)
seed, err := ptcSeed(st, epoch, slot)
if err != nil {

View File

@@ -340,6 +340,17 @@ func (s *Service) validatorEndpoints(
handler: server.GetSyncCommitteeDuties,
methods: []string{http.MethodPost},
},
{
template: "/eth/v1/validator/duties/ptc/{epoch}",
name: namespace + ".GetPTCDuties",
middleware: []middleware.Middleware{
middleware.ContentTypeHandler([]string{api.JsonMediaType}),
middleware.AcceptHeaderHandler([]string{api.JsonMediaType}),
middleware.AcceptEncodingHeaderHandler(),
},
handler: server.GetPTCDuties,
methods: []string{http.MethodPost},
},
{
template: "/eth/v1/validator/prepare_beacon_proposer",
name: namespace + ".PrepareBeaconProposer",

View File

@@ -18,6 +18,7 @@ import (
"github.com/OffchainLabs/prysm/v7/api/server/structs"
"github.com/OffchainLabs/prysm/v7/beacon-chain/builder"
"github.com/OffchainLabs/prysm/v7/beacon-chain/cache"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/gloas"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/helpers"
"github.com/OffchainLabs/prysm/v7/beacon-chain/rpc/core"
rpchelpers "github.com/OffchainLabs/prysm/v7/beacon-chain/rpc/eth/helpers"
@@ -1212,6 +1213,175 @@ func (s *Server) GetSyncCommitteeDuties(w http.ResponseWriter, r *http.Request)
httputil.WriteJson(w, resp)
}
// GetPTCDuties retrieves the payload timeliness committee (PTC) duties for the requested epoch.
// The PTC is responsible for attesting to payload timeliness in ePBS (Gloas fork and later).
func (s *Server) GetPTCDuties(w http.ResponseWriter, r *http.Request) {
ctx, span := trace.StartSpan(r.Context(), "validator.GetPTCDuties")
defer span.End()
if shared.IsSyncing(ctx, w, s.SyncChecker, s.HeadFetcher, s.TimeFetcher, s.OptimisticModeFetcher) {
return
}
_, requestedEpochUint, ok := shared.UintFromRoute(w, r, "epoch")
if !ok {
return
}
requestedEpoch := primitives.Epoch(requestedEpochUint)
// PTC duties are only available from Gloas fork onwards.
if requestedEpoch < params.BeaconConfig().GloasForkEpoch {
httputil.HandleError(w, "PTC duties are not available before Gloas fork", http.StatusBadRequest)
return
}
var indices []string
err := json.NewDecoder(r.Body).Decode(&indices)
switch {
case errors.Is(err, io.EOF):
httputil.HandleError(w, "No data submitted", http.StatusBadRequest)
return
case err != nil:
httputil.HandleError(w, "Could not decode request body: "+err.Error(), http.StatusBadRequest)
return
}
if len(indices) == 0 {
httputil.HandleError(w, "No data submitted", http.StatusBadRequest)
return
}
requestedValIndices := make([]primitives.ValidatorIndex, len(indices))
for i, ix := range indices {
valIx, valid := shared.ValidateUint(w, fmt.Sprintf("ValidatorIndices[%d]", i), ix)
if !valid {
return
}
requestedValIndices[i] = primitives.ValidatorIndex(valIx)
}
// Limit how far in the future we can query (current + 1 epoch).
cs := s.TimeFetcher.CurrentSlot()
currentEpoch := slots.ToEpoch(cs)
nextEpoch := currentEpoch + 1
if requestedEpoch > nextEpoch {
httputil.HandleError(w,
fmt.Sprintf("Request epoch %d can not be greater than next epoch %d", requestedEpoch, nextEpoch),
http.StatusBadRequest)
return
}
// For next epoch requests, we use the current epoch's state since PTC
// assignments for next epoch can be computed from current epoch's state.
// This mirrors the spec's get_ptc_assignment which asserts epoch <= next_epoch
// and uses the current state to compute assignments.
epochForState := requestedEpoch
if requestedEpoch == nextEpoch {
epochForState = currentEpoch
}
st, err := s.Stater.StateByEpoch(ctx, epochForState)
if err != nil {
shared.WriteStateFetchError(w, err)
return
}
// Build a set of requested validators for O(1) lookup.
requestedSet := make(map[primitives.ValidatorIndex]bool, len(requestedValIndices))
for _, idx := range requestedValIndices {
requestedSet[idx] = true
}
// Compute PTC duties for each slot in the epoch.
startSlot, err := slots.EpochStart(requestedEpoch)
if err != nil {
httputil.HandleError(w, "Could not get epoch start slot: "+err.Error(), http.StatusInternalServerError)
return
}
endSlot := startSlot + params.BeaconConfig().SlotsPerEpoch
duties := make([]*structs.PTCDuty, 0)
for slot := startSlot; slot < endSlot; slot++ {
ptc, err := gloas.PayloadCommittee(ctx, st, slot)
if err != nil {
httputil.HandleError(w,
fmt.Sprintf("Could not get PTC for slot %d: %s", slot, err.Error()),
http.StatusInternalServerError)
return
}
// Check which requested validators are in this slot's PTC.
for _, valIdx := range ptc {
if !requestedSet[valIdx] {
continue
}
// Validate the validator index with explicit bounds check.
if uint64(valIdx) >= uint64(st.NumValidators()) {
httputil.HandleError(w, fmt.Sprintf("Invalid validator index %d", valIdx), http.StatusBadRequest)
return
}
pubkey := st.PubkeyAtIndex(valIdx)
// Defensive check: ensure pubkey is not zero.
var zeroPubkey [fieldparams.BLSPubkeyLength]byte
if bytes.Equal(pubkey[:], zeroPubkey[:]) {
httputil.HandleError(w, fmt.Sprintf("Invalid validator index %d", valIdx), http.StatusBadRequest)
return
}
duties = append(duties, &structs.PTCDuty{
Pubkey: hexutil.Encode(pubkey[:]),
ValidatorIndex: strconv.FormatUint(uint64(valIdx), 10),
Slot: strconv.FormatUint(uint64(slot), 10),
})
}
}
// Get dependent root. The dependent root is the block root at start_slot(epoch) - 1.
// For epoch 0, this would underflow, so we use genesis block root.
// For next epoch requests, we use the same dependent root as current epoch since
// we're computing from the current epoch's state and the next epoch's RANDAO
// isn't finalized yet.
dependentEpoch := requestedEpoch
if requestedEpoch == nextEpoch {
dependentEpoch = currentEpoch
}
var dependentRoot []byte
if dependentEpoch == 0 {
r, err := s.BeaconDB.GenesisBlockRoot(ctx)
if err != nil {
httputil.HandleError(w, "Could not get genesis block root: "+err.Error(), http.StatusInternalServerError)
return
}
dependentRoot = r[:]
} else {
dependentRoot, err = ptcDependentRoot(st, dependentEpoch)
if err != nil {
httputil.HandleError(w, "Could not get dependent root: "+err.Error(), http.StatusInternalServerError)
return
}
}
isOptimistic, err := s.OptimisticModeFetcher.IsOptimistic(ctx)
if err != nil {
httputil.HandleError(w, "Could not check optimistic status: "+err.Error(), http.StatusInternalServerError)
return
}
resp := &structs.GetPTCDutiesResponse{
DependentRoot: hexutil.Encode(dependentRoot),
ExecutionOptimistic: isOptimistic,
Data: duties,
}
httputil.WriteJson(w, resp)
}
// ptcDependentRoot returns the block root that PTC assignments depend on.
// PTC depends on the shuffling, which is determined by RANDAO at epoch boundary.
func ptcDependentRoot(st state.BeaconState, epoch primitives.Epoch) ([]byte, error) {
epochStartSlot, err := slots.EpochStart(epoch)
if err != nil {
return nil, err
}
return helpers.BlockRootAtSlot(st, epochStartSlot-1)
}
// GetLiveness requests the beacon node to indicate if a validator has been observed to be live in a given epoch.
// The beacon node might detect liveness by observing messages from the validator on the network,
// in the beacon chain, from its API or from any other source.

View File

@@ -17,6 +17,7 @@ import (
mockChain "github.com/OffchainLabs/prysm/v7/beacon-chain/blockchain/testing"
builderTest "github.com/OffchainLabs/prysm/v7/beacon-chain/builder/testing"
"github.com/OffchainLabs/prysm/v7/beacon-chain/cache"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/gloas"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/helpers"
"github.com/OffchainLabs/prysm/v7/beacon-chain/core/transition"
dbutil "github.com/OffchainLabs/prysm/v7/beacon-chain/db/testing"
@@ -2959,6 +2960,208 @@ func TestGetSyncCommitteeDuties(t *testing.T) {
})
}
func TestGetPTCDuties(t *testing.T) {
helpers.ClearCache()
params.SetupTestConfigCleanup(t)
cfg := params.BeaconConfig()
cfg.GloasForkEpoch = 0
params.OverrideBeaconConfig(cfg)
// Use fixed slot 0 for deterministic tests.
slot := primitives.Slot(0)
genesisTime := time.Now()
// Need enough validators for PTC selection (PTC_SIZE is 512 on mainnet, 2 on minimal)
numVals := uint64(fieldparams.PTCSize * 2)
st, _ := util.DeterministicGenesisStateFulu(t, numVals)
require.NoError(t, st.SetGenesisTime(genesisTime))
// Set up a genesis block root for dependent_root calculation.
genesisRoot := [32]byte{1, 2, 3}
db := dbutil.SetupDB(t)
require.NoError(t, db.SaveGenesisBlockRoot(t.Context(), genesisRoot))
mockChainService := &mockChain.ChainService{Genesis: genesisTime, State: st, Slot: &slot}
s := &Server{
Stater: &testutil.MockStater{BeaconState: st},
SyncChecker: &mockSync.Sync{IsSyncing: false},
TimeFetcher: mockChainService,
HeadFetcher: mockChainService,
OptimisticModeFetcher: mockChainService,
BeaconDB: db,
}
t.Run("single validator in PTC", func(t *testing.T) {
// Request duties for validator index 0
var body bytes.Buffer
_, err := body.WriteString("[\"0\"]")
require.NoError(t, err)
request := httptest.NewRequest(http.MethodPost, "http://www.example.com/eth/v1/validator/duties/ptc/{epoch}", &body)
request.SetPathValue("epoch", "0")
writer := httptest.NewRecorder()
writer.Body = &bytes.Buffer{}
s.GetPTCDuties(writer, request)
assert.Equal(t, http.StatusOK, writer.Code)
resp := &structs.GetPTCDutiesResponse{}
require.NoError(t, json.Unmarshal(writer.Body.Bytes(), resp))
assert.NotEmpty(t, resp.DependentRoot)
})
t.Run("verifies actual PTC membership", func(t *testing.T) {
// Compute expected PTC for slot 0 using the same helper.
expectedPTC, err := gloas.PayloadCommittee(t.Context(), st, 0)
require.NoError(t, err)
require.NotEmpty(t, expectedPTC, "PTC should not be empty")
// Request duties for all validators in the expected PTC.
var indices []string
for _, idx := range expectedPTC {
indices = append(indices, strconv.FormatUint(uint64(idx), 10))
}
indicesJSON, err := json.Marshal(indices)
require.NoError(t, err)
request := httptest.NewRequest(http.MethodPost, "http://www.example.com/eth/v1/validator/duties/ptc/{epoch}", bytes.NewReader(indicesJSON))
request.SetPathValue("epoch", "0")
writer := httptest.NewRecorder()
writer.Body = &bytes.Buffer{}
s.GetPTCDuties(writer, request)
assert.Equal(t, http.StatusOK, writer.Code)
resp := &structs.GetPTCDutiesResponse{}
require.NoError(t, json.Unmarshal(writer.Body.Bytes(), resp))
// All requested validators should have duties for slot 0.
assert.Equal(t, len(expectedPTC), len(resp.Data), "Should return duties for all PTC members")
for _, duty := range resp.Data {
assert.Equal(t, "0", duty.Slot, "All duties should be for slot 0")
}
})
t.Run("multiple validators", func(t *testing.T) {
var body bytes.Buffer
_, err := body.WriteString("[\"0\",\"1\",\"2\",\"3\",\"4\"]")
require.NoError(t, err)
request := httptest.NewRequest(http.MethodPost, "http://www.example.com/eth/v1/validator/duties/ptc/{epoch}", &body)
request.SetPathValue("epoch", "0")
writer := httptest.NewRecorder()
writer.Body = &bytes.Buffer{}
s.GetPTCDuties(writer, request)
assert.Equal(t, http.StatusOK, writer.Code)
resp := &structs.GetPTCDutiesResponse{}
require.NoError(t, json.Unmarshal(writer.Body.Bytes(), resp))
assert.NotEmpty(t, resp.DependentRoot)
// Verify any returned duties have correct structure
for _, duty := range resp.Data {
assert.NotEmpty(t, duty.Pubkey)
assert.NotEmpty(t, duty.ValidatorIndex)
assert.NotEmpty(t, duty.Slot)
}
})
t.Run("pre-Gloas epoch returns error", func(t *testing.T) {
// Temporarily set GloasForkEpoch to 10
cfg := params.BeaconConfig()
cfg.GloasForkEpoch = 10
params.OverrideBeaconConfig(cfg)
defer func() {
cfg.GloasForkEpoch = 0
params.OverrideBeaconConfig(cfg)
}()
var body bytes.Buffer
_, err := body.WriteString("[\"0\"]")
require.NoError(t, err)
request := httptest.NewRequest(http.MethodPost, "http://www.example.com/eth/v1/validator/duties/ptc/{epoch}", &body)
request.SetPathValue("epoch", "0")
writer := httptest.NewRecorder()
writer.Body = &bytes.Buffer{}
s.GetPTCDuties(writer, request)
assert.Equal(t, http.StatusBadRequest, writer.Code)
e := &httputil.DefaultJsonError{}
require.NoError(t, json.Unmarshal(writer.Body.Bytes(), e))
assert.StringContains(t, "PTC duties are not available before Gloas fork", e.Message)
})
t.Run("no body", func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "http://www.example.com/eth/v1/validator/duties/ptc/{epoch}", nil)
request.SetPathValue("epoch", "0")
writer := httptest.NewRecorder()
writer.Body = &bytes.Buffer{}
s.GetPTCDuties(writer, request)
assert.Equal(t, http.StatusBadRequest, writer.Code)
e := &httputil.DefaultJsonError{}
require.NoError(t, json.Unmarshal(writer.Body.Bytes(), e))
assert.StringContains(t, "No data submitted", e.Message)
})
t.Run("empty body", func(t *testing.T) {
var body bytes.Buffer
_, err := body.WriteString("[]")
require.NoError(t, err)
request := httptest.NewRequest(http.MethodPost, "http://www.example.com/eth/v1/validator/duties/ptc/{epoch}", &body)
request.SetPathValue("epoch", "0")
writer := httptest.NewRecorder()
writer.Body = &bytes.Buffer{}
s.GetPTCDuties(writer, request)
assert.Equal(t, http.StatusBadRequest, writer.Code)
e := &httputil.DefaultJsonError{}
require.NoError(t, json.Unmarshal(writer.Body.Bytes(), e))
assert.StringContains(t, "No data submitted", e.Message)
})
t.Run("invalid validator index string", func(t *testing.T) {
var body bytes.Buffer
_, err := body.WriteString("[\"foo\"]")
require.NoError(t, err)
request := httptest.NewRequest(http.MethodPost, "http://www.example.com/eth/v1/validator/duties/ptc/{epoch}", &body)
request.SetPathValue("epoch", "0")
writer := httptest.NewRecorder()
writer.Body = &bytes.Buffer{}
s.GetPTCDuties(writer, request)
assert.Equal(t, http.StatusBadRequest, writer.Code)
})
t.Run("out of bounds validator index", func(t *testing.T) {
// Request a validator index that's way beyond the number of validators.
var body bytes.Buffer
_, err := body.WriteString("[\"999999999\"]")
require.NoError(t, err)
request := httptest.NewRequest(http.MethodPost, "http://www.example.com/eth/v1/validator/duties/ptc/{epoch}", &body)
request.SetPathValue("epoch", "0")
writer := httptest.NewRecorder()
writer.Body = &bytes.Buffer{}
s.GetPTCDuties(writer, request)
// OOB validator won't be in any PTC, so request succeeds with empty duties.
assert.Equal(t, http.StatusOK, writer.Code)
resp := &structs.GetPTCDutiesResponse{}
require.NoError(t, json.Unmarshal(writer.Body.Bytes(), resp))
assert.Equal(t, 0, len(resp.Data), "OOB validator should have no duties")
})
t.Run("epoch too far in future", func(t *testing.T) {
var body bytes.Buffer
_, err := body.WriteString("[\"0\"]")
require.NoError(t, err)
request := httptest.NewRequest(http.MethodPost, "http://www.example.com/eth/v1/validator/duties/ptc/{epoch}", &body)
request.SetPathValue("epoch", "100") // Far future epoch
writer := httptest.NewRecorder()
writer.Body = &bytes.Buffer{}
s.GetPTCDuties(writer, request)
assert.Equal(t, http.StatusBadRequest, writer.Code)
e := &httputil.DefaultJsonError{}
require.NoError(t, json.Unmarshal(writer.Body.Bytes(), e))
assert.StringContains(t, "can not be greater than next epoch", e.Message)
})
}
func TestPrepareBeaconProposer(t *testing.T) {
tests := []struct {
name string

View File

@@ -0,0 +1,3 @@
### Added
- PTC (Payload Timeliness Committee) duties endpoint: POST /eth/v1/validator/duties/ptc/{epoch} for ePBS (Gloas fork).