Skip to content

feat(syncing): detect sequencer double-signs and halt with persisted …#3310

Open
CaelRowley wants to merge 5 commits intoevstack:mainfrom
CaelRowley:cael/feat/double-sign-detection
Open

feat(syncing): detect sequencer double-signs and halt with persisted …#3310
CaelRowley wants to merge 5 commits intoevstack:mainfrom
CaelRowley:cael/feat/double-sign-detection

Conversation

@CaelRowley
Copy link
Copy Markdown

@CaelRowley CaelRowley commented May 5, 2026

Overview

Closes #1672

This change introduces detection of double-signing (equivocation) at the same block height during synchronization from DA and P2P sources.

Previously, synchronization processed the first observed block per height and silently ignored subsequent conflicting headers. This PR adds detection and handling for conflicting headers to surface potential sequencer equivocation.

Behavior

For each incoming SignedHeader from DA or P2P, the syncer enforces equivocation detection at that block height.

  • Headers with identical hashes at a given height are treated as benign duplicates.
  • When multiple distinct headers are observed at the same height and are both validly signed by the sequencer, a double-sign condition is detected. On detection:
    • Equivocation evidence is recorded under ds/<height>/<alt-hash>.
    • Error and metrics are emitted for observability.
    • The node halts via a critical error (ErrDoubleSign) to allow external resolution via human consensus, as described in the issue.

Changes

  • Added double-sign detection by comparing incoming headers against the first observed header per height. The lookup order is cache (in-flight) → store (persisted).
  • Introduced in-memory tracking of first-seen headers prior to persistence to allow for early detection.
  • Unified DA and P2P ingestion paths under a single detection flow.
  • Ensured equivocation is recorded once per (height, alternative hash) to avoid duplicate reporting.
  • Enforce ValidateBasic prior to detection to prevent invalid data from being recorded as evidence.
  • Introduced DoubleSignEvidence (proto + Go type) representing a detected equivocation at a given height, including both conflicting headers. Binary encoding support is provided for persistence and transport.
  • Added double_signs_detected_total Prometheus counter metric.
  • Ensured detection is not bypassed for already-applied heights in P2P processing.
  • Equivocation handling halts execution via ErrDoubleSign propagated through the syncer critical error channel.

Summary by CodeRabbit

  • New Features

    • Double-sign detection and reporting across DA and P2P paths, persisting evidence and triggering sync halt on confirmed equivocations.
    • Pending signed-header tracking to record first-observed headers from different sources.
  • Chores

    • Added metric for detected double-sign events.
    • Added canonical storage key and wire format for persisted evidence.
  • Tests

    • Extensive unit and concurrency tests covering detection, persistence, reporting, deduplication, and cache interactions.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 5, 2026

📝 Walkthrough

Walkthrough

Added end-to-end double-sign detection: new DoubleSignEvidence type and protobuf, detection/persistence/reporting logic, metrics and store key support, pending signed-header cache and manager APIs, wiring into DA retriever, P2P handler, and Syncer, plus extensive unit and integration tests. Also added a small cache accessor getHashByHeight and its test.

Changes

Double-Sign Feature (single coherent DAG)

Layer / File(s) Summary
Data Shape / Keys / Metrics
proto/evnode/v1/evnode.proto, types/double_sign_evidence.go, pkg/store/keys.go, block/internal/common/metrics.go
Adds DoubleSignEvidence protobuf and Go type with validation/serialization, new store key ds/<height>/<alt-hash>, and a Prometheus counter double_signs_detected_total.
Core Detection & Persistence
block/internal/syncing/doublesign.go, pkg/store/keys.go
Implements detection flow (cache → store), evidence construction, persistence (SetMetadata by canonical key), reporting with deduplication, logging, metrics increment, and ErrDoubleSign/handler types.
Cache Manager / Small Cache API
block/internal/cache/manager.go, block/internal/cache/generic_cache.go
Adds pending-signed-headers map & API (Set/Get/Remove) with first-write-wins semantics; exposes header-hash-by-height getter that delegates to header cache; adds Cache.getHashByHeight accessor.
Integration / Control-flow Wiring
block/internal/syncing/da_retriever.go, block/internal/syncing/p2p_handler.go, block/internal/syncing/syncer.go
Wires store and onDoubleSign handler into DA retriever and P2P handler, runs detection prior to envelope short-circuits, sets pending headers on first observation, and has Syncer dedup instance + handler that reports and triggers critical error halting; Syncer now removes pending header after marking header seen.
Store Key Usage
pkg/store/keys.go
Introduces DoubleSignEvidenceKey and GetDoubleSignEvidenceKey for persisted evidence key generation.
Tests — Cache & Manager
block/internal/cache/generic_cache_test.go, block/internal/cache/manager_test.go
Adds tests for getHashByHeight and pending-signed-header APIs, including concurrency stress test for manager pending-header operations.
Tests — Detection & Persistence
block/internal/syncing/doublesign_test.go, block/internal/syncing/doublesign_branches_test.go
Extensive unit and integration tests covering detection, evidence build/validation, persistence, deduplication, reporting, metrics, P2P/DA retriever interactions, and Syncer end-to-end halting.
Tests — P2P/DA Wiring
block/internal/syncing/p2p_handler_doublesign_test.go, block/internal/syncing/p2p_handler_test.go, block/internal/syncing/da_retriever_test.go, block/internal/syncing/syncer_*.go tests
Updates and adds tests to exercise new constructor signatures and double-sign behavior for P2P and DA retriever paths; adapts test helpers to new NewDARetriever/NewP2PHandler signatures.
Minor Test Adjustments
multiple test files
Calls updated constructors with two extra trailing nil args where detection not used in tests.

