diff --git a/go.mod b/go.mod index 9c626468..4419ea17 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/ethereum/go-ethereum v1.17.4 github.com/ethpandaops/ethwallclock v0.4.0 - github.com/ethpandaops/go-eth2-client v0.1.6 + github.com/ethpandaops/go-eth2-client v0.1.7-0.20260804142719-11c20aff398e github.com/ethpandaops/service-authenticatoor v0.0.2 github.com/ethpandaops/spamoor v1.2.2 github.com/glebarez/go-sqlite v1.22.0 diff --git a/go.sum b/go.sum index 6c066795..0143f4a7 100644 --- a/go.sum +++ b/go.sum @@ -81,6 +81,8 @@ github.com/ethpandaops/ethwallclock v0.4.0 h1:+sgnhf4pk6hLPukP076VxkiLloE4L0Yk1y github.com/ethpandaops/ethwallclock v0.4.0/go.mod h1:y0Cu+mhGLlem19vnAV2x0hpFS5KZ7oOi2SWYayv9l24= github.com/ethpandaops/go-eth2-client v0.1.6 h1:lG7Xz767YQQ+mN1ldzqJlj9Klg6zOElcemjXbCgJV5o= github.com/ethpandaops/go-eth2-client v0.1.6/go.mod h1:97Oq3omOQSGPPYgrbsOIIw2Pc4T5Ph21f8ZRyHJQBHU= +github.com/ethpandaops/go-eth2-client v0.1.7-0.20260804142719-11c20aff398e h1:UagcSXTMo6fkStgPbzQPGhtSS1+m4kApMnlsJMRW1uc= +github.com/ethpandaops/go-eth2-client v0.1.7-0.20260804142719-11c20aff398e/go.mod h1:qcaftWjJJcrZ/6MgmHBRdfMmUK6IygbBpT8w3TIuVPs= github.com/ethpandaops/service-authenticatoor v0.0.2 h1:0rqHA2Rw64+NG0HVGRfqVhJEkRZRzH3qhX2SkBr2u+w= github.com/ethpandaops/service-authenticatoor v0.0.2/go.mod h1:nIInMlq5O7YQDLVCL5gf0TeVAvVd9Q50k3sm3abTeG0= github.com/ethpandaops/spamoor v1.2.2 h1:hkTVJA8jRTWsR0IvH1WsQcq5cWOui4ikOya7mYZ7B6g= diff --git a/pkg/clients/consensus/blockcache.go b/pkg/clients/consensus/blockcache.go index c2a2df5d..88cc97c6 100644 --- a/pkg/clients/consensus/blockcache.go +++ b/pkg/clients/consensus/blockcache.go @@ -153,9 +153,9 @@ func (cache *BlockCache) SetClientSpecs(specValues map[string]interface{}) error cache.specMutex.Lock() defer cache.specMutex.Unlock() - specs := ChainSpec{} + specs := NewChainSpec() - err := smapping.FillStructByTags(&specs, specValues, "yaml") + err := smapping.FillStructByTags(specs, specValues, "yaml") if err != nil { return err } @@ -169,13 +169,13 @@ func (cache *BlockCache) SetClientSpecs(specValues map[string]interface{}) error } if cache.specs != nil { - mismatches := cache.specs.CheckMismatch(&specs) + mismatches := cache.specs.CheckMismatch(specs) if len(mismatches) > 0 { return fmt.Errorf("spec mismatch: %v", strings.Join(mismatches, ", ")) } } - cache.specs = &specs + cache.specs = specs cache.fullSpecs = specValues return nil diff --git a/pkg/clients/consensus/chainspec.go b/pkg/clients/consensus/chainspec.go index 22278fbc..076cc21e 100644 --- a/pkg/clients/consensus/chainspec.go +++ b/pkg/clients/consensus/chainspec.go @@ -4,6 +4,7 @@ import ( "reflect" "time" + "github.com/ethpandaops/go-eth2-client/spec" "github.com/ethpandaops/go-eth2-client/spec/phase0" ) @@ -29,18 +30,70 @@ type ChainSpec struct { ElectraForkEpoch uint64 `yaml:"ELECTRA_FORK_EPOCH"` FuluForkEpoch uint64 `yaml:"FULU_FORK_EPOCH"` GloasForkEpoch uint64 `yaml:"GLOAS_FORK_EPOCH"` + HezeForkEpoch uint64 `yaml:"HEZE_FORK_EPOCH"` SlotDurationMs uint64 `yaml:"SLOT_DURATION_MS"` SlotsPerEpoch uint64 `yaml:"SLOTS_PER_EPOCH"` MaxCommitteesPerSlot uint64 `yaml:"MAX_COMMITTEES_PER_SLOT"` } +// FarFutureEpoch marks a fork that is not scheduled on this chain. Beacon nodes +// report it for known-but-unscheduled forks and omit the key entirely for forks +// they predate; NewChainSpec seeds every fork epoch with it so both cases read +// the same, and a reported epoch of 0 keeps its literal meaning of "genesis". +const FarFutureEpoch = ^uint64(0) + +// NewChainSpec returns a ChainSpec with every fork unscheduled, ready to be +// filled from a beacon node's /eth/v1/config/spec response. +func NewChainSpec() *ChainSpec { + return &ChainSpec{ + AltairForkEpoch: FarFutureEpoch, + BellatrixForkEpoch: FarFutureEpoch, + CappellaForkEpoch: FarFutureEpoch, + DenebForkEpoch: FarFutureEpoch, + ElectraForkEpoch: FarFutureEpoch, + FuluForkEpoch: FarFutureEpoch, + GloasForkEpoch: FarFutureEpoch, + HezeForkEpoch: FarFutureEpoch, + } +} + +// isForkActive reports whether the fork scheduled at forkEpoch is active at the +// given slot. Unscheduled forks carry FarFutureEpoch (see NewChainSpec), so a +// fork epoch of 0 correctly reads as "active since genesis". The comparison +// divides rather than multiplying, so FarFutureEpoch cannot overflow it. +func (chain *ChainSpec) isForkActive(forkEpoch uint64, slot phase0.Slot) bool { + return chain.SlotsPerEpoch > 0 && uint64(slot)/chain.SlotsPerEpoch >= forkEpoch +} + // IsGloasActive returns true if the gloas fork is active at the given slot. func (chain *ChainSpec) IsGloasActive(slot phase0.Slot) bool { - if chain.GloasForkEpoch == 0 || chain.SlotsPerEpoch == 0 { - return false - } + return chain.isForkActive(chain.GloasForkEpoch, slot) +} - return uint64(slot) >= chain.GloasForkEpoch*chain.SlotsPerEpoch +// DataVersionAtSlot returns the consensus fork active at the given slot, as the +// spec.DataVersion that selects the arm of a versioned type and drives the +// Eth-Consensus-Version header. +func (chain *ChainSpec) DataVersionAtSlot(slot phase0.Slot) spec.DataVersion { + switch { + case chain.isForkActive(chain.HezeForkEpoch, slot): + return spec.DataVersionHeze + case chain.isForkActive(chain.GloasForkEpoch, slot): + return spec.DataVersionGloas + case chain.isForkActive(chain.FuluForkEpoch, slot): + return spec.DataVersionFulu + case chain.isForkActive(chain.ElectraForkEpoch, slot): + return spec.DataVersionElectra + case chain.isForkActive(chain.DenebForkEpoch, slot): + return spec.DataVersionDeneb + case chain.isForkActive(chain.CappellaForkEpoch, slot): + return spec.DataVersionCapella + case chain.isForkActive(chain.BellatrixForkEpoch, slot): + return spec.DataVersionBellatrix + case chain.isForkActive(chain.AltairForkEpoch, slot): + return spec.DataVersionAltair + default: + return spec.DataVersionPhase0 + } } func (chain *ChainSpec) CheckMismatch(chain2 *ChainSpec) []string { @@ -49,7 +102,7 @@ func (chain *ChainSpec) CheckMismatch(chain2 *ChainSpec) []string { chainT := reflect.ValueOf(chain).Elem() chain2T := reflect.ValueOf(chain2).Elem() - for i := 0; i < chainT.NumField(); i++ { + for i := range chainT.NumField() { if chainT.Field(i).Interface() != chain2T.Field(i).Interface() { mismatches = append(mismatches, chainT.Type().Field(i).Name) } diff --git a/pkg/clients/consensus/chainspec_test.go b/pkg/clients/consensus/chainspec_test.go new file mode 100644 index 00000000..b322cea0 --- /dev/null +++ b/pkg/clients/consensus/chainspec_test.go @@ -0,0 +1,115 @@ +package consensus + +import ( + "context" + "testing" + + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/phase0" + "github.com/sirupsen/logrus" +) + +func TestDataVersionAtSlot(t *testing.T) { + // Every fork scheduled, one epoch apart, so each rung of the ladder is + // reachable and the boundaries are exact. + scheduled := &ChainSpec{ + AltairForkEpoch: 1, + BellatrixForkEpoch: 2, + CappellaForkEpoch: 3, + DenebForkEpoch: 4, + ElectraForkEpoch: 5, + FuluForkEpoch: 6, + GloasForkEpoch: 7, + HezeForkEpoch: 8, + SlotsPerEpoch: 32, + } + + // A chain that never leaves electra: later forks unscheduled, as a beacon + // node reports them. + electraOnly := &ChainSpec{ + AltairForkEpoch: 0, + BellatrixForkEpoch: 0, + CappellaForkEpoch: 0, + DenebForkEpoch: 0, + ElectraForkEpoch: 0, + FuluForkEpoch: FarFutureEpoch, + GloasForkEpoch: FarFutureEpoch, + HezeForkEpoch: FarFutureEpoch, + SlotsPerEpoch: 32, + } + + tests := []struct { + name string + spec *ChainSpec + slot phase0.Slot + expected spec.DataVersion + }{ + {name: "Phase0BeforeAltair", spec: scheduled, slot: 0, expected: spec.DataVersionPhase0}, + {name: "Altair", spec: scheduled, slot: 1 * 32, expected: spec.DataVersionAltair}, + {name: "Bellatrix", spec: scheduled, slot: 2 * 32, expected: spec.DataVersionBellatrix}, + {name: "Capella", spec: scheduled, slot: 3 * 32, expected: spec.DataVersionCapella}, + {name: "Deneb", spec: scheduled, slot: 4 * 32, expected: spec.DataVersionDeneb}, + {name: "DenebUntilElectra", spec: scheduled, slot: 5*32 - 1, expected: spec.DataVersionDeneb}, + {name: "Electra", spec: scheduled, slot: 5 * 32, expected: spec.DataVersionElectra}, + {name: "Fulu", spec: scheduled, slot: 6 * 32, expected: spec.DataVersionFulu}, + {name: "Gloas", spec: scheduled, slot: 7 * 32, expected: spec.DataVersionGloas}, + {name: "Heze", spec: scheduled, slot: 8 * 32, expected: spec.DataVersionHeze}, + {name: "HezeStaysAtHead", spec: scheduled, slot: 500 * 32, expected: spec.DataVersionHeze}, + + // A fork epoch of 0 means genesis, not "unscheduled". + {name: "GenesisElectra", spec: electraOnly, slot: 0, expected: spec.DataVersionElectra}, + {name: "UnscheduledForksNeverActivate", spec: electraOnly, slot: 1e6, expected: spec.DataVersionElectra}, + + { + // SlotsPerEpoch is only zero before the spec has been fetched. + name: "ZeroSlotsPerEpoch", + spec: &ChainSpec{ElectraForkEpoch: 0}, + slot: 100 * 32, + expected: spec.DataVersionPhase0, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := test.spec.DataVersionAtSlot(test.slot); got != test.expected { + t.Errorf("DataVersionAtSlot(%d) = %v, want %v", test.slot, got, test.expected) + } + }) + } +} + +// TestSetClientSpecsForkDefaults pins the distinction the fork ladder rests on: +// a fork epoch a beacon node does not report at all must read as unscheduled, +// while a reported 0 must read as genesis. Both arrive as the same Go zero +// value unless the spec is seeded first. +func TestSetClientSpecsForkDefaults(t *testing.T) { + cache, err := NewBlockCache(context.Background(), logrus.New(), 10) + if err != nil { + t.Fatalf("failed creating block cache: %v", err) + } + + // A node that knows nothing past electra omits FULU/GLOAS/HEZE entirely. + err = cache.SetClientSpecs(map[string]interface{}{ + "SLOTS_PER_EPOCH": uint64(32), + "ALTAIR_FORK_EPOCH": uint64(0), + "DENEB_FORK_EPOCH": uint64(0), + "ELECTRA_FORK_EPOCH": uint64(0), + }) + if err != nil { + t.Fatalf("failed setting client specs: %v", err) + } + + specs := cache.GetSpecs() + + if specs.FuluForkEpoch != FarFutureEpoch { + t.Errorf("unreported FuluForkEpoch = %d, want FarFutureEpoch", specs.FuluForkEpoch) + } + + if specs.ElectraForkEpoch != 0 { + t.Errorf("reported ElectraForkEpoch = %d, want 0", specs.ElectraForkEpoch) + } + + if got := specs.DataVersionAtSlot(1e6); got != spec.DataVersionElectra { + t.Errorf("DataVersionAtSlot = %v, want electra: unreported forks must not activate", got) + } +} diff --git a/pkg/clients/consensus/rpc/beaconapi.go b/pkg/clients/consensus/rpc/beaconapi.go index d85289e4..75bec7cc 100644 --- a/pkg/clients/consensus/rpc/beaconapi.go +++ b/pkg/clients/consensus/rpc/beaconapi.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" nethttp "net/http" @@ -510,28 +511,29 @@ func (bc *BeaconClient) SubmitVoluntaryExits(ctx context.Context, exit *phase0.S return nil } -func (bc *BeaconClient) SubmitAttesterSlashing(ctx context.Context, slashing *phase0.AttesterSlashing) error { - err := bc.postJSON(ctx, fmt.Sprintf("%s/eth/v1/beacon/pool/attester_slashings", bc.endpoint), slashing, nil) - if err == nil { - return nil - } +// ErrPreElectraSlashing reports an attester slashing whose fork predates +// electra. The versioned pool endpoint was introduced in the electra release, +// so a node on an earlier fork does not serve it. +var ErrPreElectraSlashing = errors.New("attester slashing predates the versioned pool endpoint") - // Prysm removed the v1 endpoint post-Electra, fall back to v2 with version header. - if err.Error() == "not found" { - v2Err := bc.postJSONWithHeaders( - ctx, - fmt.Sprintf("%s/eth/v2/beacon/pool/attester_slashings", bc.endpoint), - slashing, nil, - map[string]string{"Eth-Consensus-Version": "electra"}, - ) - if v2Err != nil { - return v2Err - } +// SubmitAttesterSlashing submits a versioned attester slashing through +// go-eth2-client's V2 pool endpoint (/eth/v2/beacon/pool/attester_slashings). +// The client library derives the Eth-Consensus-Version header from the +// slashing's version. The V1 endpoint was deprecated in the electra release of +// the beacon APIs and removed from the spec (ethereum/beacon-APIs#549). +func (bc *BeaconClient) SubmitAttesterSlashing(ctx context.Context, slashing *spec.VersionedAttesterSlashing) error { + if slashing.Version < spec.DataVersionElectra { + return fmt.Errorf("%w: fork is %v", ErrPreElectraSlashing, slashing.Version) + } - return nil + submitter, isOk := bc.clientSvc.(eth2client.AttesterSlashingSubmitterV2) + if !isOk { + return fmt.Errorf("submit attester slashing not supported") } - return err + return submitter.SubmitAttesterSlashingV2(ctx, &api.SubmitAttesterSlashingOpts{ + AttesterSlashing: slashing, + }) } func (bc *BeaconClient) SubmitProposerSlashing(ctx context.Context, slashing *phase0.ProposerSlashing) error { diff --git a/pkg/clients/consensus/rpc/beaconapi_test.go b/pkg/clients/consensus/rpc/beaconapi_test.go new file mode 100644 index 00000000..07517231 --- /dev/null +++ b/pkg/clients/consensus/rpc/beaconapi_test.go @@ -0,0 +1,140 @@ +package rpc + +import ( + "context" + "encoding/json" + "errors" + "io" + nethttp "net/http" + "net/http/httptest" + "testing" + + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/all" + "github.com/ethpandaops/go-eth2-client/spec/phase0" +) + +// attesterSlashing builds a versioned slashing the same way generate_slashings +// does, so this test covers that construction too. +func attesterSlashing(t *testing.T, version spec.DataVersion) *spec.VersionedAttesterSlashing { + t.Helper() + + attestation := &all.IndexedAttestation{ + Version: version, + AttestingIndices: []uint64{42}, + Data: &phase0.AttestationData{Slot: 640, Source: &phase0.Checkpoint{}, Target: &phase0.Checkpoint{}}, + } + + versioned, err := (&all.AttesterSlashing{ + Version: version, + Attestation1: attestation, + Attestation2: attestation, + }).ToVersioned() + if err != nil { + t.Fatalf("failed building %v attester slashing: %v", version, err) + } + + return versioned +} + +// TestSubmitAttesterSlashing asserts that slashing submission goes through +// go-eth2-client's versioned pool endpoint, that the Eth-Consensus-Version +// header follows the slashing's fork rather than a hardcoded value, and that a +// pre-electra slashing fails on the fork instead of on the endpoint. +func TestSubmitAttesterSlashing(t *testing.T) { + var ( + postCount int + gotPath string + gotHeader string + gotBody []byte + ) + + srv := httptest.NewServer(nethttp.HandlerFunc(func(w nethttp.ResponseWriter, r *nethttp.Request) { + switch r.URL.Path { + case "/eth/v1/node/version": + _, _ = w.Write([]byte(`{"data":{"version":"test"}}`)) + case "/eth/v1/node/syncing": + _, _ = w.Write([]byte(`{"data":{"is_syncing":false,"is_optimistic":false,"el_offline":false,"head_slot":"640","sync_distance":"0"}}`)) + case "/eth/v2/beacon/pool/attester_slashings": + postCount++ + gotPath = r.URL.Path + gotHeader = r.Header.Get("Eth-Consensus-Version") + gotBody, _ = io.ReadAll(r.Body) + default: + w.WriteHeader(nethttp.StatusNotFound) + } + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client, err := NewBeaconClient("test", srv.URL, nil) + if err != nil { + t.Fatalf("failed creating beacon client: %v", err) + } + + if err = client.Initialize(ctx); err != nil { + t.Fatalf("failed initializing beacon client: %v", err) + } + + tests := []struct { + name string + version spec.DataVersion + wantHeader string + }{ + {name: "Electra", version: spec.DataVersionElectra, wantHeader: "electra"}, + {name: "Fulu", version: spec.DataVersionFulu, wantHeader: "fulu"}, + {name: "Gloas", version: spec.DataVersionGloas, wantHeader: "gloas"}, + {name: "PreElectraRejected", version: spec.DataVersionDeneb}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + postCount, gotPath, gotHeader, gotBody = 0, "", "", nil + + err := client.SubmitAttesterSlashing(ctx, attesterSlashing(t, test.version)) + + if test.wantHeader == "" { + if !errors.Is(err, ErrPreElectraSlashing) { + t.Fatalf("error = %v, want ErrPreElectraSlashing", err) + } + + if postCount != 0 { + t.Errorf("submissions = %d, want 0: a rejected fork must not reach the node", postCount) + } + + return + } + + if err != nil { + t.Fatalf("failed submitting attester slashing: %v", err) + } + + if postCount != 1 { + t.Fatalf("submissions = %d, want 1", postCount) + } + + if gotPath != "/eth/v2/beacon/pool/attester_slashings" { + t.Errorf("path = %q, want the v2 pool endpoint", gotPath) + } + + if gotHeader != test.wantHeader { + t.Errorf("Eth-Consensus-Version = %q, want %q", gotHeader, test.wantHeader) + } + + var body map[string]json.RawMessage + if err := json.Unmarshal(gotBody, &body); err != nil { + t.Fatalf("failed parsing submitted body: %v", err) + } + + // A version/arm mismatch marshals to a null body under an + // otherwise valid header, so assert the payload is really there. + for _, key := range []string{"attestation_1", "attestation_2"} { + if raw, ok := body[key]; !ok || string(raw) == "null" { + t.Errorf("submitted body %v = %q, want the fork-specific attestation", key, string(raw)) + } + } + }) + } +} diff --git a/pkg/tasks/generate_slashings/task.go b/pkg/tasks/generate_slashings/task.go index 809d6231..59e4bdf4 100644 --- a/pkg/tasks/generate_slashings/task.go +++ b/pkg/tasks/generate_slashings/task.go @@ -13,6 +13,7 @@ import ( "github.com/ethpandaops/assertoor/pkg/types" v1 "github.com/ethpandaops/go-eth2-client/api/v1" "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/go-eth2-client/spec/all" "github.com/ethpandaops/go-eth2-client/spec/phase0" hbls "github.com/herumi/bls-eth-go-binary/bls" "github.com/protolambda/zrnt/eth2/beacon/common" @@ -346,7 +347,7 @@ func (t *Task) generateSlashing(ctx context.Context, accountIdx uint64, forkStat return 0, fmt.Errorf("validator %v is not active", validator.Index) } - var attesterSlashing *phase0.AttesterSlashing + var attesterSlashing *spec.VersionedAttesterSlashing var proposerSlashing *phase0.ProposerSlashing @@ -410,7 +411,7 @@ func (t *Task) mnemonicToSeed(mnemonic string) (seed []byte, err error) { return bip39.NewSeed(mnemonic, ""), nil } -func (t *Task) generateSurroundAttesterSlashing(validatorIndex uint64, validatorKey *e2types.BLSPrivateKey, forkState *phase0.Fork) (*phase0.AttesterSlashing, error) { +func (t *Task) generateSurroundAttesterSlashing(validatorIndex uint64, validatorKey *e2types.BLSPrivateKey, forkState *phase0.Fork) (*spec.VersionedAttesterSlashing, error) { // surround attester slashing case: // different target, different source // source1 < source 2 @@ -493,21 +494,26 @@ func (t *Task) generateSurroundAttesterSlashing(validatorIndex uint64, validator signingRoot2 := common.ComputeSigningRoot(msgRoot2, dom) sig2 := secKey.SignHash(signingRoot2[:]) - att1 := &phase0.IndexedAttestation{ - AttestingIndices: []uint64{validatorIndex}, - Data: attestationData1, - Signature: phase0.BLSSignature(sig1.Serialize()), - } - att2 := &phase0.IndexedAttestation{ - AttestingIndices: []uint64{validatorIndex}, - Data: attestationData2, - Signature: phase0.BLSSignature(sig2.Serialize()), - } - - return &phase0.AttesterSlashing{ - Attestation1: att1, - Attestation2: att2, - }, nil + // all.AttesterSlashing is fork-agnostic: ToVersioned selects the arm the + // fork requires, and go-eth2-client derives the Eth-Consensus-Version + // header from the same version. No fork-specific types here. + version := specs.DataVersionAtSlot(phase0.Slot(slot1)) + + return (&all.AttesterSlashing{ + Version: version, + Attestation1: &all.IndexedAttestation{ + Version: version, + AttestingIndices: []uint64{validatorIndex}, + Data: attestationData1, + Signature: phase0.BLSSignature(sig1.Serialize()), + }, + Attestation2: &all.IndexedAttestation{ + Version: version, + AttestingIndices: []uint64{validatorIndex}, + Data: attestationData2, + Signature: phase0.BLSSignature(sig2.Serialize()), + }, + }).ToVersioned() } func (t *Task) generateProposerSlashing(validatorIndex uint64, validatorKey *e2types.BLSPrivateKey, forkState *phase0.Fork) (*phase0.ProposerSlashing, error) {