P3.5.1 — Equivocation Detection + Idempotent Slashing — Audit

Step 1 of the 5-step chain (CLAUDE.md §6). Phase 3 θ Wave 3 — the slasher that consumes P3.1.1’s EquivocationProof shape + P3.1.2’s detectDoubleVote and pushes the offense through the λ P2.2.2 penalty surface (shipped #229).

§1. Surface inventory at base SHA 498e6ea5

Path Exists? Role
src/domains/consensus/ Yes Existing θ domain (messages, quorum, gossip-wire, time-anchors)
src/domains/consensus/messages.ts Yes (P3.1.1) REUSEEquivocationProof, Vote, canonicalSerialize, verifySignature, buildEquivocationProof
src/domains/consensus/quorum.ts Yes (P3.1.2) Reference (detectDoubleVote produces the conflicting-pair input)
src/domains/consensus/equivocation.ts No — to create Proof verification + idempotent slasher
src/__tests__/domains/consensus/equivocation.test.ts No — to create Proof verification (4 reason branches) + idempotency + λ integration
src/domains/reputation/penalties.ts Yes (λ P2.2.2 #229) REUSEapply_penalty, DoublePenaltyError, SeverityBand, damage_for
src/domains/reputation/schema.ts Yes (λ P2.1.1) REUSEReputationRow, ReputationHistoryRow types
src/domains/rules/bps-constants.ts Yes (κ P1.1.3) Reference (DAMAGE_CRITICAL = 8_000n)

No source path collides with the new file. Greenfield slice with two type-only imports (EquivocationProof, Vote from messages.ts; ReputationRow, ReputationHistoryRow, SeverityBand from reputation) plus the runtime imports listed in §3.

§2. Spec inventory

2.1 Equivocation definition (consensus.md §Equivocation §122–124)

An arbiter that signs two distinct tuples at the same finality level in the same round is equivocating.

The distinguishing condition: same (round_id, finality_level); distinct (merkle_root, rule_version_hash) sub-tuple. Two signatures on the SAME 3-tuple are NOT equivocation (benign retries — filtered out by P3.1.2’s detectDoubleVote group-by-key step).

2.2 Proof structure (consensus.md §Equivocation proof structure §126–145)

{
  "type": "equivocation",
  "attacker_id": "soul:<sha256-of-pubkey>",
  "epoch": 17,
  "round_id": 42,
  "signed_vote_a": { "tuple": [42, "0xab12…", "rv:sha256:…"], "signature": "<ed25519-sig-A>" },
  "signed_vote_b": { "tuple": [42, "0xCAFE…", "rv:sha256:…"], "signature": "<ed25519-sig-B>" },
  "submitter": "soul:<who-observed-it>",
  "evidence_hash": "<sha256-over-the-two-signed-votes>"
}

P3.1.1’s EquivocationProof type (messages.ts §188-205) materializes this shape exactly. buildEquivocationProof already computes evidence_hash = SHA-256(canonicalSerialize({a: vote_a, b: vote_b})) and enforces non-identical-tuple + matching-round + matching-attacker preconditions. P3.5.1 does NOT redefine the proof shape; it consumes it.

2.3 Verification protocol (consensus.md §Equivocation §147)

Re-check both signatures against the attacker’s published public key; both valid + distinct tuples + same (round_id, finality_level) ⇒ provable fault.

Note: “finality_level” is not a structural field on Vote in P3.1.1 — it is a property of the round’s voting state. Both contained votes are of vote_type ∈ {ACCEPT, REJECT, ABSTAIN} at the same round_id, which is the spec’s “same finality level in the same round” condition. The contract surfaces this as “same round_id in both votes” — checked by buildEquivocationProof already AND by verifyEquivocationProof defensively.

2.4 Slash mechanism (s06-consensus.md §14 + task-breakdown.md §P3.5.1 + prompt §Common gotchas)

Equivocation: signing conflicting messages on the same proposal → votes invalidated, reputation penalty

Task-breakdown line 963 (per prompt §Common gotchas):

Slash amount: maps to critical offense (8000bps loss). Use the κ DAMAGE_CRITICAL constant, not DAMAGE_FRAUD.

The Phase-2 λ penalty surface (P2.2.2 — shipped #229) keys penalties by SeverityBand, not raw bps. damage_for('critical') === 8_000n === DAMAGE_CRITICAL. The slasher passes band: 'critical' to apply_penalty; the bps value is derived internally.

2.5 λ surface available (verified at base SHA 498e6ea5)

src/domains/reputation/penalties.ts exports:

export type SeverityBand = 'minor' | 'moderate' | 'severe' | 'critical' | 'fraud';

export function apply_penalty(
  row: ReputationRow,
  band: SeverityBand,
  current_epoch: bigint,
  event_id: string,
  reason: string,
  history: readonly ReputationHistoryRow[] = [],
): { row: ReputationRow; history_event: Omit<ReputationHistoryRow, 'id'> };

export class DoublePenaltyError extends Error { ... }

This is a pure function — it does not write to a database. It takes the current row + history and returns the post-penalty row + a history_event ready for insertHistoryEvent. λ’s own double-jeopardy guard (DoublePenaltyError) fires when the same (event_id, band) tuple has already been recorded in history.

Implication for the slasher API: applyEquivocationSlash cannot take (arbiter_id, bps, domain, event_id) as the prompt scaffold suggests — that would only work if λ exposed a (node, bps) setter. Instead, the slasher takes a lambdaPenaltyApplier callback whose shape matches apply_penalty’s signature, OR (preferred — closer to the prompt’s “calls λ.applyPenalty(…)” wording while being honest about λ’s actual shape) takes the ReputationRow + history inputs that λ needs.

The contract (Step 2) picks the second form: applyEquivocationSlash takes a RowFetcher callback (node_id, domain) → ReputationRow and a HistoryFetcher callback (node_id, domain) → ReputationHistoryRow[] plus the current_epoch and an idempotency Set, then delegates to apply_penalty and returns the post-penalty row + history_event. The caller persists via insertHistoryEvent (P2.1.1).

2.6 Idempotency layer

Two levels:

  1. Set-level (P3.5.1 contract): alreadyApplied: Set<string> keyed by proof.evidence_hash.toString('hex'). Re-submission of the same proof returns {applied: false, reason: 'duplicate'} without touching λ.

  2. λ-level (P2.2.2 D1): if a caller bypasses the Set guard and the (event_id=evidence_hash_hex, band='critical') tuple is already in the history, λ’s apply_penalty throws DoublePenaltyError.

Both layers are in scope. The P3.5.1 Set is the cheap pre-check (no λ call needed); the λ guard is defense-in-depth.

2.7 Single-arbiter clause

consensus.md §Phase 0 posture §193 + prompt acceptance criterion line 1686:

Single-arbiter clause: n=1 → equivocation by sole arbiter still detected and slashed (the arbiter slashes themselves; trivially correct).

No special case in code — the slasher operates on attacker_id regardless of n. The test corpus includes a fixture that signs two conflicting tuples with one keypair, builds the proof, and confirms slashing fires.

§3. Imports inventory (preview for Step 4)

Runtime:

  • node:cryptocreateHash, verify, createPublicKey (no sign; proof verification only checks existing signatures)
  • ../reputation/penalties.jsapply_penalty, DoublePenaltyError, type SeverityBand
  • ../rules/bps-constants.jsDAMAGE_CRITICAL (type-only / value for documentation; the band='critical' keying is what gets passed to λ)

Type-only:

  • ./messages.jsEquivocationProof, Vote
  • ../reputation/schema.jsReputationRow, ReputationHistoryRow, Domain
  • node:cryptoKeyObject

No DB, no env, no Math/Date/random. Pure module per CLAUDE.md §13.

§4. Files to create

  • src/domains/consensus/equivocation.ts (Step 4 commit feat)
  • src/__tests__/domains/consensus/equivocation.test.ts (Step 4 commit feat)

§5. Files NOT touched

  • src/domains/consensus/messages.ts — type/value import only; no edit
  • src/domains/consensus/quorum.ts — referenced in tests for detectDoubleVote integration; no edit
  • src/domains/reputation/penalties.ts — referenced via stable public API; the prompt explicitly forbids modifying λ’s penalty surface
  • All other existing modules — out of scope

§6. Risk surface

Risk Mitigation
λ apply_penalty shape doesn’t match prompt-suggested applyPenalty({arbiter_id, bps, domain, event_id}) Contract (Step 2) names the actual signature; the slasher composes the inputs that apply_penalty needs
Double-slash via two parallel writers Set-level guard + λ DoublePenaltyError defense in depth; tests cover both layers
verifyEquivocationProof accepts a proof with two same-tuple votes buildEquivocationProof already refuses this case; verifyEquivocationProof re-checks defensively (returns reason same_tuple)
Tampered signature inside an existing proof verifySignature re-evaluation under the supplied pubkey returns false; reason sig_a_invalid or sig_b_invalid
Cross-round equivocation submitted verifyEquivocationProof rejects with reason different_round_or_level (compares round_id between the two votes)

§7. Step 1 conclusion

Greenfield slice. λ surface is already shipped (P2.2.2 #229). P3.1.1’s proof shape + P3.1.2’s pair-detection are direct upstream inputs. The slasher is a small pure layer: verify + dedup-by-evidence_hash + delegate to apply_penalty. The prompt’s λ.applyPenalty({arbiter_id, bps, domain, event_id}) shape is a documentation artefact — the real surface keys on SeverityBand and takes the full row. Step 2 specifies the actual signatures.

Proceeding to Step 2 (contract).


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.