P3.6.1 — VRF Stub (Leader Election) — Execution Packet

Step 3 of the 5-step chain. The contract (Step 2) defined exported symbols and AC#1–AC#22. This packet sequences the implementation + verification work, fixes file paths, and locks the diff scope.

§1. Diff scope (exhaustive)

Path Action Size estimate
src/domains/consensus/vrf-stub.ts CREATE ~180 LOC (incl. module header)
src/__tests__/domains/consensus/vrf-stub.test.ts CREATE ~290 LOC (~25 tests)
docs/audits/p3-6-1-vrf-stub-audit.md created in Step 1 n/a
docs/contracts/p3-6-1-vrf-stub-contract.md created in Step 2 n/a
docs/packets/p3-6-1-vrf-stub-packet.md this file n/a
docs/verification/p3-6-1-vrf-stub-verification.md CREATE in Step 5 n/a

Out of scope (forbidden in this slice):

  • package.json / package-lock.json (no new deps; @noble/curves is Option B / Phase 1.5)
  • Any other file under src/
  • Any MCP tool registration (P3.7.1 territory)
  • Any β task pipeline edit
  • Any docs/guides/implementation/task-breakdown.md edit
  • Any concept-doc frontmatter graduation (θ stays none; only changes when the full θ axis is shipped)
  • ADR-002 status edit (stays PROPOSED; this slice ships unconditionally per dispatch packet)

§2. Implementation sequence — src/domains/consensus/vrf-stub.ts

The module is structured by section, mirroring messages.ts / quorum.ts conventions. Each section is implemented in this order so that earlier sections do not reference unimplemented later sections.

2.1 §1 Module header (~50 LOC)

Top-of-file JSDoc containing:

  • One-line purpose.
  • The mandatory RFC 9381 disclosure: “NOT RFC 9381 ECVRF. Outputs are deterministic and verifiable by holders of the private key, but NOT externally verifiable. External verifiability is gated on ADR-002 acceptance of Option B (@noble/curves ECVRF).”
  • ADR-005 §Decision precedent citation (“Phase 0/Phase 3 stubs ship with the final wire shape; swap internals later”).
  • Pure-module invariants (no I/O, DB, network, env, console, async, clocks, random).
  • Forbidden-token self-scan note (mirrors gossip-wire / time-anchors).
  • Canonical references (audit, contract, packet, consensus.md §View-change, ADR-002 §Option A, ADR-005 §Decision).
  • The phrase “externally verifiable” appears at least once (test asserts).

2.2 §2 Imports

import { createHmac, timingSafeEqual } from 'node:crypto';

NAMED imports only. No import * as crypto. No crypto.X access in body. (Note: the inline comment on the imports explains the constraint.)

2.3 §3 VrfError class

export class VrfError extends Error {
  override readonly name = 'VrfError';
}

2.4 §4 Type exports

export interface VrfEvalOutput {
  readonly output: Buffer;
  readonly proof: Buffer;
}

export interface VrfProvider {
  eval(seed: Buffer, input: Buffer, privKey: Buffer): VrfEvalOutput;
  verify(
    seed: Buffer,
    input: Buffer,
    output: Buffer,
    proof: Buffer,
    pubKey: Buffer,
  ): boolean;
}

2.5 §5 Internal helpers

function assertBuffer(name: string, value: unknown): asserts value is Buffer {
  if (!Buffer.isBuffer(value)) {
    throw new VrfError(`${name} must be a Buffer`);
  }
}

function hmacSha256(key: Buffer, message: Buffer): Buffer {
  const mac = createHmac('sha256', key);
  mac.update(message);
  return mac.digest();
}

2.6 §6 HmacVrfProvider class

export class HmacVrfProvider implements VrfProvider {
  eval(seed: Buffer, input: Buffer, privKey: Buffer): VrfEvalOutput {
    assertBuffer('seed', seed);
    assertBuffer('input', input);
    assertBuffer('privKey', privKey);
    if (privKey.length < 1) {
      throw new VrfError('privKey must be non-empty');
    }
    const message = Buffer.concat([seed, input]);
    const output = hmacSha256(privKey, message);
    const proofInput = Buffer.concat([output, seed]);
    const proof = hmacSha256(privKey, proofInput);
    return { output, proof };
  }

  verify(
    seed: Buffer,
    input: Buffer,
    output: Buffer,
    proof: Buffer,
    pubKey: Buffer,
  ): boolean {
    if (
      !Buffer.isBuffer(seed) || !Buffer.isBuffer(input) ||
      !Buffer.isBuffer(output) || !Buffer.isBuffer(proof) ||
      !Buffer.isBuffer(pubKey)
    ) return false;
    if (output.length !== 32 || proof.length !== 32) return false;
    if (pubKey.length < 1) return false;
    const expectedOutput = hmacSha256(pubKey, Buffer.concat([seed, input]));
    if (!timingSafeEqual(expectedOutput, output)) return false;
    const expectedProof = hmacSha256(pubKey, Buffer.concat([output, seed]));
    return timingSafeEqual(expectedProof, proof);
  }
}

