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.
| 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.
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.
core/fetcher
|
@claude Review this code |
|
Claude finished @emlautarom1's task in 3m 44s —— View job Code review —
|
The main merge resolved types.rs to main's version, dropping PR #454's DutyDefinition enum and promoted unsigned-data types, while keeping the new core/fetcher. The result did not compile. Adopt main's canonical DutyDefinition (scheduler + validatorapi) and the unsigneddata module as the shared types, and adapt the fetcher to them: - Enrich AttesterDutyDefinition with the committee fields the fetcher needs (committee_index/length, committees_at_slot, validator_committee_index), populated from the beacon duties datum. Mirrors Charon's AttesterDefinition wrapping the full eth2v1.AttesterDuty. - Add as_attester/as_proposer/as_sync_committee accessors to DutyDefinition. - Point the fetcher at crate::unsigneddata for UnsignedDataSet/UnsignedDutyData and populate VersionedProposal's consensus/execution block values.
These DutyType-keyed generic types in core::types are superseded by the PubKey-keyed UnsignedDataSet/UnsignedDutyData in core::unsigneddata, which all consumers (fetcher, dutydb, validatorapi) already use. The generics were dead code referenced only by their own test.
Mirror Charon's core.AttesterDefinition, which embeds the eth2
v1.AttesterDuty, instead of duplicating its fields. AttesterDutyDefinition
becomes { pubkey, duty: AttesterDuty }, so the fetcher consumes the duty
directly (att_def.duty) with no field-by-field conversion.
- Drops the AttesterDutyDefinition <-> signeddata::AttesterDuty bridge
(removes the attester_duty() helper).
- Resolves the slot type mismatch: the duty's slot is the eth2 u64 slot
rather than a separate SlotNumber on the definition.
- Scheduler reads the duty via att_duty.duty.{slot,validator_index}.
Remove as_attester/as_proposer/as_sync_committee from DutyDefinition. as_proposer/as_sync_committee were unused, and the two as_attester call sites in the fetcher are clearer with a let-else match — the idiomatic Rust equivalent of Charon's def.(core.AttesterDefinition) type assertion.
attestation_payload is only ever called with a version derived from consensus_to_data_version, which maps every ConsensusVersion variant and never yields DataVersion::Unknown. Replace the dead arm's misleading AggregateAttestationNotFound error with unreachable\!.
Add a spec-level ConversionError plus pub(crate) helpers (parse_u64, decode_hex_var, decode_hex_fixed) for converting the loosely-typed, all-string beacon-API response types into strongly-typed spec values. Re-export ConversionError from the spec module. These back the direct TryFrom conversions that replace the JSON round-trips in the fetcher.
Implement TryFrom from the generated beacon-API types into Checkpoint, AttestationData, and the phase0-style Attestation, co-located next to each target type. Tests assert each conversion equals the JSON round-trip it replaces, plus parse/decode error cases.
Convert the generated Contribution into altair::SyncCommitteeContribution directly, decoding the aggregation bits via ssz::Decode. Tests cover equivalence with the JSON round-trip and the bad-bit-length error.
Convert the committee-aware generated attestation into electra::Attestation directly, including committee_bits. Test asserts equivalence with the JSON round-trip.
Drop the round_trip helper and convert beacon-node responses into spec types via the new TryFrom impls: attestation_data and sync_committee_contribution call them directly, and attestation_payload matches on (version, untagged attestation variant) before dispatching. Add FetcherError::Conversion to surface conversion failures. This avoids the double serde pass through serde_json::Value, makes the mapping compile-checked, and matches Charon, whose go-eth2-client returns typed structs directly.
Relocate versioned_proposal_from_response from the fetcher to signeddata, co-located with VersionedProposal as TryFrom<&ProduceBlockV3ResponseResponse>. The block-contents JSON layout is a property of the proposal type, not the fetcher, which now just calls VersionedProposal::try_from. The conversion returns SignedDataError (gaining InvalidBlockValue and MissingBlockField variants); FetcherError maps it via a new SignedData(#[from]) variant and drops the two now-orphaned proposal variants. The json_from/block_field/json_from_field helpers move along.
…rsion.rs Relocate the fetcher's consensus_to_data_version into spec/version.rs as From<&ConsensusVersion> for DataVersion, co-located with DataVersion. The mapping is total (ConsensusVersion has no Unknown variant), so a plain From fits. The fetcher now calls versioned::DataVersion::from.
|
@claude Review this PR and take into consideration your previous comments. |
|
Claude finished @emlautarom1's task in 2m 3s —— View job Re-review —
|
| # | Finding | Resolution |
|---|---|---|
| 1 | PubkeysTracker::log() skipped on early-return paths (Go's defer divergence) |
impl Drop for PubkeysTracker (mod.rs:703) now logs on every scope exit — the RAII fix I suggested. The explicit .log() calls were removed, so no double-logging. Matches Go's defer pt.log(ctx) semantics. |
| 8 | use std::any::Any at module scope |
Now a full path std::any::Any inside downcast (mod.rs:560). |
| — | JSON round-trips for BN responses (called out in PR description) | Replaced with direct TryFrom conversions: phase0::AttestationData::try_from (mod.rs:467), altair::SyncCommitteeContribution::try_from (:528), and a versioned attestation_payload (:574). The new ConversionError path is wired into FetcherError::Conversion. Cleaner and strongly typed — nice improvement. |
| — | produce_block_v3 per-fork mapping |
Moved into VersionedProposal::try_from (signeddata.rs:1377), and ConsensusVersion → DataVersion into version.rs with full per-fork test coverage (version.rs:231-240). Good de-duplication. |
| 7 (partial) | aggregate-attestation version/shape mismatch silently accepted | attestation_payload now explicitly returns FetcherError::UnexpectedResponse on any version/shape mismatch (mod.rs:593) instead of decoding loosely. |
⚠️ Still open
2. fee_recipient_mismatch still silently swallows missing/malformed payload fields — mod.rs:612-638
Unchanged. The traversal still ?-bails to None (= "no mismatch, no warning") if execution_payload/execution_payload_header or fee_recipient is absent, or proposal_body returns Null. A future fork key rename would ship as a silent no-op warning. A debug_assert!/warn! when a Bellatrix+ proposal yields no extractable address would catch this in dev/test. Fix this →
3. verify_fee_recipient test still only covers Electra-unblinded — mod.rs:833-858
Unchanged. Go's TestVerifyFeeRecipient exercises 10 fork/blinded combos; the Rust test (verify_fee_recipient) covers Electra-unblinded only. Combined with 2., the other 9 paths — especially the blinded variants reading execution_payload_header.fee_recipient — are untested. Since fee_recipient_mismatch is pure, a table-driven test per variant locks the JSON shape at near-zero cost. This is the one I'd most encourage before merge. Fix this →
4. Non-Ok BN response still collapses 404 and 5xx into the same retryable error — mod.rs:466, :496, :530
Unchanged. _ => return Err(FetcherError::AggregateAttestationNotFound) (and the attestation-data / sync-contribution equivalents) treat any non-Ok variant — including server errors — as the retryable "not found by root", whereas Go separates nil-data from transport/API errors. Whether this matters depends on how the upstream pipeline reacts to the retryable variant; worth confirming it won't tight-loop against a 5xx-ing BN. Judgment call, not a blocker.
7 (proposal). Phase0/Altair arms in VersionedProposal::try_from — signeddata.rs:1387-1388
produce_block_v3 never emits pre-Bellatrix blocks, but the match still has Phase0/Altair arms (and verify_fee_recipient correctly no-ops on them). Harmless looseness; leaving as-is is defensible. Minor.
5. unsigned_set.clone() per subscriber — mod.rs:209-215 — parity with Charon, optional micro-opt. Unchanged.
Verdict
The two findings I actually flagged for action last time are half-done: 1. is fixed, 3. (test coverage) is the remaining gap and is the one I'd still close before merge — it directly de-risks the silent-fail in 2. at near-zero cost. The TryFrom/versioned-decode refactors are a clear quality improvement over the original JSON round-trips. CI is green so far (4 passed, 4 in-progress, 0 failed). LGTM with the fee-recipient test expansion encouraged.
· Branch: worktree-graceful-spinning-wombat
Expand verify_fee_recipient into a table-driven test over every fork/blinded combination from Bellatrix onwards, mirroring Go's TestVerifyFeeRecipient. The unblinded golden (Electra) is a field superset of earlier forks and the blinded golden carries the Deneb-era blob fields, so both deserialize into each fork's block type once the variable-shape lists are emptied; Electra+ blinded additionally gets an empty execution_requests. Guard fee_recipient_mismatch with a debug_assert\!/warn\! when a Bellatrix+ proposal yields no extractable fee recipient, so a future payload key rename surfaces in dev/test instead of silently reporting "no mismatch".
|
The following issue is still present:
We'll keep it simple for now and revisit this once we start doing the wiring. I don't think we should be spending much time on getting retrying working immediately without sufficient context. |
|
@claude Review this PR and take into consideration your previous comments and my own. |
|
Claude finished @emlautarom1's task in 1m 27s —— View job Re-review —
|
| # | Finding | Resolution |
|---|---|---|
| 1 | PubkeysTracker::log() skipped on early-return (Go defer divergence) |
impl Drop for PubkeysTracker (mod.rs:718) logs on every scope exit; explicit .log() calls removed. Matches defer pt.log(ctx). |
| 2 | fee_recipient_mismatch silently swallowed missing/malformed payload fields |
Now guarded (mod.rs:636-646): a Bellatrix+ proposal that yields no extractable fee_recipient hits a debug_assert!(false, …) (fails in dev/test) and a tracing::warn! (surfaces in prod) instead of returning a false "no mismatch". Exactly the fix I suggested. |
| 3 | verify_fee_recipient test only covered Electra-unblinded |
Now table-driven over all 10 combos — Bellatrix..Fulu × {unblinded, blinded} (mod.rs:910-981). Each case asserts a mismatch vs 0xdead, then None against its own address and its uppercased form (case-insensitive path). The blinded cases exercise the execution_payload_header fallback, and Electra+ blinded gets the required execution_requests. Closes the silent-fail risk from 2. at the structural level. |
The earlier round of TryFrom/versioned-decode refactors (replacing the JSON round-trips, moving per-fork proposal decoding into VersionedProposal::try_from, ConsensusVersion → DataVersion in version.rs) remain a clear quality improvement over the original.
Acknowledged / deferred
4. Non-Ok BN response collapses 404 and 5xx into the retryable …NotFound — mod.rs:466, :496, :530
Understood — keeping it simple until the pipeline wiring lands and you have the context to decide the right retry/error split. Agree this shouldn't block here; just flagging that when wiring does happen, the call site that consumes AggregateAttestationNotFound is where a 5xx-vs-nil-data distinction will matter (to avoid tight-looping a broken BN). No action now.
Still-open minors (non-blocking, unchanged)
- 5 —
unsigned_set.clone()per subscriber (mod.rs:209-215): parity with Charon, optional micro-opt. - 7 —
Phase0/Altairarms inVersionedProposal::try_from:produce_block_v3never emits them;fee_recipient_mismatchcorrectly early-returnsNonefor those versions (mod.rs:615-620). Harmless looseness.
Verdict
LGTM. Both pre-merge findings (1. fixed earlier, 2. + 3. fixed in a668f7a) are resolved, 4 is a deliberate deferral, and the remainder are judgment-call minors. CI green so far (6 passed, "Build and test" in progress, 0 failed).
· Branch: worktree-graceful-spinning-wombat
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.