/** * Frontend Contract hashing — the cross-runtime content-address (FCID). * * The keystone trust property of the Frontend Contract is that "what the agent * was served" and "what CI enforced" are provably the same bytes. That only * holds if the contract hash is computed by an *identical* function in every * runtime that touches it: the CLI (Node), the Convex isolate, and the browser. * * The fact-IR `hash64Hex` (FNV-1a, in `../facts/ids.ts`) is deliberately NOT * used here: it is a 64-bit non-cryptographic integrity reference for in-process * fact dedup, and Convex independently reaches for `crypto.subtle` (async, and a * *different* algorithm). Two different hashes over the same preimage cannot back * a "same 64 hex chars or the build is rejected" guarantee. * * So this module ships ONE function: a synchronous, dependency-free SHA-256 over * a number-normalizing canonical preimage. Synchronous so it composes with the * sync fact-compilation path; dependency-free so it is byte-identical across * Node, the Convex isolate, and the browser without a native/optional dep * (`crypto.subtle` is async; `@noble/hashes` is not a dependency of this MIT, * browser-safe package). `TextEncoder` is the only ambient global used, and it is * present in all three runtimes. */ // --------------------------------------------------------------------------- // Canonical preimage // --------------------------------------------------------------------------- /** * Normalize a number to a stable string. `JSON.stringify` is stable for finite * numbers, but renders `NaN`/`Infinity` as `null` (ambiguous) and does not * collapse `-0`. Token values flow into the contract body, so a serializer that * silently maps `NaN` to `null` would hash two different inputs to the same * preimage. Non-finite numbers get unambiguous sentinels instead; `-0` collapses * to `0`. This is total (never throws) because it runs on the ingest hot path. */ function normalizeNumber(value: number): string { if (Number.isNaN(value)) return '"@num:nan"'; if (value === Infinity) return '"@num:+inf"'; if (value === -Infinity) return '"@num:-inf"'; if (Object.is(value, -0)) return "0"; return JSON.stringify(value); } /** * Deterministic, canonical serialization of a contract body for hashing. * * - object keys sorted lexicographically; attribute order is irrelevant * - `undefined` properties dropped (so optional fields don't perturb the hash) * - numbers normalized via {@link normalizeNumber} * - `bigint` encoded as its decimal string (JSON cannot represent it natively) * * This is intentionally a separate serializer from `facts/ids.ts#canonicalJson`: * that one targets in-process fact IDs and does not normalize numbers, and we * must not change its output (it would silently re-key existing fact IDs). */ export function canonicalPreimage(value: unknown): string { if (value === undefined || value === null) return "null"; const type = typeof value; if (type === "number") return normalizeNumber(value as number); if (type === "string" || type === "boolean") return JSON.stringify(value); if (type === "bigint") return JSON.stringify((value as bigint).toString()); if (Array.isArray(value)) { return `[${value.map(canonicalPreimage).join(",")}]`; } if (type !== "object") { // functions, symbols — not valid contract content; encode as null so the // serializer stays total rather than throwing on the ingest path. return "null"; } const entries = Object.entries(value as Record) .filter(([, v]) => v !== undefined) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalPreimage(v)}`).join(",")}}`; } // --------------------------------------------------------------------------- // SHA-256 (FIPS 180-4), synchronous, pure JS // --------------------------------------------------------------------------- // First 32 bits of the fractional parts of the cube roots of the first 64 primes. const K = new Uint32Array([ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, ]); function rotr(value: number, bits: number): number { return ((value >>> bits) | (value << (32 - bits))) >>> 0; } /** * SHA-256 of a UTF-8 string, returned as 64 lowercase hex chars. * * Deterministic and synchronous in every runtime. Verified against the FIPS * 180-4 vectors in `hash.test.ts`. */ export function sha256Hex(message: string): string { const bytes = new TextEncoder().encode(message); const length = bytes.length; const bitLength = length * 8; // Padding: append 0x80, then zeros, then the 64-bit big-endian bit length, // so the total is a multiple of 64 bytes. const withMarker = length + 1; const zeroPad = (56 - (withMarker % 64) + 64) % 64; const total = withMarker + zeroPad + 8; const buffer = new Uint8Array(total); buffer.set(bytes, 0); buffer[length] = 0x80; const view = new DataView(buffer.buffer); // 64-bit length: high word then low word, big-endian. bitLength can exceed // 2^32 for very large inputs, so split rather than truncate. view.setUint32(total - 8, Math.floor(bitLength / 0x100000000)); view.setUint32(total - 4, bitLength >>> 0); let h0 = 0x6a09e667; let h1 = 0xbb67ae85; let h2 = 0x3c6ef372; let h3 = 0xa54ff53a; let h4 = 0x510e527f; let h5 = 0x9b05688c; let h6 = 0x1f83d9ab; let h7 = 0x5be0cd19; const w = new Uint32Array(64); for (let chunk = 0; chunk < total; chunk += 64) { for (let t = 0; t < 16; t++) w[t] = view.getUint32(chunk + t * 4); for (let t = 16; t < 64; t++) { const w15 = w[t - 15]; const w2 = w[t - 2]; const s0 = rotr(w15, 7) ^ rotr(w15, 18) ^ (w15 >>> 3); const s1 = rotr(w2, 17) ^ rotr(w2, 19) ^ (w2 >>> 10); w[t] = (w[t - 16] + s0 + w[t - 7] + s1) >>> 0; } let a = h0; let b = h1; let c = h2; let d = h3; let e = h4; let f = h5; let g = h6; let h = h7; for (let t = 0; t < 64; t++) { const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); const ch = (e & f) ^ (~e & g); const temp1 = (h + s1 + ch + K[t] + w[t]) >>> 0; const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); const maj = (a & b) ^ (a & c) ^ (b & c); const temp2 = (s0 + maj) >>> 0; h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0; } h0 = (h0 + a) >>> 0; h1 = (h1 + b) >>> 0; h2 = (h2 + c) >>> 0; h3 = (h3 + d) >>> 0; h4 = (h4 + e) >>> 0; h5 = (h5 + f) >>> 0; h6 = (h6 + g) >>> 0; h7 = (h7 + h) >>> 0; } return [h0, h1, h2, h3, h4, h5, h6, h7] .map((value) => value.toString(16).padStart(8, "0")) .join(""); } // --------------------------------------------------------------------------- // Contract hash (FCID) // --------------------------------------------------------------------------- /** * The Frontend Contract ID: `sha256(canonicalPreimage(body))`, 64 lowercase hex * chars. Identical inputs produce an identical FCID in the CLI, the Convex * isolate, and the browser — which is what lets the gen-time agent view and the * CI enforcement reference the same contract by hash. */ export function contractHash(body: unknown): string { return sha256Hex(canonicalPreimage(body)); }