diff --git a/CHANGELOG.md b/CHANGELOG.md index 85c1dc2..6e94e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`generateDisclosureProof()`** (`src/disclosure.ts`) — new proof generation function for selective disclosure circuit: + - Accepts `value`, `ownerPubkey`, `blinding`, `assetId`, `commitment` as `bigint` + `DisclosureMask` flags + - Computes `viewing_key = Poseidon(owner_pubkey)` via circomlibjs for ZK-friendly key derivation + - Generates Groth16 proof via `disclosure.wasm` + `disclosure_pk.zkey` artifacts + - Returns `DisclosureProofOutput` with 128-byte compressed proof, 4 public signals (LE hex), and `revealedData` decoded to human-readable values + - Validates mask: throws if all flags are `false` +- **New types exported** from `src/index.ts`: + - `DisclosureMask` — three boolean disclosure flags (`discloseValue`, `discloseAssetId`, `discloseOwner`) + - `DisclosureProofOutput` — proof + publicSignals + revealedData + - `bigIntToBytes32()` / `bytes32ToBigInt()` — big-endian field element helpers + - `hexSignalToBigInt()` — decode LE hex public signals back to BigInt + - `bigIntToHex()` — BigInt to 0x-prefixed 64-char big-endian hex +- **`CircuitType.Disclosure`** added to `src/circuits.ts` with `expectedPublicSignals: 4` +- **30 unit tests** in `tests/unit/disclosure.test.ts` across 5 describe blocks — all pass in 1.4s +- **Integration test** `tests/integration/disclosure.test.ts` with real circuit artifacts (graceful skip if artifacts unavailable) + ## [3.1.1] - 2026-02-18 ### Fixed diff --git a/jest.config.js b/jest.config.js index 5890e09..0c7911c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,6 +26,7 @@ module.exports = { { useESM: true, tsconfig: { + target: 'ES2020', module: 'esnext', }, }, diff --git a/lint-staged.config.js b/lint-staged.config.js new file mode 100644 index 0000000..d03163d --- /dev/null +++ b/lint-staged.config.js @@ -0,0 +1,6 @@ +module.exports = { + '*.ts': ['prettier --write'], + '*.{json,md}': ['prettier --write'], + // Run full project lint after all formatters complete + '*': () => 'npm run lint' +}; diff --git a/package.json b/package.json index 99a4fdd..0435368 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@orbinum/proof-generator", - "version": "3.1.1", + "version": "3.2.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", @@ -26,7 +26,7 @@ "test:disclosure": "jest disclosure.test.ts", "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"", - "lint": "tsc --noEmit", + "lint": "tsc --project tsconfig.test.json --noEmit", "prepare": "husky", "prepublishOnly": "npm run build && npm run lint && npm test", "clean": "rm -rf dist node_modules package-lock.json" @@ -59,15 +59,6 @@ "ts-jest": "29.2.5", "typescript": "^5.7.3" }, - "lint-staged": { - "*.ts": [ - "prettier --write", - "tsc --noEmit" - ], - "*.{json,md}": [ - "prettier --write" - ] - }, "engines": { "node": ">=22.0.0" } diff --git a/src/disclosure.ts b/src/disclosure.ts new file mode 100644 index 0000000..7b2f218 --- /dev/null +++ b/src/disclosure.ts @@ -0,0 +1,117 @@ +/** + * Selective Disclosure – Proof Orchestrator + * + * Generates Groth16 proofs for the `disclosure.circom` circuit. + * + * ## Circuit Public Inputs (in order) + * 0. commitment – Note commitment (always revealed) + * 1. revealed_value – Note value, or 0 if not disclosed + * 2. revealed_asset_id – Asset ID, or 0 if not disclosed + * 3. revealed_owner_hash – Poseidon(owner_pubkey), or 0 if not disclosed + * + * @module @orbinum/proof-generator/disclosure + */ + +// @ts-ignore - circomlibjs has no type declarations +import { buildPoseidon } from 'circomlibjs'; +import { generateProof, CircuitType } from './index'; +import { DisclosureMask, DisclosureProofOutput, ProofResult } from './types'; +import { + bigIntToHex, + bigIntToBytes32, + bytes32ToBigInt, + hexSignalToBigInt, + u64ToFieldStr, +} from './utils'; +import { ArtifactProvider } from './provider'; + +// ============================================================================ +// Internal: circuit input builder +// ============================================================================ + +/** + * Build the snarkjs-compatible inputs object for the disclosure circuit. + * + * All values are decimal BigInt strings — the native format that snarkjs + * expects for scalar field elements. + */ +async function buildCircuitInputs( + value: bigint, + ownerPubkey: bigint, + blinding: bigint, + assetId: bigint, + commitment: bigint, + mask: DisclosureMask +): Promise> { + const poseidon = await buildPoseidon(); + const F = poseidon.F; + + // viewing_key = Poseidon(owner_pubkey) — matches the circom constraint + const viewingKey: bigint = F.toObject(poseidon([ownerPubkey])); + + return { + // Public inputs + commitment: commitment.toString(), + revealed_value: (mask.discloseValue ? value : 0n).toString(), + revealed_asset_id: (mask.discloseAssetId ? assetId : 0n).toString(), + revealed_owner_hash: (mask.discloseOwner ? viewingKey : 0n).toString(), + // Private inputs + value: u64ToFieldStr(value), + asset_id: u64ToFieldStr(assetId), + owner_pubkey: ownerPubkey.toString(), + blinding: blinding.toString(), + viewing_key: viewingKey.toString(), + disclose_value: mask.discloseValue ? '1' : '0', + disclose_asset_id: mask.discloseAssetId ? '1' : '0', + disclose_owner: mask.discloseOwner ? '1' : '0', + }; +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Generate a selective disclosure Groth16 proof. + * + * @param value – Note value as BigInt (u64 field element) + * @param ownerPubkey – Owner public key as BigInt (BN254 scalar) + * @param blinding – Blinding factor as BigInt + * @param assetId – Asset ID as BigInt (u32) + * @param commitment – Note commitment as BigInt + * @param mask – Which fields to disclose to the auditor + * @param options – Optional artifact provider override + */ +export async function generateDisclosureProof( + value: bigint, + ownerPubkey: bigint, + blinding: bigint, + assetId: bigint, + commitment: bigint, + mask: DisclosureMask, + options: { provider?: ArtifactProvider; verbose?: boolean } = {} +): Promise { + if (!mask.discloseValue && !mask.discloseAssetId && !mask.discloseOwner) { + throw new Error( + 'DisclosureMask: at least one field (discloseValue, discloseAssetId, discloseOwner) must be true' + ); + } + + const inputs = await buildCircuitInputs(value, ownerPubkey, blinding, assetId, commitment, mask); + + // Public signal order from disclosure.circom: + // [0] commitment [1] revealed_value [2] revealed_asset_id [3] revealed_owner_hash + const result: ProofResult = await generateProof(CircuitType.Disclosure, inputs, { + provider: options.provider, + verbose: options.verbose, + }); + + const [sigCommitment, sigValue, sigAssetId, sigOwnerHash] = result.publicSignals; + + const revealedData: DisclosureProofOutput['revealedData'] = { commitment: sigCommitment }; + if (mask.discloseValue) revealedData.value = hexSignalToBigInt(sigValue).toString(10); + if (mask.discloseAssetId) revealedData.assetId = Number(hexSignalToBigInt(sigAssetId)); + if (mask.discloseOwner) revealedData.ownerHash = bigIntToHex(hexSignalToBigInt(sigOwnerHash)); + + return { proof: result.proof, publicSignals: result.publicSignals, revealedData }; +} diff --git a/src/index.ts b/src/index.ts index b635e51..d157991 100644 --- a/src/index.ts +++ b/src/index.ts @@ -145,3 +145,4 @@ export * from './types'; export * from './utils'; export * from './provider'; export { NodeArtifactProvider, WebArtifactProvider } from './circuits'; +export { generateDisclosureProof } from './disclosure'; diff --git a/src/types.ts b/src/types.ts index 8573342..ea3de4c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -79,3 +79,44 @@ export class InvalidInputsError extends ProofGeneratorError { super(message, 'INVALID_INPUTS'); } } + +// ============================================================================ +// Disclosure types +// ============================================================================ + +/** + * Which fields to reveal to the auditor. + * + * At least one of `discloseValue`, `discloseAssetId`, or `discloseOwner` + * must be `true`. + */ +export interface DisclosureMask { + /** Reveal the note value (u64) */ + discloseValue: boolean; + /** Reveal the asset ID (u32) */ + discloseAssetId: boolean; + /** Reveal the owner identity hash (Poseidon(owner_pubkey)) */ + discloseOwner: boolean; +} + +/** Proof output returned by `generateDisclosureProof`. */ +export interface DisclosureProofOutput { + /** 128-byte compressed Groth16 proof as 0x-prefixed hex string */ + proof: string; + /** + * Raw public signals in hex (0x-prefixed, 32 bytes each). + * Order: [commitment, revealed_value, revealed_asset_id, revealed_owner_hash] + */ + publicSignals: string[]; + /** Human-readable revealed data */ + revealedData: { + /** Revealed note value as decimal string, or undefined if not disclosed */ + value?: string; + /** Revealed asset ID as number, or undefined if not disclosed */ + assetId?: number; + /** Revealed owner hash as 0x-prefixed hex, or undefined if not disclosed */ + ownerHash?: string; + /** Note commitment (always present) */ + commitment: string; + }; +} diff --git a/src/utils.ts b/src/utils.ts index c617ad3..e484b20 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,6 +5,7 @@ * - Circuit inputs * - Proof hex strings * - Public signals + * - BigInt / byte-array conversions */ /** BN254 scalar field modulus */ @@ -127,3 +128,58 @@ export function formatPublicSignalsArray(signals: (bigint | number | string)[]): return '0x' + bytes.join(''); }); } + +// ============================================================================ +// BigInt / byte-array conversions +// ============================================================================ + +/** + * Convert a BigInt to a 32-byte Uint8Array (big-endian). + */ +export function bigIntToBytes32(n: bigint): Uint8Array { + const buf = new Uint8Array(32); + let remaining = n; + for (let i = 31; i >= 0; i--) { + buf[i] = Number(remaining & 0xffn); + remaining >>= 8n; + } + return buf; +} + +/** + * Convert a 32-byte Uint8Array (big-endian) to a BigInt. + */ +export function bytes32ToBigInt(buf: Uint8Array): bigint { + let result = 0n; + for (let i = 0; i < 32; i++) { + result = (result << 8n) | BigInt(buf[i]); + } + return result; +} + +/** + * Encode a u64 value as a BN254 field element decimal string for snarkjs. + */ +export function u64ToFieldStr(value: bigint): string { + return value.toString(10); +} + +/** + * Parse a little-endian hex public signal (0x-prefixed, 32 bytes) back to BigInt. + * + * Public signals returned by the proof generator are little-endian; this + * function reverses the bytes before interpreting as a field element. + */ +export function hexSignalToBigInt(hex: string): bigint { + const clean = hex.startsWith('0x') || hex.startsWith('0X') ? hex.slice(2) : hex; + const buf = Buffer.from(clean.padStart(64, '0'), 'hex'); + const reversed = Buffer.from(buf).reverse(); + return bytes32ToBigInt(new Uint8Array(reversed)); +} + +/** + * Convert a BigInt to a 0x-prefixed 64-hex-char big-endian string. + */ +export function bigIntToHex(n: bigint): string { + return '0x' + n.toString(16).padStart(64, '0'); +} diff --git a/tests/unit/disclosure.test.ts b/tests/unit/disclosure.test.ts new file mode 100644 index 0000000..d988848 --- /dev/null +++ b/tests/unit/disclosure.test.ts @@ -0,0 +1,584 @@ +/** + * Unit Tests: disclosure.ts + * + * Tests the high-level `generateDisclosureProof` API and its helpers. + * + * Strategy: + * - Mock `circomlibjs` to avoid running real Poseidon (no circuit artifacts needed). + * - Mock `../../src/index` (generateProof) to return crafted public signals. + * - Test everything the disclosure orchestrator does: + * 1. Mask validation (all-false must throw) + * 2. Circuit inputs built with correct values and viewing_key + * 3. `revealedData` decoded correctly from public signals for all mask combos + * 4. Proof / publicSignals forwarded as-is + * 5. Helper round-trips: bigIntToBytes32 ↔ bytes32ToBigInt + */ + +import { generateDisclosureProof } from '../../src/disclosure'; +import { DisclosureMask, CircuitType } from '../../src/types'; +import { bigIntToBytes32, bytes32ToBigInt } from '../../src/utils'; + +// ============================================================================ +// Mocks +// ============================================================================ + +// Fixed mock viewing key returned by the mocked Poseidon hasher. +// Using 999n so it's easy to recognise in assertions. +const MOCK_VIEWING_KEY = 999n; + +// Little-endian hex encoding of MOCK_VIEWING_KEY (32 bytes): +// 999 = 0x3E7 → byte[0]=0xE7, byte[1]=0x03, rest 0 +const MOCK_VIEWING_KEY_LE_HEX = + '0xe703000000000000000000000000000000000000000000000000000000000000'; + +// Little-endian hex for 0n (all zeros, same byte order either way) +const ZERO_LE_HEX = '0x0000000000000000000000000000000000000000000000000000000000000000'; + +// Little-endian hex for value=100n (0x64 → byte[0]=0x64, rest 0) +const VALUE_100_LE_HEX = '0x6400000000000000000000000000000000000000000000000000000000000000'; + +// Little-endian hex for assetId=7n (0x07 → byte[0]=0x07, rest 0) +const ASSET_7_LE_HEX = '0x0700000000000000000000000000000000000000000000000000000000000000'; + +// Commitment: just use a fixed LE hex (any non-zero value) +const COMMITMENT_HEX = '0x0102030400000000000000000000000000000000000000000000000000000000'; + +// Mock circomlibjs: buildPoseidon returns a minimal Poseidon stand-in. +jest.mock('circomlibjs', () => ({ + buildPoseidon: jest.fn().mockResolvedValue( + Object.assign((_inputs: any[]) => ({ _isMockFieldElement: true }), { + F: { + // toObject always returns the mock viewing key — enough to exercise the flow. + toObject: (_: any) => BigInt(999), + }, + }) + ), +})); + +// generateProof is mocked at the module level so that no artifacts are needed. +// Tests can override the resolved value for individual scenarios. +const mockGenerateProof = jest.fn(); + +jest.mock('../../src/index', () => { + const original = jest.requireActual('../../src/index'); + return { + ...original, + generateProof: (...args: any[]) => mockGenerateProof(...args), + }; +}); + +// ============================================================================ +// Test data +// ============================================================================ + +const NOTE_VALUE = 100n; +const OWNER_PUBKEY = BigInt('0x' + '2'.repeat(64)); +const BLINDING = BigInt('0x' + '9'.repeat(64)); +const ASSET_ID = 7n; +const COMMITMENT = BigInt('0x0102030400000000000000000000000000000000000000000000000000000000'); + +/** Build a mock ProofResult whose public signals reflect the given mask. */ +function buildMockResult(mask: DisclosureMask) { + return { + proof: '0x' + 'ab'.repeat(128), + publicSignals: [ + COMMITMENT_HEX, + mask.discloseValue ? VALUE_100_LE_HEX : ZERO_LE_HEX, + mask.discloseAssetId ? ASSET_7_LE_HEX : ZERO_LE_HEX, + mask.discloseOwner ? MOCK_VIEWING_KEY_LE_HEX : ZERO_LE_HEX, + ], + circuitType: CircuitType.Disclosure, + }; +} + +// ============================================================================ +// Helper tests (pure functions — no mocks needed) +// ============================================================================ + +describe('disclosure.ts helpers - bigIntToBytes32 / bytes32ToBigInt', () => { + it('should round-trip zero', () => { + const bytes = bigIntToBytes32(0n); + expect(bytes).toHaveLength(32); + expect(bytes.every(b => b === 0)).toBe(true); + expect(bytes32ToBigInt(bytes)).toBe(0n); + }); + + it('should round-trip small values', () => { + for (const n of [1n, 100n, 255n, 256n, 65535n]) { + expect(bytes32ToBigInt(bigIntToBytes32(n))).toBe(n); + } + }); + + it('should round-trip large BN254 field element', () => { + // BN254 prime - 1 + const big = 21888242871839275222246405745257275088548364400416034343698204186575808495616n; + expect(bytes32ToBigInt(bigIntToBytes32(big))).toBe(big); + }); + + it('should encode in big-endian (MSB at index 0)', () => { + const bytes = bigIntToBytes32(256n); // 0x0100 + expect(bytes[30]).toBe(1); + expect(bytes[31]).toBe(0); + }); +}); + +// ============================================================================ +// Mask validation +// ============================================================================ + +describe('generateDisclosureProof - mask validation', () => { + beforeEach(() => { + mockGenerateProof.mockResolvedValue( + buildMockResult({ discloseValue: true, discloseAssetId: false, discloseOwner: false }) + ); + }); + + it('should throw when all mask flags are false', async () => { + const mask: DisclosureMask = { + discloseValue: false, + discloseAssetId: false, + discloseOwner: false, + }; + + await expect( + generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask) + ).rejects.toThrow(/DisclosureMask/); + }); + + it('should NOT throw when at least one flag is true (discloseValue)', async () => { + const mask: DisclosureMask = { + discloseValue: true, + discloseAssetId: false, + discloseOwner: false, + }; + + await expect( + generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask) + ).resolves.toBeDefined(); + }); + + it('should NOT throw when at least one flag is true (discloseOwner)', async () => { + mockGenerateProof.mockResolvedValue( + buildMockResult({ discloseValue: false, discloseAssetId: false, discloseOwner: true }) + ); + const mask: DisclosureMask = { + discloseValue: false, + discloseAssetId: false, + discloseOwner: true, + }; + + await expect( + generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask) + ).resolves.toBeDefined(); + }); +}); + +// ============================================================================ +// Circuit inputs built correctly +// ============================================================================ + +describe('generateDisclosureProof - circuit inputs', () => { + beforeEach(() => { + mockGenerateProof.mockResolvedValue( + buildMockResult({ discloseValue: true, discloseAssetId: true, discloseOwner: true }) + ); + }); + + it('should call generateProof with CircuitType.Disclosure', async () => { + const mask: DisclosureMask = { + discloseValue: true, + discloseAssetId: true, + discloseOwner: true, + }; + + await generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask); + + expect(mockGenerateProof).toHaveBeenCalledWith( + CircuitType.Disclosure, + expect.any(Object), + expect.any(Object) + ); + }); + + it('should include commitment in circuit inputs', async () => { + const mask: DisclosureMask = { + discloseValue: true, + discloseAssetId: false, + discloseOwner: false, + }; + mockGenerateProof.mockResolvedValue(buildMockResult(mask)); + + await generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask); + + const [, inputs] = mockGenerateProof.mock.calls.at(-1)!; + expect(inputs.commitment).toBe(COMMITMENT.toString()); + }); + + it('should include viewing_key = mocked Poseidon(owner_pubkey)', async () => { + const mask: DisclosureMask = { + discloseValue: true, + discloseAssetId: false, + discloseOwner: false, + }; + + await generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask); + + const [, inputs] = mockGenerateProof.mock.calls.at(-1)!; + expect(inputs.viewing_key).toBe(MOCK_VIEWING_KEY.toString()); + }); + + it('should set disclose_value=1 when mask.discloseValue is true', async () => { + const mask: DisclosureMask = { + discloseValue: true, + discloseAssetId: false, + discloseOwner: false, + }; + + await generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask); + + const [, inputs] = mockGenerateProof.mock.calls.at(-1)!; + expect(inputs.disclose_value).toBe('1'); + expect(inputs.disclose_asset_id).toBe('0'); + expect(inputs.disclose_owner).toBe('0'); + }); + + it('should set all disclose flags correctly for all-reveal mask', async () => { + const mask: DisclosureMask = { + discloseValue: true, + discloseAssetId: true, + discloseOwner: true, + }; + + await generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask); + + const [, inputs] = mockGenerateProof.mock.calls.at(-1)!; + expect(inputs.disclose_value).toBe('1'); + expect(inputs.disclose_asset_id).toBe('1'); + expect(inputs.disclose_owner).toBe('1'); + }); + + it('should set revealed_value=0 in public inputs when discloseValue is false', async () => { + const mask: DisclosureMask = { + discloseValue: false, + discloseAssetId: true, + discloseOwner: false, + }; + mockGenerateProof.mockResolvedValue(buildMockResult(mask)); + + await generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask); + + const [, inputs] = mockGenerateProof.mock.calls.at(-1)!; + expect(inputs.revealed_value).toBe('0'); + }); + + it('should set revealed_value=value in public inputs when discloseValue is true', async () => { + const mask: DisclosureMask = { + discloseValue: true, + discloseAssetId: false, + discloseOwner: false, + }; + + await generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask); + + const [, inputs] = mockGenerateProof.mock.calls.at(-1)!; + expect(inputs.revealed_value).toBe(NOTE_VALUE.toString()); + }); + + it('should set revealed_asset_id=asset_id when discloseAssetId is true', async () => { + const mask: DisclosureMask = { + discloseValue: false, + discloseAssetId: true, + discloseOwner: false, + }; + mockGenerateProof.mockResolvedValue(buildMockResult(mask)); + + await generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask); + + const [, inputs] = mockGenerateProof.mock.calls.at(-1)!; + expect(inputs.revealed_asset_id).toBe(ASSET_ID.toString()); + }); + + it('should set revealed_owner_hash=viewingKey when discloseOwner is true', async () => { + const mask: DisclosureMask = { + discloseValue: false, + discloseAssetId: false, + discloseOwner: true, + }; + mockGenerateProof.mockResolvedValue(buildMockResult(mask)); + + await generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask); + + const [, inputs] = mockGenerateProof.mock.calls.at(-1)!; + expect(inputs.revealed_owner_hash).toBe(MOCK_VIEWING_KEY.toString()); + }); + + it('should set revealed_asset_id=0 when discloseAssetId is false', async () => { + const mask: DisclosureMask = { + discloseValue: true, + discloseAssetId: false, + discloseOwner: false, + }; + + await generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask); + + const [, inputs] = mockGenerateProof.mock.calls.at(-1)!; + expect(inputs.revealed_asset_id).toBe('0'); + }); + + it('should set revealed_owner_hash=0 when discloseOwner is false', async () => { + const mask: DisclosureMask = { + discloseValue: true, + discloseAssetId: false, + discloseOwner: false, + }; + + await generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask); + + const [, inputs] = mockGenerateProof.mock.calls.at(-1)!; + expect(inputs.revealed_owner_hash).toBe('0'); + }); + + it('should include all private inputs (value, asset_id, owner_pubkey, blinding)', async () => { + const mask: DisclosureMask = { + discloseValue: true, + discloseAssetId: true, + discloseOwner: true, + }; + + await generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask); + + const [, inputs] = mockGenerateProof.mock.calls.at(-1)!; + expect(inputs.value).toBeDefined(); + expect(inputs.asset_id).toBeDefined(); + expect(inputs.owner_pubkey).toBe(OWNER_PUBKEY.toString()); + expect(inputs.blinding).toBe(BLINDING.toString()); + }); + + it('should forward options.provider to generateProof as 3rd argument', async () => { + const mask: DisclosureMask = { + discloseValue: true, + discloseAssetId: false, + discloseOwner: false, + }; + const fakeProvider = { getCircuitWasm: jest.fn(), getCircuitZkey: jest.fn() } as any; + + await generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask, { + provider: fakeProvider, + }); + + const [, , opts] = mockGenerateProof.mock.calls.at(-1)!; + expect(opts.provider).toBe(fakeProvider); + }); +}); + +// ============================================================================ +// revealedData decoding +// ============================================================================ + +describe('generateDisclosureProof - revealedData decoding', () => { + it('should include commitment in revealedData regardless of mask', async () => { + const mask: DisclosureMask = { + discloseValue: true, + discloseAssetId: false, + discloseOwner: false, + }; + mockGenerateProof.mockResolvedValue(buildMockResult(mask)); + + const result = await generateDisclosureProof( + NOTE_VALUE, + OWNER_PUBKEY, + BLINDING, + ASSET_ID, + COMMITMENT, + mask + ); + + expect(result.revealedData.commitment).toBe(COMMITMENT_HEX); + }); + + it('should decode value when discloseValue=true', async () => { + const mask: DisclosureMask = { + discloseValue: true, + discloseAssetId: false, + discloseOwner: false, + }; + mockGenerateProof.mockResolvedValue(buildMockResult(mask)); + + const result = await generateDisclosureProof( + NOTE_VALUE, + OWNER_PUBKEY, + BLINDING, + ASSET_ID, + COMMITMENT, + mask + ); + + // value=100n → LE hex → decoded back to '100' + expect(result.revealedData.value).toBe('100'); + expect(result.revealedData.assetId).toBeUndefined(); + expect(result.revealedData.ownerHash).toBeUndefined(); + }); + + it('should decode assetId when discloseAssetId=true', async () => { + const mask: DisclosureMask = { + discloseValue: false, + discloseAssetId: true, + discloseOwner: false, + }; + mockGenerateProof.mockResolvedValue(buildMockResult(mask)); + + const result = await generateDisclosureProof( + NOTE_VALUE, + OWNER_PUBKEY, + BLINDING, + ASSET_ID, + COMMITMENT, + mask + ); + + // assetId=7n → LE hex → decoded back to 7 + expect(result.revealedData.assetId).toBe(7); + expect(result.revealedData.value).toBeUndefined(); + expect(result.revealedData.ownerHash).toBeUndefined(); + }); + + it('should decode ownerHash when discloseOwner=true', async () => { + const mask: DisclosureMask = { + discloseValue: false, + discloseAssetId: false, + discloseOwner: true, + }; + mockGenerateProof.mockResolvedValue(buildMockResult(mask)); + + const result = await generateDisclosureProof( + NOTE_VALUE, + OWNER_PUBKEY, + BLINDING, + ASSET_ID, + COMMITMENT, + mask + ); + + // ownerHash → decoded from LE hex of MOCK_VIEWING_KEY = 999n + expect(result.revealedData.ownerHash).toBe( + '0x' + MOCK_VIEWING_KEY.toString(16).padStart(64, '0') + ); + expect(result.revealedData.value).toBeUndefined(); + expect(result.revealedData.assetId).toBeUndefined(); + }); + + it('should decode all 3 fields when mask is all-true', async () => { + const mask: DisclosureMask = { + discloseValue: true, + discloseAssetId: true, + discloseOwner: true, + }; + mockGenerateProof.mockResolvedValue(buildMockResult(mask)); + + const result = await generateDisclosureProof( + NOTE_VALUE, + OWNER_PUBKEY, + BLINDING, + ASSET_ID, + COMMITMENT, + mask + ); + + expect(result.revealedData.value).toBe('100'); + expect(result.revealedData.assetId).toBe(7); + expect(result.revealedData.ownerHash).toBeDefined(); + expect(result.revealedData.commitment).toBe(COMMITMENT_HEX); + }); + + it('should forward proof and publicSignals as-is from generateProof', async () => { + const mask: DisclosureMask = { + discloseValue: true, + discloseAssetId: false, + discloseOwner: false, + }; + const mockResult = buildMockResult(mask); + mockGenerateProof.mockResolvedValue(mockResult); + + const result = await generateDisclosureProof( + NOTE_VALUE, + OWNER_PUBKEY, + BLINDING, + ASSET_ID, + COMMITMENT, + mask + ); + + expect(result.proof).toBe(mockResult.proof); + expect(result.publicSignals).toEqual(mockResult.publicSignals); + }); +}); + +// ============================================================================ +// All 7 valid mask combinations (2^3 - 1) +// ============================================================================ + +describe('generateDisclosureProof - all 7 valid mask combinations', () => { + const VALID_MASKS: DisclosureMask[] = [ + { discloseValue: true, discloseAssetId: false, discloseOwner: false }, + { discloseValue: false, discloseAssetId: true, discloseOwner: false }, + { discloseValue: false, discloseAssetId: false, discloseOwner: true }, + { discloseValue: true, discloseAssetId: true, discloseOwner: false }, + { discloseValue: true, discloseAssetId: false, discloseOwner: true }, + { discloseValue: false, discloseAssetId: true, discloseOwner: true }, + { discloseValue: true, discloseAssetId: true, discloseOwner: true }, + ]; + + test.each(VALID_MASKS)('mask V=%s A=%s O=%s should resolve without error', async mask => { + mockGenerateProof.mockResolvedValue(buildMockResult(mask)); + + const result = await generateDisclosureProof( + NOTE_VALUE, + OWNER_PUBKEY, + BLINDING, + ASSET_ID, + COMMITMENT, + mask + ); + + expect(result).toBeDefined(); + expect(result.proof).toBeDefined(); + expect(result.publicSignals).toHaveLength(4); + expect(result.revealedData.commitment).toBeDefined(); + + // Fields present iff their mask flag is true + if (mask.discloseValue) { + expect(result.revealedData.value).toBeDefined(); + } else { + expect(result.revealedData.value).toBeUndefined(); + } + if (mask.discloseAssetId) { + expect(result.revealedData.assetId).toBeDefined(); + } else { + expect(result.revealedData.assetId).toBeUndefined(); + } + if (mask.discloseOwner) { + expect(result.revealedData.ownerHash).toBeDefined(); + } else { + expect(result.revealedData.ownerHash).toBeUndefined(); + } + }); +}); + +// ============================================================================ +// Error propagation from generateProof +// ============================================================================ + +describe('generateDisclosureProof - error propagation', () => { + it('should propagate errors from generateProof', async () => { + const mask: DisclosureMask = { + discloseValue: true, + discloseAssetId: false, + discloseOwner: false, + }; + mockGenerateProof.mockRejectedValue(new Error('Circuit not found')); + + await expect( + generateDisclosureProof(NOTE_VALUE, OWNER_PUBKEY, BLINDING, ASSET_ID, COMMITMENT, mask) + ).rejects.toThrow('Circuit not found'); + }); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..63da8b0 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "noEmit": true, + "rootDir": ".", + "skipLibCheck": true + }, + "include": ["tests/**/*", "src/**/*"], + "exclude": ["node_modules", "dist"] +}