diff --git a/CHANGELOG.md b/CHANGELOG.md index e41ba3e..66d0cb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [4.0.0] - 2026-07-10 + +### Removed + +- **`WebArtifactProvider` legacy-URL mode** (the `new WebArtifactProvider('https://...')` string constructor): it fetched artifacts by filename convention with no manifest, so there was no sha256/vk_hash to verify — an unchecked path that contradicts fail-closed integrity. **Breaking:** construct with `new WebArtifactProvider()` or `new WebArtifactProvider({ baseUrl })` instead; a mirror is now a `baseUrl` that must serve a `manifest.json`. Every artifact is now integrity-checked, with no exceptions. + +### Added + +- **`WebArtifactProvider.getResolvedVersion(circuit)`** (`src/providers/web.ts`): public getter returning a `ResolvedCircuitVersion` (`{version, packageVersion, vkHash}`) — the version the provider will actually use for a circuit (the override or the manifest's `active_version`), so a consumer (SDK) can cross-check the `vkHash` against the chain's active VK before proving. New exported type `ResolvedCircuitVersion`. +- **Artifact integrity verification** (`src/providers/web.ts`): every downloaded wasm/zkey/ark is now sha256-verified against the manifest — a mismatch (tampered/stale CDN) throws and returns no bytes (fail-closed). An artifact absent from the manifest (so no sha256 to check) throws rather than being derived by convention. +- **`CIRCUIT_ID` map + `circuitTypeToId()`** (`src/circuits/types.ts`): single source of truth mapping each `CircuitType` to its on-chain numeric id, matching the node's `CircuitId` constants (Transfer=1, Unshield=2, PrivateLink=5, ValueProof=6). `circuitTypeToId` fails closed — an unknown circuit throws rather than defaulting to 0. An anti-drift test (`tests/circuits/circuit-id.test.ts`) locks the mapping to the node's values (VALUE_PROOF=6 is the non-obvious one). Groundwork for per-note circuit-version resolution. + ## [3.7.0] - 2026-05-14 ### Added diff --git a/package.json b/package.json index 0df8350..41f7a6e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@orbinum/proof-generator", - "version": "3.7.0", + "version": "4.0.0", "description": "ZK-SNARK proof generator for Orbinum. Combines snarkjs (witness) with arkworks WASM (proof generation) to produce 128-byte Groth16 proofs.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/circuits/types.ts b/src/circuits/types.ts index e797d66..59019b1 100644 --- a/src/circuits/types.ts +++ b/src/circuits/types.ts @@ -6,6 +6,33 @@ export enum CircuitType { PrivateLink = 'private_link', } +/** + * On-chain numeric circuit IDs. Single source of truth for mapping the string + * CircuitType to the id the pallet verifies against. These MUST match the node's + * `CircuitId` constants (`node/frame/zk-verifier/src/types.rs`): + * TRANSFER=1, UNSHIELD=2, PRIVATE_LINK=5, VALUE_PROOF=6. + * Note: VALUE_PROOF is 6 (NOT 4, and not sequential) — a version/vk lookup keyed + * off the wrong id would query a non-existent circuit. A drift test guards this. + */ +export const CIRCUIT_ID: Record = { + [CircuitType.Transfer]: 1, + [CircuitType.Unshield]: 2, + [CircuitType.PrivateLink]: 5, + [CircuitType.ValueProof]: 6, +}; + +/** + * Maps a CircuitType to its on-chain numeric id. Fail-closed: an unknown circuit + * throws rather than defaulting to 0 (which would silently query the wrong VK). + */ +export function circuitTypeToId(circuit: CircuitType): number { + const id = CIRCUIT_ID[circuit]; + if (id === undefined) { + throw new Error(`Unknown circuit type: ${String(circuit)}`); + } + return id; +} + /** Circuit input value types (supports nested arrays for 2D inputs) */ export type CircuitInputValue = string | number | string[] | number[] | string[][] | number[][]; diff --git a/src/index.ts b/src/index.ts index 9183067..0c3ea8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,7 @@ export { getCircuitConfig } from './circuits'; // ── Providers ───────────────────────────────────────────────────────────────── export { NodeArtifactProvider, WebArtifactProvider } from './providers'; -export type { ArtifactProvider, WebProviderOptions } from './providers'; +export type { ArtifactProvider, WebProviderOptions, ResolvedCircuitVersion } from './providers'; // ── Utils ───────────────────────────────────────────────────────────────────── export { validateInputs, validatePublicSignals, validateProofSize } from './utils/validation'; diff --git a/src/providers/index.ts b/src/providers/index.ts index a262b33..6873a1e 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,4 +1,4 @@ export type { ArtifactProvider } from './interface'; export { NodeArtifactProvider } from './node'; export { WebArtifactProvider } from './web'; -export type { WebProviderOptions } from './web'; +export type { WebProviderOptions, ResolvedCircuitVersion } from './web'; diff --git a/src/providers/web.ts b/src/providers/web.ts index a1249d6..32901e4 100644 --- a/src/providers/web.ts +++ b/src/providers/web.ts @@ -8,6 +8,19 @@ import { ArtifactProvider } from './interface'; const UNPKG_BASE = 'https://unpkg.com/@orbinum/circuits'; const MANIFEST_URL = `${UNPKG_BASE}/manifest.json`; +// ============================================================================ +// Integrity +// ============================================================================ + +/** Lowercase hex sha256 of the given bytes, via WebCrypto (browser + Node ≥ 20). */ +async function sha256Hex(bytes: Uint8Array): Promise { + const copy = new Uint8Array(bytes); + const digest = await crypto.subtle.digest('SHA-256', copy); + return Array.from(new Uint8Array(digest)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + // ============================================================================ // Manifest types // ============================================================================ @@ -66,6 +79,19 @@ export type WebProviderOptions = { circuitVersions?: Partial>; }; +/** + * The circuit version the provider resolved to, plus the identifiers a consumer + * needs to cross-check integrity against the chain before proving. + */ +export type ResolvedCircuitVersion = { + /** The resolved circuit version (the override, else the manifest active version). */ + version: number; + /** The `@orbinum/circuits` package version the artifacts are pinned to. */ + packageVersion: string; + /** The on-chain VK hash the manifest declares for this version. */ + vkHash: string; +}; + // ============================================================================ // WebArtifactProvider // ============================================================================ @@ -73,34 +99,24 @@ export type WebProviderOptions = { /** * Artifact provider for browser and mobile environments. * - * Two modes: - * - **Legacy** (`new WebArtifactProvider('https://...')`) — appends artifact - * filenames directly to the given URL, no manifest fetch. - * - **Manifest** (`new WebArtifactProvider()` or `new WebArtifactProvider({...})`) — - * fetches `manifest.json` from the npm CDN (unpkg), resolves the exact - * artifact filename per circuit version, and pins artifact URLs to the - * `package_version` declared in the manifest. Supports per-circuit version - * overrides for backwards-compatible proof generation. + * Manifest-only: fetches `manifest.json` from the npm CDN (unpkg) or a + * `baseUrl` mirror, resolves the exact artifact filename per circuit version, + * pins artifact URLs to the manifest's `package_version`, and VERIFIES every + * downloaded artifact against the manifest's sha256 (fail-closed — a mismatch + * throws). Supports per-circuit version overrides for backwards-compatible proof + * generation. There is no unverified/manifest-less mode: every artifact is + * integrity-checked. */ export class WebArtifactProvider implements ArtifactProvider { - private legacyBaseUrl: string | null = null; private manifestUrl: string; private circuitVersions: Partial>; private manifest: CircuitsManifest | null = null; private manifestPromise: Promise | null = null; - constructor(optionsOrUrl?: string | WebProviderOptions) { - if (typeof optionsOrUrl === 'string') { - // Legacy mode: direct URL, no manifest - this.legacyBaseUrl = optionsOrUrl.replace(/\/$/, ''); - this.manifestUrl = ''; - this.circuitVersions = {}; - } else { - const opts = optionsOrUrl ?? {}; - const base = opts.baseUrl?.replace(/\/$/, ''); - this.manifestUrl = base ? `${base}/manifest.json` : MANIFEST_URL; - this.circuitVersions = opts.circuitVersions ?? {}; - } + constructor(options?: WebProviderOptions) { + const base = options?.baseUrl?.replace(/\/$/, ''); + this.manifestUrl = base ? `${base}/manifest.json` : MANIFEST_URL; + this.circuitVersions = options?.circuitVersions ?? {}; } // --------------------------------------------------------------------------- @@ -126,13 +142,19 @@ export class WebArtifactProvider implements ArtifactProvider { } // --------------------------------------------------------------------------- - // Artifact URL resolution via manifest + // Version resolution via manifest // --------------------------------------------------------------------------- - private async resolveArtifactUrl( - circuitType: CircuitType, - artifactType: 'wasm' | 'zkey' | 'ark' - ): Promise { + /** + * Resolves the version + manifest data the provider will use for a circuit — + * the requested override, else the manifest's `active_version`. Throws if the + * circuit is missing or the version is unsupported (fail-closed). + */ + private async resolveVersionData(circuitType: CircuitType): Promise<{ + version: number; + versionData: ManifestCircuitVersion; + packageVersion: string; + }> { const manifest = await this.getManifest(); const circuitName = circuitType as string; const circuitManifest = manifest.circuits[circuitName]; @@ -141,33 +163,61 @@ export class WebArtifactProvider implements ArtifactProvider { throw new Error(`Circuit "${circuitName}" not found in manifest`); } - const requestedVersion = this.circuitVersions[circuitName] ?? circuitManifest.active_version; + const version = this.circuitVersions[circuitName] ?? circuitManifest.active_version; - if (!circuitManifest.supported_versions.includes(requestedVersion)) { + if (!circuitManifest.supported_versions.includes(version)) { throw new Error( - `Circuit "${circuitName}" v${requestedVersion} is no longer supported. ` + + `Circuit "${circuitName}" v${version} is no longer supported. ` + `Supported versions: [${circuitManifest.supported_versions.join(', ')}]` ); } - const versionData = circuitManifest.versions[String(requestedVersion)]; + const versionData = circuitManifest.versions[String(version)]; if (!versionData) { - throw new Error(`Circuit "${circuitName}" v${requestedVersion} not found in manifest`); + throw new Error(`Circuit "${circuitName}" v${version} not found in manifest`); } - const isCustomBase = this.manifestUrl !== MANIFEST_URL; - const artifactBase = isCustomBase - ? this.manifestUrl.replace(/\/manifest\.json$/, '') - : `${UNPKG_BASE}@${manifest.package_version}`; + return { version, versionData, packageVersion: manifest.package_version }; + } + + /** + * The version + package_version + vk_hash the provider will use for a circuit. + * Public so a consumer (SDK) can cross-check the vk_hash against the chain's + * active VK before generating a proof. + */ + async getResolvedVersion(circuitType: CircuitType): Promise { + const { version, versionData, packageVersion } = await this.resolveVersionData(circuitType); + return { version, packageVersion, vkHash: versionData.vk_hash }; + } - if (artifactType === 'ark') { - // Use the manifest entry if present; otherwise derive the filename by convention. - const filename = versionData.artifacts.ark?.file ?? `${circuitName}_pk.ark`; - return `${artifactBase}/${filename}`; + // --------------------------------------------------------------------------- + // Artifact URL resolution via manifest + // --------------------------------------------------------------------------- + + /** + * The base URL artifacts are served from. A custom `baseUrl` mirror serves + * them next to its manifest; the default (unpkg) pins them to the manifest's + * `package_version` for an immutable, cacheable URL. + */ + private artifactBase(packageVersion: string): string { + if (this.manifestUrl !== MANIFEST_URL) { + return this.manifestUrl.replace(/\/manifest\.json$/, ''); } + return `${UNPKG_BASE}@${packageVersion}`; + } + + /** Resolves the URL + expected sha256 for one artifact of a circuit. */ + private async resolveArtifact( + circuitType: CircuitType, + artifactType: 'wasm' | 'zkey' | 'ark' + ): Promise<{ url: string; sha256: string }> { + const { versionData, packageVersion } = await this.resolveVersionData(circuitType); - const filename = versionData.artifacts[artifactType].file; - return `${artifactBase}/${filename}`; + const entry = versionData.artifacts[artifactType]; + if (!entry) { + throw new Error(`Circuit "${circuitType}" has no "${artifactType}" artifact in the manifest`); + } + return { url: `${this.artifactBase(packageVersion)}/${entry.file}`, sha256: entry.sha256 }; } // --------------------------------------------------------------------------- @@ -175,31 +225,39 @@ export class WebArtifactProvider implements ArtifactProvider { // --------------------------------------------------------------------------- async getCircuitWasm(type: CircuitType): Promise { - if (this.legacyBaseUrl !== null) { - return this.fetchArtifact(`${this.legacyBaseUrl}/${type.toLowerCase()}.wasm`); - } - return this.fetchArtifact(await this.resolveArtifactUrl(type, 'wasm')); + const { url, sha256 } = await this.resolveArtifact(type, 'wasm'); + return this.fetchArtifact(url, sha256); } async getCircuitZkey(type: CircuitType): Promise { - if (this.legacyBaseUrl !== null) { - return this.fetchArtifact(`${this.legacyBaseUrl}/${type.toLowerCase()}_pk.zkey`); - } - return this.fetchArtifact(await this.resolveArtifactUrl(type, 'zkey')); + const { url, sha256 } = await this.resolveArtifact(type, 'zkey'); + return this.fetchArtifact(url, sha256); } async getCircuitProvingKey(type: CircuitType): Promise { - if (this.legacyBaseUrl !== null) { - return this.fetchArtifact(`${this.legacyBaseUrl}/${type.toLowerCase()}_pk.ark`); - } - return this.fetchArtifact(await this.resolveArtifactUrl(type, 'ark')); + const { url, sha256 } = await this.resolveArtifact(type, 'ark'); + return this.fetchArtifact(url, sha256); } - private async fetchArtifact(url: string): Promise { + /** + * Fetches an artifact and verifies the downloaded bytes against the manifest's + * sha256 — fail-closed: a mismatch throws and no bytes are returned (guards + * against a tampered/stale CDN). Every artifact is verified; there is no + * unchecked path. + */ + private async fetchArtifact(url: string, expectedSha256: string): Promise { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch circuit artifact: ${url} (${response.status})`); } - return new Uint8Array(await response.arrayBuffer()); + const bytes = new Uint8Array(await response.arrayBuffer()); + + const actual = await sha256Hex(bytes); + if (actual !== expectedSha256.toLowerCase()) { + throw new Error( + `Integrity check failed for ${url}: expected sha256 ${expectedSha256}, got ${actual}` + ); + } + return bytes; } } diff --git a/tests/circuits/circuit-id.test.ts b/tests/circuits/circuit-id.test.ts new file mode 100644 index 0000000..57bc441 --- /dev/null +++ b/tests/circuits/circuit-id.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { CircuitType, CIRCUIT_ID, circuitTypeToId } from '../../src/circuits/types'; + +/** + * Anti-drift guard for the CircuitType → on-chain id mapping. + * + * The numeric ids are the node's source of truth + * (node/frame/zk-verifier/src/types.rs): TRANSFER=1, UNSHIELD=2, PRIVATE_LINK=5, + * VALUE_PROOF=6. Three layers (node, this package, ts-sdk) must agree — a wrong + * id silently queries the wrong circuit's VK/version. VALUE_PROOF=6 is the + * non-obvious one (a prior ts-sdk had it at 4); this test locks it. + */ +const NODE_CIRCUIT_IDS: Record = { + [CircuitType.Transfer]: 1, + [CircuitType.Unshield]: 2, + [CircuitType.PrivateLink]: 5, + [CircuitType.ValueProof]: 6, +}; + +describe('CircuitType → on-chain id mapping', () => { + it('matches the node CircuitId constants exactly', () => { + expect(CIRCUIT_ID).toEqual(NODE_CIRCUIT_IDS); + }); + + it('covers every CircuitType (no circuit left unmapped)', () => { + for (const circuit of Object.values(CircuitType)) { + expect(CIRCUIT_ID[circuit]).toBeTypeOf('number'); + } + }); + + it('value_proof is 6 (not 4, not sequential)', () => { + expect(circuitTypeToId(CircuitType.ValueProof)).toBe(6); + }); + + it('circuitTypeToId resolves each known circuit', () => { + expect(circuitTypeToId(CircuitType.Transfer)).toBe(1); + expect(circuitTypeToId(CircuitType.Unshield)).toBe(2); + expect(circuitTypeToId(CircuitType.PrivateLink)).toBe(5); + expect(circuitTypeToId(CircuitType.ValueProof)).toBe(6); + }); + + it('fails closed on an unknown circuit (throws, never defaults to 0)', () => { + expect(() => circuitTypeToId('bogus' as CircuitType)).toThrow(/Unknown circuit type/); + }); +}); diff --git a/tests/providers/web.test.ts b/tests/providers/web.test.ts index 775dd4c..146c5bf 100644 --- a/tests/providers/web.test.ts +++ b/tests/providers/web.test.ts @@ -15,6 +15,12 @@ import { CircuitType } from '../../src/circuits/types'; const MOCK_PKG_VERSION = '0.4.4'; +// The manifest-mode tests mock every artifact fetch as 8 zero bytes +// (`new ArrayBuffer(8)`); this is their real sha256, so the integrity check +// passes. A dedicated integrity test below uses a different value to prove a +// mismatch throws. +const ZERO8_SHA = 'af5570f5a1810b7af78caf4bc70a660f0df51e42baf91d4de5b2328de0e83dfc'; + function buildMockManifest(overrides?: { unshieldActiveVersion?: number; supportedVersions?: number[]; @@ -33,22 +39,26 @@ function buildMockManifest(overrides?: { version: 1, vk_hash: '0x73401aa0', artifacts: { - wasm: { file: 'unshield.wasm', bytes: 2396830, sha256: 'aaa' }, - zkey: { file: 'unshield_pk.zkey', bytes: 5326768, sha256: 'bbb' }, - vk_json: { file: 'verification_key_unshield.json', bytes: 3657, sha256: 'ccc' }, - r1cs: { file: 'unshield.r1cs', bytes: 1584412, sha256: 'ddd' }, - ark: { file: 'unshield_pk.ark', bytes: 192, sha256: 'eee' }, + wasm: { file: 'unshield.wasm', bytes: 2396830, sha256: ZERO8_SHA }, + zkey: { file: 'unshield_pk.zkey', bytes: 5326768, sha256: ZERO8_SHA }, + vk_json: { file: 'verification_key_unshield.json', bytes: 3657, sha256: ZERO8_SHA }, + r1cs: { file: 'unshield.r1cs', bytes: 1584412, sha256: ZERO8_SHA }, + ark: { file: 'unshield_pk.ark', bytes: 192, sha256: ZERO8_SHA }, }, }, '2': { version: 2, vk_hash: '0xdeadbeef', artifacts: { - wasm: { file: 'unshield_v2.wasm', bytes: 2500000, sha256: 'eee' }, - zkey: { file: 'unshield_v2_pk.zkey', bytes: 6000000, sha256: 'fff' }, - vk_json: { file: 'verification_key_unshield_v2.json', bytes: 3700, sha256: 'ggg' }, - r1cs: { file: 'unshield_v2.r1cs', bytes: 1700000, sha256: 'hhh' }, - ark: { file: 'unshield_v2_pk.ark', bytes: 192, sha256: 'iii' }, + wasm: { file: 'unshield_v2.wasm', bytes: 2500000, sha256: ZERO8_SHA }, + zkey: { file: 'unshield_v2_pk.zkey', bytes: 6000000, sha256: ZERO8_SHA }, + vk_json: { + file: 'verification_key_unshield_v2.json', + bytes: 3700, + sha256: ZERO8_SHA, + }, + r1cs: { file: 'unshield_v2.r1cs', bytes: 1700000, sha256: ZERO8_SHA }, + ark: { file: 'unshield_v2_pk.ark', bytes: 192, sha256: ZERO8_SHA }, }, }, }, @@ -61,10 +71,10 @@ function buildMockManifest(overrides?: { version: 1, vk_hash: '0x2ab60d15', artifacts: { - wasm: { file: 'transfer.wasm', bytes: 3359868, sha256: 'iii' }, - zkey: { file: 'transfer_pk.zkey', bytes: 20484784, sha256: 'jjj' }, - vk_json: { file: 'verification_key_transfer.json', bytes: 3658, sha256: 'kkk' }, - r1cs: { file: 'transfer.r1cs', bytes: 6629624, sha256: 'lll' }, + wasm: { file: 'transfer.wasm', bytes: 3359868, sha256: ZERO8_SHA }, + zkey: { file: 'transfer_pk.zkey', bytes: 20484784, sha256: ZERO8_SHA }, + vk_json: { file: 'verification_key_transfer.json', bytes: 3658, sha256: ZERO8_SHA }, + r1cs: { file: 'transfer.r1cs', bytes: 6629624, sha256: ZERO8_SHA }, }, }, }, @@ -83,71 +93,6 @@ function mockManifestThenArtifact() { ); } -// ─── Legacy mode ───────────────────────────────────────────────────────────── - -describe('WebArtifactProvider — legacy mode (string URL)', () => { - const baseUrl = 'https://test.orbinum.com/circuits'; - - beforeEach(() => { - vi.unstubAllGlobals(); - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }) - ); - }); - - it('constructs correct WASM URL', async () => { - const provider = new WebArtifactProvider(baseUrl); - await provider.getCircuitWasm(CircuitType.Unshield); - - expect(global.fetch).toHaveBeenCalledTimes(1); - expect((global.fetch as ReturnType).mock.calls[0][0]).toBe( - 'https://test.orbinum.com/circuits/unshield.wasm' - ); - }); - - it('constructs correct zkey URL', async () => { - const provider = new WebArtifactProvider(baseUrl); - await provider.getCircuitZkey(CircuitType.Transfer); - - expect((global.fetch as ReturnType).mock.calls[0][0]).toBe( - 'https://test.orbinum.com/circuits/transfer_pk.zkey' - ); - }); - - it('constructs correct .ark URL', async () => { - const provider = new WebArtifactProvider(baseUrl); - await provider.getCircuitProvingKey!(CircuitType.Unshield); - - expect((global.fetch as ReturnType).mock.calls[0][0]).toBe( - 'https://test.orbinum.com/circuits/unshield_pk.ark' - ); - }); - - it('throws on failed fetch', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 })); - const provider = new WebArtifactProvider(baseUrl); - - await expect(provider.getCircuitWasm(CircuitType.Unshield)).rejects.toThrow( - 'Failed to fetch circuit artifact' - ); - }); - - it('strips trailing slash from base URL', async () => { - const provider = new WebArtifactProvider('https://slash.com/'); - await provider.getCircuitWasm(CircuitType.Unshield); - - const url = (global.fetch as ReturnType).mock.calls[0][0]; - expect(url).toBe('https://slash.com/unshield.wasm'); - }); - - it('returns Uint8Array from ArrayBuffer', async () => { - const provider = new WebArtifactProvider(baseUrl); - const result = await provider.getCircuitWasm(CircuitType.Unshield); - expect(result).toBeInstanceOf(Uint8Array); - }); -}); - // ─── Manifest mode ──────────────────────────────────────────────────────────── describe('WebArtifactProvider — manifest mode (npm CDN)', () => { @@ -318,8 +263,21 @@ describe('WebArtifactProvider — manifest mode (npm CDN)', () => { ); }); - it('derives .ark filename by convention when manifest has no ark entry', async () => { - // transfer circuit in the mock manifest has no ark entry + it('throws for an artifact with no manifest entry (fail-closed, no unverified derive)', async () => { + // transfer circuit in the mock manifest has no ark entry → cannot be + // integrity-checked, so it must not be served. + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: async () => buildMockManifest() }) + ); + + const provider = new WebArtifactProvider(); + await expect(provider.getCircuitProvingKey!(CircuitType.Transfer)).rejects.toThrow( + /no "ark" artifact/ + ); + }); + + it('getCircuitProvingKey returns Uint8Array', async () => { vi.stubGlobal( 'fetch', vi @@ -329,23 +287,75 @@ describe('WebArtifactProvider — manifest mode (npm CDN)', () => { ); const provider = new WebArtifactProvider(); - await provider.getCircuitProvingKey!(CircuitType.Transfer); + const result = await provider.getCircuitProvingKey!(CircuitType.Unshield); + expect(result).toBeInstanceOf(Uint8Array); + }); +}); + +// ─── Integrity (sha256) + getResolvedVersion (Phase 1) ──────────────────────── - const url = (global.fetch as ReturnType).mock.calls[1][0]; - expect(url).toBe(`https://unpkg.com/@orbinum/circuits@${MOCK_PKG_VERSION}/transfer_pk.ark`); +describe('WebArtifactProvider — integrity + resolved version', () => { + beforeEach(() => { + vi.unstubAllGlobals(); }); - it('getCircuitProvingKey returns Uint8Array', async () => { + it('verifies downloaded bytes against the manifest sha256 (passes on match)', async () => { + mockManifestThenArtifact(); // artifact = 8 zero bytes, manifest sha256 = ZERO8_SHA + const provider = new WebArtifactProvider(); + await expect(provider.getCircuitWasm(CircuitType.Unshield)).resolves.toBeInstanceOf(Uint8Array); + }); + + it('throws on a sha256 mismatch (tampered/stale CDN) and returns no bytes', async () => { + // Manifest declares ZERO8_SHA, but the artifact fetch returns different bytes. vi.stubGlobal( 'fetch', vi .fn() .mockResolvedValueOnce({ ok: true, json: async () => buildMockManifest() }) - .mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) }) + .mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(16) }) + ); + const provider = new WebArtifactProvider(); + await expect(provider.getCircuitWasm(CircuitType.Unshield)).rejects.toThrow( + /Integrity check failed/ ); + }); + it('getResolvedVersion returns version + package_version + vk_hash', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: async () => buildMockManifest() }) + ); const provider = new WebArtifactProvider(); - const result = await provider.getCircuitProvingKey!(CircuitType.Unshield); - expect(result).toBeInstanceOf(Uint8Array); + const resolved = await provider.getResolvedVersion(CircuitType.Unshield); + expect(resolved).toEqual({ + version: 1, + packageVersion: MOCK_PKG_VERSION, + vkHash: '0x73401aa0', + }); + }); + + it('getResolvedVersion honors a circuitVersions override', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => buildMockManifest({ supportedVersions: [1, 2] }), + }) + ); + const provider = new WebArtifactProvider({ circuitVersions: { unshield: 2 } }); + const resolved = await provider.getResolvedVersion(CircuitType.Unshield); + expect(resolved.version).toBe(2); + expect(resolved.vkHash).toBe('0xdeadbeef'); + }); + + it('getResolvedVersion throws for an unsupported version (fail-closed)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: async () => buildMockManifest() }) + ); + const provider = new WebArtifactProvider({ circuitVersions: { unshield: 2 } }); // supported: [1] + await expect(provider.getResolvedVersion(CircuitType.Unshield)).rejects.toThrow( + /no longer supported/ + ); }); });