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/curvesis 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.mdedit - 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/curvesECVRF).” - 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)whereseed_iisK1XOR’d with the index encoded as 32 bytes BE, andinput_iis the index encoded as 8 bytes BE. Collectoutput.toString('hex')into aSet<string>. Assertset.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)) throwsVrfError.
3.7 describe('vrfVerify — roundtrip') (AC#10)
vrfEval(SEED1, INPUT1, K1) → {output, proof}thenvrfVerify(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}"], callselectLeader(arbs, SEED1, 1n), assert the result isarbs.includes(result).
3.12 describe('selectLeader — empty arbiters') (AC#20)
selectLeader([], SEED1, 1n)throwsVrfError.
3.13 describe('VrfProvider interface') (AC#21)
defaultVrfhasevalandverifymethods.defaultVrf instanceof HmacVrfProvideristrue.vrfEvalanddefaultVrf.evalproduce 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
- ✓ Step 1 — audit committed at
ffc38127. - ✓ Step 2 — contract committed at
80266e15. - Step 3 — this packet (commit before implementation).
- Step 4 — create
src/domains/consensus/vrf-stub.ts, then create the test file, then runnpm 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). - Step 5 — write
docs/verification/p3-6-1-vrf-stub-verification.mdreferencing actual test counts + commit SHAs, commit.
§6. Gates
npm run build— TypeScript ES2022 ESM compile must succeed (no new type errors; pre-existingmerkletreejsambient-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.jsonedit (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.
selectLeadermodulo done in bigint to avoidNumber(token.- Function wrappers (
vrfEval,vrfVerify) delegate throughdefaultVrf— Phase 1.5 swap is one line. VrfProviderinterface implemented byHmacVrfProvideronly;NobleCurvesVrfProvideris future work, not in this slice.
Approved (self) — ready for Step 4.