feat(core): implement core/fetcher#454
Conversation
Ports the Charon core/fetcher module to Pluto, covering all four duty paths (attester, aggregator, proposer, sync contribution). Reworks DutyDefinition into a heterogeneous enum mirroring Charon's interface and promotes UnsignedDataSet/UnsignedDutyData into core::types so it is shared by the fetcher, DutyDB, and consensus. All Go fetcher tests are ported and passing. Resolves #172. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
b1295a1 to
834f8f8
Compare
emlautarom1
left a comment
There was a problem hiding this comment.
Walking through the parts of this change that warrant the most discussion. Overall the structure mirrors Charon faithfully and the test coverage looks solid. Leaving notes on a few design points rather than blocking changes.
| /// Per-validator duty definition. The Rust equivalent of Go's | ||
| /// `core.DutyDefinition` interface: a closed set of concrete duty definitions. | ||
| #[derive(Debug, Clone, PartialEq, Eq)] | ||
| pub enum DutyDefinition { |
There was a problem hiding this comment.
Change from the generic DutyDefinition<T> to an enum that mirrors Charon's core.DutyDefinition interface (AttesterDefinition / ProposerDefinition / SyncCommitteeDefinition). The set of types is known ahead of time so no need to replicate the interface with trait tricks.
This change is also required by core/scheduler so it's known to be correct.
| type CallbackFuture<T> = Pin<Box<dyn Future<Output = std::result::Result<T, BoxError>> + Send>>; | ||
|
|
||
| /// Subscriber callback invoked for each fetched duty data set. | ||
| pub type Subscriber = Arc<dyn Fn(Duty, UnsignedDataSet) -> CallbackFuture<()> + Send + Sync>; |
There was a problem hiding this comment.
Async-callback shape note: Arc<dyn Fn(...) -> Pin<Box<dyn Future + Send>>> is the same idiom sigagg already uses, so we're consistent across components.
Two small things to keep in mind for review:
BoxError = Box<dyn std::error::Error + Send + Sync>is intentionally loose so callers (AggSigDB / DutyDB / subscribers) can return their own error types without dragging concrete types into the fetcher's public surface. The cost is that errors get stringified at the boundary — fine here because the Go contract also propagates opaque errors, and the wrapping atfetch()preserves context.register_*/subscribetake&mut selfand are documented as not-thread-safe / call-before-fetch, matching Go's note on the methods. The integration code that wires Fetcher into the pipeline will need to honour that ordering.
|
|
||
| /// Converts a `produce_block_v3` response into an unsigned | ||
| /// [`VersionedProposal`]. | ||
| fn versioned_proposal_from_response( |
There was a problem hiding this comment.
This is the trickiest piece in the file. The generated produce_block_v3 response carries a loosely-typed data; mapping it onto the strongly-typed ProposalBlock enum has to fan out per fork and per blinded/unblinded shape, because Deneb+ wrap the block in {block, kzg_proofs, blobs} while pre-Deneb is the block directly. The dispatch here matches validatormock::propose.rs's build_block_body (Deneb/Electra/Fulu use block_field(&data)).
Risk surface: we're trusting the JSON shape the beacon node returns to match the fork keys exactly. The fetch_blocks test exercises the Electra-unblinded path (reusing the existing VersionedProposal golden); the other forks rely on the structural match. If a real beacon node ever returns an unexpected shape, the user sees decode beacon node response: ... from Json(#[from] ...) which is actionable.
Future improvement: once a versioned-decode helper lands in eth2api, we should swap this match out for it.
| /// Returns `Some((expected, actual))` when the proposal's fee recipient differs | ||
| /// (case-insensitively) from `fee_recipient_address`. Returns `None` for forks | ||
| /// without a fee recipient (pre-Bellatrix) or when the addresses match. | ||
| fn fee_recipient_mismatch( |
There was a problem hiding this comment.
The Go test for this is in fetcher_internal_test.go and asserts that a warning is emitted on mismatch / silent on match across every fork. Pluto has no log-capture utility yet, so the comparison logic was factored into fee_recipient_mismatch (pure, testable) and the warn\! stays in verify_fee_recipient. The ported test asserts on the pure helper.
Two notes:
- The extraction uses
execution_payload/execution_payload_headerJSON keys uniformly across forks instead of typed field navigation. Cleaner than 5x per-fork accessors and keeps the same behaviour, but it's silent if a future fork renames either key. Worth a once-over if a new fork lands. proposal.version()is queried first to early-return on Phase0/Altair which carry no fee recipient — matches Go'sdefault: returnin the switch.
core/fetcher
|
@claude Review this code |
|
Claude finished @emlautarom1's task in 3m 44s —— View job Code review —
|
The fetcher is stage 2 of the duty pipeline (
Scheduler → Fetcher → Consensus → …): it takes aDutyplus a per-validatorDutyDefinitionSetand fetches the corresponding unsigned duty data from the beacon node, producing anUnsignedDataSetthat it hands to its subscribers.Highlights
crates/core/src/fetcher/module with the fullFetcherand aGraffitiBuilder, covering all four duty paths: attester, aggregator, block proposer, and sync-committee contribution.DutyDefinitionreworked into a heterogeneous enum (Attester/Proposer/SyncCommittee), mirroring Charon'score.DutyDefinitioninterface — replacing the previous generic wrapper.UnsignedDutyData/UnsignedDataSetpromoted intocore::typesso the fetcher, DutyDB, and consensus share one canonical,PubKey-keyed type (matching Go'smap[core.PubKey]core.UnsignedData).Type-system changes (
crates/core/src/types.rs)The fetcher's input and output types did not previously exist in a usable form; both were reworked first.
DutyDefinition→ enumCharon models duty definitions as a
DutyDefinitioninterface with three implementations (AttesterDefinition,ProposerDefinition,SyncCommitteeDefinition). Pluto previously had a genericDutyDefinition<T>/DutyDefinitionSet<T>which cannot represent a heterogeneous, type-dispatched set the way the fetcher needs.This was replaced with:
enum DutyDefinition { Attester(..), Proposer(..), SyncCommittee(..) }withas_attester/as_proposer/as_sync_committeeaccessors (the Rust equivalent of Go's type assertions).pub type DutyDefinitionSet = HashMap<PubKey, DutyDefinition>(matches Go'smap[core.PubKey]core.DutyDefinition).ProposerDutyandSyncCommitteeDutystructs (faithful to eth2v1.ProposerDuty/v1.SyncCommitteeDuty) and the three*Definitionnewtype wrappers.AttesterDefinitionwraps the pre-existingsigneddata::AttesterDuty.UnsignedDataSetpromoted tocore::typesUnsignedDutyData(enum overProposal/Attestation/AggAttestation/SyncContribution) andUnsignedDataSet = HashMap<PubKey, UnsignedDutyData>were moved out ofdutydb/memory.rsintocore::types. The old generic type was removed.Fetcher implementation (
crates/core/src/fetcher/)graffiti.rsPort of
charon/core/fetcher/graffiti.go:GraffitiBuilder,client_graffiti_mappings, and the build/default/token helpers. The default graffiti uses apluto/<version>-<commit>prefix (vs Go'scharon/...).mod.rsPort of
charon/core/fetcher/fetcher.go:Fetcher::new,subscribe,register_agg_sig_db,register_await_att_data, andfetch(dispatch onDutyType).fetch_attester_data,fetch_aggregator_data,fetch_proposer_data,fetch_contribution_data, plusverify_fee_recipientand the pubkeys logging tracker.EthBeaconNodeApiClient:produce_attestation_data,get_aggregated_attestation_v2,produce_block_v3,produce_sync_committee_contribution. Loosely-typed responses are decoded into the strongly-typedsigneddatatypes via JSON round-trip; theproduce_block_v3response is mapped into an unsignedVersionedProposalper fork/blinded variant.pluto_eth2util::eth2exp(is_att_aggregator,is_sync_comm_aggregator).AggSigDBandDutyDBare injected as async callbacks (no hard dependency on those components);Box<dyn SignedData>values fromAggSigDBare down-cast to concrete types via trait upcasting.aggregate attestation not found by root (retryable), and theerrors.Wrap(err, "fetch <type> data")wrapping is reproduced via aFetcherError::Fetch { context, source }variant.Tests
All Go tests are ported and passing (test names kept where practical):
fetch_beacon_node_token,build_graffiti,default_graffiti,get_graffiti,new_graffiti_builder.fetch_attester.different_committee,same_committee,no_aggregator,nil_aggregate.aggregator,not_aggregator,data_error.fetch_blocks.verify_fee_recipient.Per the chosen approach, beacon-node responses that depend on request parameters (aggregate attestation by data root, sync contribution by subcommittee/root, block proposal echoing randao + graffiti) are provided by test-local
wiremockresponders rather than by extendingBeaconMock. The block-proposal test reuses the existing ElectraVersionedProposalgolden fixture as theproduce_block_v3payload. Aggregator/sync tests use a custom spec carrying the selection fields required byeth2exp.verify_fee_recipient's comparison logic was factored into a purefee_recipient_mismatchhelper so it can be asserted directly without a tracing-log capture harness; the warning itself still lives inverify_fee_recipient.Files changed
crates/core/src/types.rs—DutyDefinitionenum, new duty/definition types, promotedUnsignedDutyData/UnsignedDataSet.crates/core/src/dutydb/memory.rs,crates/core/src/dutydb/mod.rs— import and re-export the promoted types instead of defining them.crates/core/src/lib.rs— register thefetchermodule.crates/core/src/fetcher/mod.rs,crates/core/src/fetcher/graffiti.rs— new.Caveats and notes for review
AttesterDutyomits thePubKeyfield present in eth2'sv1.AttesterDuty. The fetcher does not depend on it (the pubkey is always the map key), but it is worth keeping in mind when the scheduler begins populating these definitions.DutyDefinitionSetis produced by the scheduler, which is in-flight onemlautarom1/core-scheduler. The definition types added here may need reconciliation if that branch introduces overlapping types.This PR was automatically generated by Claude.