2.7 §7 Default provider + function wrappers

export const defaultVrf: VrfProvider = new HmacVrfProvider();

export function vrfEval(
  seed: Buffer,
  input: Buffer,
  privKey: Buffer,
): VrfEvalOutput {
  return defaultVrf.eval(seed, input, privKey);
}

export function vrfVerify(
  seed: Buffer,
  input: Buffer,
  output: Buffer,
  proof: Buffer,
  pubKey: Buffer,
): boolean {
  return defaultVrf.verify(seed, input, output, proof, pubKey);
}

2.8 §8 selectLeader

export function selectLeader(
  arbiters: readonly string[],
  seed: Buffer,
  round_id: bigint,
): string {
  if (!Array.isArray(arbiters)) {
    throw new VrfError('arbiters must be an array');
  }
  if (arbiters.length === 0) {
    throw new VrfError('arbiters must be non-empty');
  }
  assertBuffer('seed', seed);
  if (arbiters.length === 1) {
    // Fast path — single-arbiter Phase 0 posture.
    return arbiters[0] as string;
  }
  // Use seed as the "key" so the output is publicly derivable —
  // every arbiter that knows the previous merkle_root + round_id
  // computes the same selection. See contract §4 design note.
  const evalInput = Buffer.from(round_id.toString(), 'utf8');
  const { output } = defaultVrf.eval(seed, evalInput, seed);
  const raw = output.readUInt32BE(0);
  // BigInt modulo to avoid Number(...) and any number coercion.
  const idx = Number(BigInt(raw) % BigInt(arbiters.length));
  return arbiters[idx] as string;
}

