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.


Back to top

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

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