P3.5.1 — Equivocation Slasher — Execution Packet
Step 3 of the 5-step chain. The contract (Step 2) is approved. This packet is the execution plan that gates Step 4 (implement) and Step 5 (verify).
§1. Files to create
| Path | Lines (est.) | Purpose |
|---|---|---|
src/domains/consensus/equivocation.ts |
~240 | Verify + slash + re-exports |
src/__tests__/domains/consensus/equivocation.test.ts |
~600 | 28 AC + corpus scan |
§2. Implementation outline (equivocation.ts)
/**
* Colibri — Phase 3 θ Consensus — Equivocation Slasher (P3.5.1).
*
* Pure slashing module. Consumes P3.1.1's EquivocationProof shape,
* verifies the contained signatures, delegates the slash to λ's
* pure-function apply_penalty (P2.2.2 — shipped #229), and dedups by
* evidence_hash so re-submission of the same proof never double-slashes.
*
* (Header in P3.1.2 style — canonical refs, invariants, forbidden-token
* note.)
*/
import { verify, type KeyObject } from 'node:crypto';
import { DAMAGE_CRITICAL } from '../rules/bps-constants.js';
import {
DoublePenaltyError,
apply_penalty,
type SeverityBand,
} from '../reputation/penalties.js';
import type {
Domain,
ReputationHistoryRow,
ReputationRow,
} from '../reputation/schema.js';
import {
type EquivocationProof,
type Vote,
buildEquivocationProof,
verifySignature,
} from './messages.js';
// Re-export for ergonomic call-site (per contract §6).
export { buildEquivocationProof };
export type { EquivocationProof, Vote };
// -------------------------------------------------------------------------
// §3. Constants
// -------------------------------------------------------------------------
export const EQUIVOCATION_BAND: SeverityBand = 'critical';
export const EQUIVOCATION_DOMAIN: Domain = 'arbitration';
// The slasher does NOT pass DAMAGE_CRITICAL directly to λ — λ derives
// the bps internally via damage_for('critical'). Imported here as a
// documentation anchor; AC#24 asserts the source body references it.
void DAMAGE_CRITICAL;
// -------------------------------------------------------------------------
// §4. Types
// -------------------------------------------------------------------------
export type VerifyEquivocationReason =
| 'sig_a_invalid'
| 'sig_b_invalid'
| 'same_tuple'
| 'different_round_or_level';
export type VerifyEquivocationResult =
| { readonly valid: true }
| { readonly valid: false; readonly reason: VerifyEquivocationReason };
export type ApplySlashReason =
| 'invalid_proof'
| 'duplicate'
| 'lambda_double_jeopardy';
export type ApplySlashResult =
| {
readonly applied: true;
readonly evidence_hash_hex: string;
readonly next_row: ReputationRow;
readonly history_event: Omit<ReputationHistoryRow, 'id'>;
}
| { readonly applied: false; readonly reason: ApplySlashReason };
export interface ApplySlashArgs {
readonly proof: EquivocationProof;
readonly attackerPubKey: KeyObject;
readonly current_epoch: bigint;
readonly attackerRow: ReputationRow;
readonly history: readonly ReputationHistoryRow[];
readonly alreadyApplied: Set<string>;
}
// -------------------------------------------------------------------------
// §5. evidenceHashHex
// -------------------------------------------------------------------------
export function evidenceHashHex(proof: EquivocationProof): string {
return proof.evidence_hash.toString('hex');
}
// -------------------------------------------------------------------------
// §6. verifyEquivocationProof
// -------------------------------------------------------------------------
export function verifyEquivocationProof(
proof: EquivocationProof,
attackerPubKey: KeyObject,
): VerifyEquivocationResult {
// Step 1: sig_a — verifySignature returns boolean (no throw on Vote).
if (!verifySignature(proof.signed_vote_a, attackerPubKey)) {
return { valid: false, reason: 'sig_a_invalid' };
}
// Step 2: sig_b
if (!verifySignature(proof.signed_vote_b, attackerPubKey)) {
return { valid: false, reason: 'sig_b_invalid' };
}
// Step 3: tuples distinct
const sameRoot =
Buffer.compare(
proof.signed_vote_a.merkle_root,
proof.signed_vote_b.merkle_root,
) === 0;
const sameRuleVer =
Buffer.compare(
proof.signed_vote_a.rule_version_hash,
proof.signed_vote_b.rule_version_hash,
) === 0;
if (sameRoot && sameRuleVer) {
return { valid: false, reason: 'same_tuple' };
}
// Step 4: same round_id (the spec's "same finality level in same round").
if (proof.signed_vote_a.round_id !== proof.signed_vote_b.round_id) {
return { valid: false, reason: 'different_round_or_level' };
}
return { valid: true };
}
// -------------------------------------------------------------------------
// §7. applyEquivocationSlash
// -------------------------------------------------------------------------
export function applyEquivocationSlash(args: ApplySlashArgs): ApplySlashResult {
const { proof, attackerPubKey, current_epoch, attackerRow, history,
alreadyApplied } = args;
// Step 1: cheap Set guard BEFORE crypto verify.
const hashHex = evidenceHashHex(proof);
if (alreadyApplied.has(hashHex)) {
return { applied: false, reason: 'duplicate' };
}
// Step 2: cryptographic verify.
const verifyResult = verifyEquivocationProof(proof, attackerPubKey);
if (!verifyResult.valid) {
return { applied: false, reason: 'invalid_proof' };
}
// Step 3: row sanity-check (programming error, not domain failure).
if (attackerRow.node_id !== proof.attacker_id) {
throw new Error(
`applyEquivocationSlash: row.node_id (${attackerRow.node_id}) does not match proof.attacker_id (${proof.attacker_id})`,
);
}
if (attackerRow.domain !== EQUIVOCATION_DOMAIN) {
throw new Error(
`applyEquivocationSlash: row.domain (${attackerRow.domain}) must be '${EQUIVOCATION_DOMAIN}'`,
);
}
// Step 4: delegate to λ. Catch DoublePenaltyError → structured result.
let applyOut;
try {
applyOut = apply_penalty(
attackerRow,
EQUIVOCATION_BAND,
current_epoch,
hashHex,
'EQUIVOCATION_PROVEN',
history,
);
} catch (err) {
if (err instanceof DoublePenaltyError) {
return { applied: false, reason: 'lambda_double_jeopardy' };
}
throw err;
}
// Step 5: mutate the Set on success.
alreadyApplied.add(hashHex);
return {
applied: true,
evidence_hash_hex: hashHex,
next_row: applyOut.row,
history_event: applyOut.history_event,
};
}
§3. Test outline (equivocation.test.ts)
Test groups map 1:1 to AC table in contract §8:
describe('verifyEquivocationProof', () => {
test('AC#1 happy path', ...);
test('AC#2 sig_a tampered → sig_a_invalid', ...);
test('AC#3 sig_b tampered → sig_b_invalid', ...);
test('AC#4 wrong pubkey for sig_a → sig_a_invalid', ...);
test('AC#5 same tuple → same_tuple', ...);
test('AC#6 different round_id → different_round_or_level', ...);
test('AC#7 short-circuit ordering', ...);
test('AC#8 malformed sig does not throw', ...);
});
describe('evidenceHashHex', () => {
test('AC#9 lowercase 64-char hex', ...);
test('AC#10 stable across calls', ...);
});
describe('applyEquivocationSlash', () => {
test('AC#11 happy path — score 10000 → 2000', ...);
test('AC#12 Set guard — second call returns duplicate', ...);
test('AC#13 invalid proof → invalid_proof (no λ call observed)', ...);
test('AC#14 λ double-jeopardy → lambda_double_jeopardy', ...);
test('AC#15 history_event has band:critical| prefix', ...);
test('AC#16 event_id === evidence_hash_hex', ...);
test('AC#17 domain === arbitration', ...);
test('AC#18 ban_until_epoch = current_epoch + 100', ...);
test('AC#19 row sanity-check throws on node_id mismatch', ...);
test('AC#20 row sanity-check throws on wrong domain', ...);
});
describe('single-arbiter clause', () => {
test('AC#21 n=1 self-equivocation builds + slashes', ...);
});
describe('constants', () => {
test('AC#22 EQUIVOCATION_BAND === critical', ...);
test('AC#23 EQUIVOCATION_DOMAIN === arbitration', ...);
test('AC#24 DAMAGE_CRITICAL is referenced in source', ...);
});
describe('integration with detectDoubleVote', () => {
test('AC#25 pair → buildProof → verify → slash', ...);
test('AC#26 re-slash same pair via Set → duplicate', ...);
});
describe('source body invariants', () => {
test('AC#27 forbidden-token corpus scan', ...);
test('AC#28 buildEquivocationProof is re-exported', ...);
});
§4. Fixture strategy
Two Ed25519 keypairs per test where needed (makeKeyPair() calls
generateKeyPairSync('ed25519') — same helper as P3.4.1
time-anchors.test.ts).
Helper to build a signed Vote with arbitrary (merkle_root,
rule_version_hash, round_id, sender_id, vote_type, timestamp_logical):
function makeSignedVote(args: {
sender_id: string;
round_id: bigint;
rootTag: number;
rvTag?: number;
vote_type?: VoteType;
timestamp_logical?: bigint;
privKey: KeyObject;
}): Vote {
// Allocates Buffer.alloc(32) for each hash field, writeUInt32BE(rootTag).
// Builds bare Vote with signature: Buffer.alloc(64), calls signMessage,
// returns { ...bare, signature: sig }.
}
Helper for makeRow:
function makeRow(overrides: Partial<ReputationRow> = {}): ReputationRow {
return {
node_id: 'arb:alice',
domain: 'arbitration',
score: 10_000,
scar_bps: 0,
ban_until_epoch: null,
last_activity_epoch: 0,
...overrides,
};
}
attacker_id strings: 'arb:alice' (matches P3.4.1 fixture style).
§5. Forbidden-token corpus scan (AC#27)
test('AC#27 forbidden-token corpus scan', () => {
const src = readFileSync(EQUIVOCATION_FILE, 'utf8');
const stripped = stripJSDoc(src); // strip /** ... */ blocks
const forbiddenPatterns = [
/\bMath\./,
/\bDate\.\w+\(/,
/Math\.random/,
/\bprocess\.env\b/,
];
for (const re of forbiddenPatterns) {
expect(stripped).not.toMatch(re);
}
});
Pattern mirrors time-anchors.test.ts §Group 8. JSDoc strip lets the
JSDoc body legitimately mention forbidden tokens for documentation.
§6. Step 4 commit plan
Single feat(p3-5-1-equivocation): commit holding both equivocation.ts
and equivocation.test.ts. Per CLAUDE.md §6 template:
feat(p3-5-1-equivocation): equivocation slasher (verify + idempotent slash via λ)
§7. Step 5 commit plan
After npm run build && npm run lint && npm test all pass, write
docs/verification/p3-5-1-equivocation-verification.md capturing the
green test output + pass count + AC coverage map. Single
verify(p3-5-1-equivocation): commit.
§8. Build / lint / test gates
cd .worktrees/claude/p3-5-1-equivocation
npm run build # tsc — must pass with zero errors
npm run lint # eslint — must pass with zero errors
npm test # jest — all suites green, baseline 2744 + delta
Expected new tests: ~25 it() blocks across the 28 AC IDs. Target
delta: +25.
§9. PR creation
gh pr create --title "feat(p3-5-1-equivocation): proof verification + idempotent slashing via λ penalty surface (R89 θ Wave 3)" --body "$(cat <<'EOF'
## Summary
- Equivocation slasher: verifyEquivocationProof (4 reason codes) +
evidenceHashHex helper + applyEquivocationSlash with Set-level
idempotency + λ DoublePenaltyError defense-in-depth.
- Slash maps to band='critical' (DAMAGE_CRITICAL=8000bps) on
domain='arbitration' via λ P2.2.2 apply_penalty (shipped #229).
- Pure module — no DB, no I/O, no clock, no RNG. Forbidden-token
corpus scan in test body.
## Test plan
- [ ] npm run build (tsc)
- [ ] npm run lint (eslint)
- [ ] npm test (jest — 28 AC across 25 it() blocks)
## Writeback (for PM to fold into ζ chain)
task_id: b62dfb70-a169-42d2-a2d9-0e2abf4cb78e
branch: feature/p3-5-1-equivocation
worktree: .worktrees/claude/p3-5-1-equivocation
commits: <5 SHAs>
tests: build + lint + test all PASS; <N>/<total> (delta vs 2744 baseline)
summary: <filled at Step 5>
blockers: <none expected>
EOF
)"
§10. Risk register
| Risk | Likelihood | Mitigation |
|---|---|---|
verifySignature accepts EquivocationProof and throws |
Low — P3.1.1 §444 guards | We only pass Vote shape |
λ apply_penalty shape drift between branches |
Low — base SHA pinned | Test surface checks delta = -8000, domain = arbitration |
| Jest fixture for DoublePenaltyError construction | Medium | Test builds an existing band:critical|... row in history and asserts the catch branch fires |
Buffer.compare returns non-0 on byte-identical Buffers (shouldn’t happen) |
Negligible | n/a |
Lint failures on unused void DAMAGE_CRITICAL |
Medium | Use void operator to satisfy no-unused-vars; if eslint flags it, switch to a TSDoc-only reference or a documentation comment |
The void DAMAGE_CRITICAL pattern is the most fragile. If lint complains,
fall back to:
// DAMAGE_CRITICAL = 8000n — the bps equivalent of band='critical'.
// Imported so source-body grep finds the constant (AC#24 anchor).
// The actual lookup is internal to λ's damage_for('critical').
const _DAMAGE_CRITICAL_REF: bigint = DAMAGE_CRITICAL;
void _DAMAGE_CRITICAL_REF;
Or simpler: just import the constant and place it inside a JSDoc body referenced by name — AC#24 reads the source file as text, so a JSDoc mention also counts.
§11. Approval
Step 3 packet complete. Step 4 (implementation) is unblocked.