Sequence Diagram(s)

sequenceDiagram
  participant P2P as P2P Handler
  participant DA as DA Retriever
  participant Cache as CacheManager
  participant Store as Store
  participant Reporter as reportDoubleSign
  participant Syncer as Syncer

  P2P->>Cache: GetPendingSignedHeader / Validate header
  P2P->>Store: GetHeaderByHeight (fallback)
  P2P->>Reporter: detectDoubleSign(incoming)
  alt Evidence produced
    Reporter->>Store: persistEvidence(ds/<h>/<altHash>)
    Reporter->>Syncer: handleDoubleSign(evidence)
    Syncer->>Syncer: sendCriticalError -> halt syncer
  else No evidence
    P2P->>Cache: SetPendingSignedHeader(incoming, source=p2p)
    P2P->>Syncer: continue processing
  end

  DA->>Cache: GetPendingSignedHeader / Validate header
  DA->>Store: GetHeaderByHeight (fallback)
  DA->>Reporter: detectDoubleSign(incoming)
  alt Evidence produced
    Reporter->>Store: persistEvidence(ds/<h>/<altHash>)
    Reporter->>Syncer: handleDoubleSign(evidence)
    Syncer->>Syncer: sendCriticalError -> halt syncer
  else No evidence
    DA->>Cache: SetPendingSignedHeader(incoming, source=da)
    DA->>Syncer: continue processing
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • evstack/ev-node#3146: Touches block/internal/syncing/da_retriever.go and related DA processing changes; likely related to retriever/signature flow.

Suggested reviewers

  • yarikbratashchuk
  • gupadhyaya

Poem

I'm a rabbit in a burrow, ears up, nose bright,
I sniffed two headers in the dark of night.
One hop, one nibble, I wrote them down,
Saved evidence safe in the store of town.
Hooray for the watcher who keeps the chain right! 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly indicates the main change: detecting sequencer double-signs and persisting evidence.
Description check ✅ Passed The description comprehensively covers overview, behavior, and changes with clear linked issue and detailed implementation context.
Linked Issues check ✅ Passed The PR fully implements issue #1672's requirements: detects double-signs during sync, records evidence, emits metrics, and halts node for human resolution.
Out of Scope Changes check ✅ Passed All changes directly support double-sign detection: cache methods, evidence types, detection logic, metrics, and retriever/P2P/syncer integration.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (7)
types/double_sign_evidence.go (2)

32-47: ⚡ Quick win

Consider strengthening ValidateBasic defensively.

ValidateBasic is reachable from persistEvidence and (transitively) UnmarshalBinary, but it currently doesn't:

  • Call FirstHeader.ValidateBasic() / AlternateHeader.ValidateBasic() on the embedded headers.
  • Verify the consistency check that buildEvidenceFromPair enforces (ProposerAddress equality).
  • Reject empty FirstSource / AlternateSource.

Tightening this prevents persisting malformed proto-decoded evidence and keeps the validation invariants in one place rather than relying on every caller.

♻️ Proposed change
 func (e *DoubleSignEvidence) ValidateBasic() error {
 	if e == nil {
 		return errors.New("evidence is nil")
 	}
 	if e.FirstHeader == nil || e.AlternateHeader == nil {
 		return errors.New("evidence requires both first and alternate headers")
 	}
 	if e.FirstHeader.Height() != e.Height || e.AlternateHeader.Height() != e.Height {
 		return fmt.Errorf("evidence height %d does not match both headers (%d, %d)",
 			e.Height, e.FirstHeader.Height(), e.AlternateHeader.Height())
 	}
 	if bytes.Equal(e.FirstHeader.Hash(), e.AlternateHeader.Hash()) {
 		return errors.New("evidence headers have identical hash — no equivocation")
 	}
+	if !bytes.Equal(e.FirstHeader.ProposerAddress, e.AlternateHeader.ProposerAddress) {
+		return errors.New("evidence headers have different proposers — not an equivocation")
+	}
+	if e.FirstSource == "" || e.AlternateSource == "" {
+		return errors.New("evidence requires both source labels")
+	}
 	return nil
 }

