What
The attestation duty is skipped with SOURCE_AFTER_TARGET when the target walk-back lands behind the head chain's justified checkpoint. In the 2026-09-11 three-node devnet5 run (PR #42) it fired at slots 16 and 64 (twice):
WARN duty not performed slot=64 interval=321 error=the duty would produce a rejected value: SOURCE_AFTER_TARGET
Why
Verity's attestation_target / attestation_data are a faithful transcription of leanSpec validator_duties.py (0b7d33ec), including the spot where leanSpec asserts source.slot <= target.slot. The assert condition is reachable: the persisted fork-choice tree of that run (dumped from the node's RocksDB) shows the head chain 62 → 59 → 51 → 50 → 49 → 45 → 42 with finalized 42 and the head state's justified at 49. The three-step walk-back from 62 lands on 50; 50 and 49 are not justifiable relative to finalized 42 (deltas 8 and 7), so the justifiability walk continues to 45 — behind the source at 49. Nothing in the spec bounds the second walk by the justified slot, and a sparse chain (three nodes, one proposer per slot, many orphans) makes the three-step walk cover many slots.
ethlambda (crates/blockchain/src/store.rs, get_attestation_target_with_checkpoints) and ream (crates/common/fork_choice/lean/src/store.rs) both clamp the target to the head's justified checkpoint in this case, with a comment that it is "not in the spec" (ethlambda cites zeam doing the same). Verity returns the vote as a rejection and casts nothing, so its validator's weight is missing from those slots.
Fix direction
Match the other clients: when the walked target's slot is below the head state's justified slot, return the justified checkpoint as the target (and log it). Keep the SourceAfterTarget rejection as the assertion it is, now unreachable from the duty path. Worth raising upstream on leanSpec as well, since the reference implementation would assert here.
What
The attestation duty is skipped with
SOURCE_AFTER_TARGETwhen the target walk-back lands behind the head chain's justified checkpoint. In the 2026-09-11 three-node devnet5 run (PR #42) it fired at slots 16 and 64 (twice):Why
Verity's
attestation_target/attestation_dataare a faithful transcription of leanSpecvalidator_duties.py(0b7d33ec), including the spot where leanSpec assertssource.slot <= target.slot. The assert condition is reachable: the persisted fork-choice tree of that run (dumped from the node's RocksDB) shows the head chain62 → 59 → 51 → 50 → 49 → 45 → 42with finalized 42 and the head state's justified at 49. The three-step walk-back from 62 lands on 50; 50 and 49 are not justifiable relative to finalized 42 (deltas 8 and 7), so the justifiability walk continues to 45 — behind the source at 49. Nothing in the spec bounds the second walk by the justified slot, and a sparse chain (three nodes, one proposer per slot, many orphans) makes the three-step walk cover many slots.ethlambda (
crates/blockchain/src/store.rs,get_attestation_target_with_checkpoints) and ream (crates/common/fork_choice/lean/src/store.rs) both clamp the target to the head's justified checkpoint in this case, with a comment that it is "not in the spec" (ethlambda cites zeam doing the same). Verity returns the vote as a rejection and casts nothing, so its validator's weight is missing from those slots.Fix direction
Match the other clients: when the walked target's slot is below the head state's justified slot, return the justified checkpoint as the target (and log it). Keep the
SourceAfterTargetrejection as the assertion it is, now unreachable from the duty path. Worth raising upstream on leanSpec as well, since the reference implementation would assert here.