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) | REUSE — EquivocationProof, 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) | REUSE — apply_penalty, DoublePenaltyError, SeverityBand, damage_for |
src/domains/reputation/schema.ts |
Yes (λ P2.1.1) | REUSE — ReputationRow, 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
criticaloffense (8000bps loss). Use the κDAMAGE_CRITICALconstant, notDAMAGE_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:
-
Set-level (P3.5.1 contract):
alreadyApplied: Set<string>keyed byproof.evidence_hash.toString('hex'). Re-submission of the same proof returns{applied: false, reason: 'duplicate'}without touching λ. -
λ-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, λ’sapply_penaltythrowsDoublePenaltyError.
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:crypto—createHash,verify,createPublicKey(nosign; proof verification only checks existing signatures)../reputation/penalties.js—apply_penalty,DoublePenaltyError, typeSeverityBand../rules/bps-constants.js—DAMAGE_CRITICAL(type-only / value for documentation; theband='critical'keying is what gets passed to λ)
Type-only:
./messages.js—EquivocationProof,Vote../reputation/schema.js—ReputationRow,ReputationHistoryRow,Domainnode:crypto—KeyObject
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 commitfeat)src/__tests__/domains/consensus/equivocation.test.ts(Step 4 commitfeat)
§5. Files NOT touched
src/domains/consensus/messages.ts— type/value import only; no editsrc/domains/consensus/quorum.ts— referenced in tests fordetectDoubleVoteintegration; no editsrc/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).