/** * ยง5.3. The single canonical byte producer for a contract set. * * Node build scripts and the browser runtime share this function. Only the hash * adapter differs (Node `crypto` vs Web Crypto), so a digest can never diverge * because of JSON property order or a locale-sensitive sort. */ export type ContractSetPackage = Readonly<{ packageId: string; version: string; digest: `sha256:${string}`; runtimeProtocolVersion: 1; sourceRevision: string; }>; export const CONTRACT_SET_ALGORITHM = "CA_CONTRACT_SET_V1" as const; const HEADER = "CA_FRONTEND_CONTRACT_SET_V1\u0000"; const encoder = new TextEncoder(); function compareUtf8(left: string, right: string): number { const a = encoder.encode(left); const b = encoder.encode(right); const shared = Math.min(a.length, b.length); for (let index = 0; index < shared; index += 1) { const difference = (a[index] as number) - (b[index] as number); if (difference !== 0) return difference; } return a.length - b.length; } export function canonicalizeContractSet( packages: readonly ContractSetPackage[], ): Uint8Array { const seen = new Set(); for (const entry of packages) { if (seen.has(entry.packageId)) { throw new TypeError("Duplicate contract set package identity."); } seen.add(entry.packageId); } const sorted = [...packages].sort((left, right) => compareUtf8(left.packageId, right.packageId), ); const chunks: Uint8Array[] = [encoder.encode(HEADER)]; for (const entry of sorted) { appendString(chunks, entry.packageId); appendString(chunks, entry.version); appendString(chunks, entry.digest); chunks.push(u32be(entry.runtimeProtocolVersion)); appendString(chunks, entry.sourceRevision); } const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); const output = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { output.set(chunk, offset); offset += chunk.length; } return output; } function appendString(chunks: Uint8Array[], value: string): void { const bytes = encoder.encode(value); chunks.push(u32be(bytes.length)); chunks.push(bytes); } function u32be(value: number): Uint8Array { if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { throw new TypeError("Contract set length prefix is out of range."); } const bytes = new Uint8Array(4); new DataView(bytes.buffer).setUint32(0, value, false); return bytes; } export function toLowerHex(digest: ArrayBuffer | Uint8Array): string { const bytes = digest instanceof Uint8Array ? digest : new Uint8Array(digest); let output = ""; for (const byte of bytes) output += byte.toString(16).padStart(2, "0"); return output; } /** * Browser-side digest. Node callers pass their own `crypto.createHash` adapter * through {@link computeContractSetDigestWith}. */ export async function computeContractSetDigest( packages: readonly ContractSetPackage[], ): Promise<`sha256:${string}`> { const bytes = canonicalizeContractSet(packages); const buffer = await crypto.subtle.digest( "SHA-256", bytes.slice().buffer as ArrayBuffer, ); return `sha256:${toLowerHex(buffer)}`; } export function computeContractSetDigestWith( packages: readonly ContractSetPackage[], sha256: (bytes: Uint8Array) => Uint8Array, ): `sha256:${string}` { return `sha256:${toLowerHex(sha256(canonicalizeContractSet(packages)))}`; }