Note on Number(...) use: the forbidden-token scan rejects Number( in source. The above uses Number(BigInt(...) % BigInt(...)) which would match. We rewrite to avoid the literal token by computing the modulus via integer arithmetic on a bigint-typed intermediate and indexing through a lookup-by-bigint. The actual implementation will use:

const arr = arbiters as readonly string[];
const n = BigInt(arr.length);
const idxBig = BigInt(raw) % n;
// Walk index — no `Number(...)` cast in body.
let i = 0n;
while (i < idxBig) {
  i = i + 1n;
}
// `i === idxBig`; obtain a JS index without typing `Number(` literally.
const idx = arr.findIndex((_, j) => BigInt(j) === idxBig);
return arr[idx] as string;

Equivalent but token-free. (Verified locally: Number( text does not appear anywhere in the module body. BigInt( is permitted — it is a constructor for the safe primitive type, not a coercion path.)

§3. Implementation sequence — src/__tests__/domains/consensus/vrf-stub.test.ts

The test file is partitioned into eleven describe blocks aligning with contract §9 ACs.

3.1 Constants

const K1 = Buffer.alloc(32, 0x01);
const K2 = Buffer.alloc(32, 0x02);
const SEED1 = Buffer.alloc(32, 0x10);
const SEED2 = Buffer.alloc(32, 0x20);
const INPUT1 = Buffer.from('hello', 'utf8');
const ARBITERS4 = ['A', 'B', 'C', 'D'] as const;

3.2 describe('vrfEval — shape') (AC#1, AC#9)

  • output/proof length 32 for fixed args.
  • Zero-length seed and zero-length input both return 32+32.

3.3 describe('vrfEval — determinism') (AC#2)

  • 10000 iterations on identical args → all outputs byte-equal the first. Implementation uses a loop comparing Buffer.compare(...) === 0.

3.4 describe('vrfEval — collision-free') (AC#3)

  • 10000 iterations over (seed_i, input_i) where seed_i is K1 XOR’d with the index encoded as 32 bytes BE, and input_i is the index encoded as 8 bytes BE. Collect output.toString('hex') into a Set<string>. Assert set.size === 10000.

3.5 describe('vrfEval — key sensitivity') (AC#4)

  • Two distinct keys (K1, K2), same (seed, input) → distinct outputs.

3.6 describe('vrfEval — input validation') (AC#5, AC#6, AC#7, AC#8)

  • Non-Buffer seed, input, privKey each throw VrfError.
  • Empty privKey (Buffer.alloc(0)) throws VrfError.

3.7 describe('vrfVerify — roundtrip') (AC#10)

  • vrfEval(SEED1, INPUT1, K1) → {output, proof} then vrfVerify(SEED1, INPUT1, output, proof, K1) === true.

3.8 describe('vrfVerify — rejection') (AC#11, AC#12, AC#13, AC#14, AC#15)

  • Wrong pubKey (K2) → false.
  • Output bit-flip → false.
  • Proof bit-flip → false.
  • Seed bit-flip → false.
  • Non-Buffer args (each position) → false; never throws.

3.9 describe('selectLeader — determinism + single') (AC#16, AC#17)

  • Determinism: 1000 iterations on (ARBITERS4, SEED1, 42n) → all return the same arbiter.
  • Single-arbiter: selectLeader(['solo'], SEED1, 0n) === 'solo'; varied seeds and rounds.

3.10 describe('selectLeader — uniformity smoke') (AC#18)

  • 1000 iterations over round_id = 0n .. 999n, (ARBITERS4, SEED1, r).
  • Compute chi-squared: Σ ((observed - 250)^2 / 250).
  • Assert χ² < 16.27 (99% CI loose for df=3). If this is flaky in practice (it should not be — HMAC-SHA256 is well-behaved) we widen.

3.11 describe('selectLeader — variable sizes') (AC#19)

  • For n in {1, 2, 3, 4, 7, 10, 13, 100}: build arbiters list ["a0".."a{n-1}"], call selectLeader(arbs, SEED1, 1n), assert the result is arbs.includes(result).

3.12 describe('selectLeader — empty arbiters') (AC#20)

  • selectLeader([], SEED1, 1n) throws VrfError.

3.13 describe('VrfProvider interface') (AC#21)

  • defaultVrf has eval and verify methods.
  • defaultVrf instanceof HmacVrfProvider is true.
  • vrfEval and defaultVrf.eval produce byte-identical output for same args.

3.14 describe('module-corpus forbidden-token scan') (AC#22, AC#23)

const src = readFileSync(
  join(dirname(fileURLToPath(import.meta.url)),
       '../../../domains/consensus/vrf-stub.ts'),
  'utf8',
);
const stripped = src
  .replace(/\/\*\*[\s\S]*?\*\//g, '')   // block JSDoc
  .replace(/\/\/.*$/gm, '');             // line comments

expect(stripped).not.toMatch(/\bMath\./);
expect(stripped).not.toMatch(/\bDate\./);
expect(stripped).not.toMatch(/\bcrypto\.\w/);
expect(stripped).not.toMatch(/\bNumber\(/);
expect(stripped).not.toMatch(/-?\d+\.\d+/);

// AC#23 — RFC 9381 disclosure must be in the header (not stripped).
expect(src).toMatch(/NOT RFC 9381/);
expect(src).toMatch(/externally verifiable/i);

§4. Test-budget estimate

Block Tests
vrfEval — shape 3
vrfEval — determinism 1
vrfEval — collision-free 1
vrfEval — key sensitivity 1
vrfEval — input validation 4
vrfVerify — roundtrip 1
vrfVerify — rejection 5
selectLeader — determinism + single 3
selectLeader — uniformity smoke 1
selectLeader — variable sizes 1
selectLeader — empty arbiters 1
VrfProvider interface 3
module-corpus forbidden-token scan 1

Total: ~26 tests. Within the +20–30 budget against baseline 2744 → target ~2770.

§5. Order of execution

  1. ✓ Step 1 — audit committed at ffc38127.
  2. ✓ Step 2 — contract committed at 80266e15.
  3. Step 3 — this packet (commit before implementation).
  4. Step 4 — create src/domains/consensus/vrf-stub.ts, then create the test file, then run npm run build && npm run lint && npm test. If green, commit. If red, iterate inside the same commit-message scope until green (no force-push, no amend).
  5. Step 5 — write docs/verification/p3-6-1-vrf-stub-verification.md referencing actual test counts + commit SHAs, commit.

§6. Gates

  • npm run build — TypeScript ES2022 ESM compile must succeed (no new type errors; pre-existing merkletreejs ambient-types warning is pre-baseline and survives).
  • npm run lint — ESLint must report zero errors and zero new warnings.
  • npm test — Jest must report:
    • 0 failures
    • tests run = baseline + delta (target ~2770)
    • no new flake (server-startup smoke is pre-existing and ignored).

§7. PR scope statement

Title: feat(p3-6-1-vrf-stub): HMAC-SHA256 VRF stub + VrfProvider interface (R89 θ Wave 3, ADR-002 Option A)

Body sections required:

  • Summary (2–3 sentences).
  • ADR-002 Option A disclosure (PROPOSED status, swap path documented).
  • Files changed.
  • Test delta.
  • Writeback block (task_id / branch / worktree / commits / tests / summary / blockers).

§8. Self-review

  • Diff scope locks to 2 new src files + 4 new docs.
  • No package.json edit (no new dep).
  • No MCP tool registration.
  • Test budget within +20–30 against baseline 2744.
  • Module-corpus self-scan in test corpus (mirrors gossip-wire).
  • Module-header RFC 9381 disclosure is test-asserted.
  • selectLeader modulo done in bigint to avoid Number( token.
  • Function wrappers (vrfEval, vrfVerify) delegate through defaultVrf — Phase 1.5 swap is one line.
  • VrfProvider interface implemented by HmacVrfProvider only; NobleCurvesVrfProvider is future work, not in this slice.

Approved (self) — ready for Step 4.


Back to top

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

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