From 9a2ebb1d315f99ae79df50e6af11f3a8e3a2db68 Mon Sep 17 00:00:00 2001 From: Ignat Date: Sat, 30 May 2026 00:58:42 -0300 Subject: [PATCH 1/2] feat(codec): freeze golden vectors as cross-impl oracle + CI freeze-gate (D1) - Introduce scripts/check-vectors.ts: codec-drift gate (WASM-derived vs committed vectors, vector-by-vector) + immutability gate (sha256 over committed vectors array = tamper detection). decode_* forward-compat fixtures are exempt from codec-drift check (not re-derived from builders). - Introduce scripts/run-check-vectors.test.ts: vitest wrapper with positive gate (freeze-gate must pass) + negative gate (mutated vector must be caught). - Introduce scripts/vlapp-provenance.test.ts: proves 21/21 encoder vectors are byte-identical to vl/app TS encoder at c658fff. Filters roundtrip===false vectors (decode-only BOLT-12 odd-ignore fixtures; verified by decoder tests). - Stamp content_hash in v4-codec.json (sha256 over committed vectors array). - Add freeze-gate step to lint-and-build CI job (self-contained, no vl/app checkout required). ts-rust-parity stays opt-in advisory (skipped != pass enforced in ci-gate via vars.TS_RUST_PARITY_ENABLED guard). - Drop stale malformed-unknown-tlv-tag scenario (odd tag now ignored per BOLT-12; even-tag rejection covered by separate decode_unknown_even_tag vector). - Add check-vectors pnpm script to package.json. --- .github/workflows/ci.yml | 2 + packages/codec/package.json | 1 + .../codec/scripts/check-vectors.config.ts | 30 +++ packages/codec/scripts/check-vectors.ts | 210 ++++++++++++++++++ .../codec/scripts/run-check-vectors.test.ts | 90 ++++++++ .../codec/scripts/run-freeze-write.test.ts | 32 +++ packages/codec/scripts/scenarios/malformed.ts | 36 --- .../codec/scripts/vlapp-provenance.config.ts | 29 +++ .../codec/scripts/vlapp-provenance.test.ts | 141 ++++++++++++ packages/codec/vectors/v4-codec.json | 123 +++++----- 10 files changed, 604 insertions(+), 90 deletions(-) create mode 100644 packages/codec/scripts/check-vectors.config.ts create mode 100644 packages/codec/scripts/check-vectors.ts create mode 100644 packages/codec/scripts/run-check-vectors.test.ts create mode 100644 packages/codec/scripts/run-freeze-write.test.ts create mode 100644 packages/codec/scripts/vlapp-provenance.config.ts create mode 100644 packages/codec/scripts/vlapp-provenance.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d92917..d6a1953 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,8 @@ jobs: - name: Assert size budgets run: bash scripts/assert-size.sh working-directory: packages/codec + - name: Freeze-gate (golden vector oracle check) + run: pnpm -C packages/codec check-vectors - run: | for dir in packages/codec packages/types packages/networks; do (cd "$dir" && npm pack --dry-run) diff --git a/packages/codec/package.json b/packages/codec/package.json index f4d7756..9e77546 100644 --- a/packages/codec/package.json +++ b/packages/codec/package.json @@ -57,6 +57,7 @@ "test:wasm": "wasm-pack test --node", "test:pack": "node scripts/test-pack.mjs", "generate-vectors": "vitest run --config scripts/generate-vectors.config.ts", + "check-vectors": "vitest run --config scripts/check-vectors.config.ts --coverage.enabled=false", "test:ts-rust-parity": "tsx scripts/ts-rust-parity.ts", "lint": "cargo clippy --all-targets --all-features --locked -- -D warnings && cargo fmt --manifest-path Cargo.toml -- --check && eslint src", "size": "ls -la pkg/void_layer_codec_bg.wasm" diff --git a/packages/codec/scripts/check-vectors.config.ts b/packages/codec/scripts/check-vectors.config.ts new file mode 100644 index 0000000..69ee123 --- /dev/null +++ b/packages/codec/scripts/check-vectors.config.ts @@ -0,0 +1,30 @@ +/** + * Vitest config for the freeze-gate vector checker. + * Used by: pnpm check-vectors (CI freeze-gate) + * + * Includes the run-check-vectors.test.ts wrapper which: + * - Runs the positive gate (codec output == frozen oracle) + * - Runs the negative test (mutated vector is caught) + * + * Coverage is disabled — this is a gate script, not a coverage target. + */ +import { defineConfig } from 'vitest/config' +import wasm from 'vite-plugin-wasm' +import topLevelAwait from 'vite-plugin-top-level-await' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) + +export default defineConfig({ + plugins: [wasm(), topLevelAwait()], + test: { + environment: 'node', + include: ['scripts/run-check-vectors.test.ts', 'scripts/run-freeze-write.test.ts'], + coverage: { enabled: false }, + }, + resolve: { + alias: { + 'brotli-wasm': require.resolve('brotli-wasm'), + }, + }, +}) diff --git a/packages/codec/scripts/check-vectors.ts b/packages/codec/scripts/check-vectors.ts new file mode 100644 index 0000000..7338a06 --- /dev/null +++ b/packages/codec/scripts/check-vectors.ts @@ -0,0 +1,210 @@ +/** + * Golden vector freeze-gate — @void-layer/codec + * + * Modes: + * --check (default / CI): re-derive vectors from the codec WASM and diff + * against the committed v4-codec.json. Returns a non-zero exit code + * on ANY mismatch. Never overwrites the file. + * --write (deliberate local re-capture only): same derivation, then writes + * the result to disk. Use only after a reviewed, intentional update + * to the frozen oracle (new TLV type, dict code, etc.). + * + * The frozen oracle was captured from vl/app master c658fff (release v1.1.2). + * Any change to canonical_hex or wire_hex is a regression against that deployed + * reference and must be reviewed by Kai before --write is used. + * + * Run (from packages/codec root): + * pnpm check-vectors # check mode (CI default) + * pnpm check-vectors -- --write # write mode (local only) + */ + +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as crypto from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { buildAllVectors } from './scenarios/all-vectors.js' +import { demoinvoiceVectors } from './scenarios/demo-invoices.js' +import type { NonMalformedVector } from './scenarios/non-malformed.js' +import type { MalformedVector } from './scenarios/malformed.js' + +const _filename = fileURLToPath(import.meta.url) +const _dirname = path.dirname(_filename) +const VECTORS_DIR = path.resolve(_dirname, '../vectors') +const OUT_PATH = path.join(VECTORS_DIR, 'v4-codec.json') + +// Provenance constants — update only on deliberate oracle re-capture. +const CAPTURED_FROM_VL_APP_SHA = 'c658fff' +const FROZEN_AT = '2026-05-30' + +type Vector = NonMalformedVector | MalformedVector + +/** + * Compute sha256 of the canonical vector payload (the "vectors" array JSON, + * without the header fields). This is the content_hash stored in the frozen JSON. + */ +export function computeContentHash(vectors: unknown[]): string { + const payload = JSON.stringify(vectors) + return crypto.createHash('sha256').update(payload).digest('hex') +} + +async function deriveVectors(): Promise { + return [ + ...(await buildAllVectors()), + ...(await demoinvoiceVectors()), + ] +} + +/** + * Run the freeze-gate check. Returns a list of mismatch descriptions. + * Empty list = PASS. Non-empty = FAIL. + * + * Exported so the vitest runner can assert without process.exit(). + */ +export async function runFreezeCheck(): Promise { + const derived = await deriveVectors() + const mismatches: string[] = [] + + if (!fs.existsSync(OUT_PATH)) { + return ['vectors/v4-codec.json does not exist — run pnpm check-vectors -- --write'] + } + + const committed = JSON.parse(fs.readFileSync(OUT_PATH, 'utf-8')) as { + vectors: Vector[] + content_hash?: string + frozen?: boolean + captured_from_vl_app_sha?: string + } + + const committedVectors = committed.vectors ?? [] + const derivedByName = new Map(derived.map((v) => [v.name, v])) + const committedByName = new Map(committedVectors.map((v) => [v.name, v])) + + // Check every derived vector against committed + for (const [name, derivedV] of derivedByName) { + const committedV = committedByName.get(name) + if (!committedV) { + mismatches.push(`MISSING_IN_COMMITTED: vector=${name}`) + continue + } + + const dv = derivedV as NonMalformedVector + const cv = committedV as NonMalformedVector + + if ('canonical_hex' in dv && 'canonical_hex' in cv) { + if (dv.canonical_hex !== cv.canonical_hex) { + mismatches.push( + `CANONICAL_MISMATCH vector=${name}\n` + + ` committed: ${cv.canonical_hex}\n` + + ` derived: ${dv.canonical_hex}`, + ) + } + } + if ('wire_hex' in dv && 'wire_hex' in cv) { + if (dv.wire_hex !== cv.wire_hex) { + mismatches.push( + `WIRE_MISMATCH vector=${name}\n` + + ` committed: ${cv.wire_hex}\n` + + ` derived: ${dv.wire_hex}`, + ) + } + } + if ('receipt_hash_hex' in dv && 'receipt_hash_hex' in cv) { + if (dv.receipt_hash_hex !== cv.receipt_hash_hex) { + mismatches.push( + `RECEIPT_HASH_MISMATCH vector=${name}\n` + + ` committed: ${cv.receipt_hash_hex}\n` + + ` derived: ${dv.receipt_hash_hex}`, + ) + } + } + } + + // Check for vectors in committed but not in derived. + // decode_* vectors are static forward-compat fixtures not re-derived from the + // codec scenario builders — intentionally present only in the JSON oracle. + for (const name of committedByName.keys()) { + if (!derivedByName.has(name) && !name.startsWith('decode_')) { + mismatches.push(`EXTRA_IN_COMMITTED: vector=${name} (not in derived set — stale?)`) + } + } + + // Immutability gate: sha256 is computed over the COMMITTED vectors array as + // stored in the frozen JSON — not over WASM-derived output. decode-only + // fixtures (roundtrip===false) are part of the frozen oracle and must be + // included in the hash. This detects any tampering with the committed file. + if (committed.content_hash) { + const expectedHash = computeContentHash(committedVectors) + if (committed.content_hash !== expectedHash) { + mismatches.push( + `CONTENT_HASH_MISMATCH (tamper detected)\n` + + ` committed: ${committed.content_hash}\n` + + ` recomputed: ${expectedHash}`, + ) + } + } + + return mismatches +} + +/** + * Write mode: re-derive and stamp the oracle with provenance headers. + * Only called with explicit --write flag. + */ +export async function runWrite(): Promise { + const derived = await deriveVectors() + const content_hash = computeContentHash(derived) + const output = { + schema_version: 1, + frozen: true, + captured_from_vl_app_sha: CAPTURED_FROM_VL_APP_SHA, + frozen_at: FROZEN_AT, + content_hash, + generated_by: '@void-layer/codec v0.1.0', + vectors: derived, + } + fs.mkdirSync(VECTORS_DIR, { recursive: true }) + fs.writeFileSync(OUT_PATH, JSON.stringify(output, null, 2) + '\n') + console.log(`[freeze-gate] wrote ${derived.length} vectors → ${OUT_PATH}`) + console.log(`[freeze-gate] content_hash: ${content_hash}`) +} + +// --------------------------------------------------------------------------- +// CLI entry point (direct invocation only — not called when imported as module) +// --------------------------------------------------------------------------- + +async function main(): Promise { + const args = process.argv.slice(2) + const writeMode = args.includes('--write') + + if (writeMode) { + console.log(`\n[freeze-gate] mode=write`) + console.log(`[freeze-gate] oracle: vectors/v4-codec.json`) + console.log(`[freeze-gate] captured_from_vl_app_sha: ${CAPTURED_FROM_VL_APP_SHA}\n`) + await runWrite() + return + } + + console.log(`\n[freeze-gate] mode=check`) + console.log(`[freeze-gate] oracle: vectors/v4-codec.json`) + console.log(`[freeze-gate] captured_from_vl_app_sha: ${CAPTURED_FROM_VL_APP_SHA}\n`) + + const mismatches = await runFreezeCheck() + + if (mismatches.length > 0) { + console.error('[freeze-gate] FAIL: v4-codec.json has drifted from codec output\n') + for (const m of mismatches) console.error(` ${m}\n`) + console.error('\nTo re-capture: pnpm check-vectors -- --write (requires Kai review)') + process.exit(1) + } + + console.log(`[freeze-gate] PASS — all vectors match committed oracle`) +} + +// Only run main() when this file is the direct entrypoint, not when imported as a module. +// Vitest imports this as a module to call runFreezeCheck()/runWrite() directly. +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((err) => { + console.error('[freeze-gate] fatal:', err) + process.exit(1) + }) +} diff --git a/packages/codec/scripts/run-check-vectors.test.ts b/packages/codec/scripts/run-check-vectors.test.ts new file mode 100644 index 0000000..5f514b5 --- /dev/null +++ b/packages/codec/scripts/run-check-vectors.test.ts @@ -0,0 +1,90 @@ +/** + * Vitest wrapper — runs the freeze-gate checker as a test so vitest's + * module resolver (brotli-wasm alias, wasm plugin) are active. + * + * CI usage: + * pnpm -C packages/codec exec vitest run scripts/run-check-vectors.test.ts --coverage.enabled=false + * + * This test FAILS if any derived vector byte differs from the committed oracle. + * A failure here means @void-layer/codec output has drifted from the frozen + * vl/app reference (captured at c658fff). Do NOT suppress — escalate to Kai. + * + * NEGATIVE TEST: a deliberately mutated single byte also lives here to prove + * the gate has teeth (see "freeze-gate rejects mutated vector"). + */ +import { test, expect } from 'vitest' +import * as fs from 'node:fs' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' +import { runFreezeCheck } from './check-vectors.js' + +const _dirname = path.dirname(fileURLToPath(import.meta.url)) +const VECTORS_PATH = path.resolve(_dirname, '../vectors/v4-codec.json') + +// --------------------------------------------------------------------------- +// Positive test — codec output must match frozen oracle +// --------------------------------------------------------------------------- + +test( + 'freeze-gate: codec output matches committed v4-codec.json (oracle integrity)', + async () => { + const mismatches = await runFreezeCheck() + if (mismatches.length > 0) { + // Surface all mismatches in one assertion so CI output is actionable. + throw new Error( + `[freeze-gate] ${mismatches.length} mismatch(es) detected:\n\n` + + mismatches.map((m) => ` ${m}`).join('\n\n') + + '\n\nTo re-capture: pnpm check-vectors -- --write (requires Kai review)', + ) + } + }, + 120_000, +) + +// --------------------------------------------------------------------------- +// Negative test — proves the gate has teeth: a mutated byte must be caught +// --------------------------------------------------------------------------- + +test('freeze-gate rejects mutated vector (gate has teeth)', async () => { + if (!fs.existsSync(VECTORS_PATH)) { + // Skip gracefully if oracle is absent (fresh checkout pre-write) + return + } + + const original = fs.readFileSync(VECTORS_PATH, 'utf-8') + const parsed = JSON.parse(original) as { + vectors: Array<{ name: string; canonical_hex?: string; [k: string]: unknown }> + } + + // Find first non-malformed vector with a canonical_hex to mutate + const target = parsed.vectors.find((v) => typeof v.canonical_hex === 'string') + if (!target || typeof target.canonical_hex !== 'string') { + throw new Error('No non-malformed vector found to mutate — test is invalid') + } + + // Flip one nibble at position 4 (after the magic+version bytes) + const original_hex = target.canonical_hex + const mutated_hex = + original_hex.slice(0, 4) + + // XOR the nibble at position 4 with 0x1 to ensure it changes + (parseInt(original_hex[4]!, 16) ^ 1).toString(16) + + original_hex.slice(5) + target.canonical_hex = mutated_hex + + const mutatedPath = path.resolve(_dirname, '../vectors/v4-codec-mutated-test.json') + fs.writeFileSync(mutatedPath, JSON.stringify(parsed, null, 2)) + + try { + // Temporarily point the checker at the mutated file by monkey-patching + // the environment. We do this by writing the mutated file over the real + // path, running the check, then restoring. + fs.writeFileSync(VECTORS_PATH, JSON.stringify(parsed, null, 2)) + const mismatches = await runFreezeCheck() + expect(mismatches.length).toBeGreaterThan(0) + expect(mismatches.some((m) => m.includes('CANONICAL_MISMATCH'))).toBe(true) + } finally { + // Always restore the original file + fs.writeFileSync(VECTORS_PATH, original) + fs.rmSync(mutatedPath, { force: true }) + } +}) diff --git a/packages/codec/scripts/run-freeze-write.test.ts b/packages/codec/scripts/run-freeze-write.test.ts new file mode 100644 index 0000000..adc3cf4 --- /dev/null +++ b/packages/codec/scripts/run-freeze-write.test.ts @@ -0,0 +1,32 @@ +/** + * One-shot oracle freeze writer. + * + * Rewrites vectors/v4-codec.json with provenance headers and the current + * codec's derived bytes. Run ONLY when making a deliberate, reviewed update + * to the frozen oracle (e.g. adding a new TLV type, new dict code). + * + * Usage (from packages/codec root): + * FREEZE_GATE_WRITE=1 pnpm exec vitest run scripts/run-freeze-write.test.ts --config scripts/check-vectors.config.ts + * + * After running, verify with: + * pnpm check-vectors + * + * Requires Kai review before merging any oracle update. + */ +import { test } from 'vitest' +import { runWrite } from './check-vectors.js' + +test( + 'freeze-write: stamp v4-codec.json with provenance headers', + async () => { + if (!process.env['FREEZE_GATE_WRITE']) { + console.log( + '[freeze-write] Skipped — set FREEZE_GATE_WRITE=1 to actually write the oracle', + ) + return + } + await runWrite() + console.log('[freeze-write] Oracle written successfully') + }, + 120_000, +) diff --git a/packages/codec/scripts/scenarios/malformed.ts b/packages/codec/scripts/scenarios/malformed.ts index e12c143..ab0e07c 100644 --- a/packages/codec/scripts/scenarios/malformed.ts +++ b/packages/codec/scripts/scenarios/malformed.ts @@ -139,42 +139,6 @@ export function buildLateMalformedVectors(): MalformedVector[] { }) } - // 7a. malformed-unknown-tlv-tag - { - const minHex = - '56010d0202000104046553f100060380a3050801060a14d8da6bf26964af9d7eed9e03e53415d37aa960450c0200010e10010a436f6e73756c74696e67000101061005416c6963651203426f621410deadbeefdeadbeefdeadbeefdeadbeef1607494e562d303031180201061f20e7620cf63c7f087f05bd266fba981b1e79c3697a22fcaf710f6c2b69db868be5' - const minBytes = new Uint8Array(Buffer.from(minHex, 'hex')) - - const contentRecords = new Map() - let off = 3 - while (off < minBytes.length) { - const tag = minBytes[off]! - off++ - let len = 0 - let shift = 0 - while (true) { - const b = minBytes[off]! - off++ - len |= (b & 0x7f) << shift - shift += 7 - if (!(b & 0x80)) break - } - const val = minBytes.slice(off, off + len) - off += len - if (tag !== 31) contentRecords.set(tag, val) - } - - contentRecords.set(99, new Uint8Array([0xde, 0xad])) - - const payload = buildCanonicalPayload(contentRecords) - vectors.push({ - name: 'malformed-unknown-tlv-tag', - canonical_hex: toHex(payload), - diagnostic: 'malformed:canonical', - expected_error: 'UnknownExtension', - }) - } - // 7b. malformed-duplicate-tlv-tag { const minHex = diff --git a/packages/codec/scripts/vlapp-provenance.config.ts b/packages/codec/scripts/vlapp-provenance.config.ts new file mode 100644 index 0000000..52968f3 --- /dev/null +++ b/packages/codec/scripts/vlapp-provenance.config.ts @@ -0,0 +1,29 @@ +/** + * Vitest config for vl/app provenance verification. + * Adds @/ → voidpay/src alias so vl/app's encode.ts can be imported directly. + * brotli-wasm is aliased to Node variant (same as main vitest.config.ts). + */ +import { defineConfig } from 'vitest/config' +import wasm from 'vite-plugin-wasm' +import topLevelAwait from 'vite-plugin-top-level-await' +import { createRequire } from 'node:module' +import * as path from 'node:path' + +const require = createRequire(import.meta.url) + +const VOIDPAY_SRC = path.resolve('/Users/ignat/code/voidpay/src') + +export default defineConfig({ + plugins: [wasm(), topLevelAwait()], + test: { + environment: 'node', + include: ['scripts/vlapp-provenance.test.ts'], + coverage: { enabled: false }, + }, + resolve: { + alias: { + 'brotli-wasm': require.resolve('brotli-wasm'), + '@': VOIDPAY_SRC, + }, + }, +}) diff --git a/packages/codec/scripts/vlapp-provenance.test.ts b/packages/codec/scripts/vlapp-provenance.test.ts new file mode 100644 index 0000000..a56da7a --- /dev/null +++ b/packages/codec/scripts/vlapp-provenance.test.ts @@ -0,0 +1,141 @@ +/** + * vl/app provenance verification test. + * + * Proves that committed canonical_hex / wire_hex in v4-codec.json were + * genuinely captured from vl/app's TS encoder at master c658fff. + * + * Method: imports vl/app encode.ts directly (via @/ alias → voidpay/src), + * encodes each non-malformed vector's decoded invoice with its deterministic + * salt, decompresses the wire output to recover canonical bytes, and compares + * against the committed hex strings. + * + * Run (from packages/codec): + * pnpm exec vitest run scripts/vlapp-provenance.test.ts --config scripts/vlapp-provenance.config.ts + */ + +import { test } from 'vitest' +import * as fs from 'node:fs' +import { encodeInvoice } from '@/features/invoice-codec/lib/encode.js' +import { decompressPayload, decodeBase64url } from '@/shared/lib/tlv-codec/index.js' + +const VECTORS_PATH = new URL('../vectors/v4-codec.json', import.meta.url).pathname + +function toHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +function hexToBytes(hex: string): Uint8Array { + const b = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) b[i / 2] = parseInt(hex.slice(i, i + 2), 16) + return b +} + +function vectorDecodedToInvoice(d: Record) { + const from = d['from'] as Record + const client = d['client'] as Record + const items = d['items'] as Array> + return { + invoiceId: d['invoice_id'] as string, + issuedAt: d['issued_at'] as number, + dueAt: d['due_at'] as number, + networkId: d['network_id'] as number, + currency: d['currency'] as string, + decimals: d['decimals'] as number, + tokenAddress: d['token_address'] as string | undefined, + from: { + name: from['name'] as string, + walletAddress: from['wallet_address'] as string, + email: from['email'] as string | undefined, + phone: from['phone'] as string | undefined, + physicalAddress: from['physical_address'] as string | undefined, + taxId: from['tax_id'] as string | undefined, + }, + client: { + name: client['name'] as string, + walletAddress: client['wallet_address'] as string | undefined, + email: client['email'] as string | undefined, + phone: client['phone'] as string | undefined, + physicalAddress: client['physical_address'] as string | undefined, + taxId: client['tax_id'] as string | undefined, + }, + items: items.map((i) => ({ + description: i['description'] as string, + quantity: i['quantity'] as number, + rate: i['rate'] as string, + })), + notes: d['notes'] as string | undefined, + tax: d['tax'] as string | undefined, + discount: d['discount'] as string | undefined, + total: d['total'] as string, + } +} + +const json = JSON.parse(fs.readFileSync(VECTORS_PATH, 'utf-8')) as { + captured_from_vl_app_sha: string + vectors: Array<{ + name: string + canonical_hex?: string + wire_hex?: string + decoded?: Record | null + diagnostic?: string + roundtrip?: boolean + }> +} + +// Encoder-provable vectors: have canonical bytes, a decoded invoice to re-encode, +// are not malformed fixtures, and are not decode-only forward-compat fixtures +// (roundtrip===false means the encoder never emits this wire — correct by design, +// per BOLT-12 odd-ignore and strict-monotone rules; verified by DECODER tests only). +const nonMalformed = json.vectors.filter( + (v) => + v.canonical_hex && + v.decoded && + !v.diagnostic?.startsWith('malformed') && + v.roundtrip !== false, +) + +test( + `vl/app provenance: all ${nonMalformed.length} non-malformed vectors match vl/app TS encoder (sha=${json.captured_from_vl_app_sha})`, + async () => { + const mismatches: string[] = [] + + for (const v of nonMalformed) { + const d = v.decoded! + const salt = hexToBytes(d['salt'] as string) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const invoice = vectorDecodedToInvoice(d) as any + + const wireB64 = await encodeInvoice(invoice, salt) + const wireBytes = decodeBase64url(wireB64) + const wire_hex = toHex(wireBytes) + const canonicalBytes = await decompressPayload(wireBytes) + const canonical_hex = toHex(canonicalBytes) + + if (canonical_hex !== v.canonical_hex) { + mismatches.push( + `CANONICAL_MISMATCH vector=${v.name}\n` + + ` committed: ${v.canonical_hex}\n` + + ` derived: ${canonical_hex}`, + ) + } + if (wire_hex !== v.wire_hex) { + mismatches.push( + `WIRE_MISMATCH vector=${v.name}\n` + + ` committed: ${v.wire_hex}\n` + + ` derived: ${wire_hex}`, + ) + } + } + + if (mismatches.length > 0) { + throw new Error( + `[vl/app provenance] ${mismatches.length} mismatch(es) — committed hex ≠ vl/app TS output:\n\n` + + mismatches.join('\n\n') + + '\n\nProvenance stamp is INVALID — escalate to Kai before committing.', + ) + } + }, + 120_000, +) diff --git a/packages/codec/vectors/v4-codec.json b/packages/codec/vectors/v4-codec.json index bc093d6..097281d 100644 --- a/packages/codec/vectors/v4-codec.json +++ b/packages/codec/vectors/v4-codec.json @@ -1,7 +1,10 @@ { "schema_version": 1, + "frozen": true, + "captured_from_vl_app_sha": "c658fff", + "frozen_at": "2026-05-30", + "content_hash": "00708f3bd86ecf1a45efcc1f9a9bc07fd5547150c7ab24bb0c9141464e948311", "generated_by": "@void-layer/codec v0.1.0", - "generated_at": "2026-05-25", "vectors": [ { "name": "minimal-single-tlv", @@ -33,7 +36,7 @@ "salt": "deadbeefdeadbeefdeadbeefdeadbeef" }, "roundtrip": true, - "diagnostic": "Smallest valid invoice — all required fields, one item, no optional fields. wire_hex = Brotli-compressed wire, or == canonical_hex when Brotli expands (small payloads)" + "diagnostic": "Smallest valid invoice \u2014 all required fields, one item, no optional fields. wire_hex = Brotli-compressed wire, or == canonical_hex when Brotli expands (small payloads)" }, { "name": "chain-ethereum", @@ -289,7 +292,7 @@ "salt": "deadbeefdeadbeefdeadbeefdeadbeef" }, "roundtrip": true, - "diagnostic": "BigInt edge: total = U256::MAX (115792089237316195423570985008687907853269984665640564039457584007913129639935) — largest encodable value after U256 widening. wire_hex = Brotli-compressed wire, or == canonical_hex when Brotli expands (small payloads)" + "diagnostic": "BigInt edge: total = U256::MAX (115792089237316195423570985008687907853269984665640564039457584007913129639935) \u2014 largest encodable value after U256 widening. wire_hex = Brotli-compressed wire, or == canonical_hex when Brotli expands (small payloads)" }, { "name": "bigint-amount-over-u256", @@ -432,7 +435,7 @@ "salt": "deadbeefdeadbeefdeadbeefdeadbeef" }, "roundtrip": true, - "diagnostic": "Extension: sub-invoice chain — ETH on Arbitrum with tax and discount fields. wire_hex = Brotli-compressed wire, or == canonical_hex when Brotli expands (small payloads)" + "diagnostic": "Extension: sub-invoice chain \u2014 ETH on Arbitrum with tax and discount fields. wire_hex = Brotli-compressed wire, or == canonical_hex when Brotli expands (small payloads)" }, { "name": "unicode-cyrillic", @@ -447,20 +450,20 @@ "currency": "USDC", "decimals": 6, "from": { - "name": "Алиса Разработчик", + "name": "\u0410\u043b\u0438\u0441\u0430 \u0420\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a", "wallet_address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" }, "client": { - "name": "Боб Клиент" + "name": "\u0411\u043e\u0431 \u041a\u043b\u0438\u0435\u043d\u0442" }, "items": [ { - "description": "Консультационные услуги", + "description": "\u041a\u043e\u043d\u0441\u0443\u043b\u044c\u0442\u0430\u0446\u0438\u043e\u043d\u043d\u044b\u0435 \u0443\u0441\u043b\u0443\u0433\u0438", "quantity": 1, "rate": "2000000" } ], - "notes": "Оплата в течение 30 дней", + "notes": "\u041e\u043f\u043b\u0430\u0442\u0430 \u0432 \u0442\u0435\u0447\u0435\u043d\u0438\u0435 30 \u0434\u043d\u0435\u0439", "total": "2000000", "salt": "deadbeefdeadbeefdeadbeefdeadbeef" }, @@ -488,12 +491,12 @@ }, "items": [ { - "description": "软件开发咨询服务", + "description": "\u8f6f\u4ef6\u5f00\u53d1\u54a8\u8be2\u670d\u52a1", "quantity": 1, "rate": "3000000" } ], - "notes": "請在30天內付款。感謝您的支持。", + "notes": "\u8acb\u572830\u5929\u5167\u4ed8\u6b3e\u3002\u611f\u8b1d\u60a8\u7684\u652f\u6301\u3002", "total": "3000000", "salt": "deadbeefdeadbeefdeadbeefdeadbeef" }, @@ -513,7 +516,7 @@ "currency": "USDC", "decimals": 6, "from": { - "name": "Alice 🚀", + "name": "Alice \ud83d\ude80", "wallet_address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" }, "client": { @@ -526,12 +529,12 @@ "rate": "5000000" } ], - "notes": "✅ Payment confirmed 🎉 Thank you! 💎", + "notes": "\u2705 Payment confirmed \ud83c\udf89 Thank you! \ud83d\udc8e", "total": "5000000", "salt": "deadbeefdeadbeefdeadbeefdeadbeef" }, "roundtrip": true, - "diagnostic": "Unicode: emoji (4-byte UTF-8 surrogate pairs) in from.name and notes. Codec treats as bytes — no normalization. wire_hex = Brotli-compressed wire, or == canonical_hex when Brotli expands (small payloads)" + "diagnostic": "Unicode: emoji (4-byte UTF-8 surrogate pairs) in from.name and notes. Codec treats as bytes \u2014 no normalization. wire_hex = Brotli-compressed wire, or == canonical_hex when Brotli expands (small payloads)" }, { "name": "unicode-rtl", @@ -546,7 +549,7 @@ "currency": "USDC", "decimals": 6, "from": { - "name": "أليس المطور", + "name": "\u0623\u0644\u064a\u0633 \u0627\u0644\u0645\u0637\u0648\u0631", "wallet_address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" }, "client": { @@ -554,17 +557,17 @@ }, "items": [ { - "description": "خدمات استشارية", + "description": "\u062e\u062f\u0645\u0627\u062a \u0627\u0633\u062a\u0634\u0627\u0631\u064a\u0629", "quantity": 1, "rate": "1500000" } ], - "notes": "يرجى الدفع خلال 30 يوماً", + "notes": "\u064a\u0631\u062c\u0649 \u0627\u0644\u062f\u0641\u0639 \u062e\u0644\u0627\u0644 30 \u064a\u0648\u0645\u0627\u064b", "total": "1500000", "salt": "deadbeefdeadbeefdeadbeefdeadbeef" }, "roundtrip": true, - "diagnostic": "Unicode: Arabic RTL (2-4 byte UTF-8) in from.name, description, notes. Codec treats as opaque bytes — no reorder or normalize. wire_hex = Brotli-compressed wire, or == canonical_hex when Brotli expands (small payloads)" + "diagnostic": "Unicode: Arabic RTL (2-4 byte UTF-8) in from.name, description, notes. Codec treats as opaque bytes \u2014 no reorder or normalize. wire_hex = Brotli-compressed wire, or == canonical_hex when Brotli expands (small payloads)" }, { "name": "unicode-mixed", @@ -579,20 +582,20 @@ "currency": "USDC", "decimals": 6, "from": { - "name": "Alice 🌍", + "name": "Alice \ud83c\udf0d", "wallet_address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" }, "client": { - "name": "Боб / 鲍勃" + "name": "\u0411\u043e\u0431 / \u9c8d\u52c3" }, "items": [ { - "description": "咨询服务 / Consulting / Консультации", + "description": "\u54a8\u8be2\u670d\u52a1 / Consulting / \u041a\u043e\u043d\u0441\u0443\u043b\u044c\u0442\u0430\u0446\u0438\u0438", "quantity": 1, "rate": "4000000" } ], - "notes": "Mixed: Кириллица + 中文 + العربية + emoji 🎯", + "notes": "Mixed: \u041a\u0438\u0440\u0438\u043b\u043b\u0438\u0446\u0430 + \u4e2d\u6587 + \u0627\u0644\u0639\u0631\u0628\u064a\u0629 + emoji \ud83c\udfaf", "total": "4000000", "salt": "deadbeefdeadbeefdeadbeefdeadbeef" }, @@ -626,40 +629,20 @@ { "name": "malformed-non-canonical-varint", "canonical_hex": "56018000", - "diagnostic": "malformed:canonical — LEB128 varint [0x80, 0x00] encodes value 0 with a spurious continuation byte; canonical form requires the shortest encoding (single 0x00 byte). Decoder must reject.", + "diagnostic": "malformed:canonical \u2014 LEB128 varint [0x80, 0x00] encodes value 0 with a spurious continuation byte; canonical form requires the shortest encoding (single 0x00 byte). Decoder must reject.", "expected_error": "Truncated" }, { - "name": "decode_unknown_odd_tag_in_full_invoice", - "canonical_hex": "56010e0202000104046553f100060380a3050801060a14d8da6bf26964af9d7eed9e03e53415d37aa960450c0200010e10010a436f6e73756c74696e67000101061005416c6963651203426f621410deadbeefdeadbeefdeadbeefdeadbeef1607494e562d303031180201061f2037e4c5c28ca49a1e1c851f159401a9a27812e1ecca7a63d4d4d0046dfe14b0652702dead", - "wire_hex": "56010e0202000104046553f100060380a3050801060a14d8da6bf26964af9d7eed9e03e53415d37aa960450c0200010e10010a436f6e73756c74696e67000101061005416c6963651203426f621410deadbeefdeadbeefdeadbeefdeadbeef1607494e562d303031180201061f2037e4c5c28ca49a1e1c851f159401a9a27812e1ecca7a63d4d4d0046dfe14b0652702dead", - "receipt_hash_hex": "81adb55e8e4e566d386b320fe0bf942ff54e8a1e75f41a4781b870937d95d364", - "decoded": { - "invoice_id": "INV-001", - "issued_at": 1700000000, - "due_at": 1700086400, - "network_id": 1, - "currency": "USDC", - "decimals": 6, - "from": { - "name": "Alice", - "wallet_address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" - }, - "client": { - "name": "Bob" - }, - "items": [ - { - "description": "Consulting", - "quantity": 1, - "rate": "1000000" - } - ], - "total": "1000000", - "salt": "deadbeefdeadbeefdeadbeefdeadbeef" - }, - "roundtrip": false, - "diagnostic": "Y1 forward-compat: unknown odd TLV tag 39 (0x27, value=0xDEAD) embedded in a full invoice is silently ignored per BOLT-12 odd/even rule. Domain separator (TLV type 31) is computed over ALL TLV bytes including the odd-tag bytes, excluding type 31 itself. Decoder produces the original Invoice struct unchanged. Decision: codec-bolt12-odd-even-forward-compat." + "name": "malformed-unknown-content-tag", + "canonical_hex": "56010e0202000104046553f100060380a3050801060a14d8da6bf26964af9d7eed9e03e53415d37aa960450c0200010e10010a436f6e73756c74696e67000101061005416c6963651203426f621410deadbeefdeadbeefdeadbeefdeadbeef1607494e562d303031180201061f20e7620cf63c7f087f05bd266fba981b1e79c3697a22fcaf710f6c2b69db868be52702dead", + "diagnostic": "malformed:canonical \u2014 TLV tag 39 (0x27) is outside the v1 KNOWN_TAGS set. Decoder must reject with UnknownExtension before checksum validation.", + "expected_error": "UnknownExtension" + }, + { + "name": "malformed-unknown-content-tag", + "canonical_hex": "56010e0202000104046553f100060380a3050801060a14d8da6bf26964af9d7eed9e03e53415d37aa960450c0200010e10010a436f6e73756c74696e67000101061005416c6963651203426f621410deadbeefdeadbeefdeadbeefdeadbeef1607494e562d303031180201061f20e7620cf63c7f087f05bd266fba981b1e79c3697a22fcaf710f6c2b69db868be52702dead", + "diagnostic": "malformed:canonical \u2014 TLV tag 39 (0x27) is outside the v1 KNOWN_TAGS set. Decoder must reject with UnknownExtension before checksum validation.", + "expected_error": "UnknownExtension" }, { "name": "demo-landing-eth-001", @@ -954,6 +937,38 @@ "roundtrip": true, "diagnostic": "Video demo: Base (chain 8453), USDC, VoidPay treasury, total includes Magic Dust (+187 atomic units). wire_hex = Brotli-compressed wire, or == canonical_hex when Brotli expands (small payloads)" }, + { + "name": "decode_unknown_odd_tag_in_full_invoice", + "canonical_hex": "56010e0202000104046553f100060380a3050801060a14d8da6bf26964af9d7eed9e03e53415d37aa960450c0200010e10010a436f6e73756c74696e67000101061005416c6963651203426f621410deadbeefdeadbeefdeadbeefdeadbeef1607494e562d303031180201061f2037e4c5c28ca49a1e1c851f159401a9a27812e1ecca7a63d4d4d0046dfe14b0652702dead", + "wire_hex": "56010e0202000104046553f100060380a3050801060a14d8da6bf26964af9d7eed9e03e53415d37aa960450c0200010e10010a436f6e73756c74696e67000101061005416c6963651203426f621410deadbeefdeadbeefdeadbeefdeadbeef1607494e562d303031180201061f2037e4c5c28ca49a1e1c851f159401a9a27812e1ecca7a63d4d4d0046dfe14b0652702dead", + "receipt_hash_hex": "81adb55e8e4e566d386b320fe0bf942ff54e8a1e75f41a4781b870937d95d364", + "decoded": { + "invoice_id": "INV-001", + "issued_at": 1700000000, + "due_at": 1700086400, + "network_id": 1, + "currency": "USDC", + "decimals": 6, + "from": { + "name": "Alice", + "wallet_address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045" + }, + "client": { + "name": "Bob" + }, + "items": [ + { + "description": "Consulting", + "quantity": 1, + "rate": "1000000" + } + ], + "total": "1000000", + "salt": "deadbeefdeadbeefdeadbeefdeadbeef" + }, + "roundtrip": false, + "diagnostic": "Y1 forward-compat: unknown odd TLV tag 39 (0x27, value=0xDEAD) embedded in a full invoice is silently ignored per BOLT-12 odd/even rule. Domain separator (TLV type 31) is computed over ALL TLV bytes including the odd-tag bytes, excluding type 31 itself. Decoder produces the original Invoice struct unchanged. Decision: codec-bolt12-odd-even-forward-compat." + }, { "name": "decode_unknown_odd_tag_ignored", "canonical_hex": "56010e0202000104046553f100060380f5240801060a14aabbccddee0011223344556677889900aabbccdd0c0200010e0a0104576f726b000101061005416c6963651203426f62141000112233445566778899aabbccddeeff160a494e562d59312d4f4444180201061f209de8af9d9acf4160b757b5cbab1c7cc689d59faa005d4c7d9236c1391b1670bd2702dead", @@ -994,7 +1009,7 @@ "decoded": null, "roundtrip": false, "expected_error": "UnknownExtension(26)", - "diagnostic": "Y1 forward-compat: unknown even tag 26 (value=0xBEEF) must be rejected with UnknownExtension(26). Even tags = mandatory schema change — decoder MUST fail if unknown (schema_version bump required). Decision: codec-bolt12-odd-even-forward-compat." + "diagnostic": "Y1 forward-compat: unknown even tag 26 (value=0xBEEF) must be rejected with UnknownExtension(26). Even tags = mandatory schema change \u2014 decoder MUST fail if unknown (schema_version bump required). Decision: codec-bolt12-odd-even-forward-compat." }, { "name": "decode_non_monotone_rejected", @@ -1004,7 +1019,7 @@ "decoded": null, "roundtrip": false, "expected_error": "InvalidData(\"non-monotone TLV stream\")", - "diagnostic": "Y2 strict-monotone: TLV stream with tags [5, 3, 7] — tag 3 appears after tag 5, violating strict-monotone ordering. Decoder must reject with InvalidData. Pre-fix: BTreeMap silently re-canonicalized to {3,5,7} and accepted the wire. Decision: codec-bolt12-strict-monotone-decode." + "diagnostic": "Y2 strict-monotone: TLV stream with tags [5, 3, 7] \u2014 tag 3 appears after tag 5, violating strict-monotone ordering. Decoder must reject with InvalidData. Pre-fix: BTreeMap silently re-canonicalized to {3,5,7} and accepted the wire. Decision: codec-bolt12-strict-monotone-decode." } ] } From a8246730ed1eef1e471361c4ed06d2e735195195 Mon Sep 17 00:00:00 2001 From: Ignat Date: Sat, 30 May 2026 01:26:14 -0300 Subject: [PATCH 2/2] fix(codec): immutable provenance record + freeze-gate review fixes (A2 P1/P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: add vectors/PROVENANCE.md as an immutable audit record (capture SHA c658fff, 21/21 vectors, codec commit 285dd4b, date 2026-05-30); wire runFreezeCheck() to accept an optional committedPath so the negative test no longer overwrites the live oracle; add VOIDPAY_SRC env-var support to vlapp-provenance.config.ts with LOCAL-ONLY header comment. P2-1: reword CONTENT_HASH_MISMATCH comment — integrity checksum against accidental corruption, not tamper-proof security boundary; note that real cross-impl identity is enforced by the codec-drift gate. P2-2: align ts-rust-parity CI job pin to c658fff (was e4926b7) — single provenance reference across oracle + CI. P2-3: negative freeze-gate test now writes mutated content to a temp dir via mkdtemp and passes it to runFreezeCheck(mutatedPath) — a process kill no longer corrupts v4-codec.json. --- .github/workflows/ci.yml | 2 +- packages/codec/scripts/check-vectors.ts | 15 +++++--- .../codec/scripts/run-check-vectors.test.ts | 16 ++++---- .../codec/scripts/vlapp-provenance.config.ts | 8 +++- packages/codec/vectors/PROVENANCE.md | 37 +++++++++++++++++++ 5 files changed, 61 insertions(+), 17 deletions(-) create mode 100644 packages/codec/vectors/PROVENANCE.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6a1953..875ffef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: ignromanov/voidpay - ref: e4926b7f7b08ca4f72b707df8796bfd4a4b0a3b3 + ref: c658fff token: ${{ secrets.VOIDPAY_READ_TOKEN }} path: vl-app sparse-checkout: | diff --git a/packages/codec/scripts/check-vectors.ts b/packages/codec/scripts/check-vectors.ts index 7338a06..d7ff828 100644 --- a/packages/codec/scripts/check-vectors.ts +++ b/packages/codec/scripts/check-vectors.ts @@ -60,15 +60,15 @@ async function deriveVectors(): Promise { * * Exported so the vitest runner can assert without process.exit(). */ -export async function runFreezeCheck(): Promise { +export async function runFreezeCheck(committedPath: string = OUT_PATH): Promise { const derived = await deriveVectors() const mismatches: string[] = [] - if (!fs.existsSync(OUT_PATH)) { + if (!fs.existsSync(committedPath)) { return ['vectors/v4-codec.json does not exist — run pnpm check-vectors -- --write'] } - const committed = JSON.parse(fs.readFileSync(OUT_PATH, 'utf-8')) as { + const committed = JSON.parse(fs.readFileSync(committedPath, 'utf-8')) as { vectors: Vector[] content_hash?: string frozen?: boolean @@ -128,15 +128,18 @@ export async function runFreezeCheck(): Promise { } } - // Immutability gate: sha256 is computed over the COMMITTED vectors array as + // Integrity gate: sha256 is computed over the COMMITTED vectors array as // stored in the frozen JSON — not over WASM-derived output. decode-only // fixtures (roundtrip===false) are part of the frozen oracle and must be - // included in the hash. This detects any tampering with the committed file. + // included in the hash. This detects accidental corruption (bitrot, bad + // merge, unintended manual edit). It is NOT a tamper-proof security + // boundary — a malicious editor can edit the vectors and re-stamp the hash. + // Real cross-impl byte identity is enforced by the codec-drift gate (check a). if (committed.content_hash) { const expectedHash = computeContentHash(committedVectors) if (committed.content_hash !== expectedHash) { mismatches.push( - `CONTENT_HASH_MISMATCH (tamper detected)\n` + + `CONTENT_HASH_MISMATCH (integrity check failed — vectors edited without re-stamping hash)\n` + ` committed: ${committed.content_hash}\n` + ` recomputed: ${expectedHash}`, ) diff --git a/packages/codec/scripts/run-check-vectors.test.ts b/packages/codec/scripts/run-check-vectors.test.ts index 5f514b5..31ab36c 100644 --- a/packages/codec/scripts/run-check-vectors.test.ts +++ b/packages/codec/scripts/run-check-vectors.test.ts @@ -14,6 +14,7 @@ */ import { test, expect } from 'vitest' import * as fs from 'node:fs' +import * as os from 'node:os' import * as path from 'node:path' import { fileURLToPath } from 'node:url' import { runFreezeCheck } from './check-vectors.js' @@ -71,20 +72,17 @@ test('freeze-gate rejects mutated vector (gate has teeth)', async () => { original_hex.slice(5) target.canonical_hex = mutated_hex - const mutatedPath = path.resolve(_dirname, '../vectors/v4-codec-mutated-test.json') + // Write mutated content to a temp file — never overwrite the live oracle. + // A process kill between write and restore would otherwise corrupt v4-codec.json. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codec-freeze-gate-')) + const mutatedPath = path.join(tmpDir, 'v4-codec.json') fs.writeFileSync(mutatedPath, JSON.stringify(parsed, null, 2)) try { - // Temporarily point the checker at the mutated file by monkey-patching - // the environment. We do this by writing the mutated file over the real - // path, running the check, then restoring. - fs.writeFileSync(VECTORS_PATH, JSON.stringify(parsed, null, 2)) - const mismatches = await runFreezeCheck() + const mismatches = await runFreezeCheck(mutatedPath) expect(mismatches.length).toBeGreaterThan(0) expect(mismatches.some((m) => m.includes('CANONICAL_MISMATCH'))).toBe(true) } finally { - // Always restore the original file - fs.writeFileSync(VECTORS_PATH, original) - fs.rmSync(mutatedPath, { force: true }) + fs.rmSync(tmpDir, { recursive: true, force: true }) } }) diff --git a/packages/codec/scripts/vlapp-provenance.config.ts b/packages/codec/scripts/vlapp-provenance.config.ts index 52968f3..c5feaeb 100644 --- a/packages/codec/scripts/vlapp-provenance.config.ts +++ b/packages/codec/scripts/vlapp-provenance.config.ts @@ -2,6 +2,12 @@ * Vitest config for vl/app provenance verification. * Adds @/ → voidpay/src alias so vl/app's encode.ts can be imported directly. * brotli-wasm is aliased to Node variant (same as main vitest.config.ts). + * + * LOCAL-ONLY provenance audit — NOT a CI gate. + * This test requires a local checkout of the vl/app (voidpay) repo. + * Set VOIDPAY_SRC to the absolute path of voidpay/src before running: + * VOIDPAY_SRC=/path/to/voidpay/src pnpm exec vitest run scripts/vlapp-provenance.test.ts \ + * --config scripts/vlapp-provenance.config.ts */ import { defineConfig } from 'vitest/config' import wasm from 'vite-plugin-wasm' @@ -11,7 +17,7 @@ import * as path from 'node:path' const require = createRequire(import.meta.url) -const VOIDPAY_SRC = path.resolve('/Users/ignat/code/voidpay/src') +const VOIDPAY_SRC = path.resolve(process.env['VOIDPAY_SRC'] ?? '/Users/ignat/code/voidpay/src') export default defineConfig({ plugins: [wasm(), topLevelAwait()], diff --git a/packages/codec/vectors/PROVENANCE.md b/packages/codec/vectors/PROVENANCE.md new file mode 100644 index 0000000..6e76a28 --- /dev/null +++ b/packages/codec/vectors/PROVENANCE.md @@ -0,0 +1,37 @@ +# Frozen Oracle Provenance Record + +> **Immutable audit record.** Do not edit without re-running the provenance proof +> and updating this file + the `content_hash` in `v4-codec.json`. + +## Capture summary + +| Field | Value | +|-------|-------| +| Oracle file | `packages/codec/vectors/v4-codec.json` | +| Source | vl/app TS codec at `ignromanov/voidpay` master `c658fff` (release v1.1.2) | +| Vectors verified | 21/21 non-malformed vectors byte-identical (round-trip: canonical_hex + wire_hex) | +| Proof script | `packages/codec/scripts/vlapp-provenance.test.ts` | +| Proof run on codec commit | `285dd4b` | +| Proof run date | 2026-05-30 | +| Related decision | `2026-05-29-codec-d1-frozen-vectors-oracle` | + +## Decode-only fixtures + +The 2 `decode_unknown_odd_tag_*` vectors (`roundtrip: false`) are **decode-only +forward-compat fixtures**. They are excluded from encoder provenance because the +encoder never emits them — their canonical_hex was hand-crafted to exercise the +odd-ignore rule (BOLT-12). They are included in the `content_hash` so any edit +is still detected. + +## Re-capture procedure + +Re-capture is required when the vl/app TS codec changes in a way that alters +encoded output (schema change, compression strategy change, etc.). + +1. Checkout vl/app at the new SHA. +2. Run: `VOIDPAY_SRC=/path/to/voidpay/src pnpm exec vitest run scripts/vlapp-provenance.test.ts --config scripts/vlapp-provenance.config.ts` +3. If all vectors match: capture is confirmed unchanged (no re-stamp needed). +4. If vectors differ: re-generate with `pnpm check-vectors -- --write`, which + re-stamps `content_hash`. +5. Update this file: new vl/app SHA, new codec commit, new date. +6. Obtain Kai review before merging.