As per coding guidelines: "Validate all external inputs at type boundaries to prevent invalid state".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@types/double_sign_evidence.go` around lines 32 - 47,
DoubleSignEvidence.ValidateBasic currently omits checks on embedded headers and
some invariants; update ValidateBasic to call FirstHeader.ValidateBasic() and
AlternateHeader.ValidateBasic(), verify that FirstHeader.ProposerAddress equals
AlternateHeader.ProposerAddress (the same invariant enforced by
buildEvidenceFromPair), and reject empty FirstSource and AlternateSource (return
errors if zero-length). Preserve the existing height/hash checks and error
messages, and ensure all new failures return clear errors from ValidateBasic.

73-92: ⚡ Quick win

Add nil checks for required protobuf sub-messages in FromProto.

p.FirstHeader and p.AlternateHeader are required message fields that should be validated before use, consistent with how SignedHeader.FromProto checks other.Header == nil and how ValidateBasic enforces both headers are present. The codebase pattern is to validate required nested messages upfront.

♻️ Proposed change
 func (e *DoubleSignEvidence) FromProto(p *pb.DoubleSignEvidence) error {
 	if p == nil {
 		return errors.New("proto evidence is nil")
 	}
+	if p.FirstHeader == nil || p.AlternateHeader == nil {
+		return errors.New("proto evidence missing first or alternate header")
+	}
 	first := new(SignedHeader)
 	if err := first.FromProto(p.FirstHeader); err != nil {
 		return fmt.Errorf("unmarshal first header: %w", err)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@types/double_sign_evidence.go` around lines 73 - 92,
DoubleSignEvidence.FromProto must validate required nested proto messages before
using them: check that p.FirstHeader and p.AlternateHeader are not nil at the
top of DoubleSignEvidence.FromProto and return a descriptive error if either is
nil, then proceed to call SignedHeader.FromProto on each; this mirrors
SignedHeader.FromProto's own nil checks and the expectations in ValidateBasic
and ensures you don't pass nil into first.FromProto or alt.FromProto.
block/internal/syncing/doublesign.go (1)

47-57: 💤 Low value

Use a typed constant for the "stored" source.

types.EvidenceSourceP2P and types.EvidenceSourceDA already exist; the third source ("stored") is referenced as a magic string here and almost certainly in tests. Adding EvidenceSourceStored keeps the canonical set of ingestion-source labels in one place and avoids drift if the value ever changes.

♻️ Proposed change

In types/double_sign_evidence.go:

 const (
 	EvidenceSourceP2P = "p2p"
 	EvidenceSourceDA  = "da"
+	// EvidenceSourceStored is used when the conflicting header was loaded
+	// from the local store rather than observed live on a network path.
+	EvidenceSourceStored = "stored"
 )

In this file:

-	return buildEvidenceFromPair(storedHeader, incoming, "stored", incomingSource), nil
+	return buildEvidenceFromPair(storedHeader, incoming, types.EvidenceSourceStored, incomingSource), nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@block/internal/syncing/doublesign.go` around lines 47 - 57, The code uses the
magic string "stored" as an evidence source; add a new typed constant (e.g.,
EvidenceSourceStored) to the existing enum/constants in types (where
EvidenceSourceP2P and EvidenceSourceDA are defined, likely in
double_sign_evidence.go) and replace the literal "stored" in
buildEvidenceFromPair calls (in function that references storedHeader, incoming,
"stored", incomingSource) with types.EvidenceSourceStored; update any tests that
assert the string value to use the new constant so all ingestion-source labels
are centralized and consistent.
block/internal/syncing/syncer.go (1)

1078-1080: ⚡ Quick win

Don't silently swallow reportDoubleSign's error.

reportDoubleSign already invokes sendCriticalError for the halt, so the returned error here is most likely an evidence-persistence failure. Discarding it means an operator sees the node halt without any signal that the evidence record at ds/<height>/<alt-hash> failed to write — exactly the artifact human consensus is supposed to inspect per the PR's resolution model.

📝 Suggested log on error
 func (s *Syncer) handleDoubleSign(ctx context.Context, ev *types.DoubleSignEvidence) {
-	_ = reportDoubleSign(ctx, s.store, s.metrics, s.logger, s.doubleSignSeen, s.sendCriticalError, ev)
+	if err := reportDoubleSign(ctx, s.store, s.metrics, s.logger, s.doubleSignSeen, s.sendCriticalError, ev); err != nil {
+		s.logger.Error().Err(err).Uint64("height", ev.Height()).Msg("failed to report double-sign evidence")
+	}
 }

Adjust the height accessor (ev.Height() / ev.Conflicting[0].Height()) to whatever types.DoubleSignEvidence exposes.

As per coding guidelines: "Use structured logging with appropriate log levels" and "Wrap errors with context using fmt.Errorf in Go code".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@block/internal/syncing/syncer.go` around lines 1078 - 1080, handleDoubleSign
is currently ignoring the error returned by reportDoubleSign; instead capture
its error return and log it (don't swallow it) using the structured logger
(e.g., s.logger.Error) with wrapped context (fmt.Errorf) and include identifying
evidence fields such as ev.Height() and the conflicting block hash/alt-hash
(e.g., ev.Conflicting[0].Hash or whatever the DoubleSignEvidence exposes) so
operators see that persistence of the evidence record (ds/<height>/<alt-hash>)
failed; note that reportDoubleSign already triggers sendCriticalError for the
halt, so only log/wrap the returned error here with clear context.
block/internal/syncing/p2p_handler_doublesign_test.go (1)

85-90: 💤 Low value

Optional: simplify the forged-header construction by skipping the no-op proto round-trip.

var pbDecoded = pbHdr copies the pointer — both names refer to the same proto object. The proto.Unmarshal(bin, pbDecoded) call re-decodes bin (marshaled from pbHdr) back into the very same message, leaving it unchanged. The scoped block and the intermediate variable add noise without changing the outcome.

♻️ Proposed simplification
 forged := new(types.SignedHeader)
-{
-    var pbDecoded = pbHdr
-    require.NoError(t, proto.Unmarshal(bin, pbDecoded))
-    require.NoError(t, forged.FromProto(pbDecoded))
-}
+require.NoError(t, forged.FromProto(pbHdr))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@block/internal/syncing/p2p_handler_doublesign_test.go` around lines 85 - 90,
The test currently does a no-op proto round-trip by setting var pbDecoded =
pbHdr and calling proto.Unmarshal(bin, pbDecoded) before forged.FromProto;
remove the unnecessary pbDecoded and proto.Unmarshal call and call
forged.FromProto directly with pbHdr (or use a proper proto.Clone(pbHdr) if you
need an independent copy) so the forged header is constructed without the
redundant unmarshal step; update the block that references forged, pbHdr,
proto.Unmarshal, and forged.FromProto accordingly.
block/internal/syncing/doublesign_test.go (2)

386-393: 💤 Low value

Optional: same no-op proto round-trip as in p2p_handler_doublesign_test.go.

bin is already a proto-marshal of pbHdr (with tampered signature). r.tryDecodeHeader(bin, 100) receives those raw bytes to exercise the legacy decode path, which is the correct intent. The intermediate proto.Unmarshal(bin, pbHdr) call (same pointer) adds nothing: it decodes bin back into the same message it was marshaled from.

♻️ Proposed simplification
 good := env.signHeaderAtHeight(5, 0x01)
 pbHdr, err := good.ToProto()
 require.NoError(t, err)
 pbHdr.Signature = append([]byte(nil), good.Signature...)
 pbHdr.Signature[0] ^= 0xff
 bin, err := proto.Marshal(pbHdr)
 require.NoError(t, err)

-mockClient := testmocks.NewMockClient(t)
+// bin is a proto-encoded header with a tampered signature; pass it directly.
+mockClient := testmocks.NewMockClient(t)

(Drop the intermediate proto.Unmarshal step entirely — bin is already correct.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@block/internal/syncing/doublesign_test.go` around lines 386 - 393, The test
performs a no-op proto round-trip that is unnecessary: after creating and
tampering pbHdr (via env.signHeaderAtHeight and good.ToProto) you already
marshal it into bin, so the intermediate proto.Unmarshal(bin, pbHdr) is
redundant; remove that proto.Unmarshal call and pass bin directly to
r.tryDecodeHeader (the bytes are already the intended legacy-encoded input),
keeping the tampering of pbHdr.Signature and the subsequent proto.Marshal(pbHdr)
unchanged.

386-393: 💤 Low value

Optional: same redundant proto round-trip pattern as in TestP2PHandler_InvalidSigRejectedBeforeDetector.

The intermediate proto.Marshalproto.Unmarshal cycle is a no-op here: bin is marshaled from pbHdr, then decoded back into the same pointer, restoring identical state. tryDecodeHeader receives bin which represents the proto-encoded format needed for the legacy path — that part is correct. The forged header itself can be derived directly.

♻️ Proposed simplification
 good := env.signHeaderAtHeight(5, 0x01)
 pbHdr, err := good.ToProto()
 require.NoError(t, err)
 pbHdr.Signature = append([]byte(nil), good.Signature...)
 pbHdr.Signature[0] ^= 0xff
 bin, err := proto.Marshal(pbHdr)
 require.NoError(t, err)

(The rest of the test uses bin directly — no forged type.SignedHeader is needed for tryDecodeHeader.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@block/internal/syncing/doublesign_test.go` around lines 386 - 393, The test
performs an unnecessary proto marshal/unmarshal round-trip to create `bin` after
mutating `pbHdr.Signature`; simplify by mutating the proto header produced by
`env.signHeaderAtHeight(...).ToProto()` (the `pbHdr` variable) and then directly
proto.Marshal that `pbHdr` into `bin` to pass to `tryDecodeHeader`, removing the
redundant unmarshal/restore sequence and any unused `forged` SignedHeader
creation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@block/internal/syncing/doublesign_test.go`:
- Around line 618-625: Replace the bare string literal "stored" used for
FirstSource in the DoubleSignEvidence test with a typed evidence-source value to
match AlternateSource usage: either add a typed constant (e.g.,
EvidenceSourceStored types.EvidenceSource = "stored") in the types package and
use types.EvidenceSourceStored for FirstSource in the two test instances (p2pEv
and daEv), or define a test-scoped constant in this file (e.g., stored :=
types.EvidenceSource("stored")) and use that for FirstSource so both FirstSource
and AlternateSource are typed as types.EvidenceSource.
- Around line 618-625: Add a typed constant for the "stored" evidence-source
alongside the existing EvidenceSourceP2P and EvidenceSourceDA (e.g., const
EvidenceSourceStored types.EvidenceSource = "stored") in the same file where
EvidenceSourceP2P/DA are defined (the types/double_sign_evidence.go symbol
block). Replace all string-literal uses of "stored" in production code (e.g., in
the DoubleSign handling in doublesign.go where the sentinel is currently
hardcoded) and tests (symbols: the DoubleSignEvidence struct usages in
doublesign_test.go and doublesign_branches_test.go) with the new typed constant
types.EvidenceSourceStored so the sentinel is consistent everywhere.

In `@types/double_sign_evidence.go`:
- Around line 22-29: The DoubleSignEvidence struct lacks JSON tags and a
String() method; update the type DoubleSignEvidence by adding JSON tags to each
field (Height, FirstHeader, AlternateHeader, DetectedAt, FirstSource,
AlternateSource) so they marshal/unmarshal with conventional names (e.g.,
`height`, `first_header`, etc.), and implement a String() method on
DoubleSignEvidence that returns a human-readable representation (include Height,
DetectedAt, FirstSource, AlternateSource and concise summaries of
FirstHeader/AlternateHeader) to satisfy the codebase's inspection/debugging
requirements.

---

Nitpick comments:
In `@block/internal/syncing/doublesign_test.go`:
- Around line 386-393: The test performs a no-op proto round-trip that is
unnecessary: after creating and tampering pbHdr (via env.signHeaderAtHeight and
good.ToProto) you already marshal it into bin, so the intermediate
proto.Unmarshal(bin, pbHdr) is redundant; remove that proto.Unmarshal call and
pass bin directly to r.tryDecodeHeader (the bytes are already the intended
legacy-encoded input), keeping the tampering of pbHdr.Signature and the
subsequent proto.Marshal(pbHdr) unchanged.
- Around line 386-393: The test performs an unnecessary proto marshal/unmarshal
round-trip to create `bin` after mutating `pbHdr.Signature`; simplify by
mutating the proto header produced by `env.signHeaderAtHeight(...).ToProto()`
(the `pbHdr` variable) and then directly proto.Marshal that `pbHdr` into `bin`
to pass to `tryDecodeHeader`, removing the redundant unmarshal/restore sequence
and any unused `forged` SignedHeader creation.

In `@block/internal/syncing/doublesign.go`:
- Around line 47-57: The code uses the magic string "stored" as an evidence
source; add a new typed constant (e.g., EvidenceSourceStored) to the existing
enum/constants in types (where EvidenceSourceP2P and EvidenceSourceDA are
defined, likely in double_sign_evidence.go) and replace the literal "stored" in
buildEvidenceFromPair calls (in function that references storedHeader, incoming,
"stored", incomingSource) with types.EvidenceSourceStored; update any tests that
assert the string value to use the new constant so all ingestion-source labels
are centralized and consistent.

In `@block/internal/syncing/p2p_handler_doublesign_test.go`:
- Around line 85-90: The test currently does a no-op proto round-trip by setting
var pbDecoded = pbHdr and calling proto.Unmarshal(bin, pbDecoded) before
forged.FromProto; remove the unnecessary pbDecoded and proto.Unmarshal call and
call forged.FromProto directly with pbHdr (or use a proper proto.Clone(pbHdr) if
you need an independent copy) so the forged header is constructed without the
redundant unmarshal step; update the block that references forged, pbHdr,
proto.Unmarshal, and forged.FromProto accordingly.

In `@block/internal/syncing/syncer.go`:
- Around line 1078-1080: handleDoubleSign is currently ignoring the error
returned by reportDoubleSign; instead capture its error return and log it (don't
swallow it) using the structured logger (e.g., s.logger.Error) with wrapped
context (fmt.Errorf) and include identifying evidence fields such as ev.Height()
and the conflicting block hash/alt-hash (e.g., ev.Conflicting[0].Hash or
whatever the DoubleSignEvidence exposes) so operators see that persistence of
the evidence record (ds/<height>/<alt-hash>) failed; note that reportDoubleSign
already triggers sendCriticalError for the halt, so only log/wrap the returned
error here with clear context.

In `@types/double_sign_evidence.go`:
- Around line 32-47: DoubleSignEvidence.ValidateBasic currently omits checks on
embedded headers and some invariants; update ValidateBasic to call
FirstHeader.ValidateBasic() and AlternateHeader.ValidateBasic(), verify that
FirstHeader.ProposerAddress equals AlternateHeader.ProposerAddress (the same
invariant enforced by buildEvidenceFromPair), and reject empty FirstSource and
AlternateSource (return errors if zero-length). Preserve the existing
height/hash checks and error messages, and ensure all new failures return clear
errors from ValidateBasic.
- Around line 73-92: DoubleSignEvidence.FromProto must validate required nested
proto messages before using them: check that p.FirstHeader and p.AlternateHeader
are not nil at the top of DoubleSignEvidence.FromProto and return a descriptive
error if either is nil, then proceed to call SignedHeader.FromProto on each;
this mirrors SignedHeader.FromProto's own nil checks and the expectations in
ValidateBasic and ensures you don't pass nil into first.FromProto or
alt.FromProto.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f0e8f031-b97f-4dad-94c6-82f3ba156f19

📥 Commits

Reviewing files that changed from the base of the PR and between f36fbd2 and 821a0ef.

⛔ Files ignored due to path filters (1)
  • types/pb/evnode/v1/evnode.pb.go is excluded by !**/*.pb.go
📒 Files selected for processing (19)
  • block/internal/cache/generic_cache.go
  • block/internal/cache/generic_cache_test.go
  • block/internal/cache/manager.go
  • block/internal/cache/manager_test.go
  • block/internal/common/metrics.go
  • block/internal/syncing/da_retriever.go
  • block/internal/syncing/da_retriever_test.go
  • block/internal/syncing/doublesign.go
  • block/internal/syncing/doublesign_branches_test.go
  • block/internal/syncing/doublesign_test.go
  • block/internal/syncing/p2p_handler.go
  • block/internal/syncing/p2p_handler_doublesign_test.go
  • block/internal/syncing/p2p_handler_test.go
  • block/internal/syncing/syncer.go
  • block/internal/syncing/syncer_forced_inclusion_test.go
  • block/internal/syncing/syncer_test.go
  • pkg/store/keys.go
  • proto/evnode/v1/evnode.proto
  • types/double_sign_evidence.go

Comment thread block/internal/syncing/doublesign_test.go
Comment on lines +22 to +29
type DoubleSignEvidence struct {
Height uint64
FirstHeader *SignedHeader
AlternateHeader *SignedHeader
DetectedAt time.Time
FirstSource string
AlternateSource string
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add JSON tags and a String() method to DoubleSignEvidence.

The repository coding guidelines for types/**/*.go require JSON tags on struct fields and a String() method on core types. Both help when inspecting persisted/decoded evidence in logs and debug tooling.

♻️ Proposed change
 type DoubleSignEvidence struct {
-	Height          uint64
-	FirstHeader     *SignedHeader
-	AlternateHeader *SignedHeader
-	DetectedAt      time.Time
-	FirstSource     string
-	AlternateSource string
+	Height          uint64        `json:"height"`
+	FirstHeader     *SignedHeader `json:"first_header"`
+	AlternateHeader *SignedHeader `json:"alternate_header"`
+	DetectedAt      time.Time     `json:"detected_at"`
+	FirstSource     string        `json:"first_source"`
+	AlternateSource string        `json:"alternate_source"`
 }
+
+// String returns a concise human-readable summary of the evidence.
+func (e *DoubleSignEvidence) String() string {
+	if e == nil {
+		return "DoubleSignEvidence(nil)"
+	}
+	var firstHash, altHash string
+	if e.FirstHeader != nil {
+		firstHash = e.FirstHeader.Hash().String()
+	}
+	if e.AlternateHeader != nil {
+		altHash = e.AlternateHeader.Hash().String()
+	}
+	return fmt.Sprintf("DoubleSignEvidence{height=%d first=%s(%s) alt=%s(%s) detected_at=%s}",
+		e.Height, firstHash, e.FirstSource, altHash, e.AlternateSource, e.DetectedAt.Format(time.RFC3339Nano))
+}

As per coding guidelines: "Add JSON tags to type struct fields for inspection and marshaling" and "Implement String() methods for all core types to aid in debugging and inspection".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type DoubleSignEvidence struct {
Height uint64
FirstHeader *SignedHeader
AlternateHeader *SignedHeader
DetectedAt time.Time
FirstSource string
AlternateSource string
}
type DoubleSignEvidence struct {
Height uint64 `json:"height"`
FirstHeader *SignedHeader `json:"first_header"`
AlternateHeader *SignedHeader `json:"alternate_header"`
DetectedAt time.Time `json:"detected_at"`
FirstSource string `json:"first_source"`
AlternateSource string `json:"alternate_source"`
}
// String returns a concise human-readable summary of the evidence.
func (e *DoubleSignEvidence) String() string {
if e == nil {
return "DoubleSignEvidence(nil)"
}
var firstHash, altHash string
if e.FirstHeader != nil {
firstHash = e.FirstHeader.Hash().String()
}
if e.AlternateHeader != nil {
altHash = e.AlternateHeader.Hash().String()
}
return fmt.Sprintf("DoubleSignEvidence{height=%d first=%s(%s) alt=%s(%s) detected_at=%s}",
e.Height, firstHash, e.FirstSource, altHash, e.AlternateSource, e.DetectedAt.Format(time.RFC3339Nano))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@types/double_sign_evidence.go` around lines 22 - 29, The DoubleSignEvidence
struct lacks JSON tags and a String() method; update the type DoubleSignEvidence
by adding JSON tags to each field (Height, FirstHeader, AlternateHeader,
DetectedAt, FirstSource, AlternateSource) so they marshal/unmarshal with
conventional names (e.g., `height`, `first_header`, etc.), and implement a
String() method on DoubleSignEvidence that returns a human-readable
representation (include Height, DetectedAt, FirstSource, AlternateSource and
concise summaries of FirstHeader/AlternateHeader) to satisfy the codebase's
inspection/debugging requirements.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
types/double_sign_evidence.go (1)

111-117: ⚡ Quick win

Wrap the proto.Unmarshal error in UnmarshalBinary.

Line 114 returns the raw error from proto.Unmarshal. Per the coding guidelines ("Wrap errors with context using fmt.Errorf"), this should be wrapped so callers get actionable context.

♻️ Proposed fix
 func (e *DoubleSignEvidence) UnmarshalBinary(data []byte) error {
 	p := new(pb.DoubleSignEvidence)
 	if err := proto.Unmarshal(data, p); err != nil {
-		return err
+		return fmt.Errorf("proto unmarshal double sign evidence: %w", err)
 	}
 	return e.FromProto(p)
 }

As per coding guidelines: "Wrap errors with context using fmt.Errorf in Go code".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@types/double_sign_evidence.go` around lines 111 - 117, In
DoubleSignEvidence.UnmarshalBinary, wrap the error returned by proto.Unmarshal
with contextual information using fmt.Errorf (e.g., include "failed to unmarshal
DoubleSignEvidence" or similar) so callers receive actionable context; update
the error return at the proto.Unmarshal call in UnmarshalBinary to return the
wrapped error and ensure fmt is imported if not already.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@types/double_sign_evidence.go`:
- Around line 54-74: DoubleSignEvidence.ToProto currently only checks e == nil
but calls e.FirstHeader.ToProto() and e.AlternateHeader.ToProto() which will
panic if those fields are nil; update DoubleSignEvidence.ToProto to validate
that e.FirstHeader and e.AlternateHeader are non-nil before calling ToProto
(e.g., if e.FirstHeader == nil return an error like fmt.Errorf("marshal first
header: header is nil") and similarly for AlternateHeader), then proceed to call
FirstHeader.ToProto() and AlternateHeader.ToProto() and return the assembled
pb.DoubleSignEvidence.

---

Nitpick comments:
In `@types/double_sign_evidence.go`:
- Around line 111-117: In DoubleSignEvidence.UnmarshalBinary, wrap the error
returned by proto.Unmarshal with contextual information using fmt.Errorf (e.g.,
include "failed to unmarshal DoubleSignEvidence" or similar) so callers receive
actionable context; update the error return at the proto.Unmarshal call in
UnmarshalBinary to return the wrapped error and ensure fmt is imported if not
already.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9b38bf58-b4ab-4b27-8362-0e93a90b1d81

📥 Commits

Reviewing files that changed from the base of the PR and between 821a0ef and efcd4d9.

📒 Files selected for processing (4)
  • block/internal/syncing/doublesign.go
  • block/internal/syncing/doublesign_branches_test.go
  • block/internal/syncing/doublesign_test.go
  • types/double_sign_evidence.go
✅ Files skipped from review due to trivial changes (2)
  • block/internal/syncing/doublesign_test.go
  • block/internal/syncing/doublesign.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • block/internal/syncing/doublesign_branches_test.go

Comment thread types/double_sign_evidence.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@types/double_sign_evidence.go`:
- Around line 32-50: Update DoubleSignEvidence.ValidateBasic to fully validate
nested SignedHeader contents and the evidence Source set: ensure e.FirstHeader
and e.AlternateHeader are non-nil and call their ValidateBasic/consistency
checks (verify header signatures, commit presence, heights, round, blockID
consistency as applicable via SignedHeader.ValidateBasic or equivalent),
re-check proposers and that header hashes differ, and validate that e.Source (or
Source string/enum) is in the allowed set; then, in FromProto (the constructor
that builds DoubleSignEvidence from protobufs), call the updated ValidateBasic()
and return an error if it fails so malformed protobufs are rejected at the
boundary. Use the unique symbols DoubleSignEvidence.ValidateBasic, FromProto,
and SignedHeader (or SignedHeader.ValidateBasic) to locate and modify the code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 75193429-0609-4fe8-b0cc-38b99be22131

📥 Commits

Reviewing files that changed from the base of the PR and between efcd4d9 and e810cf7.

📒 Files selected for processing (3)
  • block/internal/syncing/doublesign_branches_test.go
  • block/internal/syncing/doublesign_test.go
  • types/double_sign_evidence.go

Comment on lines +32 to +50
// ValidateBasic checks structural consistency of the evidence.
func (e *DoubleSignEvidence) ValidateBasic() error {
if e == nil {
return errors.New("evidence is nil")
}
if e.FirstHeader == nil || e.AlternateHeader == nil {
return errors.New("evidence requires both first and alternate headers")
}
if e.FirstHeader.Height() != e.Height || e.AlternateHeader.Height() != e.Height {
return fmt.Errorf("evidence height %d does not match both headers (%d, %d)",
e.Height, e.FirstHeader.Height(), e.AlternateHeader.Height())
}
if bytes.Equal(e.FirstHeader.Hash(), e.AlternateHeader.Hash()) {
return errors.New("evidence headers have identical hash — no equivocation")
}
if !bytes.Equal(e.FirstHeader.ProposerAddress, e.AlternateHeader.ProposerAddress) {
return errors.New("evidence headers have different proposers — not an equivocation")
}
return nil
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject malformed evidence at the protobuf boundary.

FromProto() only enforces non-nil submessages, and ValidateBasic() currently only checks header-to-header relationships. That means a corrupted protobuf with invalid nested SignedHeaders, mismatched invariants, or arbitrary source strings can deserialize cleanly and then participate in persistence/reporting. Please make ValidateBasic() validate both nested headers plus the allowed source set, and call it from FromProto() before returning.

🛡️ Proposed fix
 func (e *DoubleSignEvidence) ValidateBasic() error {
 	if e == nil {
 		return errors.New("evidence is nil")
 	}
 	if e.FirstHeader == nil || e.AlternateHeader == nil {
 		return errors.New("evidence requires both first and alternate headers")
 	}
+	if err := e.FirstHeader.ValidateBasic(); err != nil {
+		return fmt.Errorf("validate first header: %w", err)
+	}
+	if err := e.AlternateHeader.ValidateBasic(); err != nil {
+		return fmt.Errorf("validate alternate header: %w", err)
+	}
 	if e.FirstHeader.Height() != e.Height || e.AlternateHeader.Height() != e.Height {
 		return fmt.Errorf("evidence height %d does not match both headers (%d, %d)",
 			e.Height, e.FirstHeader.Height(), e.AlternateHeader.Height())
 	}
 	if bytes.Equal(e.FirstHeader.Hash(), e.AlternateHeader.Hash()) {
 		return errors.New("evidence headers have identical hash — no equivocation")
 	}
 	if !bytes.Equal(e.FirstHeader.ProposerAddress, e.AlternateHeader.ProposerAddress) {
 		return errors.New("evidence headers have different proposers — not an equivocation")
 	}
+	switch e.FirstSource {
+	case EvidenceSourceP2P, EvidenceSourceDA, EvidenceSourceStored:
+	default:
+		return fmt.Errorf("invalid first source %q", e.FirstSource)
+	}
+	switch e.AlternateSource {
+	case EvidenceSourceP2P, EvidenceSourceDA, EvidenceSourceStored:
+	default:
+		return fmt.Errorf("invalid alternate source %q", e.AlternateSource)
+	}
 	return nil
 }
@@
 	e.DetectedAt = time.Unix(0, p.DetectedAt).UTC()
 	e.FirstSource = p.FirstSource
 	e.AlternateSource = p.AlternateSource
-	return nil
+	if err := e.ValidateBasic(); err != nil {
+		return fmt.Errorf("validate double sign evidence: %w", err)
+	}
+	return nil
 }

As per coding guidelines: "Implement ValidateBasic() methods that check required fields, validate ranges, and check consistency for all types" and "Validate all external inputs at type boundaries to prevent invalid state".

Also applies to: 80-101

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@types/double_sign_evidence.go` around lines 32 - 50, Update
DoubleSignEvidence.ValidateBasic to fully validate nested SignedHeader contents
and the evidence Source set: ensure e.FirstHeader and e.AlternateHeader are
non-nil and call their ValidateBasic/consistency checks (verify header
signatures, commit presence, heights, round, blockID consistency as applicable
via SignedHeader.ValidateBasic or equivalent), re-check proposers and that
header hashes differ, and validate that e.Source (or Source string/enum) is in
the allowed set; then, in FromProto (the constructor that builds
DoubleSignEvidence from protobufs), call the updated ValidateBasic() and return
an error if it fails so malformed protobufs are rejected at the boundary. Use
the unique symbols DoubleSignEvidence.ValidateBasic, FromProto, and SignedHeader
(or SignedHeader.ValidateBasic) to locate and modify the code.

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.

[Feature Request]: double-sign detection

1 participant