Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
27 changes: 27 additions & 0 deletions src/circuits/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, number> = {
[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[][];

Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -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';
168 changes: 113 additions & 55 deletions src/providers/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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
// ============================================================================
Expand Down Expand Up @@ -66,41 +79,44 @@ export type WebProviderOptions = {
circuitVersions?: Partial<Record<string, number>>;
};

/**
* 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
// ============================================================================

/**
* 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<Record<string, number>>;
private manifest: CircuitsManifest | null = null;
private manifestPromise: Promise<CircuitsManifest> | 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 ?? {};
}

// ---------------------------------------------------------------------------
Expand All @@ -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<string> {
/**
* 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];
Expand All @@ -141,65 +163,101 @@ 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<ResolvedCircuitVersion> {
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 };
}

// ---------------------------------------------------------------------------
// ArtifactProvider implementation
// ---------------------------------------------------------------------------

async getCircuitWasm(type: CircuitType): Promise<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
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<Uint8Array> {
/**
* 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<Uint8Array> {
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;
}
}
45 changes: 45 additions & 0 deletions tests/circuits/circuit-id.test.ts
Original file line number Diff line number Diff line change
@@ -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, number> = {
[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/);
});
});
Loading
Loading