Skip to content

Submit attester slashings via go-eth2-client's versioned submitter - #229

Open
parithosh wants to merge 4 commits into
masterfrom
attester-slashing-v2
Open

Submit attester slashings via go-eth2-client's versioned submitter#229
parithosh wants to merge 4 commits into
masterfrom
attester-slashing-v2

Conversation

@parithosh

@parithosh parithosh commented Aug 4, 2026

Copy link
Copy Markdown
Member

Summary

Submit attester slashings through the beacon-APIs V2 pool endpoint, using go-eth2-client's AttesterSlashingSubmitterV2 rather than a bespoke HTTP call.

  • BeaconClient.SubmitAttesterSlashing now takes a *spec.VersionedAttesterSlashing and delegates to bc.clientSvc.(eth2client.AttesterSlashingSubmitterV2), in the same shape as the neighbouring SubmitBLSToExecutionChanges / SubmitVoluntaryExits. Both the hand-rolled V2 POST and the legacy V1 helper are deleted; assertoor no longer builds this request itself.
  • ChainSpec.ConsensusForkVersionAtSlot becomes ChainSpec.DataVersionAtSlot and returns a spec.DataVersion instead of a bare header string. One value now selects the versioned arm and drives the Eth-Consensus-Version header, so the two cannot disagree.
  • generate_slashings builds an all.AttesterSlashing and calls ToVersioned(), so the fork-specific types stay inside go-eth2-client and a future fork needs no change here.
  • go-eth2-client bumped to v0.1.7-0.20260804142719-11c20aff398e (master, the merge commit of ethpandaops/go-eth2-client#46). Happy to re-pin to a release tag once one is cut.

Motivation

The V1 pool endpoint was deprecated in the electra release of the beacon APIs and removed from the spec (ethereum/beacon-APIs#549); Prysm already drops it. On fulu/gloas devnets the old code therefore either hit a removed endpoint or sent the wrong consensus-version header.

The original version of this PR fixed that with its own V2 submission function. Now that ethpandaops/go-eth2-client#46 is merged, the library owns the endpoint, the header derivation and the fork-specific marshalling, and assertoor should not keep a second implementation of the same request. bc.clientSvc is already a go-eth2-client/http.Service, so this is a delegation, not a new dependency.

Behavioural change: the V1 fallback is gone

The earlier revision fell back to a V1 POST on any V2 failure. That is dropped, deliberately:

  • The library has no fallback, and re-adding one in assertoor would recreate exactly the bespoke submission path this change removes.
  • The fallback could not be correct anyway: from electra onwards the payload is an electra/gloas AttesterSlashing, so retrying it against the V1 endpoint sends a post-electra body to a pre-electra endpoint.
  • The fallback only helped pre-electra nodes, which are not a target for these devnets, and it converted a real submission failure into a second failure with a concatenated error message.

Pre-electra chains fail on the fork, not on the endpoint

The versioned pool endpoint only exists from electra. Rather than letting a pre-electra chain produce an opaque 404/400, SubmitAttesterSlashing rejects the submission up front with ErrPreElectraSlashing. It is one ordered comparison on spec.DataVersion, not a per-fork list, and it lives on the submitter because it is a property of the endpoint rather than of any task.

playbooks/stable/validator-lifecycle-test-v2.yaml and playbooks/stable/kurtosis/validator-slashing-test.yaml are the fork-generic consumers that run slashingType: attester; both now get a named fork error instead of an opaque pool-endpoint failure if pointed at a pre-electra chain.

Fork resolution

DataVersionAtSlot is a single ladder over one isForkActive helper covering every fork spec.DataVersion knows, phase0 through heze; IsGloasActive is now the same helper. HEZE_FORK_EPOCH is added to ChainSpec so the ladder spans the full range.

That ladder needs a fork epoch of 0 to mean genesis rather than "not scheduled". The two were conflated because both arrive as the Go zero value: a beacon node reports FAR_FUTURE_EPOCH for a known-but-unscheduled fork and omits the key entirely for a fork it predates. NewChainSpec now seeds every fork epoch with FarFutureEpoch before smapping fills it, so an omitted key stays unscheduled and a reported 0 keeps its literal meaning.

This is load-bearing, not cosmetic: without the seeding, a node that has never heard of gloas reports GLOAS_FORK_EPOCH absent, and a uniform ladder would then resolve every slot to gloas.

Scope

Still a spec-correctness fix for the submission path. It does not claim to change slashing-inclusion behaviour on any specific CL client: how a node parses the JSON body server-side is a separate, server-side question.

Unchanged and intentionally out of scope: beaconapi.go:573 still hardcodes Eth-Consensus-Version: electra in SubmitAttestations (the v2 attestations endpoint used by generate_attestations). Same defect class; DataVersionAtSlot is the same fix, but it wants its own PR, and go-eth2-client's SubmitAttestations would be the right thing to delegate to there too.

Test plan

  • go build ./..., go vet ./..., gofmt -l . clean; full go test ./... passes
  • golangci-lint run on the touched packages reports no new findings (two pre-existing goconst hits on "head" in beaconapi.go / beaconstream.go are unchanged from master)
  • rpc.TestSubmitAttesterSlashing — hermetic httptest end-to-end through the real go-eth2-client/http.Service, as a table over electra/fulu/gloas plus a pre-electra case. Asserts the v2 pool path, that Eth-Consensus-Version tracks the fork rather than a constant, and that the body carries a non-null fork-specific attestation_1 / attestation_2 (an arm/version mismatch marshals to null under a valid header). The pre-electra case asserts ErrPreElectraSlashing and that no request reaches the node. Inputs are built through all.AttesterSlashing.ToVersioned() exactly as the task does, so the construction is covered too. Verified to fail if the guard is replaced with if false.
  • consensus.TestDataVersionAtSlot — every rung of the ladder and its boundary, plus genesis-activated forks and unscheduled ones.
  • consensus.TestSetClientSpecsForkDefaults — the load-bearing case: a spec response that omits FULU/GLOAS/HEZE leaves them unscheduled while a reported 0 stays genesis. Verified to fail if the seeding is dropped, with the exact symptom it prevents: DataVersionAtSlot = fulu, want electra.
  • not run against a live devnet

@parithosh
parithosh force-pushed the attester-slashing-v2 branch from f7c8ca6 to 4544769 Compare August 4, 2026 09:44
The V1 pool endpoint (/eth/v1/beacon/pool/attester_slashings) was
deprecated in the Electra release of the beacon APIs and removed from the
spec (ethereum/beacon-APIs#549). generate_slashings still used it, and
the legacy fallback only fired on a 404 and hardcoded
Eth-Consensus-Version: electra even on fulu/gloas networks.

- add BeaconClient.SubmitAttesterSlashingV2: V2 primary path with the
  caller-supplied consensus version, falling back to the legacy V1
  submission on 404 (pre-Electra nodes)
- generate_slashings now submits via V2 with the version derived from
  the chain spec (new ChainSpec.ConsensusForkVersionAtSlot helper)
- simplify the legacy SubmitAttesterSlashing to the plain V1 POST; its
  nested v2 fallback with the hardcoded electra header is superseded
@parithosh
parithosh force-pushed the attester-slashing-v2 branch from 4544769 to 0a4c843 Compare August 4, 2026 09:49
Follow-up to the V2 submission change: drop assertoor's hand-rolled
V2 POST and route the submission through go-eth2-client's
AttesterSlashingSubmitterV2, merged in ethpandaops/go-eth2-client#46.

- BeaconClient.SubmitAttesterSlashing now takes a
  spec.VersionedAttesterSlashing and delegates to the library, matching
  the shape of SubmitBLSToExecutionChanges / SubmitVoluntaryExits. The
  library derives Eth-Consensus-Version from the slashing's version, so
  the header can no longer drift from the payload. Both the bespoke V2
  POST and the legacy V1 helper are gone.
- ChainSpec.ConsensusForkVersionAtSlot becomes DataVersionAtSlot and
  returns a spec.DataVersion, which selects the versioned arm and drives
  the header in one value instead of a bare header string.
- generate_slashings wraps the generated phase0 slashing in the arm
  matching the fork at the slot it was built for; electra/fulu need
  electra.AttesterSlashing and gloas needs gloas.AttesterSlashing, since
  EIP-7549 and the progressive-list migration each gave
  IndexedAttestation its own Go type.
@parithosh parithosh changed the title Submit attester slashings via the beacon-APIs V2 endpoint Submit attester slashings via go-eth2-client's versioned submitter Aug 4, 2026
@parithosh

Copy link
Copy Markdown
Member Author

Reworked on top of the merged ethpandaops/go-eth2-client#46 (26ceadc). The bespoke V2 submission function is gone; BeaconClient.SubmitAttesterSlashing now takes a *spec.VersionedAttesterSlashing and delegates to the library's AttesterSlashingSubmitterV2, so the endpoint, the Eth-Consensus-Version derivation and the fork-specific marshalling all live in one place.

Two consequences worth calling out explicitly:

  1. The V1 fallback is dropped. It could not have been correct: from electra onwards the payload is an electra/gloas AttesterSlashing, so retrying it against the V1 endpoint sends a post-electra body to a pre-electra endpoint. It only ever helped pre-electra nodes, and it turned one real failure into a concatenated two-failure message. Reasoning is in the PR description.
  2. ConsensusForkVersionAtSlot became DataVersionAtSlot and returns spec.DataVersion rather than a header string, because the same value now has to select the versioned arm and produce the header. A string could only do the latter, which is precisely the drift that caused the original bug.

Dependency is pinned to the master pseudo-version v0.1.7-0.20260804142719-11c20aff398e since no tag has been cut past v0.1.6; say the word and I'll re-pin to a release.

Test coverage is now end-to-end rather than unit-only: rpc.TestSubmitAttesterSlashing drives a real go-eth2-client/http.Service against an httptest server and asserts the v2 path, that the header is gloas (derived, not constant), and a non-null fork-specific body — which also exercises the AttesterSlashingSubmitterV2 type assertion that would otherwise only fail at runtime.

Separately: the root cause of that whole class of bug is that go-eth2-client has no compile-time conformance assertions anywhere in the module. I've opened that as its own PR upstream against attestantio.

Review follow-up. DataVersionAtSlot floored at electra, so on a chain
that schedules electra at a later epoch a pre-electra slot produced an
electra-shaped slashing and an Eth-Consensus-Version: electra POST to a
v2 pool endpoint the node does not serve. The resulting HTTP error is
easy to misdiagnose as a client bug rather than an unsupported fork.

DataVersionAtSlot now returns (spec.DataVersion, error) and reports
ErrPreElectraFork with the current and activation epochs when electra is
positively scheduled at a later epoch. The floor stays for the ambiguous
case: a fork epoch of 0 means "not scheduled" under this ChainSpec's
convention, so a genesis-activated electra cannot be told apart from one
an older node never reported, and treating that as pre-electra would
break every genesis-electra devnet.

Also fold the duplicated fork-activation arithmetic into isForkActive,
which IsGloasActive now uses too; it divides instead of multiplying, so
a FAR_FUTURE fork epoch cannot overflow the comparison.
@parithosh

Copy link
Copy Markdown
Member Author

Thanks — taking the suggestion. Pushed b471237.

On the 🟡: deliberate, but you're right that the failure mode was the bad part. DataVersionAtSlot now returns (spec.DataVersion, error) and reports ErrPreElectraFork with both the current and the activation epoch, so the task fails with cannot build attester slashing: fork active at slot predates electra: slot 128 is in epoch 4, electra activates at epoch 5 instead of an opaque v2 pool-endpoint error.

I could only make that positive in one direction, and it's worth being explicit about why. Under this ChainSpec's convention a fork epoch of 0 means "not scheduled" — older nodes omit the key from /eth/v1/config/spec and it unmarshals to 0 — so a genesis-activated electra is indistinguishable from one the node never reported. Reading ElectraForkEpoch == 0 as pre-electra would break every genesis-electra devnet, which is most of them. So the guard fires only when electra is positively scheduled at a later epoch, and the floor stays for the ambiguous case. That positive case is also the realistic one: a fork-transition devnet running generate_slashings before the fork.

On the playbooks: the fork-generic consumers running slashingType: attester are playbooks/stable/validator-lifecycle-test-v2.yaml and playbooks/stable/kurtosis/validator-slashing-test.yaml. Everything else is under a fork-pinned directory (gloas-dev/, pectra-dev/, dev/). Neither stable playbook pins a fork, so both are exactly the misdiagnosis case you describe, and both now get the named error.

Two incidental things in the same commit:

  • The duplicated fork-activation arithmetic is folded into an isForkActive helper that IsGloasActive also uses. It divides rather than multiplying, so IsGloasActive's old GloasForkEpoch * SlotsPerEpoch can no longer overflow on a FAR_FUTURE_EPOCH.
  • TestDataVersionAtSlot gains the pre-electra cases, including the one asserting that an unreported (0) electra is not reported as pre-electra. Verified it bites: replacing the guard with case false: fails BeforeScheduledElectra.

Build, vet, gofmt, full test suite and golangci-lint on the touched packages are all clean (the two goconst "head" hits are pre-existing on master).

Review follow-up, net -83 lines.

DataVersionAtSlot had three different activation checks in one function
and only named electra, fulu and gloas. It is now a single ladder over
isForkActive covering every fork spec.DataVersion knows, phase0 through
heze, and IsGloasActive is the same helper.

That ladder needs a fork epoch of 0 to mean genesis rather than "not
scheduled", which is the reading the old > 0 guards used. The two cases
were conflated because both arrive as the Go zero value: a beacon node
reports FAR_FUTURE_EPOCH for a known-but-unscheduled fork and omits the
key entirely for a fork it predates. NewChainSpec now seeds every fork
epoch with FarFutureEpoch before smapping fills it, so an omitted key
stays unscheduled and a reported 0 keeps its literal meaning. Without
that seeding a node that has never heard of gloas reports GLOAS at
genesis, which TestSetClientSpecsForkDefaults pins.

Also add HEZE_FORK_EPOCH so the ladder covers the full DataVersion range.

generate_slashings now builds an all.AttesterSlashing and calls
ToVersioned instead of rewrapping phase0 types into electra/gloas ones
by hand: no per-fork switch, and future forks need no change here. That
removes versionedAttesterSlashing and its four converters.

The pre-electra guard moves to the submitter as a single ordered
comparison, since it is a property of the versioned pool endpoint rather
than of any one fork, and it is likewise fork-independent.
@parithosh

Copy link
Copy Markdown
Member Author

Both fair — aecc726, net -83 lines.

all.AttesterSlashing: exactly right, thanks. generate_slashings now builds one and calls ToVersioned(); the fork-specific types stay inside go-eth2-client. That deleted versionedAttesterSlashing and its four converters, and the per-fork switch is gone, so a future fork needs no change here.

The three activation checks: guilty. DataVersionAtSlot is now one ladder over a single isForkActive, covering phase0 through heze, and IsGloasActive is the same helper. I added HEZE_FORK_EPOCH to ChainSpec so the ladder spans the whole DataVersion range.

One thing that had to change for the uniform ladder to be safe, and it's the part worth your eyes:

the > 0 guards were conflating "unscheduled" with "genesis"

A uniform ladder needs forkEpoch == 0 to mean genesis, otherwise genesis-electra devnets resolve to phase0. But dropping the > 0 guard naively is worse: a node that has never heard of gloas omits GLOAS_FORK_EPOCH, smapping leaves the Go zero value, and every slot resolves to gloas. The two cases were indistinguishable because a node reports FAR_FUTURE_EPOCH for a known-but-unscheduled fork and omits the key entirely for one it predates.

So NewChainSpec seeds every fork epoch with FarFutureEpoch before smapping fills it — verified FillStructByTags leaves absent keys untouched and still overwrites on an explicit 0. Omitted key stays unscheduled, reported 0 keeps its literal meaning, and isForkActive is a one-line divide (no multiply, so FAR_FUTURE_EPOCH can't overflow it). TestSetClientSpecsForkDefaults pins exactly this; dropping the seeding fails it with DataVersionAtSlot = fulu, want electra.

Knock-on: IsGloasActive no longer treats GloasForkEpoch == 0 as inactive, so a genesis-gloas devnet now reports gloas correctly. It had no other callers.

Pre-electra guard (from the earlier round) is no longer a per-fork thing either — it's one ordered slashing.Version < spec.DataVersionElectra on the submitter, since it's a property of the versioned pool endpoint rather than of any task.

Tests consolidated to where the behaviour now lives: generate_slashings/task_test.go is deleted (nothing left to unit-test after the helpers went), and rpc.TestSubmitAttesterSlashing became a table over electra/fulu/gloas + pre-electra that builds its input via all.AttesterSlashing.ToVersioned() exactly as the task does. Both new guards mutation-tested.

Build, vet, gofmt, full suite and golangci-lint clean (the two goconst "head" hits are pre-existing).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant