From 305a74696c4cf60fb5519ad10df6258f8ba9f6d5 Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:56:50 +0200 Subject: [PATCH] Add PayloadAttestation duty --- app/eth2wrap/utils.go | 59 ++++++++++++++++++---------- app/eth2wrap/utils_internal_test.go | 61 +++++++++++++++++++++-------- app/eth2wrap/utils_test.go | 10 +++-- core/slotoffset.go | 9 +++-- core/slotoffset_internal_test.go | 26 +++++++++--- core/types.go | 13 +++++- core/types_test.go | 13 +++++- eth2util/signing/signing.go | 1 + eth2util/signing/signing_test.go | 13 ++++++ 9 files changed, 152 insertions(+), 53 deletions(-) diff --git a/app/eth2wrap/utils.go b/app/eth2wrap/utils.go index e068b2b977..d2f295cd05 100644 --- a/app/eth2wrap/utils.go +++ b/app/eth2wrap/utils.go @@ -56,11 +56,8 @@ var ( // BasisPoints is the total number of basis points, ie. 100% of the slot duration. const BasisPoints = 10_000 -// gloasSuffix is appended to the intra-slot deadline spec keys that the gloas fork overrides. -const gloasSuffix = "_GLOAS" - -// ForkBPS defines an intra-slot duty deadline in basis points of the slot duration, -// both before and after the gloas fork. +// ForkBPS defines an intra-slot duty deadline in basis points of the slot duration, per fork. +// PreGloas is zero for deadlines that the gloas fork introduces, since they don't apply before it. type ForkBPS struct { PreGloas uint64 Gloas uint64 @@ -72,6 +69,11 @@ type SlotTimingConfig struct { Aggregate ForkBPS SyncMessage ForkBPS Contribution ForkBPS + // Payload is the deadline for the builder to reveal the execution payload. + Payload ForkBPS + // PayloadAttestation is the deadline for payload timeliness committee members to broadcast + // payload attestations. + PayloadAttestation ForkBPS // GloasEpoch is the epoch at which the gloas deadlines take effect. It is math.MaxUint64 // if the beacon node doesn't publish GLOAS_FORK_EPOCH or hasn't scheduled the fork. GloasEpoch eth2p0.Epoch @@ -80,10 +82,12 @@ type SlotTimingConfig struct { // Intra-slot duty deadlines as basis points of the slot duration as defined by the consensus spec. // These are applied for beacon nodes that predate the corresponding spec keys. var ( - defaultAttestationBPS = ForkBPS{PreGloas: 3333, Gloas: 2500} - defaultAggregateBPS = ForkBPS{PreGloas: 6667, Gloas: 5000} - defaultSyncMessageBPS = ForkBPS{PreGloas: 3333, Gloas: 2500} - defaultContributionBPS = ForkBPS{PreGloas: 6667, Gloas: 5000} + defaultAttestationBPS = ForkBPS{PreGloas: 3333, Gloas: 2500} + defaultAggregateBPS = ForkBPS{PreGloas: 6667, Gloas: 5000} + defaultSyncMessageBPS = ForkBPS{PreGloas: 3333, Gloas: 2500} + defaultContributionBPS = ForkBPS{PreGloas: 6667, Gloas: 5000} + defaultPayloadBPS = ForkBPS{Gloas: 5000} + defaultPayloadAttestationBPS = ForkBPS{Gloas: 7500} ) // FetchSlotTimingConfig returns the network's intra-slot duty deadlines. @@ -109,27 +113,40 @@ func parseSlotTimingConfig(data map[string]any) (SlotTimingConfig, error) { resp.GloasEpoch = eth2p0.Epoch(epoch) } + // Note that the deadlines introduced by the gloas fork have no pre-gloas key, since the + // unsuffixed key is itself the gloas value. for _, field := range []struct { - Key string - Default ForkBPS - Resolved *ForkBPS + PreGloasKey string + GloasKey string + Default ForkBPS + Resolved *ForkBPS }{ - {Key: "ATTESTATION_DUE_BPS", Default: defaultAttestationBPS, Resolved: &resp.Attestation}, - {Key: "AGGREGATE_DUE_BPS", Default: defaultAggregateBPS, Resolved: &resp.Aggregate}, - {Key: "SYNC_MESSAGE_DUE_BPS", Default: defaultSyncMessageBPS, Resolved: &resp.SyncMessage}, - {Key: "CONTRIBUTION_DUE_BPS", Default: defaultContributionBPS, Resolved: &resp.Contribution}, + {PreGloasKey: "ATTESTATION_DUE_BPS", GloasKey: "ATTESTATION_DUE_BPS_GLOAS", Default: defaultAttestationBPS, Resolved: &resp.Attestation}, + {PreGloasKey: "AGGREGATE_DUE_BPS", GloasKey: "AGGREGATE_DUE_BPS_GLOAS", Default: defaultAggregateBPS, Resolved: &resp.Aggregate}, + {PreGloasKey: "SYNC_MESSAGE_DUE_BPS", GloasKey: "SYNC_MESSAGE_DUE_BPS_GLOAS", Default: defaultSyncMessageBPS, Resolved: &resp.SyncMessage}, + {PreGloasKey: "CONTRIBUTION_DUE_BPS", GloasKey: "CONTRIBUTION_DUE_BPS_GLOAS", Default: defaultContributionBPS, Resolved: &resp.Contribution}, + {GloasKey: "PAYLOAD_DUE_BPS", Default: defaultPayloadBPS, Resolved: &resp.Payload}, + {GloasKey: "PAYLOAD_ATTESTATION_DUE_BPS", Default: defaultPayloadAttestationBPS, Resolved: &resp.PayloadAttestation}, } { - preGloas, err := parseBPS(data, field.Key, field.Default.PreGloas) - if err != nil { - return SlotTimingConfig{}, err + resolved := field.Default + + if field.PreGloasKey != "" { + preGloas, err := parseBPS(data, field.PreGloasKey, field.Default.PreGloas) + if err != nil { + return SlotTimingConfig{}, err + } + + resolved.PreGloas = preGloas } - gloas, err := parseBPS(data, field.Key+gloasSuffix, field.Default.Gloas) + gloas, err := parseBPS(data, field.GloasKey, field.Default.Gloas) if err != nil { return SlotTimingConfig{}, err } - *field.Resolved = ForkBPS{PreGloas: preGloas, Gloas: gloas} + resolved.Gloas = gloas + + *field.Resolved = resolved } return resp, nil diff --git a/app/eth2wrap/utils_internal_test.go b/app/eth2wrap/utils_internal_test.go index 5b6cc48ccf..10e672f077 100644 --- a/app/eth2wrap/utils_internal_test.go +++ b/app/eth2wrap/utils_internal_test.go @@ -63,11 +63,13 @@ func TestFetchNetworkSpecErrors(t *testing.T) { func TestParseSlotTimingConfig(t *testing.T) { // specDefaults is the config returned when the beacon node publishes none of the keys. specDefaults := SlotTimingConfig{ - Attestation: ForkBPS{PreGloas: 3333, Gloas: 2500}, - Aggregate: ForkBPS{PreGloas: 6667, Gloas: 5000}, - SyncMessage: ForkBPS{PreGloas: 3333, Gloas: 2500}, - Contribution: ForkBPS{PreGloas: 6667, Gloas: 5000}, - GloasEpoch: math.MaxUint64, + Attestation: ForkBPS{PreGloas: 3333, Gloas: 2500}, + Aggregate: ForkBPS{PreGloas: 6667, Gloas: 5000}, + SyncMessage: ForkBPS{PreGloas: 3333, Gloas: 2500}, + Contribution: ForkBPS{PreGloas: 6667, Gloas: 5000}, + Payload: ForkBPS{Gloas: 5000}, + PayloadAttestation: ForkBPS{Gloas: 7500}, + GloasEpoch: math.MaxUint64, } tests := []struct { @@ -93,16 +95,41 @@ func TestParseSlotTimingConfig(t *testing.T) { "CONTRIBUTION_DUE_BPS": uint64(6667), "CONTRIBUTION_DUE_BPS_GLOAS": uint64(5000), "GLOAS_FORK_EPOCH": uint64(1024), - "PAYLOAD_ATTESTATION_DUE_BPS": uint64(7500), // Unused, must be ignored. + "PAYLOAD_DUE_BPS": uint64(5000), + "PAYLOAD_ATTESTATION_DUE_BPS": uint64(7500), + "INCLUSION_LIST_DUE_BPS": uint64(6667), // Heze only, must be ignored. }, expect: SlotTimingConfig{ - Attestation: ForkBPS{PreGloas: 3333, Gloas: 2500}, - Aggregate: ForkBPS{PreGloas: 6667, Gloas: 5000}, - SyncMessage: ForkBPS{PreGloas: 3333, Gloas: 2500}, - Contribution: ForkBPS{PreGloas: 6667, Gloas: 5000}, - GloasEpoch: 1024, + Attestation: ForkBPS{PreGloas: 3333, Gloas: 2500}, + Aggregate: ForkBPS{PreGloas: 6667, Gloas: 5000}, + SyncMessage: ForkBPS{PreGloas: 3333, Gloas: 2500}, + Contribution: ForkBPS{PreGloas: 6667, Gloas: 5000}, + Payload: ForkBPS{Gloas: 5000}, + PayloadAttestation: ForkBPS{Gloas: 7500}, + GloasEpoch: 1024, }, }, + { + name: "custom payload deadlines", + data: map[string]any{ + "PAYLOAD_DUE_BPS": uint64(4000), + "PAYLOAD_ATTESTATION_DUE_BPS": uint64(8000), + }, + expect: SlotTimingConfig{ + Attestation: ForkBPS{PreGloas: 3333, Gloas: 2500}, + Aggregate: ForkBPS{PreGloas: 6667, Gloas: 5000}, + SyncMessage: ForkBPS{PreGloas: 3333, Gloas: 2500}, + Contribution: ForkBPS{PreGloas: 6667, Gloas: 5000}, + Payload: ForkBPS{Gloas: 4000}, + PayloadAttestation: ForkBPS{Gloas: 8000}, + GloasEpoch: math.MaxUint64, + }, + }, + { + name: "invalid payload attestation basis points", + data: map[string]any{"PAYLOAD_ATTESTATION_DUE_BPS": uint64(10001)}, + errorIs: "invalid basis points in network spec", + }, { name: "custom values override defaults", data: map[string]any{ @@ -111,11 +138,13 @@ func TestParseSlotTimingConfig(t *testing.T) { "GLOAS_FORK_EPOCH": uint64(0), }, expect: SlotTimingConfig{ - Attestation: ForkBPS{PreGloas: 2000, Gloas: 1500}, - Aggregate: ForkBPS{PreGloas: 6667, Gloas: 5000}, - SyncMessage: ForkBPS{PreGloas: 3333, Gloas: 2500}, - Contribution: ForkBPS{PreGloas: 6667, Gloas: 5000}, - GloasEpoch: 0, + Attestation: ForkBPS{PreGloas: 2000, Gloas: 1500}, + Aggregate: ForkBPS{PreGloas: 6667, Gloas: 5000}, + SyncMessage: ForkBPS{PreGloas: 3333, Gloas: 2500}, + Contribution: ForkBPS{PreGloas: 6667, Gloas: 5000}, + Payload: ForkBPS{Gloas: 5000}, + PayloadAttestation: ForkBPS{Gloas: 7500}, + GloasEpoch: 0, }, }, { diff --git a/app/eth2wrap/utils_test.go b/app/eth2wrap/utils_test.go index 32ebd05894..34e6ba355e 100644 --- a/app/eth2wrap/utils_test.go +++ b/app/eth2wrap/utils_test.go @@ -50,10 +50,12 @@ func TestFetchSlotTimingConfig(t *testing.T) { require.Equal(t, eth2wrap.SlotTimingConfig{ Attestation: eth2wrap.ForkBPS{PreGloas: 2000, Gloas: 1500}, // Keys the beacon node doesn't publish default to the consensus spec values. - Aggregate: eth2wrap.ForkBPS{PreGloas: 6667, Gloas: 5000}, - SyncMessage: eth2wrap.ForkBPS{PreGloas: 3333, Gloas: 2500}, - Contribution: eth2wrap.ForkBPS{PreGloas: 6667, Gloas: 5000}, - GloasEpoch: 1024, + Aggregate: eth2wrap.ForkBPS{PreGloas: 6667, Gloas: 5000}, + SyncMessage: eth2wrap.ForkBPS{PreGloas: 3333, Gloas: 2500}, + Contribution: eth2wrap.ForkBPS{PreGloas: 6667, Gloas: 5000}, + Payload: eth2wrap.ForkBPS{Gloas: 5000}, + PayloadAttestation: eth2wrap.ForkBPS{Gloas: 7500}, + GloasEpoch: 1024, }, timing) } diff --git a/core/slotoffset.go b/core/slotoffset.go index 9a84c47a3e..ca12f84a9d 100644 --- a/core/slotoffset.go +++ b/core/slotoffset.go @@ -37,10 +37,11 @@ func NewSlotOffsetFunc(ctx context.Context, eth2Cl eth2wrap.Client) (SlotOffsetF // newSlotOffsetFunc returns a slot offset function for the provided spec values. func newSlotOffsetFunc(slotDuration time.Duration, slotsPerEpoch uint64, timing eth2wrap.SlotTimingConfig) SlotOffsetFunc { bpsByDuty := map[DutyType]eth2wrap.ForkBPS{ - DutyAttester: timing.Attestation, - DutyAggregator: timing.Aggregate, - DutySyncMessage: timing.SyncMessage, - DutySyncContribution: timing.Contribution, + DutyAttester: timing.Attestation, + DutyAggregator: timing.Aggregate, + DutySyncMessage: timing.SyncMessage, + DutySyncContribution: timing.Contribution, + DutyPayloadAttestation: timing.Payload, } gloasSlot, gloasScheduled := forkSlot(timing.GloasEpoch, slotsPerEpoch) diff --git a/core/slotoffset_internal_test.go b/core/slotoffset_internal_test.go index fc5c434bac..6b411968ba 100644 --- a/core/slotoffset_internal_test.go +++ b/core/slotoffset_internal_test.go @@ -15,14 +15,30 @@ import ( // mainnetTiming returns the intra-slot deadlines of a network that schedules the gloas fork at epoch 64. func mainnetTiming() eth2wrap.SlotTimingConfig { return eth2wrap.SlotTimingConfig{ - Attestation: eth2wrap.ForkBPS{PreGloas: 3333, Gloas: 2500}, - Aggregate: eth2wrap.ForkBPS{PreGloas: 6667, Gloas: 5000}, - SyncMessage: eth2wrap.ForkBPS{PreGloas: 3333, Gloas: 2500}, - Contribution: eth2wrap.ForkBPS{PreGloas: 6667, Gloas: 5000}, - GloasEpoch: 64, + Attestation: eth2wrap.ForkBPS{PreGloas: 3333, Gloas: 2500}, + Aggregate: eth2wrap.ForkBPS{PreGloas: 6667, Gloas: 5000}, + SyncMessage: eth2wrap.ForkBPS{PreGloas: 3333, Gloas: 2500}, + Contribution: eth2wrap.ForkBPS{PreGloas: 6667, Gloas: 5000}, + Payload: eth2wrap.ForkBPS{Gloas: 5000}, + PayloadAttestation: eth2wrap.ForkBPS{Gloas: 7500}, + GloasEpoch: 64, } } +func TestSlotOffsetPayloadAttestation(t *testing.T) { + offsetFunc := newSlotOffsetFunc(12*time.Second, 16, mainnetTiming()) + + const gloasSlot = 64 * 16 + + // The payload attestation duty is triggered when the builder's payload is due (1/2 into the + // slot), not at its own deadline of 3/4 into the slot, so that consensus, partial signature + // exchange and submission can complete before the deadline. + require.Equal(t, 6*time.Second, offsetFunc(Duty{Slot: gloasSlot, Type: DutyPayloadAttestation})) + + // The duty doesn't exist before the gloas fork. + require.Zero(t, offsetFunc(Duty{Slot: gloasSlot - 1, Type: DutyPayloadAttestation})) +} + func TestSlotOffsetPreGloasMatchesFractions(t *testing.T) { // A 12 second slot duration must resolve to the exact fractions used before the gloas fork, // since 3333 and 6667 basis points are the consensus spec's approximations of 1/3 and 2/3. diff --git a/core/types.go b/core/types.go index cd4b6f3243..6d0a228c07 100644 --- a/core/types.go +++ b/core/types.go @@ -44,9 +44,10 @@ const ( DutyPrepareSyncContribution DutyType = 11 DutySyncContribution DutyType = 12 DutyInfoSync DutyType = 13 + DutyPayloadAttestation DutyType = 14 // Only ever append new types here... - dutySentinel DutyType = 14 // Must always be last + dutySentinel DutyType = 15 // Must always be last ) func (d DutyType) Valid() bool { @@ -69,6 +70,7 @@ func (d DutyType) String() string { DutyPrepareSyncContribution: "prepare_sync_contribution", DutySyncContribution: "sync_contribution", DutyInfoSync: "info_sync", + DutyPayloadAttestation: "payload_attestation", }[d] } @@ -260,6 +262,15 @@ func NewInfoSyncDuty(slot uint64) Duty { } } +// NewPayloadAttestationDuty returns a new payload attestation duty. It is a convenience function +// that is slightly more readable and concise than the struct literal equivalent. +func NewPayloadAttestationDuty(slot uint64) Duty { + return Duty{ + Slot: slot, + Type: DutyPayloadAttestation, + } +} + const ( pkLen = 98 // "0x" + hex.Encode([48]byte) = 2+2*48 sigLen = 96 diff --git a/core/types_test.go b/core/types_test.go index 74c97949e1..f702b0f22c 100644 --- a/core/types_test.go +++ b/core/types_test.go @@ -32,9 +32,10 @@ func TestBackwardsCompatibility(t *testing.T) { require.EqualValues(t, 11, core.DutyPrepareSyncContribution) require.EqualValues(t, 12, core.DutySyncContribution) require.EqualValues(t, 13, core.DutyInfoSync) + require.EqualValues(t, 14, core.DutyPayloadAttestation) // Add more types here. - const sentinel = core.DutyType(14) + const sentinel = core.DutyType(15) for i := core.DutyUnknown; i <= sentinel; i++ { switch i { case core.DutyUnknown: @@ -78,7 +79,7 @@ func TestWithDutySpanCtx(t *testing.T) { func TestAllDutyTypes(t *testing.T) { adt := core.AllDutyTypes() - require.Len(t, adt, 13) + require.Len(t, adt, 14) for i, dt := range adt { require.Equal(t, i, slices.Index(adt, dt)) @@ -149,6 +150,14 @@ func TestNewInfoSyncDuty(t *testing.T) { require.EqualValues(t, 1, d.Slot) } +func TestNewPayloadAttestationDuty(t *testing.T) { + d := core.NewPayloadAttestationDuty(1) + + require.Equal(t, core.DutyPayloadAttestation, d.Type) + require.Equal(t, "1/payload_attestation", d.String()) + require.EqualValues(t, 1, d.Slot) +} + func TestPubKeyFrom48Bytes(t *testing.T) { k := testutil.RandomEth2PubKey(t) pk := core.PubKeyFrom48Bytes(k) diff --git a/eth2util/signing/signing.go b/eth2util/signing/signing.go index 0a12956871..c96d7a0f53 100644 --- a/eth2util/signing/signing.go +++ b/eth2util/signing/signing.go @@ -32,6 +32,7 @@ const ( DomainContributionAndProof DomainName = "DOMAIN_CONTRIBUTION_AND_PROOF" DomainDeposit DomainName = "DOMAIN_DEPOSIT" DomainBlobSidecar DomainName = "DOMAIN_BLOB_SIDECAR" + DomainPTCAttester DomainName = "DOMAIN_PTC_ATTESTER" ) // GetDomain returns the beacon domain for the provided type. diff --git a/eth2util/signing/signing_test.go b/eth2util/signing/signing_test.go index 3982e22d49..f89c4fcc99 100644 --- a/eth2util/signing/signing_test.go +++ b/eth2util/signing/signing_test.go @@ -21,6 +21,19 @@ import ( "github.com/obolnetwork/charon/testutil/beaconmock" ) +func TestGetDomainPTCAttester(t *testing.T) { + // The gloas fork adds DOMAIN_PTC_ATTESTER for payload timeliness attestations. + // See https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.13/specs/gloas/beacon-chain.md + bmock, err := beaconmock.New(t.Context(), beaconmock.WithSpecOverride("DOMAIN_PTC_ATTESTER", "0x0c000000")) + require.NoError(t, err) + + domain, err := signing.GetDomain(t.Context(), bmock, signing.DomainPTCAttester, 0) + require.NoError(t, err) + + // The first four bytes of a signing domain are its domain type. + require.Equal(t, []byte{0x0c, 0x00, 0x00, 0x00}, domain[:4]) +} + func TestVerifyRegistrationReference(t *testing.T) { bmock, err := beaconmock.New(t.Context()) require.NoError(t, err)